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