Skip to main content

moex_client/models/
domain.rs

1use std::convert::Infallible;
2use std::fmt;
3use std::num::NonZeroU32;
4use std::str::FromStr;
5
6use chrono::{NaiveDate, NaiveDateTime, NaiveTime};
7use thiserror::Error;
8
9#[derive(Debug, Error, Clone, PartialEq, Eq)]
10/// Ошибки построения [`Index`].
11pub enum ParseIndexError {
12    /// Пустой `indexid`.
13    #[error("index id must not be empty")]
14    EmptyIndexId,
15    /// Пустое краткое имя индекса.
16    #[error("index short name must not be empty")]
17    EmptyShortName,
18    /// Границы активности индекса заданы в неверном порядке.
19    #[error("invalid index date range: from={from} is after till={till}")]
20    InvalidDateRange {
21        /// Начальная дата периода.
22        from: NaiveDate,
23        /// Конечная дата периода.
24        till: NaiveDate,
25    },
26}
27
28impl From<Infallible> for ParseIndexError {
29    fn from(value: Infallible) -> Self {
30        match value {}
31    }
32}
33
34#[derive(Debug, Error, Clone, PartialEq, Eq)]
35/// Ошибки построения [`HistoryDates`].
36pub enum ParseHistoryDatesError {
37    /// Границы доступной истории заданы в неверном порядке.
38    #[error("invalid history dates range: from={from} is after till={till}")]
39    InvalidDateRange {
40        /// Начальная дата доступной истории.
41        from: NaiveDate,
42        /// Конечная дата доступной истории.
43        till: NaiveDate,
44    },
45}
46
47#[derive(Debug, Error, Clone, PartialEq, Eq)]
48/// Ошибки построения [`HistoryRecord`].
49pub enum ParseHistoryRecordError {
50    /// Некорректный `boardid`.
51    #[error(transparent)]
52    InvalidBoardId(#[from] ParseBoardIdError),
53    /// Некорректный `secid`.
54    #[error(transparent)]
55    InvalidSecId(#[from] ParseSecIdError),
56    /// Количество сделок отрицательное.
57    #[error("history numtrades must not be negative, got {0}")]
58    NegativeNumTrades(i64),
59    /// Объём торгов отрицательный.
60    #[error("history volume must not be negative, got {0}")]
61    NegativeVolume(i64),
62}
63
64#[derive(Debug, Error, Clone, PartialEq, Eq)]
65/// Ошибки построения [`Turnover`].
66pub enum ParseTurnoverError {
67    /// Пустое поле `name`.
68    #[error("turnover name must not be empty")]
69    EmptyName,
70    /// Идентификатор должен быть положительным.
71    #[error("turnover id must be positive, got {0}")]
72    NonPositiveId(i64),
73    /// Идентификатор не помещается в `u32`.
74    #[error("turnover id is out of range for u32, got {0}")]
75    IdOutOfRange(i64),
76    /// Количество сделок отрицательное.
77    #[error("turnover numtrades must not be negative, got {0}")]
78    NegativeNumTrades(i64),
79    /// Пустое поле `title`.
80    #[error("turnover title must not be empty")]
81    EmptyTitle,
82}
83
84#[derive(Debug, Error, Clone, PartialEq, Eq)]
85/// Ошибки построения [`SecStat`].
86pub enum ParseSecStatError {
87    /// Некорректный `secid`.
88    #[error(transparent)]
89    InvalidSecId(#[from] ParseSecIdError),
90    /// Некорректный `boardid`.
91    #[error(transparent)]
92    InvalidBoardId(#[from] ParseBoardIdError),
93    /// Объём торгов отрицательный.
94    #[error("secstats voltoday must not be negative, got {0}")]
95    NegativeVolToday(i64),
96    /// Количество сделок отрицательное.
97    #[error("secstats numtrades must not be negative, got {0}")]
98    NegativeNumTrades(i64),
99}
100
101#[derive(Debug, Error, Clone, PartialEq, Eq)]
102/// Ошибки построения [`SiteNews`].
103pub enum ParseSiteNewsError {
104    /// Идентификатор новости должен быть положительным.
105    #[error("sitenews id must be positive, got {0}")]
106    NonPositiveId(i64),
107    /// Идентификатор новости не помещается в `u64`.
108    #[error("sitenews id is out of range for u64, got {0}")]
109    IdOutOfRange(i64),
110    /// Пустое поле `tag`.
111    #[error("sitenews tag must not be empty")]
112    EmptyTag,
113    /// Пустое поле `title`.
114    #[error("sitenews title must not be empty")]
115    EmptyTitle,
116}
117
118#[derive(Debug, Error, Clone, PartialEq, Eq)]
119/// Ошибки построения [`Event`].
120pub enum ParseEventError {
121    /// Идентификатор события должен быть положительным.
122    #[error("events id must be positive, got {0}")]
123    NonPositiveId(i64),
124    /// Идентификатор события не помещается в `u64`.
125    #[error("events id is out of range for u64, got {0}")]
126    IdOutOfRange(i64),
127    /// Пустое поле `tag`.
128    #[error("events tag must not be empty")]
129    EmptyTag,
130    /// Пустое поле `title`.
131    #[error("events title must not be empty")]
132    EmptyTitle,
133}
134
135#[derive(Debug, Error, Clone, PartialEq, Eq)]
136/// Ошибки построения [`IndexAnalytics`].
137pub enum ParseIndexAnalyticsError {
138    /// Некорректные данные самого индекса.
139    #[error(transparent)]
140    InvalidIndexId(#[from] ParseIndexError),
141    /// Некорректный `ticker`.
142    #[error("ticker is invalid: {0}")]
143    InvalidTicker(ParseSecIdError),
144    /// Некорректный `secid`.
145    #[error("secid is invalid: {0}")]
146    InvalidSecId(ParseSecIdError),
147    /// Пустое поле `shortnames`.
148    #[error("shortnames must not be empty")]
149    EmptyShortnames,
150    /// Вес компонента не является конечным числом.
151    #[error("weight must be finite")]
152    NonFiniteWeight,
153    /// Вес компонента отрицательный.
154    #[error("weight must not be negative")]
155    NegativeWeight,
156    /// Недопустимое значение `tradingsession`.
157    #[error("tradingsession must be 1, 2 or 3, got {0}")]
158    InvalidTradingsession(i64),
159}
160
161#[derive(Debug, Error, Clone, PartialEq, Eq)]
162/// Ошибки построения [`Engine`].
163pub enum ParseEngineError {
164    /// Идентификатор движка должен быть положительным.
165    #[error("engine id must be positive, got {0}")]
166    NonPositiveId(i64),
167    /// Идентификатор движка не помещается в `u32`.
168    #[error("engine id is out of range for u32, got {0}")]
169    IdOutOfRange(i64),
170    /// Некорректное имя движка.
171    #[error(transparent)]
172    InvalidName(#[from] ParseEngineNameError),
173    /// Пустой заголовок движка.
174    #[error("engine title must not be empty")]
175    EmptyTitle,
176}
177
178#[derive(Debug, Error, Clone, PartialEq, Eq)]
179/// Ошибки разбора имени торгового движка.
180pub enum ParseEngineNameError {
181    /// Пустое имя.
182    #[error("engine name must not be empty")]
183    Empty,
184    /// Имя содержит символ `/`, запрещённый в path-сегменте.
185    #[error("engine name must not contain '/'")]
186    ContainsSlash,
187}
188
189impl From<Infallible> for ParseEngineNameError {
190    fn from(value: Infallible) -> Self {
191        match value {}
192    }
193}
194
195#[derive(Debug, Error, Clone, PartialEq, Eq)]
196/// Ошибки построения [`Market`].
197pub enum ParseMarketError {
198    /// Идентификатор рынка должен быть положительным.
199    #[error("market id must be positive, got {0}")]
200    NonPositiveId(i64),
201    /// Идентификатор рынка не помещается в `u32`.
202    #[error("market id is out of range for u32, got {0}")]
203    IdOutOfRange(i64),
204    /// Некорректное имя рынка.
205    #[error(transparent)]
206    InvalidName(#[from] ParseMarketNameError),
207    /// Пустой заголовок рынка.
208    #[error("market title must not be empty")]
209    EmptyTitle,
210}
211
212#[derive(Debug, Error, Clone, PartialEq, Eq)]
213/// Ошибки построения [`Board`].
214pub enum ParseBoardError {
215    /// Идентификатор board должен быть положительным.
216    #[error("board id must be positive, got {0}")]
217    NonPositiveId(i64),
218    /// Идентификатор board не помещается в `u32`.
219    #[error("board id is out of range for u32, got {0}")]
220    IdOutOfRange(i64),
221    /// `board_group_id` отрицательный.
222    #[error("board_group_id must not be negative, got {0}")]
223    NegativeBoardGroupId(i64),
224    /// `board_group_id` не помещается в `u32`.
225    #[error("board_group_id is out of range for u32, got {0}")]
226    BoardGroupIdOutOfRange(i64),
227    /// Некорректный текстовый `boardid`.
228    #[error(transparent)]
229    InvalidBoardId(#[from] ParseBoardIdError),
230    /// Пустой заголовок board.
231    #[error("board title must not be empty")]
232    EmptyTitle,
233    /// Некорректный флаг `is_traded` (допустимы только 0/1).
234    #[error("is_traded must be 0 or 1, got {0}")]
235    InvalidIsTraded(i64),
236}
237
238#[derive(Debug, Error, Clone, PartialEq, Eq)]
239/// Ошибки построения [`Security`].
240pub enum ParseSecurityError {
241    /// Некорректный `secid`.
242    #[error(transparent)]
243    InvalidSecId(#[from] ParseSecIdError),
244    /// Пустое поле `shortname`.
245    #[error("security shortname must not be empty")]
246    EmptyShortname,
247    /// Пустое поле `secname`.
248    #[error("security secname must not be empty")]
249    EmptySecname,
250    /// Пустое поле `status`.
251    #[error("security status must not be empty")]
252    EmptyStatus,
253}
254
255#[derive(Debug, Error, Clone, PartialEq, Eq)]
256/// Ошибки построения [`SecurityBoard`].
257pub enum ParseSecurityBoardError {
258    /// Некорректное имя движка.
259    #[error(transparent)]
260    InvalidEngine(#[from] ParseEngineNameError),
261    /// Некорректное имя рынка.
262    #[error(transparent)]
263    InvalidMarket(#[from] ParseMarketNameError),
264    /// Некорректный `boardid`.
265    #[error(transparent)]
266    InvalidBoardId(#[from] ParseBoardIdError),
267    /// Некорректный флаг `is_primary` (допустимы только 0/1).
268    #[error("is_primary must be 0 or 1, got {0}")]
269    InvalidIsPrimary(i64),
270}
271
272#[derive(Debug, Error, Clone, PartialEq)]
273/// Ошибки построения [`SecuritySnapshot`].
274pub enum ParseSecuritySnapshotError {
275    /// Некорректный `secid`.
276    #[error(transparent)]
277    InvalidSecId(#[from] ParseSecIdError),
278    /// Размер лота отрицательный.
279    #[error("lot size must not be negative, got {0}")]
280    NegativeLotSize(i64),
281    /// Размер лота не помещается в `u32`.
282    #[error("lot size is out of range for u32, got {0}")]
283    LotSizeOutOfRange(i64),
284    /// Значение `last` не является конечным числом.
285    #[error("last must be finite")]
286    NonFiniteLast(f64),
287}
288
289#[derive(Debug, Error, Clone, PartialEq, Eq)]
290/// Ошибки построения [`Candle`].
291pub enum ParseCandleError {
292    /// Границы свечи заданы в неверном порядке.
293    #[error("invalid candle datetime range: begin={begin} is after end={end}")]
294    InvalidDateRange {
295        /// Начало свечи.
296        begin: NaiveDateTime,
297        /// Конец свечи.
298        end: NaiveDateTime,
299    },
300    /// Объём свечи отрицательный.
301    #[error("candle volume must not be negative, got {0}")]
302    NegativeVolume(i64),
303}
304
305#[derive(Debug, Error, Clone, PartialEq, Eq)]
306/// Ошибки разбора значения интервала свечей.
307pub enum ParseCandleIntervalError {
308    /// Неизвестный код интервала ISS.
309    #[error("invalid candle interval code, got {0}")]
310    InvalidCode(i64),
311}
312
313#[derive(Debug, Error, Clone, PartialEq, Eq)]
314/// Ошибки построения [`CandleQuery`].
315pub enum ParseCandleQueryError {
316    /// В запросе свечей `from` больше `till`.
317    #[error("invalid candle query datetime range: from={from} is after till={till}")]
318    InvalidDateRange {
319        /// Начальная дата и время выборки.
320        from: NaiveDateTime,
321        /// Конечная дата и время выборки.
322        till: NaiveDateTime,
323    },
324}
325
326#[derive(Debug, Error, Clone, PartialEq, Eq)]
327/// Ошибки построения [`CandleBorder`].
328pub enum ParseCandleBorderError {
329    /// Границы диапазона заданы в неверном порядке.
330    #[error("invalid candle borders range: begin={begin} is after end={end}")]
331    InvalidDateRange {
332        /// Начало доступного диапазона.
333        begin: NaiveDateTime,
334        /// Конец доступного диапазона.
335        end: NaiveDateTime,
336    },
337    /// Некорректный код интервала.
338    #[error(transparent)]
339    InvalidInterval(#[from] ParseCandleIntervalError),
340    /// `board_group_id` отрицательный.
341    #[error("board_group_id must not be negative, got {0}")]
342    NegativeBoardGroupId(i64),
343    /// `board_group_id` не помещается в `u32`.
344    #[error("board_group_id is out of range for u32, got {0}")]
345    BoardGroupIdOutOfRange(i64),
346}
347
348#[derive(Debug, Error, Clone, PartialEq, Eq)]
349/// Ошибки построения [`Trade`].
350pub enum ParseTradeError {
351    /// Номер сделки должен быть положительным.
352    #[error("trade number must be positive, got {0}")]
353    NonPositiveTradeNo(i64),
354    /// Номер сделки не помещается в `u64`.
355    #[error("trade number is out of range for u64, got {0}")]
356    TradeNoOutOfRange(i64),
357    /// Количество в сделке отрицательное.
358    #[error("trade quantity must not be negative, got {0}")]
359    NegativeQuantity(i64),
360}
361
362#[derive(Debug, Error, Clone, PartialEq, Eq)]
363/// Ошибки построения [`OrderbookLevel`].
364pub enum ParseOrderbookError {
365    /// Некорректное направление заявки (`B`/`S`).
366    #[error("orderbook side must be 'B' or 'S', got '{0}'")]
367    InvalidSide(Box<str>),
368    /// Отсутствует цена уровня стакана.
369    #[error("orderbook price must be present")]
370    MissingPrice,
371    /// Цена уровня стакана отрицательная.
372    #[error("orderbook price must not be negative")]
373    NegativePrice,
374    /// Отсутствует объём уровня стакана.
375    #[error("orderbook quantity must be present")]
376    MissingQuantity,
377    /// Объём уровня стакана отрицательный.
378    #[error("orderbook quantity must not be negative, got {0}")]
379    NegativeQuantity(i64),
380}
381
382#[derive(Debug, Error, Clone, PartialEq, Eq)]
383/// Ошибки разбора идентификатора инструмента (`secid`).
384pub enum ParseSecIdError {
385    /// Пустой `secid`.
386    #[error("secid must not be empty")]
387    Empty,
388    /// `secid` содержит символ `/`, запрещённый в path-сегменте.
389    #[error("secid must not contain '/'")]
390    ContainsSlash,
391}
392
393impl From<Infallible> for ParseSecIdError {
394    fn from(value: Infallible) -> Self {
395        match value {}
396    }
397}
398
399#[derive(Debug, Error, Clone, PartialEq, Eq)]
400/// Ошибки разбора идентификатора режима торгов (`boardid`).
401pub enum ParseBoardIdError {
402    /// Пустой `boardid`.
403    #[error("boardid must not be empty")]
404    Empty,
405    /// `boardid` содержит символ `/`, запрещённый в path-сегменте.
406    #[error("boardid must not contain '/'")]
407    ContainsSlash,
408}
409
410impl From<Infallible> for ParseBoardIdError {
411    fn from(value: Infallible) -> Self {
412        match value {}
413    }
414}
415
416#[derive(Debug, Error, Clone, PartialEq, Eq)]
417/// Ошибки разбора имени рынка MOEX.
418pub enum ParseMarketNameError {
419    /// Пустое имя.
420    #[error("market name must not be empty")]
421    Empty,
422    /// Имя содержит символ `/`, запрещённый в path-сегменте.
423    #[error("market name must not contain '/'")]
424    ContainsSlash,
425}
426
427impl From<Infallible> for ParseMarketNameError {
428    fn from(value: Infallible) -> Self {
429        match value {}
430    }
431}
432
433#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
434/// Интервалы свечей в терминах ISS.
435pub enum CandleInterval {
436    /// 1 минута.
437    Minute1,
438    /// 10 минут.
439    Minute10,
440    /// 1 час.
441    Hour1,
442    /// 1 день.
443    Day1,
444    /// 1 неделя.
445    Week1,
446    /// 1 месяц.
447    Month1,
448    /// 1 квартал.
449    Quarter1,
450}
451
452impl CandleInterval {
453    /// Вернуть строковый код интервала для query-параметра `interval`.
454    pub fn as_str(self) -> &'static str {
455        match self {
456            Self::Minute1 => "1",
457            Self::Minute10 => "10",
458            Self::Hour1 => "60",
459            Self::Day1 => "24",
460            Self::Week1 => "7",
461            Self::Month1 => "31",
462            Self::Quarter1 => "4",
463        }
464    }
465}
466
467impl TryFrom<i64> for CandleInterval {
468    type Error = ParseCandleIntervalError;
469
470    fn try_from(value: i64) -> Result<Self, Self::Error> {
471        match value {
472            1 => Ok(Self::Minute1),
473            10 => Ok(Self::Minute10),
474            60 => Ok(Self::Hour1),
475            24 => Ok(Self::Day1),
476            7 => Ok(Self::Week1),
477            31 => Ok(Self::Month1),
478            4 => Ok(Self::Quarter1),
479            other => Err(ParseCandleIntervalError::InvalidCode(other)),
480        }
481    }
482}
483
484#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
485/// Режим получения страницы данных ISS.
486///
487/// Позволяет единообразно описать: первую страницу, произвольную страницу
488/// (`start`, `limit`) или полную выборку с авто-пагинацией.
489pub enum PageRequest {
490    /// Первая страница ISS (без явных `start`, `limit`).
491    #[default]
492    FirstPage,
493    /// Явные параметры пагинации ISS.
494    Page(Pagination),
495    /// Полная выгрузка с авто-пагинацией и размером страницы.
496    All {
497        /// Размер страницы ISS (`limit`) при авто-пагинации.
498        page_limit: NonZeroU32,
499    },
500}
501
502impl PageRequest {
503    /// Запросить первую страницу ISS.
504    pub fn first_page() -> Self {
505        Self::FirstPage
506    }
507
508    /// Запросить страницу ISS с явными параметрами.
509    pub fn page(pagination: Pagination) -> Self {
510        Self::Page(pagination)
511    }
512
513    /// Запросить полную выборку ISS с авто-пагинацией.
514    pub fn all(page_limit: NonZeroU32) -> Self {
515        Self::All { page_limit }
516    }
517}
518
519#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
520/// Направление заявки в стакане.
521pub enum BuySell {
522    /// Покупка (`B`).
523    Buy,
524    /// Продажа (`S`).
525    Sell,
526}
527
528impl BuySell {
529    /// Вернуть строковый код для ISS (`B` или `S`).
530    pub fn as_str(self) -> &'static str {
531        match self {
532            Self::Buy => "B",
533            Self::Sell => "S",
534        }
535    }
536}
537
538impl TryFrom<String> for BuySell {
539    type Error = ParseOrderbookError;
540
541    fn try_from(value: String) -> Result<Self, Self::Error> {
542        let value = value.trim();
543        match value {
544            "B" => Ok(Self::Buy),
545            "S" => Ok(Self::Sell),
546            _ => Err(ParseOrderbookError::InvalidSide(
547                value.to_owned().into_boxed_str(),
548            )),
549        }
550    }
551}
552
553#[derive(Debug, Clone, PartialEq, Eq, Hash)]
554/// Идентификатор индекса MOEX (`indexid`).
555pub struct IndexId(Box<str>);
556
557impl IndexId {
558    /// Вернуть строковое представление идентификатора.
559    pub fn as_str(&self) -> &str {
560        self.0.as_ref()
561    }
562}
563
564impl AsRef<str> for IndexId {
565    fn as_ref(&self) -> &str {
566        self.as_str()
567    }
568}
569
570impl From<&IndexId> for IndexId {
571    fn from(value: &IndexId) -> Self {
572        value.clone()
573    }
574}
575
576impl TryFrom<String> for IndexId {
577    type Error = ParseIndexError;
578
579    fn try_from(value: String) -> Result<Self, Self::Error> {
580        Self::try_from(value.as_str())
581    }
582}
583
584impl TryFrom<&str> for IndexId {
585    type Error = ParseIndexError;
586
587    fn try_from(value: &str) -> Result<Self, Self::Error> {
588        let value = value.trim();
589        if value.is_empty() {
590            return Err(ParseIndexError::EmptyIndexId);
591        }
592        Ok(Self(value.to_owned().into_boxed_str()))
593    }
594}
595
596impl FromStr for IndexId {
597    type Err = ParseIndexError;
598
599    fn from_str(value: &str) -> Result<Self, Self::Err> {
600        Self::try_from(value)
601    }
602}
603
604impl fmt::Display for IndexId {
605    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
606        f.write_str(self.as_str())
607    }
608}
609
610#[derive(Debug, Clone, PartialEq, Eq, Hash)]
611/// Имя торгового движка MOEX (`engine`).
612pub struct EngineName(Box<str>);
613
614impl EngineName {
615    /// Вернуть строковое представление имени движка.
616    pub fn as_str(&self) -> &str {
617        self.0.as_ref()
618    }
619}
620
621impl AsRef<str> for EngineName {
622    fn as_ref(&self) -> &str {
623        self.as_str()
624    }
625}
626
627impl From<&EngineName> for EngineName {
628    fn from(value: &EngineName) -> Self {
629        value.clone()
630    }
631}
632
633impl TryFrom<String> for EngineName {
634    type Error = ParseEngineNameError;
635
636    fn try_from(value: String) -> Result<Self, Self::Error> {
637        Self::try_from(value.as_str())
638    }
639}
640
641impl TryFrom<&str> for EngineName {
642    type Error = ParseEngineNameError;
643
644    fn try_from(value: &str) -> Result<Self, Self::Error> {
645        let value = value.trim();
646        if value.is_empty() {
647            return Err(ParseEngineNameError::Empty);
648        }
649        if value.contains('/') {
650            return Err(ParseEngineNameError::ContainsSlash);
651        }
652        Ok(Self(value.to_owned().into_boxed_str()))
653    }
654}
655
656impl FromStr for EngineName {
657    type Err = ParseEngineNameError;
658
659    fn from_str(value: &str) -> Result<Self, Self::Err> {
660        Self::try_from(value)
661    }
662}
663
664impl fmt::Display for EngineName {
665    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
666        f.write_str(self.as_str())
667    }
668}
669
670#[derive(Debug, Clone, PartialEq, Eq, Hash)]
671/// Идентификатор инструмента MOEX (`secid`).
672pub struct SecId(Box<str>);
673
674impl SecId {
675    /// Вернуть строковое представление идентификатора.
676    pub fn as_str(&self) -> &str {
677        self.0.as_ref()
678    }
679}
680
681impl AsRef<str> for SecId {
682    fn as_ref(&self) -> &str {
683        self.as_str()
684    }
685}
686
687impl From<&SecId> for SecId {
688    fn from(value: &SecId) -> Self {
689        value.clone()
690    }
691}
692
693impl TryFrom<String> for SecId {
694    type Error = ParseSecIdError;
695
696    fn try_from(value: String) -> Result<Self, Self::Error> {
697        Self::try_from(value.as_str())
698    }
699}
700
701impl TryFrom<&str> for SecId {
702    type Error = ParseSecIdError;
703
704    fn try_from(value: &str) -> Result<Self, Self::Error> {
705        let value = value.trim();
706        if value.is_empty() {
707            return Err(ParseSecIdError::Empty);
708        }
709        if value.contains('/') {
710            return Err(ParseSecIdError::ContainsSlash);
711        }
712        Ok(Self(value.to_owned().into_boxed_str()))
713    }
714}
715
716impl FromStr for SecId {
717    type Err = ParseSecIdError;
718
719    fn from_str(value: &str) -> Result<Self, Self::Err> {
720        Self::try_from(value)
721    }
722}
723
724impl fmt::Display for SecId {
725    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
726        f.write_str(self.as_str())
727    }
728}
729
730#[derive(Debug, Clone, PartialEq, Eq, Hash)]
731/// Идентификатор режима торгов MOEX (`boardid`).
732pub struct BoardId(Box<str>);
733
734impl BoardId {
735    /// Вернуть строковое представление идентификатора.
736    pub fn as_str(&self) -> &str {
737        self.0.as_ref()
738    }
739}
740
741impl AsRef<str> for BoardId {
742    fn as_ref(&self) -> &str {
743        self.as_str()
744    }
745}
746
747impl From<&BoardId> for BoardId {
748    fn from(value: &BoardId) -> Self {
749        value.clone()
750    }
751}
752
753impl TryFrom<String> for BoardId {
754    type Error = ParseBoardIdError;
755
756    fn try_from(value: String) -> Result<Self, Self::Error> {
757        Self::try_from(value.as_str())
758    }
759}
760
761impl TryFrom<&str> for BoardId {
762    type Error = ParseBoardIdError;
763
764    fn try_from(value: &str) -> Result<Self, Self::Error> {
765        let value = value.trim();
766        if value.is_empty() {
767            return Err(ParseBoardIdError::Empty);
768        }
769        if value.contains('/') {
770            return Err(ParseBoardIdError::ContainsSlash);
771        }
772        Ok(Self(value.to_owned().into_boxed_str()))
773    }
774}
775
776impl FromStr for BoardId {
777    type Err = ParseBoardIdError;
778
779    fn from_str(value: &str) -> Result<Self, Self::Err> {
780        Self::try_from(value)
781    }
782}
783
784impl fmt::Display for BoardId {
785    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
786        f.write_str(self.as_str())
787    }
788}
789
790#[derive(Debug, Clone, PartialEq, Eq, Hash)]
791/// Имя рынка MOEX (`market`).
792pub struct MarketName(Box<str>);
793
794impl MarketName {
795    /// Вернуть строковое представление имени рынка.
796    pub fn as_str(&self) -> &str {
797        self.0.as_ref()
798    }
799}
800
801impl AsRef<str> for MarketName {
802    fn as_ref(&self) -> &str {
803        self.as_str()
804    }
805}
806
807impl From<&MarketName> for MarketName {
808    fn from(value: &MarketName) -> Self {
809        value.clone()
810    }
811}
812
813impl TryFrom<String> for MarketName {
814    type Error = ParseMarketNameError;
815
816    fn try_from(value: String) -> Result<Self, Self::Error> {
817        Self::try_from(value.as_str())
818    }
819}
820
821impl TryFrom<&str> for MarketName {
822    type Error = ParseMarketNameError;
823
824    fn try_from(value: &str) -> Result<Self, Self::Error> {
825        let value = value.trim();
826        if value.is_empty() {
827            return Err(ParseMarketNameError::Empty);
828        }
829        if value.contains('/') {
830            return Err(ParseMarketNameError::ContainsSlash);
831        }
832        Ok(Self(value.to_owned().into_boxed_str()))
833    }
834}
835
836impl FromStr for MarketName {
837    type Err = ParseMarketNameError;
838
839    fn from_str(value: &str) -> Result<Self, Self::Err> {
840        Self::try_from(value)
841    }
842}
843
844impl fmt::Display for MarketName {
845    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
846        f.write_str(self.as_str())
847    }
848}
849
850#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
851/// Идентификатор торгового движка MOEX.
852pub struct EngineId(u32);
853
854impl EngineId {
855    /// Вернуть числовое значение идентификатора.
856    pub fn get(self) -> u32 {
857        self.0
858    }
859}
860
861impl fmt::Display for EngineId {
862    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
863        write!(f, "{}", self.0)
864    }
865}
866
867#[derive(Debug, Clone, PartialEq, Eq)]
868/// Торговый движок MOEX (`engines`).
869pub struct Engine {
870    id: EngineId,
871    name: EngineName,
872    title: Box<str>,
873}
874
875impl Engine {
876    /// Построить движок из wire-значений ISS с валидацией инвариантов.
877    pub fn try_new(id: i64, name: String, title: String) -> Result<Self, ParseEngineError> {
878        if id <= 0 {
879            return Err(ParseEngineError::NonPositiveId(id));
880        }
881        let id = u32::try_from(id)
882            .map(EngineId)
883            .map_err(|_| ParseEngineError::IdOutOfRange(id))?;
884
885        let name = EngineName::try_from(name)?;
886
887        let title = title.trim();
888        if title.is_empty() {
889            return Err(ParseEngineError::EmptyTitle);
890        }
891
892        Ok(Self {
893            id,
894            name,
895            title: title.to_owned().into_boxed_str(),
896        })
897    }
898
899    /// Идентификатор движка.
900    pub fn id(&self) -> EngineId {
901        self.id
902    }
903
904    /// Короткое имя движка, используемое в URL ISS.
905    pub fn name(&self) -> &EngineName {
906        &self.name
907    }
908
909    /// Человекочитаемое название движка.
910    pub fn title(&self) -> &str {
911        self.title.as_ref()
912    }
913}
914
915#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
916/// Идентификатор рынка MOEX.
917pub struct MarketId(u32);
918
919impl MarketId {
920    /// Вернуть числовое значение идентификатора.
921    pub fn get(self) -> u32 {
922        self.0
923    }
924}
925
926impl fmt::Display for MarketId {
927    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
928        write!(f, "{}", self.0)
929    }
930}
931
932#[derive(Debug, Clone, PartialEq, Eq)]
933/// Рынок MOEX (`markets`).
934pub struct Market {
935    id: MarketId,
936    name: MarketName,
937    title: Box<str>,
938}
939
940impl Market {
941    /// Построить рынок из wire-значений ISS с валидацией инвариантов.
942    pub fn try_new(id: i64, name: String, title: String) -> Result<Self, ParseMarketError> {
943        if id <= 0 {
944            return Err(ParseMarketError::NonPositiveId(id));
945        }
946        let id = u32::try_from(id)
947            .map(MarketId)
948            .map_err(|_| ParseMarketError::IdOutOfRange(id))?;
949
950        let name = MarketName::try_from(name)?;
951
952        let title = title.trim();
953        if title.is_empty() {
954            return Err(ParseMarketError::EmptyTitle);
955        }
956
957        Ok(Self {
958            id,
959            name,
960            title: title.to_owned().into_boxed_str(),
961        })
962    }
963
964    /// Идентификатор рынка.
965    pub fn id(&self) -> MarketId {
966        self.id
967    }
968
969    /// Короткое имя рынка, используемое в URL ISS.
970    pub fn name(&self) -> &MarketName {
971        &self.name
972    }
973
974    /// Человекочитаемое название рынка.
975    pub fn title(&self) -> &str {
976        self.title.as_ref()
977    }
978}
979
980#[derive(Debug, Clone, PartialEq, Eq)]
981/// Режим торгов MOEX (`boards`).
982pub struct Board {
983    id: u32,
984    board_group_id: u32,
985    boardid: BoardId,
986    title: Box<str>,
987    is_traded: bool,
988}
989
990impl Board {
991    /// Построить режим торгов из wire-значений ISS с валидацией инвариантов.
992    pub fn try_new(
993        id: i64,
994        board_group_id: i64,
995        boardid: String,
996        title: String,
997        is_traded: i64,
998    ) -> Result<Self, ParseBoardError> {
999        if id <= 0 {
1000            return Err(ParseBoardError::NonPositiveId(id));
1001        }
1002        let id = u32::try_from(id).map_err(|_| ParseBoardError::IdOutOfRange(id))?;
1003
1004        if board_group_id < 0 {
1005            return Err(ParseBoardError::NegativeBoardGroupId(board_group_id));
1006        }
1007        let board_group_id = u32::try_from(board_group_id)
1008            .map_err(|_| ParseBoardError::BoardGroupIdOutOfRange(board_group_id))?;
1009
1010        let boardid = BoardId::try_from(boardid)?;
1011
1012        let title = title.trim();
1013        if title.is_empty() {
1014            return Err(ParseBoardError::EmptyTitle);
1015        }
1016
1017        let is_traded = match is_traded {
1018            0 => false,
1019            1 => true,
1020            other => return Err(ParseBoardError::InvalidIsTraded(other)),
1021        };
1022
1023        Ok(Self {
1024            id,
1025            board_group_id,
1026            boardid,
1027            title: title.to_owned().into_boxed_str(),
1028            is_traded,
1029        })
1030    }
1031
1032    /// Числовой идентификатор режима торгов.
1033    pub fn id(&self) -> u32 {
1034        self.id
1035    }
1036
1037    /// Идентификатор группы board.
1038    pub fn board_group_id(&self) -> u32 {
1039        self.board_group_id
1040    }
1041
1042    /// Символьный идентификатор режима торгов (`boardid`).
1043    pub fn boardid(&self) -> &BoardId {
1044        &self.boardid
1045    }
1046
1047    /// Человекочитаемое название режима торгов.
1048    pub fn title(&self) -> &str {
1049        self.title.as_ref()
1050    }
1051
1052    /// Признак, что режим предназначен для торгов (`1` в ISS).
1053    pub fn is_traded(&self) -> bool {
1054        self.is_traded
1055    }
1056}
1057
1058#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1059/// Режим торгов инструмента из `securities/{secid}` таблицы `boards`.
1060pub struct SecurityBoard {
1061    engine: EngineName,
1062    market: MarketName,
1063    boardid: BoardId,
1064    is_primary: bool,
1065}
1066
1067impl SecurityBoard {
1068    /// Построить режим торгов инструмента из wire-значений ISS.
1069    pub fn try_new(
1070        engine: String,
1071        market: String,
1072        boardid: String,
1073        is_primary: i64,
1074    ) -> Result<Self, ParseSecurityBoardError> {
1075        let engine = EngineName::try_from(engine)?;
1076        let market = MarketName::try_from(market)?;
1077        let boardid = BoardId::try_from(boardid)?;
1078        let is_primary = match is_primary {
1079            0 => false,
1080            1 => true,
1081            other => return Err(ParseSecurityBoardError::InvalidIsPrimary(other)),
1082        };
1083
1084        Ok(Self {
1085            engine,
1086            market,
1087            boardid,
1088            is_primary,
1089        })
1090    }
1091
1092    /// Имя движка из ответа ISS (`engine`).
1093    pub fn engine(&self) -> &EngineName {
1094        &self.engine
1095    }
1096
1097    /// Имя рынка из ответа ISS (`market`).
1098    pub fn market(&self) -> &MarketName {
1099        &self.market
1100    }
1101
1102    /// Идентификатор режима торгов из ответа ISS (`boardid`).
1103    pub fn boardid(&self) -> &BoardId {
1104        &self.boardid
1105    }
1106
1107    /// Признак первичного режима (`is_primary`).
1108    pub fn is_primary(&self) -> bool {
1109        self.is_primary
1110    }
1111}
1112
1113#[derive(Debug, Clone, PartialEq, Eq)]
1114/// Инструмент MOEX (`securities`).
1115pub struct Security {
1116    secid: SecId,
1117    shortname: Box<str>,
1118    secname: Box<str>,
1119    status: Box<str>,
1120}
1121
1122impl Security {
1123    /// Построить инструмент из wire-значений ISS с валидацией инвариантов.
1124    pub fn try_new(
1125        secid: String,
1126        shortname: String,
1127        secname: String,
1128        status: String,
1129    ) -> Result<Self, ParseSecurityError> {
1130        let secid = SecId::try_from(secid)?;
1131
1132        let shortname = shortname.trim();
1133        if shortname.is_empty() {
1134            return Err(ParseSecurityError::EmptyShortname);
1135        }
1136
1137        let secname = secname.trim();
1138        if secname.is_empty() {
1139            return Err(ParseSecurityError::EmptySecname);
1140        }
1141
1142        let status = status.trim();
1143        if status.is_empty() {
1144            return Err(ParseSecurityError::EmptyStatus);
1145        }
1146
1147        Ok(Self {
1148            secid,
1149            shortname: shortname.to_owned().into_boxed_str(),
1150            secname: secname.to_owned().into_boxed_str(),
1151            status: status.to_owned().into_boxed_str(),
1152        })
1153    }
1154
1155    /// Идентификатор инструмента (`secid`).
1156    pub fn secid(&self) -> &SecId {
1157        &self.secid
1158    }
1159
1160    /// Краткое имя инструмента.
1161    pub fn shortname(&self) -> &str {
1162        self.shortname.as_ref()
1163    }
1164
1165    /// Полное имя инструмента.
1166    pub fn secname(&self) -> &str {
1167        self.secname.as_ref()
1168    }
1169
1170    /// Текущий статус инструмента в ISS.
1171    pub fn status(&self) -> &str {
1172        self.status.as_ref()
1173    }
1174}
1175
1176#[derive(Debug, Clone, PartialEq)]
1177/// Снимок инструмента с полями `LOTSIZE` и `LAST`.
1178pub struct SecuritySnapshot {
1179    secid: SecId,
1180    lot_size: Option<u32>,
1181    last: Option<f64>,
1182}
1183
1184impl SecuritySnapshot {
1185    /// Построить снимок инструмента из wire-значений ISS.
1186    pub fn try_new(
1187        secid: String,
1188        lot_size: Option<i64>,
1189        last: Option<f64>,
1190    ) -> Result<Self, ParseSecuritySnapshotError> {
1191        let secid = SecId::try_from(secid).map_err(ParseSecuritySnapshotError::InvalidSecId)?;
1192        let lot_size = match lot_size {
1193            None => None,
1194            Some(raw) if raw < 0 => return Err(ParseSecuritySnapshotError::NegativeLotSize(raw)),
1195            Some(raw) => Some(
1196                u32::try_from(raw)
1197                    .map_err(|_| ParseSecuritySnapshotError::LotSizeOutOfRange(raw))?,
1198            ),
1199        };
1200        Self::try_from_parts(secid, lot_size, last)
1201    }
1202
1203    /// Внутренний конструктор для уже нормализованных значений snapshot-а.
1204    pub(crate) fn try_from_parts(
1205        secid: SecId,
1206        lot_size: Option<u32>,
1207        last: Option<f64>,
1208    ) -> Result<Self, ParseSecuritySnapshotError> {
1209        // Для `LAST` запрещаем NaN/Infinity, чтобы downstream-код работал с корректным числом.
1210        if let Some(last) = last
1211            && !last.is_finite()
1212        {
1213            return Err(ParseSecuritySnapshotError::NonFiniteLast(last));
1214        }
1215
1216        Ok(Self {
1217            secid,
1218            lot_size,
1219            last,
1220        })
1221    }
1222
1223    /// Идентификатор инструмента (`secid`).
1224    pub fn secid(&self) -> &SecId {
1225        &self.secid
1226    }
1227
1228    /// Размер лота (`LOTSIZE`), если поле присутствует в ISS.
1229    pub fn lot_size(&self) -> Option<u32> {
1230        self.lot_size
1231    }
1232
1233    /// Последняя цена (`LAST`), если поле присутствует в ISS.
1234    pub fn last(&self) -> Option<f64> {
1235        self.last
1236    }
1237}
1238
1239#[derive(Debug, Clone, PartialEq, Eq)]
1240/// Доступные границы свечных данных (`candleborders`).
1241pub struct CandleBorder {
1242    begin: NaiveDateTime,
1243    end: NaiveDateTime,
1244    interval: CandleInterval,
1245    board_group_id: u32,
1246}
1247
1248impl CandleBorder {
1249    /// Построить границы доступных свечей из wire-значений ISS.
1250    pub fn try_new(
1251        begin: NaiveDateTime,
1252        end: NaiveDateTime,
1253        interval: i64,
1254        board_group_id: i64,
1255    ) -> Result<Self, ParseCandleBorderError> {
1256        if begin > end {
1257            return Err(ParseCandleBorderError::InvalidDateRange { begin, end });
1258        }
1259
1260        let interval = CandleInterval::try_from(interval)?;
1261        if board_group_id < 0 {
1262            return Err(ParseCandleBorderError::NegativeBoardGroupId(board_group_id));
1263        }
1264        let board_group_id = u32::try_from(board_group_id)
1265            .map_err(|_| ParseCandleBorderError::BoardGroupIdOutOfRange(board_group_id))?;
1266
1267        Ok(Self {
1268            begin,
1269            end,
1270            interval,
1271            board_group_id,
1272        })
1273    }
1274
1275    /// Начало доступного диапазона.
1276    pub fn begin(&self) -> NaiveDateTime {
1277        self.begin
1278    }
1279
1280    /// Конец доступного диапазона.
1281    pub fn end(&self) -> NaiveDateTime {
1282        self.end
1283    }
1284
1285    /// Интервал свечей.
1286    pub fn interval(&self) -> CandleInterval {
1287        self.interval
1288    }
1289
1290    /// Идентификатор группы режимов торгов.
1291    pub fn board_group_id(&self) -> u32 {
1292        self.board_group_id
1293    }
1294}
1295
1296#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1297/// Строгие параметры запроса свечей ISS с инвариантом `from <= till`.
1298pub struct CandleQuery {
1299    from: Option<NaiveDateTime>,
1300    till: Option<NaiveDateTime>,
1301    interval: Option<CandleInterval>,
1302}
1303
1304impl CandleQuery {
1305    /// Построить запрос свечей с проверкой инварианта `from <= till`.
1306    pub fn try_new(
1307        from: Option<NaiveDateTime>,
1308        till: Option<NaiveDateTime>,
1309        interval: Option<CandleInterval>,
1310    ) -> Result<Self, ParseCandleQueryError> {
1311        if let (Some(from), Some(till)) = (from, till)
1312            && from > till
1313        {
1314            return Err(ParseCandleQueryError::InvalidDateRange { from, till });
1315        }
1316
1317        Ok(Self {
1318            from,
1319            till,
1320            interval,
1321        })
1322    }
1323
1324    /// Дата и время начала выборки (`from`).
1325    pub fn from(&self) -> Option<NaiveDateTime> {
1326        self.from
1327    }
1328
1329    /// Дата и время окончания выборки (`till`).
1330    pub fn till(&self) -> Option<NaiveDateTime> {
1331        self.till
1332    }
1333
1334    /// Интервал свечей (`interval`).
1335    pub fn interval(&self) -> Option<CandleInterval> {
1336        self.interval
1337    }
1338
1339    /// Вернуть копию запроса с новым `from`.
1340    pub fn with_from(self, from: NaiveDateTime) -> Result<Self, ParseCandleQueryError> {
1341        Self::try_new(Some(from), self.till, self.interval)
1342    }
1343
1344    /// Вернуть копию запроса с новым `till`.
1345    pub fn with_till(self, till: NaiveDateTime) -> Result<Self, ParseCandleQueryError> {
1346        Self::try_new(self.from, Some(till), self.interval)
1347    }
1348
1349    /// Вернуть копию запроса с новым интервалом свечей.
1350    pub fn with_interval(mut self, interval: CandleInterval) -> Self {
1351        self.interval = Some(interval);
1352        self
1353    }
1354}
1355
1356#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
1357/// Параметры пагинации ISS API (`start`, `limit`).
1358pub struct Pagination {
1359    /// Смещение первой записи (`start`).
1360    pub start: Option<u32>,
1361    /// Максимальный размер страницы (`limit`).
1362    pub limit: Option<NonZeroU32>,
1363}
1364
1365impl Pagination {
1366    /// Вернуть копию с установленным `start`.
1367    pub fn with_start(mut self, start: u32) -> Self {
1368        self.start = Some(start);
1369        self
1370    }
1371
1372    /// Вернуть копию с установленным `limit`.
1373    pub fn with_limit(mut self, limit: NonZeroU32) -> Self {
1374        self.limit = Some(limit);
1375        self
1376    }
1377}
1378
1379#[derive(Debug, Clone, PartialEq)]
1380/// Свеча торгового инструмента (`candles`).
1381pub struct Candle {
1382    begin: NaiveDateTime,
1383    end: NaiveDateTime,
1384    open: Option<f64>,
1385    close: Option<f64>,
1386    high: Option<f64>,
1387    low: Option<f64>,
1388    value: Option<f64>,
1389    volume: Option<u64>,
1390}
1391
1392impl Candle {
1393    /// Построить свечу из границ времени и набора OHLCV с проверкой инвариантов.
1394    pub fn try_new(
1395        begin: NaiveDateTime,
1396        end: NaiveDateTime,
1397        ohlcv: CandleOhlcv,
1398    ) -> Result<Self, ParseCandleError> {
1399        if begin > end {
1400            return Err(ParseCandleError::InvalidDateRange { begin, end });
1401        }
1402
1403        let volume = match ohlcv.volume {
1404            None => None,
1405            Some(raw) if raw >= 0 => Some(raw as u64),
1406            Some(raw) => return Err(ParseCandleError::NegativeVolume(raw)),
1407        };
1408
1409        Ok(Self {
1410            begin,
1411            end,
1412            open: ohlcv.open,
1413            close: ohlcv.close,
1414            high: ohlcv.high,
1415            low: ohlcv.low,
1416            value: ohlcv.value,
1417            volume,
1418        })
1419    }
1420
1421    /// Время начала свечи.
1422    pub fn begin(&self) -> NaiveDateTime {
1423        self.begin
1424    }
1425
1426    /// Время окончания свечи.
1427    pub fn end(&self) -> NaiveDateTime {
1428        self.end
1429    }
1430
1431    /// Цена открытия.
1432    pub fn open(&self) -> Option<f64> {
1433        self.open
1434    }
1435
1436    /// Цена закрытия.
1437    pub fn close(&self) -> Option<f64> {
1438        self.close
1439    }
1440
1441    /// Максимальная цена.
1442    pub fn high(&self) -> Option<f64> {
1443        self.high
1444    }
1445
1446    /// Минимальная цена.
1447    pub fn low(&self) -> Option<f64> {
1448        self.low
1449    }
1450
1451    /// Объём в денежном выражении.
1452    pub fn value(&self) -> Option<f64> {
1453        self.value
1454    }
1455
1456    /// Объём в лотах/штуках.
1457    pub fn volume(&self) -> Option<u64> {
1458        self.volume
1459    }
1460}
1461
1462#[derive(Debug, Clone, Copy, PartialEq)]
1463/// Компоненты OHLCV для построения [`Candle`].
1464pub struct CandleOhlcv {
1465    open: Option<f64>,
1466    close: Option<f64>,
1467    high: Option<f64>,
1468    low: Option<f64>,
1469    value: Option<f64>,
1470    volume: Option<i64>,
1471}
1472
1473impl CandleOhlcv {
1474    /// Создать набор OHLCV-значений без валидации.
1475    pub fn new(
1476        open: Option<f64>,
1477        close: Option<f64>,
1478        high: Option<f64>,
1479        low: Option<f64>,
1480        value: Option<f64>,
1481        volume: Option<i64>,
1482    ) -> Self {
1483        Self {
1484            open,
1485            close,
1486            high,
1487            low,
1488            value,
1489            volume,
1490        }
1491    }
1492}
1493
1494#[derive(Debug, Clone, PartialEq)]
1495/// Сделка (`trades`).
1496pub struct Trade {
1497    tradeno: u64,
1498    tradetime: NaiveTime,
1499    price: Option<f64>,
1500    quantity: Option<u64>,
1501    value: Option<f64>,
1502}
1503
1504impl Trade {
1505    /// Построить сделку из wire-значений ISS с валидацией инвариантов.
1506    pub fn try_new(
1507        tradeno: i64,
1508        tradetime: NaiveTime,
1509        price: Option<f64>,
1510        quantity: Option<i64>,
1511        value: Option<f64>,
1512    ) -> Result<Self, ParseTradeError> {
1513        if tradeno <= 0 {
1514            return Err(ParseTradeError::NonPositiveTradeNo(tradeno));
1515        }
1516        let tradeno =
1517            u64::try_from(tradeno).map_err(|_| ParseTradeError::TradeNoOutOfRange(tradeno))?;
1518
1519        let quantity = match quantity {
1520            None => None,
1521            Some(raw) if raw >= 0 => Some(raw as u64),
1522            Some(raw) => return Err(ParseTradeError::NegativeQuantity(raw)),
1523        };
1524
1525        Ok(Self {
1526            tradeno,
1527            tradetime,
1528            price,
1529            quantity,
1530            value,
1531        })
1532    }
1533
1534    /// Уникальный номер сделки.
1535    pub fn tradeno(&self) -> u64 {
1536        self.tradeno
1537    }
1538
1539    /// Время сделки.
1540    pub fn tradetime(&self) -> NaiveTime {
1541        self.tradetime
1542    }
1543
1544    /// Цена сделки.
1545    pub fn price(&self) -> Option<f64> {
1546        self.price
1547    }
1548
1549    /// Количество в сделке.
1550    pub fn quantity(&self) -> Option<u64> {
1551        self.quantity
1552    }
1553
1554    /// Объём сделки в денежном выражении.
1555    pub fn value(&self) -> Option<f64> {
1556        self.value
1557    }
1558}
1559
1560#[derive(Debug, Clone, PartialEq)]
1561/// Уровень стакана (`orderbook`).
1562pub struct OrderbookLevel {
1563    buy_sell: BuySell,
1564    price: f64,
1565    quantity: u64,
1566}
1567
1568impl OrderbookLevel {
1569    /// Построить уровень стакана из wire-значений ISS с валидацией.
1570    pub fn try_new(
1571        buy_sell: String,
1572        price: Option<f64>,
1573        quantity: Option<i64>,
1574    ) -> Result<Self, ParseOrderbookError> {
1575        let buy_sell = BuySell::try_from(buy_sell)?;
1576
1577        let Some(price) = price else {
1578            return Err(ParseOrderbookError::MissingPrice);
1579        };
1580        if price.is_sign_negative() {
1581            return Err(ParseOrderbookError::NegativePrice);
1582        }
1583
1584        let Some(quantity) = quantity else {
1585            return Err(ParseOrderbookError::MissingQuantity);
1586        };
1587        let quantity = match quantity {
1588            raw if raw >= 0 => raw as u64,
1589            raw => return Err(ParseOrderbookError::NegativeQuantity(raw)),
1590        };
1591
1592        Ok(Self {
1593            buy_sell,
1594            price,
1595            quantity,
1596        })
1597    }
1598
1599    /// Направление заявки (`buy`/`sell`).
1600    pub fn buy_sell(&self) -> BuySell {
1601        self.buy_sell
1602    }
1603
1604    /// Цена уровня стакана.
1605    pub fn price(&self) -> f64 {
1606        self.price
1607    }
1608
1609    /// Количество на уровне стакана.
1610    pub fn quantity(&self) -> u64 {
1611        self.quantity
1612    }
1613}
1614
1615#[derive(Debug, Clone, PartialEq, Eq)]
1616/// Индекс MOEX (`indices`).
1617pub struct Index {
1618    id: IndexId,
1619    short_name: Box<str>,
1620    from: Option<NaiveDate>,
1621    till: Option<NaiveDate>,
1622}
1623
1624impl Index {
1625    /// Построить индекс из wire-значений ISS с валидацией инвариантов.
1626    pub fn try_new(
1627        id: String,
1628        short_name: String,
1629        from: Option<NaiveDate>,
1630        till: Option<NaiveDate>,
1631    ) -> Result<Self, ParseIndexError> {
1632        let id = IndexId::try_from(id)?;
1633        let short_name = short_name.trim();
1634        if short_name.is_empty() {
1635            return Err(ParseIndexError::EmptyShortName);
1636        }
1637        if let (Some(from_date), Some(till_date)) = (from, till)
1638            && from_date > till_date
1639        {
1640            return Err(ParseIndexError::InvalidDateRange {
1641                from: from_date,
1642                till: till_date,
1643            });
1644        }
1645
1646        Ok(Self {
1647            id,
1648            short_name: short_name.to_owned().into_boxed_str(),
1649            from,
1650            till,
1651        })
1652    }
1653
1654    /// Идентификатор индекса (`indexid`).
1655    pub fn id(&self) -> &IndexId {
1656        &self.id
1657    }
1658
1659    /// Краткое наименование индекса.
1660    pub fn short_name(&self) -> &str {
1661        self.short_name.as_ref()
1662    }
1663
1664    /// Дата начала действия индекса, если задана.
1665    pub fn from(&self) -> Option<NaiveDate> {
1666        self.from
1667    }
1668
1669    /// Дата окончания действия индекса, если задана.
1670    pub fn till(&self) -> Option<NaiveDate> {
1671        self.till
1672    }
1673
1674    /// Проверить, что индекс активен на указанную дату.
1675    pub fn is_active_on(&self, date: NaiveDate) -> bool {
1676        self.from.is_none_or(|from| from <= date) && self.till.is_none_or(|till| date <= till)
1677    }
1678}
1679
1680#[derive(Debug, Clone, PartialEq, Eq)]
1681/// Диапазон доступных исторических дат (`history/.../dates`).
1682pub struct HistoryDates {
1683    from: NaiveDate,
1684    till: NaiveDate,
1685}
1686
1687impl HistoryDates {
1688    /// Построить диапазон доступных исторических дат с проверкой порядка.
1689    pub fn try_new(from: NaiveDate, till: NaiveDate) -> Result<Self, ParseHistoryDatesError> {
1690        if from > till {
1691            return Err(ParseHistoryDatesError::InvalidDateRange { from, till });
1692        }
1693        Ok(Self { from, till })
1694    }
1695
1696    /// Начальная дата доступной истории.
1697    pub fn from(&self) -> NaiveDate {
1698        self.from
1699    }
1700
1701    /// Конечная дата доступной истории.
1702    pub fn till(&self) -> NaiveDate {
1703        self.till
1704    }
1705}
1706
1707#[derive(Debug, Clone, PartialEq)]
1708/// Строка исторических дневных торгов (`history`).
1709pub struct HistoryRecord {
1710    boardid: BoardId,
1711    tradedate: NaiveDate,
1712    secid: SecId,
1713    numtrades: Option<u64>,
1714    value: Option<f64>,
1715    open: Option<f64>,
1716    low: Option<f64>,
1717    high: Option<f64>,
1718    close: Option<f64>,
1719    volume: Option<u64>,
1720}
1721
1722impl HistoryRecord {
1723    /// Построить запись истории из wire-значений ISS с валидацией инвариантов.
1724    pub(crate) fn try_new(input: HistoryRecordInput) -> Result<Self, ParseHistoryRecordError> {
1725        let HistoryRecordInput {
1726            boardid,
1727            tradedate,
1728            secid,
1729            numtrades,
1730            value,
1731            open,
1732            low,
1733            high,
1734            close,
1735            volume,
1736        } = input;
1737
1738        let boardid = BoardId::try_from(boardid)?;
1739        let secid = SecId::try_from(secid)?;
1740
1741        let numtrades = match numtrades {
1742            None => None,
1743            Some(raw) if raw >= 0 => Some(raw as u64),
1744            Some(raw) => return Err(ParseHistoryRecordError::NegativeNumTrades(raw)),
1745        };
1746
1747        let volume = match volume {
1748            None => None,
1749            Some(raw) if raw >= 0 => Some(raw as u64),
1750            Some(raw) => return Err(ParseHistoryRecordError::NegativeVolume(raw)),
1751        };
1752
1753        Ok(Self {
1754            boardid,
1755            tradedate,
1756            secid,
1757            numtrades,
1758            value,
1759            open,
1760            low,
1761            high,
1762            close,
1763            volume,
1764        })
1765    }
1766
1767    /// Идентификатор режима торгов (`boardid`).
1768    pub fn boardid(&self) -> &BoardId {
1769        &self.boardid
1770    }
1771
1772    /// Дата торговой сессии (`tradedate`).
1773    pub fn tradedate(&self) -> NaiveDate {
1774        self.tradedate
1775    }
1776
1777    /// Идентификатор инструмента (`secid`).
1778    pub fn secid(&self) -> &SecId {
1779        &self.secid
1780    }
1781
1782    /// Количество сделок (`numtrades`).
1783    pub fn numtrades(&self) -> Option<u64> {
1784        self.numtrades
1785    }
1786
1787    /// Оборот в денежном выражении (`value`).
1788    pub fn value(&self) -> Option<f64> {
1789        self.value
1790    }
1791
1792    /// Цена открытия (`open`).
1793    pub fn open(&self) -> Option<f64> {
1794        self.open
1795    }
1796
1797    /// Минимальная цена (`low`).
1798    pub fn low(&self) -> Option<f64> {
1799        self.low
1800    }
1801
1802    /// Максимальная цена (`high`).
1803    pub fn high(&self) -> Option<f64> {
1804        self.high
1805    }
1806
1807    /// Цена закрытия (`close`).
1808    pub fn close(&self) -> Option<f64> {
1809        self.close
1810    }
1811
1812    /// Объём торгов (`volume`).
1813    pub fn volume(&self) -> Option<u64> {
1814        self.volume
1815    }
1816}
1817
1818#[derive(Debug, Clone, PartialEq)]
1819/// Обороты торгов (`turnovers`).
1820pub struct Turnover {
1821    name: Box<str>,
1822    id: u32,
1823    valtoday: Option<f64>,
1824    valtoday_usd: Option<f64>,
1825    numtrades: Option<u64>,
1826    updatetime: NaiveDateTime,
1827    title: Box<str>,
1828}
1829
1830impl Turnover {
1831    /// Построить запись оборотов из wire-значений ISS с валидацией инвариантов.
1832    pub fn try_new(
1833        name: String,
1834        id: i64,
1835        valtoday: Option<f64>,
1836        valtoday_usd: Option<f64>,
1837        numtrades: Option<i64>,
1838        updatetime: NaiveDateTime,
1839        title: String,
1840    ) -> Result<Self, ParseTurnoverError> {
1841        let name = name.trim();
1842        if name.is_empty() {
1843            return Err(ParseTurnoverError::EmptyName);
1844        }
1845
1846        if id <= 0 {
1847            return Err(ParseTurnoverError::NonPositiveId(id));
1848        }
1849        let id = u32::try_from(id).map_err(|_| ParseTurnoverError::IdOutOfRange(id))?;
1850
1851        let numtrades = match numtrades {
1852            None => None,
1853            Some(raw) if raw >= 0 => Some(raw as u64),
1854            Some(raw) => return Err(ParseTurnoverError::NegativeNumTrades(raw)),
1855        };
1856
1857        let title = title.trim();
1858        if title.is_empty() {
1859            return Err(ParseTurnoverError::EmptyTitle);
1860        }
1861
1862        Ok(Self {
1863            name: name.to_owned().into_boxed_str(),
1864            id,
1865            valtoday,
1866            valtoday_usd,
1867            numtrades,
1868            updatetime,
1869            title: title.to_owned().into_boxed_str(),
1870        })
1871    }
1872
1873    /// Наименование строки оборотов (`NAME`).
1874    pub fn name(&self) -> &str {
1875        self.name.as_ref()
1876    }
1877
1878    /// Числовой идентификатор (`ID`).
1879    pub fn id(&self) -> u32 {
1880        self.id
1881    }
1882
1883    /// Оборот в рублях (`VALTODAY`).
1884    pub fn valtoday(&self) -> Option<f64> {
1885        self.valtoday
1886    }
1887
1888    /// Оборот в долларах (`VALTODAY_USD`).
1889    pub fn valtoday_usd(&self) -> Option<f64> {
1890        self.valtoday_usd
1891    }
1892
1893    /// Количество сделок (`NUMTRADES`).
1894    pub fn numtrades(&self) -> Option<u64> {
1895        self.numtrades
1896    }
1897
1898    /// Время обновления (`UPDATETIME`).
1899    pub fn updatetime(&self) -> NaiveDateTime {
1900        self.updatetime
1901    }
1902
1903    /// Человекочитаемый заголовок (`TITLE`).
1904    pub fn title(&self) -> &str {
1905        self.title.as_ref()
1906    }
1907}
1908
1909#[derive(Debug, Clone, PartialEq)]
1910/// Статистика торгов по инструментам (`secstats`).
1911pub struct SecStat {
1912    secid: SecId,
1913    boardid: BoardId,
1914    voltoday: Option<u64>,
1915    valtoday: Option<f64>,
1916    highbid: Option<f64>,
1917    lowoffer: Option<f64>,
1918    lastoffer: Option<f64>,
1919    lastbid: Option<f64>,
1920    open: Option<f64>,
1921    low: Option<f64>,
1922    high: Option<f64>,
1923    last: Option<f64>,
1924    numtrades: Option<u64>,
1925    waprice: Option<f64>,
1926}
1927
1928impl SecStat {
1929    /// Построить запись `secstats` из wire-значений ISS с валидацией инвариантов.
1930    pub(crate) fn try_new(input: SecStatInput) -> Result<Self, ParseSecStatError> {
1931        let SecStatInput {
1932            secid,
1933            boardid,
1934            voltoday,
1935            valtoday,
1936            highbid,
1937            lowoffer,
1938            lastoffer,
1939            lastbid,
1940            open,
1941            low,
1942            high,
1943            last,
1944            numtrades,
1945            waprice,
1946        } = input;
1947
1948        let secid = SecId::try_from(secid)?;
1949        let boardid = BoardId::try_from(boardid)?;
1950
1951        let voltoday = match voltoday {
1952            None => None,
1953            Some(raw) if raw >= 0 => Some(raw as u64),
1954            Some(raw) => return Err(ParseSecStatError::NegativeVolToday(raw)),
1955        };
1956
1957        let numtrades = match numtrades {
1958            None => None,
1959            Some(raw) if raw >= 0 => Some(raw as u64),
1960            Some(raw) => return Err(ParseSecStatError::NegativeNumTrades(raw)),
1961        };
1962
1963        Ok(Self {
1964            secid,
1965            boardid,
1966            voltoday,
1967            valtoday,
1968            highbid,
1969            lowoffer,
1970            lastoffer,
1971            lastbid,
1972            open,
1973            low,
1974            high,
1975            last,
1976            numtrades,
1977            waprice,
1978        })
1979    }
1980
1981    /// Идентификатор инструмента (`SECID`).
1982    pub fn secid(&self) -> &SecId {
1983        &self.secid
1984    }
1985
1986    /// Идентификатор режима торгов (`BOARDID`).
1987    pub fn boardid(&self) -> &BoardId {
1988        &self.boardid
1989    }
1990
1991    /// Объём в лотах/штуках за день (`VOLTODAY`).
1992    pub fn voltoday(&self) -> Option<u64> {
1993        self.voltoday
1994    }
1995
1996    /// Оборот в денежном выражении (`VALTODAY`).
1997    pub fn valtoday(&self) -> Option<f64> {
1998        self.valtoday
1999    }
2000
2001    /// Лучшая цена спроса (`HIGHBID`).
2002    pub fn highbid(&self) -> Option<f64> {
2003        self.highbid
2004    }
2005
2006    /// Лучшая цена предложения (`LOWOFFER`).
2007    pub fn lowoffer(&self) -> Option<f64> {
2008        self.lowoffer
2009    }
2010
2011    /// Последняя цена предложения (`LASTOFFER`).
2012    pub fn lastoffer(&self) -> Option<f64> {
2013        self.lastoffer
2014    }
2015
2016    /// Последняя цена спроса (`LASTBID`).
2017    pub fn lastbid(&self) -> Option<f64> {
2018        self.lastbid
2019    }
2020
2021    /// Цена открытия (`OPEN`).
2022    pub fn open(&self) -> Option<f64> {
2023        self.open
2024    }
2025
2026    /// Минимальная цена (`LOW`).
2027    pub fn low(&self) -> Option<f64> {
2028        self.low
2029    }
2030
2031    /// Максимальная цена (`HIGH`).
2032    pub fn high(&self) -> Option<f64> {
2033        self.high
2034    }
2035
2036    /// Последняя цена (`LAST`).
2037    pub fn last(&self) -> Option<f64> {
2038        self.last
2039    }
2040
2041    /// Количество сделок (`NUMTRADES`).
2042    pub fn numtrades(&self) -> Option<u64> {
2043        self.numtrades
2044    }
2045
2046    /// Средневзвешенная цена (`WAPRICE`).
2047    pub fn waprice(&self) -> Option<f64> {
2048        self.waprice
2049    }
2050}
2051
2052#[derive(Debug, Clone, PartialEq, Eq)]
2053/// Новость MOEX ISS (`sitenews`).
2054pub struct SiteNews {
2055    id: u64,
2056    tag: Box<str>,
2057    title: Box<str>,
2058    published_at: NaiveDateTime,
2059    modified_at: NaiveDateTime,
2060}
2061
2062impl SiteNews {
2063    /// Построить запись новости из wire-значений ISS с валидацией.
2064    pub fn try_new(
2065        id: i64,
2066        tag: String,
2067        title: String,
2068        published_at: NaiveDateTime,
2069        modified_at: NaiveDateTime,
2070    ) -> Result<Self, ParseSiteNewsError> {
2071        if id <= 0 {
2072            return Err(ParseSiteNewsError::NonPositiveId(id));
2073        }
2074        let id = u64::try_from(id).map_err(|_| ParseSiteNewsError::IdOutOfRange(id))?;
2075
2076        let tag = tag.trim();
2077        if tag.is_empty() {
2078            return Err(ParseSiteNewsError::EmptyTag);
2079        }
2080        let title = title.trim();
2081        if title.is_empty() {
2082            return Err(ParseSiteNewsError::EmptyTitle);
2083        }
2084
2085        Ok(Self {
2086            id,
2087            tag: tag.to_owned().into_boxed_str(),
2088            title: title.to_owned().into_boxed_str(),
2089            published_at,
2090            modified_at,
2091        })
2092    }
2093
2094    /// Идентификатор новости (`id`).
2095    pub fn id(&self) -> u64 {
2096        self.id
2097    }
2098
2099    /// Тег новости (`tag`).
2100    pub fn tag(&self) -> &str {
2101        self.tag.as_ref()
2102    }
2103
2104    /// Заголовок новости (`title`).
2105    pub fn title(&self) -> &str {
2106        self.title.as_ref()
2107    }
2108
2109    /// Время публикации (`published_at`).
2110    pub fn published_at(&self) -> NaiveDateTime {
2111        self.published_at
2112    }
2113
2114    /// Время изменения (`modified_at`).
2115    pub fn modified_at(&self) -> NaiveDateTime {
2116        self.modified_at
2117    }
2118}
2119
2120#[derive(Debug, Clone, PartialEq, Eq)]
2121/// Событие MOEX ISS (`events`).
2122pub struct Event {
2123    id: u64,
2124    tag: Box<str>,
2125    title: Box<str>,
2126    from: Option<NaiveDateTime>,
2127    modified_at: NaiveDateTime,
2128}
2129
2130impl Event {
2131    /// Построить запись события из wire-значений ISS с валидацией.
2132    pub fn try_new(
2133        id: i64,
2134        tag: String,
2135        title: String,
2136        from: Option<NaiveDateTime>,
2137        modified_at: NaiveDateTime,
2138    ) -> Result<Self, ParseEventError> {
2139        if id <= 0 {
2140            return Err(ParseEventError::NonPositiveId(id));
2141        }
2142        let id = u64::try_from(id).map_err(|_| ParseEventError::IdOutOfRange(id))?;
2143
2144        let tag = tag.trim();
2145        if tag.is_empty() {
2146            return Err(ParseEventError::EmptyTag);
2147        }
2148        let title = title.trim();
2149        if title.is_empty() {
2150            return Err(ParseEventError::EmptyTitle);
2151        }
2152
2153        Ok(Self {
2154            id,
2155            tag: tag.to_owned().into_boxed_str(),
2156            title: title.to_owned().into_boxed_str(),
2157            from,
2158            modified_at,
2159        })
2160    }
2161
2162    /// Идентификатор события (`id`).
2163    pub fn id(&self) -> u64 {
2164        self.id
2165    }
2166
2167    /// Тег события (`tag`).
2168    pub fn tag(&self) -> &str {
2169        self.tag.as_ref()
2170    }
2171
2172    /// Заголовок события (`title`).
2173    pub fn title(&self) -> &str {
2174        self.title.as_ref()
2175    }
2176
2177    /// Время начала/актуальности события (`from`), если задано.
2178    pub fn from(&self) -> Option<NaiveDateTime> {
2179        self.from
2180    }
2181
2182    /// Время изменения (`modified_at`).
2183    pub fn modified_at(&self) -> NaiveDateTime {
2184        self.modified_at
2185    }
2186}
2187
2188/// Внутренний набор wire-полей для построения [`SecStat`].
2189///
2190/// Отдельная структура снижает вероятность перепутать порядок однотипных
2191/// аргументов при передаче данных из wire-слоя.
2192pub(crate) struct SecStatInput {
2193    pub(crate) secid: String,
2194    pub(crate) boardid: String,
2195    pub(crate) voltoday: Option<i64>,
2196    pub(crate) valtoday: Option<f64>,
2197    pub(crate) highbid: Option<f64>,
2198    pub(crate) lowoffer: Option<f64>,
2199    pub(crate) lastoffer: Option<f64>,
2200    pub(crate) lastbid: Option<f64>,
2201    pub(crate) open: Option<f64>,
2202    pub(crate) low: Option<f64>,
2203    pub(crate) high: Option<f64>,
2204    pub(crate) last: Option<f64>,
2205    pub(crate) numtrades: Option<i64>,
2206    pub(crate) waprice: Option<f64>,
2207}
2208
2209/// Внутренний набор wire-полей для построения [`HistoryRecord`].
2210pub(crate) struct HistoryRecordInput {
2211    pub(crate) boardid: String,
2212    pub(crate) tradedate: NaiveDate,
2213    pub(crate) secid: String,
2214    pub(crate) numtrades: Option<i64>,
2215    pub(crate) value: Option<f64>,
2216    pub(crate) open: Option<f64>,
2217    pub(crate) low: Option<f64>,
2218    pub(crate) high: Option<f64>,
2219    pub(crate) close: Option<f64>,
2220    pub(crate) volume: Option<i64>,
2221}
2222
2223#[derive(Debug, Clone, PartialEq)]
2224/// Компонент индекса из таблицы `analytics`.
2225pub struct IndexAnalytics {
2226    indexid: IndexId,
2227    tradedate: NaiveDate,
2228    ticker: SecId,
2229    shortnames: Box<str>,
2230    secid: SecId,
2231    weight: f64,
2232    tradingsession: u8,
2233    trade_session_date: NaiveDate,
2234}
2235
2236impl IndexAnalytics {
2237    /// Построить компонент индекса из wire-значений ISS с валидацией инвариантов.
2238    pub(crate) fn try_new(input: IndexAnalyticsInput) -> Result<Self, ParseIndexAnalyticsError> {
2239        let IndexAnalyticsInput {
2240            indexid,
2241            tradedate,
2242            ticker,
2243            shortnames,
2244            secid,
2245            weight,
2246            tradingsession,
2247            trade_session_date,
2248        } = input;
2249
2250        let indexid = IndexId::try_from(indexid)?;
2251        let ticker = SecId::try_from(ticker).map_err(ParseIndexAnalyticsError::InvalidTicker)?;
2252        let secid = SecId::try_from(secid).map_err(ParseIndexAnalyticsError::InvalidSecId)?;
2253
2254        let shortnames = shortnames.trim();
2255        if shortnames.is_empty() {
2256            return Err(ParseIndexAnalyticsError::EmptyShortnames);
2257        }
2258        if !weight.is_finite() {
2259            return Err(ParseIndexAnalyticsError::NonFiniteWeight);
2260        }
2261        if weight.is_sign_negative() {
2262            return Err(ParseIndexAnalyticsError::NegativeWeight);
2263        }
2264        if !(1..=3).contains(&tradingsession) {
2265            return Err(ParseIndexAnalyticsError::InvalidTradingsession(
2266                tradingsession,
2267            ));
2268        }
2269
2270        Ok(Self {
2271            indexid,
2272            tradedate,
2273            ticker,
2274            shortnames: shortnames.to_owned().into_boxed_str(),
2275            secid,
2276            weight,
2277            tradingsession: tradingsession as u8,
2278            trade_session_date,
2279        })
2280    }
2281
2282    /// Идентификатор индекса (`indexid`).
2283    pub fn indexid(&self) -> &IndexId {
2284        &self.indexid
2285    }
2286
2287    /// Дата торгов (`tradedate`).
2288    pub fn tradedate(&self) -> NaiveDate {
2289        self.tradedate
2290    }
2291
2292    /// Тикер компонента.
2293    pub fn ticker(&self) -> &SecId {
2294        &self.ticker
2295    }
2296
2297    /// Краткие имена бумаг.
2298    pub fn shortnames(&self) -> &str {
2299        self.shortnames.as_ref()
2300    }
2301
2302    /// Идентификатор инструмента компонента.
2303    pub fn secid(&self) -> &SecId {
2304        &self.secid
2305    }
2306
2307    /// Вес компонента в индексе.
2308    pub fn weight(&self) -> f64 {
2309        self.weight
2310    }
2311
2312    /// Торговая сессия (`1..=3`).
2313    pub fn tradingsession(&self) -> u8 {
2314        self.tradingsession
2315    }
2316
2317    /// Дата торговой сессии (`tradedate` в источнике `analytics`).
2318    pub fn trade_session_date(&self) -> NaiveDate {
2319        self.trade_session_date
2320    }
2321}
2322
2323/// Внутренний набор wire-полей для построения [`IndexAnalytics`].
2324pub(crate) struct IndexAnalyticsInput {
2325    pub(crate) indexid: String,
2326    pub(crate) tradedate: NaiveDate,
2327    pub(crate) ticker: String,
2328    pub(crate) shortnames: String,
2329    pub(crate) secid: String,
2330    pub(crate) weight: f64,
2331    pub(crate) tradingsession: i64,
2332    pub(crate) trade_session_date: NaiveDate,
2333}
2334
2335/// Итератор по «актуальным» индексам: с максимальной датой `till`.
2336pub fn actual_indexes(indexes: &[Index]) -> impl Iterator<Item = &Index> {
2337    let latest_till = indexes.iter().filter_map(Index::till).max();
2338    indexes
2339        .iter()
2340        .filter(move |index| index.till() == latest_till)
2341}