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 ¤t_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}