Skip to main content

actual_indexes_dump/
actual_indexes_dump.rs

1use std::collections::{HashMap, HashSet};
2use std::num::NonZeroU32;
3use std::time::Duration;
4
5use moex_client::blocking::Client;
6use moex_client::models::{IndexAnalytics, SecId, SecuritySnapshot};
7use moex_client::prelude::*;
8use moex_client::{MoexError, RateLimit, RetryPolicy, decode, with_retry};
9use thiserror::Error;
10
11const REQUEST_TIMEOUT_SECS: u64 = 5;
12const REQUEST_RETRIES: u32 = 3;
13const RETRY_DELAY_MILLIS: u64 = 400;
14const RATE_LIMIT_MILLIS: u64 = 50;
15const INDEX_ANALYTICS_PAGE_LIMIT: u32 = 5_000;
16const BOARD_SNAPSHOTS_PAGE_LIMIT: u32 = 100;
17
18#[derive(Debug, Error)]
19enum ExampleError {
20    #[error(transparent)]
21    Moex(#[from] MoexError),
22}
23
24#[derive(Debug, Clone)]
25struct IndexDump {
26    index_id: Box<str>,
27    short_name: Box<str>,
28    components: Vec<IndexAnalytics>,
29}
30
31#[derive(Debug, Clone)]
32struct ResolvedSnapshot {
33    market: Box<str>,
34    board: Box<str>,
35    lot_size: Option<u32>,
36    last: Option<f64>,
37}
38
39#[derive(Debug, serde::Deserialize)]
40struct RawSecuritySnapshotRow {
41    #[serde(rename = "SECID")]
42    secid: String,
43    #[serde(rename = "LOTSIZE", default)]
44    lot_size: Option<i64>,
45}
46
47#[derive(Debug, serde::Deserialize)]
48struct RawMarketdataSnapshotRow {
49    #[serde(rename = "SECID")]
50    secid: String,
51    #[serde(rename = "LAST", default)]
52    last: Option<f64>,
53}
54
55fn main() -> Result<(), ExampleError> {
56    let moex_client = Client::builder()
57        .user_agent_from_crate()
58        .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
59        .rate_limit(RateLimit::every(Duration::from_millis(RATE_LIMIT_MILLIS)))
60        .metadata(false)
61        .build()?;
62
63    println!("loading active indexes...");
64    let index_dumps = load_actual_index_dumps(&moex_client)?;
65    println!("active indexes loaded: {}", index_dumps.len());
66
67    let wanted_secids = collect_unique_secids(&index_dumps);
68    println!(
69        "resolving snapshots for {} unique securities...",
70        wanted_secids.len()
71    );
72    let (snapshots, missing_mapping) = load_security_snapshots(&moex_client, &wanted_secids)?;
73    println!(
74        "resolved snapshots: {}, missing: {}",
75        snapshots.len(),
76        missing_mapping.len()
77    );
78
79    print_dump(&index_dumps, &snapshots, &missing_mapping);
80    Ok(())
81}
82
83fn load_actual_index_dumps(moex_client: &Client) -> Result<Vec<IndexDump>, ExampleError> {
84    let indexes = with_retry(retry_policy(), || moex_client.indexes())?.into_actual_by_till();
85    let page_limit = NonZeroU32::new(INDEX_ANALYTICS_PAGE_LIMIT)
86        .expect("INDEX_ANALYTICS_PAGE_LIMIT constant must be greater than zero");
87
88    indexes
89        .into_iter()
90        .map(|index| {
91            let components = with_retry(retry_policy(), || {
92                moex_client
93                    .index(index.id().clone())
94                    .expect("index id from payload must be valid")
95                    .analytics_pages(page_limit)
96                    .all()
97            })?
98            .into_actual_by_session()
99            .into_sorted_by_weight_desc();
100            Ok(IndexDump {
101                index_id: index.id().as_str().to_owned().into_boxed_str(),
102                short_name: index.short_name().to_owned().into_boxed_str(),
103                components,
104            })
105        })
106        .collect()
107}
108
109fn collect_unique_secids(index_dumps: &[IndexDump]) -> HashSet<SecId> {
110    index_dumps
111        .iter()
112        .flat_map(|index| index.components.iter().map(|row| row.secid().clone()))
113        .collect()
114}
115
116fn load_security_snapshots(
117    moex_client: &Client,
118    wanted_secids: &HashSet<SecId>,
119) -> Result<(HashMap<SecId, ResolvedSnapshot>, Vec<SecId>), ExampleError> {
120    let stock_scope = moex_client
121        .stock()
122        .expect("stock engine literal must be valid");
123    let markets = with_retry(retry_policy(), || stock_scope.markets())?;
124    let mut snapshots = HashMap::with_capacity(wanted_secids.len());
125
126    'markets: for market in markets {
127        let boards = with_retry(retry_policy(), || {
128            moex_client.boards(stock_scope.engine(), market.name())
129        })?;
130
131        for board in boards.into_iter().filter(|board| board.is_traded()) {
132            let board_snapshots = load_board_snapshots_all_pages(
133                moex_client,
134                stock_scope.engine().as_str(),
135                market.name().as_str(),
136                board.boardid().as_str(),
137            )?;
138
139            for snapshot in board_snapshots {
140                if !wanted_secids.contains(snapshot.secid()) {
141                    continue;
142                }
143
144                snapshots
145                    .entry(snapshot.secid().clone())
146                    .or_insert_with(|| ResolvedSnapshot {
147                        market: market.name().as_str().to_owned().into_boxed_str(),
148                        board: board.boardid().as_str().to_owned().into_boxed_str(),
149                        lot_size: snapshot.lot_size(),
150                        last: snapshot.last(),
151                    });
152            }
153
154            if snapshots.len() == wanted_secids.len() {
155                break 'markets;
156            }
157        }
158    }
159
160    let missing_mapping = wanted_secids
161        .iter()
162        .filter(|secid| !snapshots.contains_key(*secid))
163        .cloned()
164        .collect();
165
166    Ok((snapshots, missing_mapping))
167}
168
169fn load_board_snapshots_all_pages(
170    moex_client: &Client,
171    engine: &str,
172    market: &str,
173    board: &str,
174) -> Result<Vec<SecuritySnapshot>, MoexError> {
175    let endpoint = format!("engines/{engine}/markets/{market}/boards/{board}/securities.json");
176    let mut start = 0_u32;
177    let mut snapshots = Vec::new();
178    let mut first_secid_on_previous_page = None;
179    let limit = BOARD_SNAPSHOTS_PAGE_LIMIT.to_string();
180
181    loop {
182        let payload = with_retry(retry_policy(), || {
183            moex_client
184                .raw()
185                .path(endpoint.as_str())
186                .only("securities,marketdata")
187                .columns("securities", "SECID,LOTSIZE")
188                .columns("marketdata", "SECID,LAST")
189                .param("start", start.to_string())
190                .param("limit", limit.as_str())
191                .send_payload()
192        })?;
193
194        let (page, first_secid_on_page) = parse_board_snapshots_page(&payload, endpoint.as_str())?;
195        if page.is_empty() {
196            break;
197        }
198
199        if let (Some(previous), Some(current)) =
200            (&first_secid_on_previous_page, &first_secid_on_page)
201            && previous == current
202        {
203            break;
204        }
205        first_secid_on_previous_page = first_secid_on_page;
206
207        let page_len = u32::try_from(page.len()).map_err(|_| MoexError::PaginationOverflow {
208            endpoint: endpoint.clone().into_boxed_str(),
209            start,
210            limit: BOARD_SNAPSHOTS_PAGE_LIMIT,
211        })?;
212
213        snapshots.extend(page);
214        if page_len < BOARD_SNAPSHOTS_PAGE_LIMIT {
215            break;
216        }
217        start = start
218            .checked_add(page_len)
219            .ok_or_else(|| MoexError::PaginationOverflow {
220                endpoint: endpoint.clone().into_boxed_str(),
221                start,
222                limit: BOARD_SNAPSHOTS_PAGE_LIMIT,
223            })?;
224    }
225
226    Ok(snapshots)
227}
228
229fn parse_board_snapshots_page(
230    payload: &str,
231    endpoint: &str,
232) -> Result<(Vec<SecuritySnapshot>, Option<SecId>), MoexError> {
233    let mut tables = decode::raw_tables_json(payload, endpoint)?;
234    let security_rows: Vec<RawSecuritySnapshotRow> = tables.take_rows("securities")?;
235    let marketdata_rows: Vec<RawMarketdataSnapshotRow> = tables.take_rows("marketdata")?;
236
237    let mut marketdata_by_secid = HashMap::with_capacity(marketdata_rows.len());
238    for (row, marketdata) in marketdata_rows.into_iter().enumerate() {
239        marketdata_by_secid.insert(marketdata.secid, (row, marketdata.last));
240    }
241
242    let mut first_secid_on_page = None;
243    let mut snapshots = Vec::with_capacity(security_rows.len().max(marketdata_by_secid.len()));
244
245    for (row, security) in security_rows.into_iter().enumerate() {
246        let last = marketdata_by_secid
247            .remove(security.secid.as_str())
248            .and_then(|(_, last)| last);
249        let snapshot = SecuritySnapshot::try_new(security.secid, security.lot_size, last).map_err(
250            |source| MoexError::InvalidSecuritySnapshot {
251                endpoint: endpoint.to_owned().into_boxed_str(),
252                table: "securities",
253                row,
254                source,
255            },
256        )?;
257        if first_secid_on_page.is_none() {
258            first_secid_on_page = Some(snapshot.secid().clone());
259        }
260        snapshots.push(snapshot);
261    }
262
263    for (secid, (row, last)) in marketdata_by_secid {
264        let snapshot = SecuritySnapshot::try_new(secid, None, last).map_err(|source| {
265            MoexError::InvalidSecuritySnapshot {
266                endpoint: endpoint.to_owned().into_boxed_str(),
267                table: "marketdata",
268                row,
269                source,
270            }
271        })?;
272        if first_secid_on_page.is_none() {
273            first_secid_on_page = Some(snapshot.secid().clone());
274        }
275        snapshots.push(snapshot);
276    }
277
278    Ok((snapshots, first_secid_on_page))
279}
280
281fn retry_policy() -> RetryPolicy {
282    RetryPolicy::new(
283        NonZeroU32::new(REQUEST_RETRIES)
284            .expect("REQUEST_RETRIES constant must be greater than zero"),
285    )
286    .with_delay(Duration::from_millis(RETRY_DELAY_MILLIS))
287}
288
289fn print_dump(
290    index_dumps: &[IndexDump],
291    snapshots: &HashMap<SecId, ResolvedSnapshot>,
292    missing_mapping: &[SecId],
293) {
294    println!("active indexes: {}", index_dumps.len());
295
296    let total_components = index_dumps
297        .iter()
298        .map(|index| index.components.len())
299        .sum::<usize>();
300    println!("total components across active indexes: {total_components}");
301    println!("resolved security snapshots: {}", snapshots.len());
302
303    if !missing_mapping.is_empty() {
304        let preview = missing_mapping
305            .iter()
306            .take(10)
307            .map(SecId::as_str)
308            .collect::<Vec<_>>()
309            .join(", ");
310        println!(
311            "warning: {} securities have no stock snapshot data (first 10): {preview}",
312            missing_mapping.len()
313        );
314    }
315
316    for index in index_dumps {
317        println!();
318        println!("=== {} | {} ===", index.index_id, index.short_name);
319        if let Some(first_component) = index.components.first() {
320            println!(
321                "trade_session_date={} tradingsession={} components={}",
322                first_component.trade_session_date(),
323                first_component.tradingsession(),
324                index.components.len()
325            );
326        } else {
327            println!("components=0");
328        }
329
330        println!("SECID\tTICKER\tSHORTNAME\tWEIGHT\tLAST\tLOTSIZE\tMARKET\tBOARD");
331        for component in &index.components {
332            let snapshot = snapshots.get(component.secid());
333
334            let last = format_optional_f64(snapshot.and_then(|item| item.last));
335            let lot_size = format_optional_u32(snapshot.and_then(|item| item.lot_size));
336            let market = snapshot.map_or("N/A", |item| item.market.as_ref());
337            let board = snapshot.map_or("N/A", |item| item.board.as_ref());
338
339            println!(
340                "{}\t{}\t{}\t{:.6}\t{}\t{}\t{}\t{}",
341                component.secid(),
342                component.ticker(),
343                component.shortnames(),
344                component.weight(),
345                last,
346                lot_size,
347                market,
348                board,
349            );
350        }
351    }
352}
353
354fn format_optional_f64(value: Option<f64>) -> String {
355    value
356        .map(|value| format!("{value:.6}"))
357        .unwrap_or_else(|| "N/A".to_owned())
358}
359
360fn format_optional_u32(value: Option<u32>) -> String {
361    value
362        .map(|value| value.to_string())
363        .unwrap_or_else(|| "N/A".to_owned())
364}