Skip to main content

tse_client/
group.rs

1//! Weekly / monthly price grouping based on the Jalali (Solar Hijri) calendar.
2//!
3//! Daily [`ClosingPrice`] rows are aggregated into weekly or monthly OHLCV
4//! bars using Jalali calendar boundaries:
5//!
6//! - **Weekly** groups run from Saturday (شنبه) through Friday (جمعه), the
7//!   conventional Iranian trading week.
8//! - **Monthly** groups follow the Jalali month (e.g. Farvardin, Ordibehesht).
9//!
10//! Aggregation rules per group (matching common OHLCV resampling):
11//!
12//! - `price_first` (open)      = first row's open
13//! - `price_max` (high)        = max high across the group
14//! - `price_min` (low)         = min low across the group
15//! - `pclosing` (close)        = last row's close
16//! - `pdr_cot_val` (last)      = last row's last trade price
17//! - `price_yesterday`         = first row's yesterday price
18//! - `qtot_tran5j` (volume)    = sum of volume
19//! - `ztot_tran` (count)       = sum of trade count
20//! - `qtot_cap` (value)        = sum of value
21//! - `deven`                   = the last (most recent) trading day in the group
22//! - `ins_code`                = carried over from the rows
23//!
24//! Rows are assumed to be sorted ascending by `deven`; the result is sorted the
25//! same way. Rows whose dates can't be converted to the Jalali calendar are
26//! skipped.
27
28use std::str::FromStr;
29
30use rust_decimal::Decimal;
31
32use crate::models::ClosingPrice;
33use crate::util::{shamsi_month_key, shamsi_week_key};
34
35/// The grouping period for [`group`].
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum Period {
38    /// No grouping; daily rows are returned unchanged.
39    #[default]
40    Daily,
41    /// Group into Jalali weeks (Saturday–Friday).
42    Weekly,
43    /// Group into Jalali (Solar Hijri) calendar months.
44    Monthly,
45}
46
47fn dec(s: &str) -> Decimal {
48    Decimal::from_str(s).unwrap_or(Decimal::ZERO)
49}
50
51/// Sum a numeric string field, preserving an integer-style textual form.
52fn sum_field<'a, I>(values: I) -> String
53where
54    I: IntoIterator<Item = &'a str>,
55{
56    let total: Decimal = values.into_iter().map(dec).sum();
57    total.normalize().to_string()
58}
59
60/// Aggregate the rows of a single group into one [`ClosingPrice`] bar.
61///
62/// `rows` must be non-empty and ordered ascending by `deven`.
63fn aggregate(rows: &[ClosingPrice], key: &str) -> ClosingPrice {
64    let first = &rows[0];
65    let last = &rows[rows.len() - 1];
66
67    // High = max of highs, Low = min of lows (compared as decimals).
68    let mut high = dec(&first.price_max);
69    let mut low = dec(&first.price_min);
70    for r in &rows[1..] {
71        let h = dec(&r.price_max);
72        if h > high {
73            high = h;
74        }
75        let l = dec(&r.price_min);
76        if l < low {
77            low = l;
78        }
79    }
80    ClosingPrice {
81        ins_code: first.ins_code.clone(),
82        deven: key.to_string(),
83        pclosing: last.pclosing.clone(),
84        pdr_cot_val: last.pdr_cot_val.clone(),
85        ztot_tran: sum_field(rows.iter().map(|r| r.ztot_tran.as_str())),
86        qtot_tran5j: sum_field(rows.iter().map(|r| r.qtot_tran5j.as_str())),
87        qtot_cap: sum_field(rows.iter().map(|r| r.qtot_cap.as_str())),
88        price_min: low.normalize().to_string(),
89        price_max: high.normalize().to_string(),
90        price_yesterday: first.price_yesterday.clone(),
91        price_first: first.price_first.clone(),
92    }
93}
94
95/// Group daily [`ClosingPrice`] rows into Jalali weekly or monthly OHLCV bars.
96///
97/// Rows should be sorted ascending by `deven`. For [`Period::Daily`] the input
98/// is returned unchanged (cloned). Rows whose `deven` can't be converted to the
99/// Jalali calendar are dropped from weekly/monthly output.
100///
101/// # Examples
102///
103/// ```
104/// use tse_client::{group, ClosingPrice, Period};
105///
106/// let daily = vec![/* ClosingPrice rows sorted by date */];
107/// let weekly = group(&daily, Period::Weekly);
108/// let monthly = group(&daily, Period::Monthly);
109/// let _ = (weekly, monthly);
110/// ```
111pub fn group(prices: &[ClosingPrice], period: Period) -> Vec<ClosingPrice> {
112    if period == Period::Daily || prices.is_empty() {
113        return prices.to_vec();
114    }
115
116    let key_of = |p: &ClosingPrice| -> Option<String> {
117        match period {
118            Period::Weekly => shamsi_week_key(&p.deven),
119            Period::Monthly => shamsi_month_key(&p.deven),
120            Period::Daily => unreachable!(),
121        }
122    };
123
124    let mut out: Vec<ClosingPrice> = Vec::new();
125    let mut current_key: Option<String> = None;
126    let mut bucket: Vec<ClosingPrice> = Vec::new();
127
128    for p in prices {
129        let Some(key) = key_of(p) else {
130            // Unconvertible date: skip the row rather than mis-group it.
131            continue;
132        };
133        match &current_key {
134            Some(k) if *k == key => bucket.push(p.clone()),
135            Some(ck) => {
136                out.push(aggregate(&bucket, &ck));
137                bucket.clear();
138                bucket.push(p.clone());
139                current_key = Some(key);
140            }
141            None => {
142                current_key = Some(key);
143                bucket.push(p.clone());
144            }
145        }
146    }
147
148    if !bucket.is_empty() {
149        out.push(aggregate(
150            &bucket,
151            &key_of(&bucket.last().unwrap()).unwrap(),
152        ));
153    }
154
155    out
156}