Skip to main content

tse_client/
intraday.rs

1//! Intraday data fetching, ported from the JS `getIntraday`, `extractAndStore`
2//! and `itdUpdateManager`.
3//!
4//! As with prices, the JS kept a module-level mutable `stored` map that was
5//! never cleared between calls. Here the working map is owned per call.
6
7use std::collections::{HashMap, HashSet};
8
9use crate::client::Client;
10use crate::config::IntradaySettings;
11use crate::error::{Error, Result, ResultError};
12use crate::request::intraday_url;
13use crate::storage::ItdValue;
14use crate::util::clean_fa;
15
16/// Per-symbol intraday payload: a list of `(deven, payload)` pairs, where
17/// `payload` is the decompressed text unless `gzip` was requested.
18pub type SymbolIntraday = Vec<(String, Option<ItdValue>)>;
19
20/// Result of `get_intraday`.
21#[derive(Debug, Default)]
22pub struct IntradayResult {
23    /// For each requested symbol (in order), its intraday payload (if present).
24    pub data: Vec<Option<SymbolIntraday>>,
25    pub error: Option<ResultError>,
26}
27
28/// Parse a `var XData=[ ... ];` block out of the page text into JSON rows.
29fn parse_raw(separator: &str, text: &str) -> Result<serde_json::Value> {
30    let after = text
31        .split(separator)
32        .nth(1)
33        .ok_or_else(|| Error::InvalidData(format!("missing block: {separator}")))?;
34    let inner = after.split("];").next().unwrap_or("");
35    let json = format!("[{}]", inner.replace('\'', "\""));
36    Ok(serde_json::from_str(&json)?)
37}
38
39fn cell(v: &serde_json::Value) -> String {
40    match v {
41        serde_json::Value::String(s) => s.clone(),
42        serde_json::Value::Number(n) => n.to_string(),
43        serde_json::Value::Bool(b) => b.to_string(),
44        serde_json::Value::Null => String::new(),
45        other => other.to_string(),
46    }
47}
48
49/// Port of JS `extractAndStore` for a single (deven, text) pair: returns the
50/// assembled, newline-joined record (uncompressed).
51fn extract_record(text: &str) -> Result<String> {
52    if text == "N/A" {
53        return Ok("N/A".to_string());
54    }
55    let closing = parse_raw("var ClosingPriceData=[", text)?;
56    let best_limit = parse_raw("var BestLimitData=[", text)?;
57    let intra_trade = parse_raw("var IntraTradeData=[", text)?;
58    let client_type = parse_raw("var ClientTypeData=[", text)?;
59    let inst_state = parse_raw("var InstrumentStateData=[", text)?;
60    let static_tresh = parse_raw("var StaticTreshholdData=[", text)?;
61    let inst_simple = parse_raw("var InstSimpleData=[", text)?;
62    let share_holder = parse_raw("var ShareHolderData=[", text)?;
63
64    let row_cols = |row: &serde_json::Value, idxs: &[usize]| -> String {
65        idxs.iter()
66            .map(|&i| row.get(i).map(cell).unwrap_or_default())
67            .collect::<Vec<_>>()
68            .join(",")
69    };
70
71    // price
72    let price = arr(&closing)
73        .iter()
74        .map(|r| row_cols(r, &[12, 2, 3, 4, 6, 7, 8, 9, 10, 11]))
75        .collect::<Vec<_>>()
76        .join("\n");
77
78    // order
79    let order = arr(&best_limit)
80        .iter()
81        .map(|r| row_cols(r, &[0, 1, 2, 3, 4, 5, 6, 7]))
82        .collect::<Vec<_>>()
83        .join("\n");
84
85    // trade (convert HH:MM:SS to int, sort by time)
86    let mut trade_rows: Vec<Vec<String>> = arr(&intra_trade)
87        .iter()
88        .map(|r| {
89            let time_str = r.get(1).map(cell).unwrap_or_default();
90            let parts: Vec<&str> = time_str.split(':').collect();
91            let h: i64 = parts.first().and_then(|s| s.parse().ok()).unwrap_or(0);
92            let m: i64 = parts.get(1).and_then(|s| s.parse().ok()).unwrap_or(0);
93            let s: i64 = parts.get(2).and_then(|s| s.parse().ok()).unwrap_or(0);
94            let timeint = (h * 10000 + m * 100 + s).to_string();
95            // coli = [1,0,2,3,4] with row[1] replaced by timeint
96            vec![
97                timeint,
98                r.get(0).map(cell).unwrap_or_default(),
99                r.get(2).map(cell).unwrap_or_default(),
100                r.get(3).map(cell).unwrap_or_default(),
101                r.get(4).map(cell).unwrap_or_default(),
102            ]
103        })
104        .collect();
105    trade_rows.sort_by_key(|r| r[0].parse::<i64>().unwrap_or(0));
106    let trade = trade_rows
107        .iter()
108        .map(|r| r.join(","))
109        .collect::<Vec<_>>()
110        .join("\n");
111
112    // client
113    let ct = arr(&client_type);
114    let client = [
115        4, 0, 12, 16, 8, 6, 2, 14, 18, 10, 5, 1, 13, 17, 9, 7, 3, 15, 19, 11, 20,
116    ]
117    .iter()
118    .map(|&i| ct.get(i).map(cell).unwrap_or_default())
119    .collect::<Vec<_>>()
120    .join(",");
121
122    // misc
123    let a = arr(&inst_state);
124    let b = arr(&static_tresh);
125    let state = a
126        .first()
127        .and_then(|r| r.get(2))
128        .map(cell)
129        .unwrap_or_default();
130    let (daymin, daymax) = if b.len() > 1 {
131        (
132            b[1].get(2).map(cell).unwrap_or_default(),
133            b[1].get(1).map(cell).unwrap_or_default(),
134        )
135    } else {
136        (String::new(), String::new())
137    };
138    let is = arr(&inst_simple);
139    let flow = is.get(4).map(cell).unwrap_or_default();
140    let basevol = is.get(9).map(cell).unwrap_or_default();
141    let misc = [basevol, flow, daymin, daymax, state].join(",");
142
143    // shareholder
144    let shareholder = arr(&share_holder)
145        .iter()
146        .filter(|r| r.get(4).map(|v| !cell(v).is_empty()).unwrap_or(false))
147        .map(|r| {
148            let arrow = match cell(&r[4]).as_str() {
149                "ArrowUp" => "+",
150                "ArrowDown" => "-",
151                _ => "",
152            }
153            .to_string();
154            let name = clean_fa(&cell(r.get(5).unwrap_or(&serde_json::Value::Null)));
155            // coli = [2,3,4,0,5] where col4 replaced by arrow, col5 cleaned
156            [
157                r.get(2).map(cell).unwrap_or_default(),
158                r.get(3).map(cell).unwrap_or_default(),
159                arrow,
160                r.get(0).map(cell).unwrap_or_default(),
161                name,
162            ]
163            .join(",")
164        })
165        .collect::<Vec<_>>()
166        .join("\n");
167
168    let mut file = vec![price, order, trade, client, misc];
169    if !shareholder.is_empty() {
170        file.push(shareholder);
171    }
172    Ok(file.join("\n\n"))
173}
174
175fn arr(v: &serde_json::Value) -> Vec<serde_json::Value> {
176    v.as_array().cloned().unwrap_or_default()
177}
178
179impl Client {
180    /// Port of JS `getIntraday`.
181    pub async fn get_intraday(
182        &self,
183        symbols: &[String],
184        settings: &IntradaySettings,
185    ) -> Result<IntradayResult> {
186        let mut result = IntradayResult::default();
187        if symbols.is_empty() {
188            return Ok(result);
189        }
190
191        if let Some(err) = self.update_instruments().await? {
192            result.error = Some(err);
193            return Ok(result);
194        }
195
196        let instruments = self.get_instruments().await?;
197        // index by symbol
198        let by_symbol: HashMap<String, &crate::models::Instrument> = instruments
199            .values()
200            .map(|i| (i.symbol.clone(), i))
201            .collect();
202
203        let selection: Vec<Option<crate::models::Instrument>> = symbols
204            .iter()
205            .map(|s| by_symbol.get(s).map(|i| (*i).clone()))
206            .collect();
207        let not_founds: Vec<String> = symbols
208            .iter()
209            .zip(&selection)
210            .filter(|(_, s)| s.is_none())
211            .map(|(s, _)| s.clone())
212            .collect();
213        if !not_founds.is_empty() {
214            result.error = Some(ResultError::IncorrectSymbol {
215                symbols: not_founds,
216            });
217            return Ok(result);
218        }
219
220        let selins: HashSet<String> = selection
221            .iter()
222            .flatten()
223            .map(|i| i.ins_code.clone())
224            .collect();
225
226        // We rely on cached inscode_devens; price update path is reused via
227        // get_prices' machinery only when needed. For brevity and to keep the
228        // intraday device list correct, read it from cache.
229        let stored_raw = self.storage().get_item_async("tse.inscode_devens", false)?;
230        let mut stored_inscode_devens: HashMap<String, Vec<i64>> = HashMap::new();
231        if !stored_raw.is_empty() {
232            for line in stored_raw.split('\n') {
233                let mut it = line.split(';');
234                if let (Some(i), Some(d)) = (it.next(), it.next()) {
235                    let devens = d.split(',').filter_map(|x| x.parse::<i64>().ok()).collect();
236                    stored_inscode_devens.insert(i.to_string(), devens);
237                }
238            }
239        }
240
241        let start_date: i64 = settings.start_date.parse().unwrap_or(0);
242        let end_date: i64 = settings.end_date.parse().unwrap_or(0);
243
244        let in_range = |i: i64| -> bool {
245            if end_date != 0 {
246                i >= start_date && i <= end_date
247            } else {
248                i >= start_date
249            }
250        };
251
252        let asked_inscode_devens: Vec<(String, Vec<i64>)> = selins
253            .iter()
254            .map(|inscode| {
255                let devens = stored_inscode_devens
256                    .get(inscode)
257                    .map(|all| all.iter().copied().filter(|d| in_range(*d)).collect())
258                    .unwrap_or_default();
259                (inscode.clone(), devens)
260            })
261            .collect();
262
263        // load stored intraday data (owned, no global leak)
264        let mut stored = self.storage().itd_get_items(
265            &selins,
266            if settings.update_only {
267                settings.re_update_no_trades
268            } else {
269                true
270            },
271        )?;
272
273        // determine what to update
274        let mut to_update: Vec<(String, Vec<i64>)> = Vec::new();
275        for (inscode, devens) in &asked_inscode_devens {
276            if devens.is_empty() {
277                continue;
278            }
279            match stored.get(inscode) {
280                None => to_update.push((inscode.clone(), devens.clone())),
281                Some(have) => {
282                    let need: Vec<i64> = if settings.re_update_no_trades {
283                        devens
284                            .iter()
285                            .copied()
286                            .filter(|d| {
287                                match have.get(&d.to_string()).and_then(|v| v.to_text()) {
288                                    None => true,
289                                    Some(text) => {
290                                        // trade is the 3rd block (index 2)
291                                        text.split("\n\n")
292                                            .nth(2)
293                                            .map(|t| t.is_empty())
294                                            .unwrap_or(true)
295                                    }
296                                }
297                            })
298                            .collect()
299                    } else {
300                        devens
301                            .iter()
302                            .copied()
303                            .filter(|d| !have.contains_key(&d.to_string()))
304                            .collect()
305                    };
306                    if !need.is_empty() {
307                        to_update.push((inscode.clone(), need));
308                    }
309                }
310            }
311        }
312
313        let mut servers = settings.servers.clone();
314        if servers.is_empty() || servers.iter().any(|&s| s < 0 && s != -1) {
315            servers = self.config().intraday_update_servers.clone();
316        }
317
318        if !to_update.is_empty() {
319            let (succs, fails) = self
320                .run_intraday_update(&mut stored, &to_update, settings, &servers)
321                .await?;
322
323            if !fails.is_empty() {
324                let k: HashMap<String, String> = selection
325                    .iter()
326                    .flatten()
327                    .map(|i| (i.ins_code.clone(), i.symbol.clone()))
328                    .collect();
329                let group = |pairs: &[(String, String)]| -> HashMap<String, Vec<String>> {
330                    let mut o: HashMap<String, Vec<String>> = HashMap::new();
331                    for (i, d) in pairs {
332                        let sym = k.get(i).cloned().unwrap_or_else(|| i.clone());
333                        o.entry(sym).or_default().push(d.clone());
334                    }
335                    o
336                };
337                result.error = Some(ResultError::IncompleteIntradayUpdate {
338                    fails: group(&fails),
339                    succs: group(&succs),
340                });
341            }
342        }
343
344        if !settings.update_only {
345            result.data = asked_inscode_devens
346                .iter()
347                .map(|(inscode, devens)| {
348                    stored.get(inscode).map(|instr| {
349                        devens
350                            .iter()
351                            .map(|deven| {
352                                let key = deven.to_string();
353                                let val = instr.get(&key).cloned();
354                                if settings.gzip {
355                                    (key, val)
356                                } else {
357                                    let plain = val.and_then(|v| match &v {
358                                        ItdValue::Present => None,
359                                        ItdValue::Plain(_) => Some(v.clone()),
360                                        ItdValue::Gzipped(_) => v.to_text().map(ItdValue::Plain),
361                                    });
362                                    (key, plain)
363                                }
364                            })
365                            .collect()
366                    })
367                })
368                .collect();
369        }
370
371        Ok(result)
372    }
373
374    /// Port of JS `itdUpdateManager`, as bounded async/await with retries.
375    /// Returns (succs, fails) as (inscode, deven) string pairs.
376    async fn run_intraday_update(
377        &self,
378        stored: &mut HashMap<String, HashMap<String, ItdValue>>,
379        to_update: &[(String, Vec<i64>)],
380        settings: &IntradaySettings,
381        servers: &[i32],
382    ) -> Result<(Vec<(String, String)>, Vec<(String, String)>)> {
383        let chunk_delay = settings.chunk_delay;
384        let retry_count = settings.retry_count;
385        let retry_delay = settings.retry_delay;
386        let should_cache = settings.cache;
387
388        // flatten to (server_idx, inscode, deven)
389        let mut chunks: Vec<(usize, String, String)> = Vec::new();
390        for (inscode, devens) in to_update {
391            for d in devens {
392                chunks.push((0usize, inscode.clone(), d.to_string()));
393            }
394        }
395
396        let mut succs: Vec<(String, String)> = Vec::new();
397        let mut fails: HashSet<(String, String)> = HashSet::new();
398        // track newly extracted instruments to persist into instruments.intraday
399        let mut extracted_ins: HashMap<String, String> = HashMap::new();
400        // last deven per inscode for the InstSimple extraction step
401        let mut inslastdeven: HashMap<String, String> = HashMap::new();
402        for (inscode, devens) in to_update {
403            if let Some(last) = devens.last() {
404                inslastdeven.insert(inscode.clone(), last.to_string());
405            }
406        }
407
408        let mut retries = 0u32;
409        loop {
410            let mut retry_chunks: Vec<(usize, String, String)> = Vec::new();
411
412            for (i, (srv_idx, inscode, deven)) in chunks.iter().enumerate() {
413                if i > 0 && chunk_delay > 0 {
414                    tokio::time::sleep(std::time::Duration::from_millis(chunk_delay)).await;
415                }
416                let server = servers[*srv_idx % servers.len()];
417                let url = intraday_url(server, inscode, deven);
418                let outcome = self.fetch_intraday(&url, settings.chunk_max_wait).await;
419
420                match outcome {
421                    Some(text) => {
422                        fails.remove(&(inscode.clone(), deven.clone()));
423                        succs.push((inscode.clone(), deven.clone()));
424
425                        // extract InstSimple for instrument list when needed
426                        let is_last = inslastdeven
427                            .get(inscode)
428                            .map(|s| s == deven)
429                            .unwrap_or(false);
430                        if is_last && text != "N/A" {
431                            if let Some(after) = text.split("var InstSimpleData=").nth(1) {
432                                let block = after.split(';').next().unwrap_or("");
433                                let json = block.replace('\'', "\"");
434                                if let Ok(serde_json::Value::Array(row)) =
435                                    serde_json::from_str::<serde_json::Value>(&json)
436                                {
437                                    let mut parts = vec![inscode.clone()];
438                                    parts.extend(row.iter().map(cell));
439                                    extracted_ins.insert(inscode.clone(), parts.join(","));
440                                }
441                            }
442                        }
443
444                        // extract and store this record
445                        match extract_record(&text) {
446                            Ok(record) => {
447                                let entry = stored.entry(inscode.clone()).or_default();
448                                entry.insert(deven.clone(), ItdValue::Plain(record));
449                                if should_cache {
450                                    let single: HashMap<String, ItdValue> = entry
451                                        .iter()
452                                        .filter(|(_, v)| !matches!(v, ItdValue::Present))
453                                        .map(|(k, v)| (k.clone(), v.clone()))
454                                        .collect();
455                                    self.storage().itd_set_item(inscode, &single)?;
456                                }
457                            }
458                            Err(_) => {
459                                // treat parse failure as a soft failure to retry
460                                fails.insert((inscode.clone(), deven.clone()));
461                                retry_chunks.push((
462                                    next_server(servers, *srv_idx),
463                                    inscode.clone(),
464                                    deven.clone(),
465                                ));
466                            }
467                        }
468                    }
469                    None => {
470                        fails.insert((inscode.clone(), deven.clone()));
471                        retry_chunks.push((
472                            next_server(servers, *srv_idx),
473                            inscode.clone(),
474                            deven.clone(),
475                        ));
476                    }
477                }
478            }
479
480            if retry_chunks.is_empty() || retries >= retry_count {
481                break;
482            }
483            retries += 1;
484            if retry_delay > 0 {
485                tokio::time::sleep(std::time::Duration::from_millis(retry_delay)).await;
486            }
487            for (_, inscode, deven) in &retry_chunks {
488                fails.remove(&(inscode.clone(), deven.clone()));
489            }
490            chunks = retry_chunks;
491        }
492
493        // persist instrument list for intraday (JS does this in poll())
494        if !extracted_ins.is_empty() {
495            let mut current = self.get_intraday_instruments_raw()?;
496            current.extend(extracted_ins);
497            let joined = current.values().cloned().collect::<Vec<_>>().join("\n");
498            self.storage()
499                .set_item("tse.instruments.intraday", &joined)?;
500        }
501
502        Ok((succs, fails.into_iter().collect()))
503    }
504
505    fn get_intraday_instruments_raw(&self) -> Result<HashMap<String, String>> {
506        let rows = self.storage().get_item("tse.instruments.intraday")?;
507        let mut map = HashMap::new();
508        if rows.is_empty() {
509            return Ok(map);
510        }
511        for row in rows.split('\n').filter(|r| !r.is_empty()) {
512            if let Some(code) = row.split(',').next() {
513                map.insert(code.to_string(), row.to_string());
514            }
515        }
516        Ok(map)
517    }
518
519    /// Fetch one intraday page; returns `Some(text)` on a usable page,
520    /// `Some("N/A")` when the server reports no data, or `None` on failure.
521    async fn fetch_intraday(&self, url: &str, max_wait: u64) -> Option<String> {
522        let client = reqwest::Client::builder()
523            .timeout(std::time::Duration::from_millis(max_wait.max(1)))
524            .build()
525            .ok()?;
526        let res = client.get(url).send().await.ok()?;
527        if res.status().as_u16() != 200 {
528            return None;
529        }
530        let text = res.text().await.ok()?;
531        if text.contains(
532            "Object moved to <a href=\"/GeneralError.aspx?aspxerrorpath=/Loader.aspx\">here</a>",
533        ) {
534            return Some("N/A".to_string());
535        }
536        // verify the page is up to date
537        let after = text.split("var StaticTreshholdData").nth(1)?;
538        let up_to_date = [
539            "ClosingPrice",
540            "IntraDayPrice",
541            "IntraTrade",
542            "ClientType",
543            "BestLimit",
544        ]
545        .iter()
546        .any(|name| {
547            after
548                .split(&format!("var {name}Data=["))
549                .nth(1)
550                .and_then(|s| s.split("];").next())
551                .map(|s| !s.is_empty())
552                .unwrap_or(false)
553        });
554        if up_to_date { Some(text) } else { None }
555    }
556}
557
558fn next_server(servers: &[i32], current_idx: usize) -> usize {
559    (current_idx + 1) % servers.len()
560}