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
39fn main() -> Result<(), ExampleError> {
40 let moex_client = Client::builder()
41 .user_agent_from_crate()
42 .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS))
43 .rate_limit(RateLimit::every(Duration::from_millis(RATE_LIMIT_MILLIS)))
44 .metadata(false)
45 .build()?;
46
47 println!("loading active indexes...");
48 let index_dumps = load_actual_index_dumps(&moex_client)?;
49 println!("active indexes loaded: {}", index_dumps.len());
50
51 let wanted_secids = collect_unique_secids(&index_dumps);
52 println!(
53 "resolving snapshots for {} unique securities...",
54 wanted_secids.len()
55 );
56 let (snapshots, missing_mapping) = load_security_snapshots(&moex_client, &wanted_secids)?;
57 println!(
58 "resolved snapshots: {}, missing: {}",
59 snapshots.len(),
60 missing_mapping.len()
61 );
62
63 print_dump(&index_dumps, &snapshots, &missing_mapping);
64 Ok(())
65}
66
67fn load_actual_index_dumps(moex_client: &Client) -> Result<Vec<IndexDump>, ExampleError> {
68 let indexes = with_retry(retry_policy(), || moex_client.indexes())?.into_actual_by_till();
69 let page_limit = NonZeroU32::new(INDEX_ANALYTICS_PAGE_LIMIT)
70 .expect("INDEX_ANALYTICS_PAGE_LIMIT constant must be greater than zero");
71
72 indexes
73 .into_iter()
74 .map(|index| {
75 let components = with_retry(retry_policy(), || {
76 moex_client
77 .index(index.id().clone())
78 .expect("index id from payload must be valid")
79 .analytics_pages(page_limit)
80 .all()
81 })?
82 .into_actual_by_session()
83 .into_sorted_by_weight_desc();
84 Ok(IndexDump {
85 index_id: index.id().as_str().to_owned().into_boxed_str(),
86 short_name: index.short_name().to_owned().into_boxed_str(),
87 components,
88 })
89 })
90 .collect()
91}
92
93fn collect_unique_secids(index_dumps: &[IndexDump]) -> HashSet<SecId> {
94 index_dumps
95 .iter()
96 .flat_map(|index| index.components.iter().map(|row| row.secid().clone()))
97 .collect()
98}
99
100fn load_security_snapshots(
101 moex_client: &Client,
102 wanted_secids: &HashSet<SecId>,
103) -> Result<(HashMap<SecId, ResolvedSnapshot>, Vec<SecId>), ExampleError> {
104 let stock_scope = moex_client
105 .stock()
106 .expect("stock engine literal must be valid");
107 let markets = with_retry(retry_policy(), || stock_scope.markets())?;
108 let mut snapshots = HashMap::with_capacity(wanted_secids.len());
109
110 'markets: for market in markets {
111 let boards = with_retry(retry_policy(), || {
112 moex_client.boards(stock_scope.engine(), market.name())
113 })?;
114
115 for board in boards.into_iter().filter(|board| board.is_traded()) {
116 let board_snapshots = load_board_snapshots_all_pages(
117 moex_client,
118 stock_scope.engine().as_str(),
119 market.name().as_str(),
120 board.boardid().as_str(),
121 )?;
122
123 for snapshot in board_snapshots {
124 if !wanted_secids.contains(snapshot.secid()) {
125 continue;
126 }
127
128 snapshots
129 .entry(snapshot.secid().clone())
130 .or_insert_with(|| ResolvedSnapshot {
131 market: market.name().as_str().to_owned().into_boxed_str(),
132 board: board.boardid().as_str().to_owned().into_boxed_str(),
133 lot_size: snapshot.lot_size(),
134 last: snapshot.last(),
135 });
136 }
137
138 if snapshots.len() == wanted_secids.len() {
139 break 'markets;
140 }
141 }
142 }
143
144 let missing_mapping = wanted_secids
145 .iter()
146 .filter(|secid| !snapshots.contains_key(*secid))
147 .cloned()
148 .collect();
149
150 Ok((snapshots, missing_mapping))
151}
152
153fn load_board_snapshots_all_pages(
154 moex_client: &Client,
155 engine: &str,
156 market: &str,
157 board: &str,
158) -> Result<Vec<SecuritySnapshot>, MoexError> {
159 let endpoint = format!("engines/{engine}/markets/{market}/boards/{board}/securities.json");
160 let mut start = 0_u32;
161 let mut snapshots = Vec::new();
162 let mut first_secid_on_previous_page = None;
163 let limit = BOARD_SNAPSHOTS_PAGE_LIMIT.to_string();
164
165 loop {
166 let payload = with_retry(retry_policy(), || {
167 moex_client
168 .raw()
169 .path(endpoint.as_str())
170 .only("securities,marketdata")
171 .columns("securities", "SECID,LOTSIZE")
172 .columns("marketdata", "SECID,LAST")
173 .param("start", start.to_string())
174 .param("limit", limit.as_str())
175 .send_payload()
176 })?;
177
178 let (page, first_secid_on_page) = parse_board_snapshots_page(&payload, endpoint.as_str())?;
179 if page.is_empty() {
180 break;
181 }
182
183 if let (Some(previous), Some(current)) =
184 (&first_secid_on_previous_page, &first_secid_on_page)
185 && previous == current
186 {
187 break;
188 }
189 first_secid_on_previous_page = first_secid_on_page;
190
191 let page_len = u32::try_from(page.len()).map_err(|_| MoexError::PaginationOverflow {
192 endpoint: endpoint.clone().into_boxed_str(),
193 start,
194 limit: BOARD_SNAPSHOTS_PAGE_LIMIT,
195 })?;
196
197 snapshots.extend(page);
198 if page_len < BOARD_SNAPSHOTS_PAGE_LIMIT {
199 break;
200 }
201 start = start
202 .checked_add(page_len)
203 .ok_or_else(|| MoexError::PaginationOverflow {
204 endpoint: endpoint.clone().into_boxed_str(),
205 start,
206 limit: BOARD_SNAPSHOTS_PAGE_LIMIT,
207 })?;
208 }
209
210 Ok(snapshots)
211}
212
213fn parse_board_snapshots_page(
214 payload: &str,
215 endpoint: &str,
216) -> Result<(Vec<SecuritySnapshot>, Option<SecId>), MoexError> {
217 let mut tables = decode::raw_tables_json(payload, endpoint)?;
218 let security_rows: Vec<RawSecuritySnapshotRow> = tables.take_rows("securities")?;
219 let marketdata_rows: Vec<RawMarketdataSnapshotRow> = tables.take_rows("marketdata")?;
220
221 let mut marketdata_by_secid = HashMap::with_capacity(marketdata_rows.len());
222 for (row, marketdata) in marketdata_rows.into_iter().enumerate() {
223 marketdata_by_secid.insert(marketdata.secid, (row, marketdata.last));
224 }
225
226 let mut first_secid_on_page = None;
227 let mut snapshots = Vec::with_capacity(security_rows.len().max(marketdata_by_secid.len()));
228
229 for (row, security) in security_rows.into_iter().enumerate() {
230 let last = marketdata_by_secid
231 .remove(security.secid.as_str())
232 .and_then(|(_, last)| last);
233 let snapshot = SecuritySnapshot::try_new(security.secid, security.lot_size, last).map_err(
234 |source| MoexError::InvalidSecuritySnapshot {
235 endpoint: endpoint.to_owned().into_boxed_str(),
236 table: "securities",
237 row,
238 source,
239 },
240 )?;
241 if first_secid_on_page.is_none() {
242 first_secid_on_page = Some(snapshot.secid().clone());
243 }
244 snapshots.push(snapshot);
245 }
246
247 for (secid, (row, last)) in marketdata_by_secid {
248 let snapshot = SecuritySnapshot::try_new(secid, None, last).map_err(|source| {
249 MoexError::InvalidSecuritySnapshot {
250 endpoint: endpoint.to_owned().into_boxed_str(),
251 table: "marketdata",
252 row,
253 source,
254 }
255 })?;
256 if first_secid_on_page.is_none() {
257 first_secid_on_page = Some(snapshot.secid().clone());
258 }
259 snapshots.push(snapshot);
260 }
261
262 Ok((snapshots, first_secid_on_page))
263}
264
265#[derive(Debug, serde::Deserialize)]
266struct RawSecuritySnapshotRow {
267 #[serde(rename = "SECID")]
268 secid: String,
269 #[serde(rename = "LOTSIZE", default)]
270 lot_size: Option<i64>,
271}
272
273#[derive(Debug, serde::Deserialize)]
274struct RawMarketdataSnapshotRow {
275 #[serde(rename = "SECID")]
276 secid: String,
277 #[serde(rename = "LAST", default)]
278 last: Option<f64>,
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}