Skip to main content

tse_client/
client.rs

1//! The main client: instrument/price updates, merging, and `get_prices`.
2//!
3//! Memory-safety note vs. the original JS:
4//! the JS kept `storedPrices`, `lastdevens` and `stored` as *module-level*
5//! mutable maps that were never cleared between calls, so a long-lived process
6//! accumulated every instrument it ever fetched (an unbounded leak). Here all
7//! of that working state is owned locally per call (`PricesState`) and dropped
8//! when the call returns.
9
10use std::collections::{HashMap, HashSet};
11
12use regex::Regex;
13
14use crate::adjust::{AdjustInfo, adjust};
15use crate::config::{Config, PriceSettings, SYMBOL_RENAME_STRING, TRADING_SESSION_END_HOUR};
16use crate::error::{Result, ResultError};
17use crate::group::group;
18use crate::models::{ClosingPrice, Column, Instrument, Share};
19use crate::request::Requester;
20use crate::storage::Storage;
21use crate::util::{date_to_str, greg_to_shamsi, should_update, today};
22
23const MERGED_SYMBOL_CONTENT: &str = "merged";
24
25/// Per-`get_prices` working state — replaces the JS module globals
26/// `storedPrices` and `lastdevens`, scoped to a single call so it cannot leak.
27#[derive(Default)]
28struct PricesState {
29    stored_prices: HashMap<String, String>,
30    lastdevens: HashMap<String, String>,
31}
32
33/// The library client. Holds configuration, the HTTP requester and the cache.
34#[derive(Clone)]
35pub struct Client {
36    config: Config,
37    storage: Storage,
38    requester: Requester,
39}
40
41/// A single output cell value (string or number), used in the structured
42/// (non-CSV) result.
43#[derive(Debug, Clone)]
44pub enum Cell {
45    Text(String),
46    Number(f64),
47}
48
49#[derive(Debug, Clone, Default)]
50pub struct OhlcData {
51    pub date: Vec<String>,
52    pub open: Vec<f64>,
53    pub high: Vec<f64>,
54    pub low: Vec<f64>,
55    pub close: Vec<f64>,
56    pub volume: Vec<f64>,
57}
58
59/// Structured per-instrument price output.
60#[derive(Debug, Clone, Default)]
61pub struct InstrumentPrices {
62    /// header -> column of cells
63    pub columns: HashMap<String, Vec<Cell>>,
64    pub ohlc_data: OhlcData,
65    pub adjust_info: Option<AdjustInfo>,
66    /// Set when this symbol was merged into another (JS `MERGED_SYMBOL_CONTENT`).
67    pub merged: bool,
68}
69
70/// The result of `get_prices`.
71#[derive(Debug, Default)]
72pub struct PricesResult {
73    pub data: Vec<Option<InstrumentPrices>>,
74    pub csv: Vec<Option<String>>,
75    pub error: Option<ResultError>,
76}
77
78impl Client {
79    pub fn new() -> Result<Self> {
80        let config = Config::default();
81        Ok(Client {
82            requester: Requester::new(config.api_url.clone()),
83            storage: Storage::new()?,
84            config,
85        })
86    }
87
88    pub fn with_parts(config: Config, storage: Storage) -> Self {
89        let requester = Requester::new(config.api_url.clone());
90        Client {
91            config,
92            storage,
93            requester,
94        }
95    }
96
97    pub fn config(&self) -> &Config {
98        &self.config
99    }
100
101    pub fn config_mut(&mut self) -> &mut Config {
102        &mut self.config
103    }
104
105    pub fn storage(&self) -> &Storage {
106        &self.storage
107    }
108
109    // ----- instruments -----------------------------------------------------
110
111    fn parse_instruments_by_symbol(&self) -> Result<HashMap<String, Instrument>> {
112        let rows = self.storage.get_item("tse.instruments")?;
113        let mut map = HashMap::new();
114        if rows.is_empty() {
115            return Ok(map);
116        }
117        for row in rows.split('\n') {
118            if row.is_empty() {
119                continue;
120            }
121            let ins = Instrument::parse(row)?;
122            map.insert(ins.symbol.clone(), ins);
123        }
124        Ok(map)
125    }
126
127    fn parse_shares_struct(&self) -> Result<Vec<Share>> {
128        let rows = self.storage.get_item("tse.shares")?;
129        let mut out = Vec::new();
130        if rows.is_empty() {
131            return Ok(out);
132        }
133        for row in rows.split('\n') {
134            if row.is_empty() {
135                continue;
136            }
137            out.push(Share::parse(row)?);
138        }
139        Ok(out)
140    }
141
142    fn parse_shares_raw(&self) -> Result<Vec<String>> {
143        let rows = self.storage.get_item("tse.shares")?;
144        if rows.is_empty() {
145            return Ok(vec![]);
146        }
147        Ok(rows.split('\n').map(|s| s.to_string()).collect())
148    }
149
150    /// Port of JS `getLastPossibleDevens`. Returns `(NO, ID)` or a domain error.
151    async fn get_last_possible_devens(
152        &self,
153    ) -> Result<std::result::Result<(String, String), ResultError>> {
154        let mut no = String::new();
155        let mut id = String::new();
156
157        let stored = self.storage.get_item("tse.lastPossibleDevens")?;
158        if !stored.is_empty() {
159            let mut it = stored.split(',');
160            no = it.next().unwrap_or("").to_string();
161            id = it.next().unwrap_or("").to_string();
162        }
163
164        let today_str = date_to_str(today());
165        let last_update = self.storage.get_item("tse.lastLPDUpdate")?;
166        let today_num: i64 = today_str.parse().unwrap_or(0);
167        let last_update_num: i64 = last_update.parse().unwrap_or(0);
168        if today_num <= last_update_num {
169            return Ok(Ok((no, id)));
170        }
171
172        if stored.is_empty() || should_update(&today_str, &no) || should_update(&today_str, &id) {
173            match self.requester.last_possible_deven().await {
174                Ok(res) => {
175                    let re = Regex::new(r"^\d{8};\d{8}$").unwrap();
176                    if !re.is_match(res.trim()) {
177                        return Ok(Err(ResultError::Request {
178                            title: "Invalid server response: LastPossibleDeven".into(),
179                            detail: String::new(),
180                        }));
181                    }
182                    let parts: Vec<&str> = res.trim().split(';').collect();
183                    self.storage
184                        .set_item("tse.lastPossibleDevens", &parts.join(","))?;
185                    self.storage.set_item("tse.lastLPDUpdate", &today_str)?;
186                    no = parts[0].to_string();
187                    id = parts[1].to_string();
188                }
189                Err(e) => {
190                    return Ok(Err(ResultError::Request {
191                        title: "Failed request: LastPossibleDeven".into(),
192                        detail: e.to_string(),
193                    }));
194                }
195            }
196        }
197
198        Ok(Ok((no, id)))
199    }
200
201    /// Port of JS `updateInstruments`. Returns an optional domain error.
202    pub async fn update_instruments(&self) -> Result<Option<ResultError>> {
203        let last_update: i64 = self
204            .storage
205            .get_item("tse.lastInstrumentUpdate")?
206            .trim()
207            .parse()
208            .unwrap_or(0);
209        let now =
210            time::OffsetDateTime::now_local().unwrap_or_else(|_| time::OffsetDateTime::now_utc());
211        let today_deven: i64 = date_to_str(now.date()).parse().unwrap_or(0);
212        if last_update != 0
213            && (today_deven <= last_update || now.hour() <= TRADING_SESSION_END_HOUR)
214        {
215            return Ok(None);
216        }
217
218        let mut current_shares: Option<Vec<String>> = None;
219        let last_id: i64 = if last_update == 0 {
220            0
221        } else {
222            let cs = self.parse_shares_raw()?;
223            let max = cs
224                .iter()
225                .filter_map(|i| i.split(',').next())
226                .filter_map(|s| s.parse::<i64>().ok())
227                .max()
228                .unwrap_or(0);
229            current_shares = Some(cs);
230            max
231        };
232
233        let res = match self
234            .requester
235            .instrument_and_share(&today_deven.to_string(), last_id)
236            .await
237        {
238            Ok(r) => r,
239            Err(e) => {
240                return Ok(Some(ResultError::Request {
241                    title: "Failed request: InstrumentAndShare".into(),
242                    detail: e.to_string(),
243                }));
244            }
245        };
246        let shares_part = res.split('@').nth(1).unwrap_or("").to_string();
247
248        let instruments_raw = match self.requester.instrument("0").await {
249            Ok(r) => r,
250            Err(e) => {
251                return Ok(Some(ResultError::Request {
252                    title: "Failed request: Instrument".into(),
253                    detail: e.to_string(),
254                }));
255            }
256        };
257
258        let mut wrote_instruments = false;
259        if !instruments_raw.is_empty() && instruments_raw != "*" {
260            let instruments = Self::dedupe_and_rename(&instruments_raw);
261            self.storage.set_item("tse.instruments", &instruments)?;
262            wrote_instruments = true;
263        }
264
265        let mut wrote_shares = false;
266        if !shares_part.is_empty() {
267            let shares_str = if let Some(cs) = current_shares.filter(|c| !c.is_empty()) {
268                let mut all = cs;
269                all.extend(shares_part.split(';').map(|s| s.to_string()));
270                all.join("\n")
271            } else {
272                shares_part.replace(';', "\n")
273            };
274            self.storage.set_item("tse.shares", &shares_str)?;
275            wrote_shares = true;
276        }
277
278        if wrote_instruments || wrote_shares {
279            self.storage
280                .set_item("tse.lastInstrumentUpdate", &today_deven.to_string())?;
281        }
282
283        Ok(None)
284    }
285
286    /// Port of the duplicate-symbol renaming block inside `updateInstruments`.
287    fn dedupe_and_rename(instruments_raw: &str) -> String {
288        let mut rows: Vec<Vec<String>> = instruments_raw
289            .split(';')
290            .map(|i| i.split(',').map(|s| s.to_string()).collect())
291            .collect();
292
293        // cleaned symbols for duplicate detection
294        let cleaned: Vec<String> = rows
295            .iter()
296            .map(|r| crate::util::clean_fa(&r[5]).trim().to_string())
297            .collect();
298
299        // unique symbols that appear more than once
300        let mut seen = HashSet::new();
301        let mut dup_syms: Vec<String> = Vec::new();
302        let mut counts: HashMap<&String, usize> = HashMap::new();
303        for s in &cleaned {
304            *counts.entry(s).or_insert(0) += 1;
305        }
306        for s in &cleaned {
307            if counts[s] > 1 && seen.insert(s.clone()) {
308                dup_syms.push(s.clone());
309            }
310        }
311
312        let code_idx: HashMap<String, usize> = rows
313            .iter()
314            .enumerate()
315            .map(|(j, r)| (r[0].clone(), j))
316            .collect();
317
318        for dsym in dup_syms {
319            // indices of rows whose cleaned symbol equals dsym
320            let mut group: Vec<usize> = cleaned
321                .iter()
322                .enumerate()
323                .filter(|(_, c)| **c == dsym)
324                .map(|(i, _)| i)
325                .collect();
326            // sort by DEven (col 8) descending
327            group.sort_by(|&a, &b| {
328                let da: i64 = rows[a][8].parse().unwrap_or(0);
329                let db: i64 = rows[b][8].parse().unwrap_or(0);
330                db.cmp(&da)
331            });
332
333            for (j, &ridx) in group.iter().enumerate() {
334                let orig = rows[ridx][5].clone();
335                if j > 0 {
336                    let postfix = format!("{}{}", SYMBOL_RENAME_STRING, j + 1);
337                    // push original symbol as the 19th field
338                    if rows[ridx].len() == 18 {
339                        rows[ridx].push(orig.clone());
340                    } else {
341                        rows[ridx][18] = orig.clone();
342                    }
343                    rows[ridx][5] = format!("{}{}", orig.trim(), postfix);
344                } else {
345                    rows[ridx][5] = orig;
346                }
347            }
348        }
349
350        let _ = code_idx; // parity with JS (kept for clarity)
351
352        rows.iter()
353            .map(|r| r.join(","))
354            .collect::<Vec<_>>()
355            .join("\n")
356    }
357
358    // ----- prices update ---------------------------------------------------
359
360    /// Port of JS `updatePrices`, operating on owned `PricesState`.
361    async fn update_prices(
362        &self,
363        state: &mut PricesState,
364        selection: &[Option<Instrument>],
365        should_cache: bool,
366    ) -> Result<std::result::Result<(Vec<String>, Vec<String>), ResultError>> {
367        // load lastdevens from cache
368        let raw = self.storage.get_item("tse.inscode_lastdeven")?;
369        let mut inscodes: HashSet<String> = HashSet::new();
370        if !raw.is_empty() {
371            for line in raw.split('\n') {
372                let mut it = line.split(',');
373                if let (Some(k), Some(v)) = (it.next(), it.next()) {
374                    state.lastdevens.insert(k.to_string(), v.to_string());
375                    inscodes.insert(k.to_string());
376                }
377            }
378        }
379
380        let last_possible = match self.get_last_possible_devens().await? {
381            Ok(v) => v,
382            Err(e) => return Ok(Err(e)),
383        };
384        let (lpd_no, lpd_id) = last_possible;
385        let first_possible_deven = "20010321";
386
387        let mut to_update: Vec<(String, String, i32)> = Vec::new();
388        for instrument in selection.iter().flatten() {
389            let inscode = &instrument.ins_code;
390            let market = &instrument.ymar_nsc;
391            let is_not_normal = if market == "NO" { 0 } else { 1 };
392
393            if !inscodes.contains(inscode) {
394                to_update.push((
395                    inscode.clone(),
396                    first_possible_deven.to_string(),
397                    is_not_normal,
398                ));
399            } else {
400                let lastdeven = state.lastdevens.get(inscode).cloned().unwrap_or_default();
401                let last_possible_deven = if market != "NO" { &lpd_id } else { &lpd_no };
402                if lastdeven.is_empty() {
403                    continue; // expired symbol
404                }
405                if should_update(&lastdeven, last_possible_deven) {
406                    to_update.push((inscode.clone(), lastdeven, is_not_normal));
407                }
408            }
409        }
410
411        // load any not-yet-loaded stored prices
412        let selins: HashSet<String> = selection
413            .iter()
414            .flatten()
415            .map(|i| i.ins_code.clone())
416            .collect();
417        let stored_has: HashSet<String> = state.stored_prices.keys().cloned().collect();
418        if stored_has.is_empty() || selins.iter().any(|i| !stored_has.contains(i)) {
419            self.storage.get_items(&selins, &mut state.stored_prices)?;
420        }
421
422        if to_update.is_empty() {
423            return Ok(Ok((vec![], vec![])));
424        }
425
426        let (succs, fails) = self
427            .run_prices_update(state, &to_update, should_cache, &lpd_no)
428            .await?;
429
430        if !succs.is_empty() && should_cache {
431            let str = state
432                .lastdevens
433                .iter()
434                .map(|(k, v)| format!("{k},{v}"))
435                .collect::<Vec<_>>()
436                .join("\n");
437            self.storage.set_item("tse.inscode_lastdeven", &str)?;
438        }
439
440        Ok(Ok((succs, fails)))
441    }
442
443    /// Port of the JS `pricesUpdateManager`, expressed as straightforward
444    /// async/await with bounded concurrency and retries instead of the
445    /// setTimeout/poll state machine.
446    async fn run_prices_update(
447        &self,
448        state: &mut PricesState,
449        to_update: &[(String, String, i32)],
450        should_cache: bool,
451        last_possible_deven: &str,
452    ) -> Result<(Vec<String>, Vec<String>)> {
453        let chunk_size = self.config.prices_update_chunk;
454        let chunk_delay = self.config.prices_update_chunk_delay;
455        let retry_count = self.config.prices_update_retry_count;
456        let retry_delay = self.config.prices_update_retry_delay;
457
458        let mut succs: Vec<String> = Vec::new();
459        let mut fails: HashSet<String> = HashSet::new();
460
461        // current set of chunks to process
462        let mut chunks: Vec<Vec<(String, String, i32)>> =
463            to_update.chunks(chunk_size).map(|c| c.to_vec()).collect();
464
465        let mut retries = 0u32;
466        loop {
467            let mut retry_chunks: Vec<Vec<(String, String, i32)>> = Vec::new();
468
469            for (idx, chunk) in chunks.iter().enumerate() {
470                if idx > 0 && chunk_delay > 0 {
471                    tokio::time::sleep(std::time::Duration::from_millis(chunk_delay)).await;
472                }
473                let ins_codes = chunk
474                    .iter()
475                    .map(|(c, d, m)| format!("{c},{d},{m}"))
476                    .collect::<Vec<_>>()
477                    .join(";");
478
479                match self.requester.closing_prices(&ins_codes).await {
480                    Ok(resp) if is_valid_prices_response(&resp) => {
481                        let parts: Vec<&str> = resp.split('@').collect();
482                        for (i, item) in chunk.iter().enumerate() {
483                            let inscode = &item.0;
484                            let newdata = parts.get(i).copied().unwrap_or("").replace(';', "\n");
485                            succs.push(inscode.clone());
486                            fails.remove(inscode);
487
488                            if !newdata.is_empty() {
489                                let data = match state.stored_prices.get(inscode) {
490                                    Some(old) => format!("{old}\n{newdata}"),
491                                    None => newdata.clone(),
492                                };
493                                // last deven = second field of last row
494                                if let Some(last_row) = newdata.split('\n').next_back() {
495                                    if let Some(dv) = last_row.split(',').nth(1) {
496                                        state.lastdevens.insert(inscode.clone(), dv.to_string());
497                                    }
498                                }
499                                if should_cache {
500                                    self.storage.set_item_async(
501                                        &format!("tse.prices.{inscode}"),
502                                        &data,
503                                        false,
504                                    )?;
505                                }
506                                state.stored_prices.insert(inscode.clone(), data);
507                            } else {
508                                state
509                                    .lastdevens
510                                    .insert(inscode.clone(), last_possible_deven.to_string());
511                            }
512                        }
513                    }
514                    _ => {
515                        for item in chunk {
516                            fails.insert(item.0.clone());
517                        }
518                        retry_chunks.push(chunk.clone());
519                    }
520                }
521            }
522
523            if retry_chunks.is_empty() || retries >= retry_count {
524                break;
525            }
526            retries += 1;
527            if retry_delay > 0 {
528                tokio::time::sleep(std::time::Duration::from_millis(retry_delay)).await;
529            }
530            // a chunk that is being retried is no longer a hard fail yet
531            for ch in &retry_chunks {
532                for item in ch {
533                    fails.remove(&item.0);
534                }
535            }
536            chunks = retry_chunks;
537        }
538
539        Ok((succs, fails.into_iter().collect()))
540    }
541
542    // ----- public: get_prices ---------------------------------------------
543
544    /// Port of JS `getPrices`.
545    pub async fn get_prices(
546        &self,
547        symbols: &[String],
548        settings: &PriceSettings,
549    ) -> Result<PricesResult> {
550        let mut result = PricesResult::default();
551        if symbols.is_empty() {
552            return Ok(result);
553        }
554
555        if let Some(err) = self.update_instruments().await? {
556            result.error = Some(err);
557            return Ok(result);
558        }
559
560        let instruments = self.parse_instruments_by_symbol()?;
561        // selection preserving order & not-found tracking
562        let mut selection: Vec<Option<Instrument>> = symbols
563            .iter()
564            .map(|s| instruments.get(s).cloned())
565            .collect();
566        let not_founds: Vec<String> = symbols
567            .iter()
568            .zip(&selection)
569            .filter(|(_, sel)| sel.is_none())
570            .map(|(s, _)| s.clone())
571            .collect();
572        if !not_founds.is_empty() {
573            result.error = Some(ResultError::IncorrectSymbol {
574                symbols: not_founds,
575            });
576            return Ok(result);
577        }
578
579        // ----- merge similar symbols -----
580        let merge = settings.merge_similar_symbols;
581        let mut merges: HashMap<String, Vec<MergeItem>> = HashMap::new();
582        let mut extras_index: isize = -1;
583
584        if merge {
585            let re = Regex::new(&format!("{}(\\d+)", regex::escape(SYMBOL_RENAME_STRING))).unwrap();
586            let roots: HashSet<String> = instruments
587                .values()
588                .filter_map(|i| i.symbol_original.clone())
589                .collect();
590            for r in &roots {
591                merges.insert(r.clone(), Vec::new());
592            }
593            for i in instruments.values() {
594                let renamed_or_root = i
595                    .symbol_original
596                    .clone()
597                    .unwrap_or_else(|| i.symbol.clone());
598                if let Some(v) = merges.get_mut(&renamed_or_root) {
599                    let order = if let Some(_orig) = &i.symbol_original {
600                        re.captures(&i.symbol)
601                            .and_then(|c| c.get(1))
602                            .and_then(|m| m.as_str().parse::<i64>().ok())
603                            .unwrap_or(1)
604                    } else {
605                        1
606                    };
607                    v.push(MergeItem {
608                        sym: i.symbol.clone(),
609                        code: i.ins_code.clone(),
610                        order,
611                    });
612                }
613            }
614            for v in merges.values_mut() {
615                v.sort_by_key(|m| m.order);
616            }
617
618            let selsyms: HashSet<String> = selection
619                .iter()
620                .flatten()
621                .map(|i| i.symbol.clone())
622                .collect();
623            let mut extras: Vec<Instrument> = Vec::new();
624            for ins in selection.iter().flatten() {
625                if let Some(items) = merges.get(&ins.symbol) {
626                    for leaf in items.iter().skip(1) {
627                        if !selsyms.contains(&leaf.sym) {
628                            if let Some(li) = instruments.get(&leaf.sym) {
629                                extras.push(li.clone());
630                            }
631                        }
632                    }
633                }
634            }
635            if !extras.is_empty() {
636                extras_index = selection.len() as isize;
637                selection.extend(extras.into_iter().map(Some));
638            }
639        }
640
641        // ----- update prices (owned state, no leak) -----
642        let mut state = PricesState::default();
643        let update_result = self
644            .update_prices(&mut state, &selection, settings.cache)
645            .await?;
646        let (succs, fails) = match update_result {
647            Ok(v) => v,
648            Err(e) => {
649                result.error = Some(e);
650                return Ok(result);
651            }
652        };
653
654        if !fails.is_empty() {
655            let syms: HashMap<String, String> = selection
656                .iter()
657                .flatten()
658                .map(|i| (i.ins_code.clone(), i.symbol.clone()))
659                .collect();
660            let fail_set: HashSet<String> = fails.iter().cloned().collect();
661            result.error = Some(ResultError::IncompletePriceUpdate {
662                fails: fails.iter().filter_map(|k| syms.get(k).cloned()).collect(),
663                succs: succs.iter().filter_map(|k| syms.get(k).cloned()).collect(),
664            });
665            for sel in selection.iter_mut() {
666                if let Some(ins) = sel {
667                    if fail_set.contains(&ins.ins_code) {
668                        *sel = None;
669                    }
670                }
671            }
672        }
673
674        if merge && extras_index > -1 {
675            selection.truncate(extras_index as usize);
676        }
677
678        // resolve columns
679        let columns: Vec<Column> = settings
680            .columns
681            .iter()
682            .map(|&i| Column::new(i, None))
683            .collect::<Result<_>>()?;
684
685        let all_shares = self.parse_shares_struct()?;
686
687        // ----- merged price assembly -----
688        let mut stored_prices_merged: HashMap<String, String> = HashMap::new();
689        if merge {
690            self.build_merged_prices(&state, &merges, &mut stored_prices_merged)?;
691        }
692
693        // ----- build output -----
694        let textcols: HashSet<&str> = ["companycode", "namelatin", "symbol", "name"]
695            .into_iter()
696            .collect();
697
698        if settings.csv {
699            let headers = if settings.csv_headers {
700                columns
701                    .iter()
702                    .map(|c| c.header.clone())
703                    .collect::<Vec<_>>()
704                    .join(",")
705                    + "\n"
706            } else {
707                String::new()
708            };
709            for instrument in &selection {
710                let Some(instrument) = instrument else {
711                    result.csv.push(None);
712                    continue;
713                };
714                let insdata = self.get_instrument_data(
715                    instrument,
716                    &state,
717                    &merges,
718                    &stored_prices_merged,
719                    &all_shares,
720                    settings,
721                )?;
722                let Some(insdata) = insdata else {
723                    result.csv.push(Some(headers.clone()));
724                    continue;
725                };
726                if insdata.merged {
727                    result.csv.push(Some(MERGED_SYMBOL_CONTENT.to_string()));
728                    continue;
729                }
730                if settings.get_adjust_info_only {
731                    result.csv.push(Some(String::new()));
732                    continue;
733                }
734                let body = insdata
735                    .prices
736                    .iter()
737                    .map(|p| {
738                        columns
739                            .iter()
740                            .map(|c| get_cell(c.name, instrument, p))
741                            .collect::<Vec<_>>()
742                            .join(&settings.csv_delimiter)
743                    })
744                    .collect::<Vec<_>>()
745                    .join("\n");
746                result.csv.push(Some(format!("{headers}{body}")));
747            }
748        } else {
749            for instrument in &selection {
750                let Some(instrument) = instrument else {
751                    result.data.push(None);
752                    continue;
753                };
754                let mut out = InstrumentPrices {
755                    columns: columns
756                        .iter()
757                        .map(|c| (c.header.clone(), Vec::new()))
758                        .collect(),
759                    ..Default::default()
760                };
761                let insdata = self.get_instrument_data(
762                    instrument,
763                    &state,
764                    &merges,
765                    &stored_prices_merged,
766                    &all_shares,
767                    settings,
768                )?;
769                let Some(insdata) = insdata else {
770                    result.data.push(Some(out));
771                    continue;
772                };
773                if insdata.merged {
774                    out.merged = true;
775                    result.data.push(Some(out));
776                    continue;
777                }
778                out.adjust_info = insdata.adjust_info.clone();
779                if settings.get_adjust_info_only {
780                    result.data.push(Some(out));
781                    continue;
782                }
783                for p in &insdata.prices {
784                    for c in &columns {
785                        let cell = get_cell(c.name, instrument, p);
786                        let col = out.columns.get_mut(&c.header).unwrap();
787                        if textcols.contains(c.name) {
788                            col.push(Cell::Text(cell));
789                        } else {
790                            col.push(Cell::Number(cell.parse::<f64>().unwrap_or(f64::NAN)));
791                        }
792                    }
793                }
794                // populate OHLC data
795                insdata.prices.iter().for_each(|item| {
796                    out.ohlc_data.date.push(item.deven.clone());
797                    out.ohlc_data
798                        .open
799                        .push(item.price_first.parse::<f64>().unwrap_or(f64::NAN));
800                    out.ohlc_data
801                        .high
802                        .push(item.price_max.parse::<f64>().unwrap_or(f64::NAN));
803                    out.ohlc_data
804                        .low
805                        .push(item.price_min.parse::<f64>().unwrap_or(f64::NAN));
806                    out.ohlc_data
807                        .close
808                        .push(item.pdr_cot_val.parse::<f64>().unwrap_or(f64::NAN));
809                    out.ohlc_data
810                        .volume
811                        .push(item.qtot_tran5j.parse::<f64>().unwrap_or(f64::NAN));
812                });
813                result.data.push(Some(out));
814            }
815        }
816
817        Ok(result)
818    }
819
820    fn build_merged_prices(
821        &self,
822        state: &PricesState,
823        merges: &HashMap<String, Vec<MergeItem>>,
824        out: &mut HashMap<String, String>,
825    ) -> Result<()> {
826        for items in merges.values() {
827            // collect rows by code
828            let mut day_bounds: Vec<(i64, i64)> = Vec::new();
829            let codes: Vec<String> = items.iter().rev().map(|i| i.code.clone()).collect();
830            let latest_code = match codes.last() {
831                Some(c) => c.clone(),
832                None => continue,
833            };
834            for it in items.iter().rev() {
835                if let Some(data) = state.stored_prices.get(&it.code).filter(|d| !d.is_empty()) {
836                    let rows: Vec<&str> = data.split('\n').collect();
837                    let first = rows
838                        .first()
839                        .and_then(|r| r.split(',').nth(1))
840                        .and_then(|s| s.parse::<i64>().ok())
841                        .unwrap_or(0);
842                    let last = rows
843                        .last()
844                        .and_then(|r| r.split(',').nth(1))
845                        .and_then(|s| s.parse::<i64>().ok())
846                        .unwrap_or(0);
847                    day_bounds.push((first, last));
848                } else {
849                    day_bounds.push((0, 0));
850                }
851            }
852
853            // flatten bounds and detect overlap
854            let flat: Vec<i64> = day_bounds.iter().flat_map(|(a, b)| [*a, *b]).collect();
855            let overlap = flat
856                .iter()
857                .enumerate()
858                .any(|(i, v)| i > 0 && *v < flat[i - 1]);
859
860            if overlap {
861                let fixed = self.merge_overlapping(state, &codes)?;
862                out.insert(latest_code, fixed);
863            } else {
864                let merged = codes
865                    .iter()
866                    .filter_map(|c| state.stored_prices.get(c))
867                    .filter(|d| !d.is_empty())
868                    .cloned()
869                    .collect::<Vec<_>>()
870                    .join("\n");
871                out.insert(latest_code, merged);
872            }
873        }
874        Ok(())
875    }
876
877    /// Port of the overlap-resolution block of `getPrices` (the conflict
878    /// reconciliation using trade counts and InsCode distance).
879    fn merge_overlapping(&self, state: &PricesState, codes: &[String]) -> Result<String> {
880        let mut a: Vec<ClosingPrice> = Vec::new();
881        for code in codes {
882            if let Some(data) = state.stored_prices.get(code).filter(|d| !d.is_empty()) {
883                for row in data.split('\n') {
884                    a.push(ClosingPrice::parse(row)?);
885                }
886            }
887        }
888        if a.is_empty() {
889            return Ok(String::new());
890        }
891
892        let cp_eq = |x: &ClosingPrice, y: &ClosingPrice| x == y;
893        let sum = |xs: &[i64]| xs.iter().sum::<i64>();
894        let ins = |s: &str| s.parse::<i64>().unwrap_or(0);
895
896        let mut m: HashMap<String, ClosingPrice> = HashMap::new();
897        m.insert(a[0].deven.clone(), a[0].clone());
898
899        let len = a.len();
900        if len >= 2 {
901            for i in 1..len - 1 {
902                let prev = a[i - 1].clone();
903                let curr = a[i].clone();
904                let day = curr.deven.clone();
905                let mut select = curr.clone();
906                if let Some(existing) = m.get(&day).cloned() {
907                    select = resolve_conflict(&existing, &curr, &prev, ins, sum);
908                }
909                m.insert(day, select);
910
911                let has_adj =
912                    curr.ins_code == prev.ins_code && curr.price_yesterday != prev.pclosing;
913                if has_adj {
914                    let yday = prev.deven.clone();
915                    let mut select = prev.clone();
916                    if let Some(existing) = m.get(&yday).cloned() {
917                        if cp_eq(&existing, &prev) {
918                            continue;
919                        }
920                        if existing.pclosing != prev.pclosing {
921                            let trades = sum(&[ins(&existing.ztot_tran), ins(&prev.ztot_tran)]);
922                            let mut row = prev.clone();
923                            row.ztot_tran = trades.to_string();
924                            select = row;
925                        } else {
926                            select = resolve_conflict(&existing, &prev, &curr, ins, sum);
927                        }
928                    }
929                    m.insert(yday, select);
930                }
931            }
932        }
933
934        let mut fixed_rows: Vec<ClosingPrice> = m.into_values().collect();
935        fixed_rows.sort_by_key(|r| r.deven.parse::<i64>().unwrap_or(0));
936        Ok(fixed_rows
937            .iter()
938            .map(|r| r.to_csv_row())
939            .collect::<Vec<_>>()
940            .join("\n"))
941    }
942
943    fn get_instrument_data(
944        &self,
945        instrument: &Instrument,
946        state: &PricesState,
947        merges: &HashMap<String, Vec<MergeItem>>,
948        merged: &HashMap<String, String>,
949        all_shares: &[Share],
950        settings: &PriceSettings,
951    ) -> Result<Option<InstrumentDataOut>> {
952        let inscode = &instrument.ins_code;
953        let sym = &instrument.symbol;
954
955        let (prices_raw, inscodes): (Option<String>, HashSet<String>) =
956            if instrument.symbol_original.is_some() {
957                if settings.merge_similar_symbols {
958                    return Ok(Some(InstrumentDataOut {
959                        prices: vec![],
960                        adjust_info: None,
961                        merged: true,
962                    }));
963                }
964                (
965                    state.stored_prices.get(inscode).cloned(),
966                    HashSet::from([inscode.clone()]),
967                )
968            } else {
969                let is_root = merges.contains_key(sym);
970                if is_root {
971                    let codes: HashSet<String> = merges
972                        .get(sym)
973                        .map(|v| v.iter().map(|m| m.code.clone()).collect())
974                        .unwrap_or_default();
975                    (merged.get(inscode).cloned(), codes)
976                } else {
977                    (
978                        state.stored_prices.get(inscode).cloned(),
979                        HashSet::from([inscode.clone()]),
980                    )
981                }
982            };
983
984        let Some(prices_raw) = prices_raw.filter(|p| !p.is_empty()) else {
985            return Ok(None);
986        };
987
988        let mut prices: Vec<ClosingPrice> = prices_raw
989            .split('\n')
990            .map(ClosingPrice::parse)
991            .collect::<Result<_>>()?;
992
993        let mut adjust_info: Option<AdjustInfo> = None;
994        let should_get_info = settings.get_adjust_info || settings.get_adjust_info_only;
995        if settings.adjust_prices > 0 || should_get_info {
996            let related: HashMap<String, Share> = all_shares
997                .iter()
998                .filter(|s| inscodes.contains(&s.ins_code))
999                .map(|s| (s.deven.clone(), s.clone()))
1000                .collect();
1001            let res = adjust(
1002                settings.adjust_prices,
1003                &prices,
1004                &related,
1005                settings.get_adjust_info,
1006                settings.get_adjust_info_only,
1007            );
1008            if settings.adjust_prices > 0 {
1009                prices = res.prices.unwrap_or_default();
1010            }
1011            if should_get_info {
1012                adjust_info = res.info;
1013            }
1014        }
1015
1016        if !settings.days_without_trade {
1017            prices.retain(|p| p.ztot_tran.parse::<i64>().unwrap_or(0) > 0);
1018        }
1019
1020        // Validate date range: start_date <= end_date
1021        let start: i64 = settings.start_date.parse().unwrap_or(0);
1022        if !settings.end_date.is_empty() {
1023            let end: i64 = settings.end_date.parse().unwrap_or(i64::MAX);
1024            if start > 0 && end < i64::MAX && start > end {
1025                // Invalid date range - return empty result
1026                prices.clear();
1027                return Ok(Some(InstrumentDataOut {
1028                    prices,
1029                    adjust_info,
1030                    merged: false,
1031                }));
1032            }
1033        }
1034
1035        prices.retain(|p| p.deven.parse::<i64>().unwrap_or(0) >= start);
1036
1037        // Apply end date filtering if specified
1038        if !settings.end_date.is_empty() {
1039            let end: i64 = settings.end_date.parse().unwrap_or(i64::MAX);
1040            prices.retain(|p| p.deven.parse::<i64>().unwrap_or(0) <= end);
1041        }
1042
1043        // Resample into weekly/monthly Jalali bars when requested. Daily is a
1044        // no-op, so this is free for the default path. Done last so grouping
1045        // sees adjusted, filtered, date-bounded rows.
1046        let prices = group(&prices, settings.period);
1047
1048        Ok(Some(InstrumentDataOut {
1049            prices,
1050            adjust_info,
1051            merged: false,
1052        }))
1053    }
1054}
1055
1056struct InstrumentDataOut {
1057    prices: Vec<ClosingPrice>,
1058    adjust_info: Option<AdjustInfo>,
1059    merged: bool,
1060}
1061
1062#[derive(Debug, Clone)]
1063struct MergeItem {
1064    sym: String,
1065    code: String,
1066    order: i64,
1067}
1068
1069// Two stable passes mirror the JS exactly; collapsing them would change the
1070// tie-breaking order, so the line-by-line form is intentional.
1071fn resolve_conflict(
1072    existing: &ClosingPrice,
1073    candidate: &ClosingPrice,
1074    reference: &ClosingPrice,
1075    ins: impl Fn(&str) -> i64,
1076    sum: impl Fn(&[i64]) -> i64,
1077) -> ClosingPrice {
1078    // pick higher trade count, then lower InsCode distance to `reference`
1079    let ref_ins = ins(&reference.ins_code);
1080    let candidates = [existing, candidate];
1081    let mut ranked: Vec<(&ClosingPrice, i64, i64)> = candidates
1082        .iter()
1083        .map(|c| (*c, ins(&c.ztot_tran), (ins(&c.ins_code) - ref_ins).abs()))
1084        .collect();
1085    // Two stable passes, exactly mirroring the JS:
1086    //   .sort((a,b)=>a[2]-b[2]).sort((a,b)=>b[1]-a[1])
1087    // i.e. order by trade-count desc, ties broken by smaller InsCode distance.
1088    ranked.sort_by_key(|a| a.2);
1089    ranked.sort_by_key(|a| std::cmp::Reverse(a.1));
1090    let chosen = ranked[0].0;
1091    let trades = sum(&[ins(&existing.ztot_tran), ins(&candidate.ztot_tran)]);
1092    let mut row = chosen.clone();
1093    row.ztot_tran = trades.to_string();
1094    row
1095}
1096
1097/// Port of JS `getCell`.
1098fn get_cell(name: &str, instrument: &Instrument, cp: &ClosingPrice) -> String {
1099    match name {
1100        "date" => cp.deven.clone(),
1101        "dateshamsi" => greg_to_shamsi(&cp.deven),
1102        "open" => cp.price_first.clone(),
1103        "high" => cp.price_max.clone(),
1104        "low" => cp.price_min.clone(),
1105        "last" => cp.pdr_cot_val.clone(),
1106        "close" => cp.pclosing.clone(),
1107        "vol" => cp.qtot_tran5j.clone(),
1108        "count" => cp.ztot_tran.clone(),
1109        "value" => cp.qtot_cap.clone(),
1110        "yesterday" => cp.price_yesterday.clone(),
1111        "symbol" => instrument.symbol.clone(),
1112        "name" => instrument.name.clone(),
1113        "namelatin" => instrument.latin_name.clone(),
1114        "companycode" => instrument.company_code.clone(),
1115        _ => String::new(),
1116    }
1117}
1118
1119/// JS regex `/^[\d.,;@-]+$/` test (or empty string).
1120fn is_valid_prices_response(resp: &str) -> bool {
1121    if resp.is_empty() {
1122        return true;
1123    }
1124    resp.chars()
1125        .all(|c| c.is_ascii_digit() || matches!(c, '.' | ',' | ';' | '@' | '-'))
1126}