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]) -> 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
81 ClosingPrice {
82 ins_code: first.ins_code.clone(),
83 deven: last.deven.clone(),
84 pclosing: last.pclosing.clone(),
85 pdr_cot_val: last.pdr_cot_val.clone(),
86 ztot_tran: sum_field(rows.iter().map(|r| r.ztot_tran.as_str())),
87 qtot_tran5j: sum_field(rows.iter().map(|r| r.qtot_tran5j.as_str())),
88 qtot_cap: sum_field(rows.iter().map(|r| r.qtot_cap.as_str())),
89 price_min: low.normalize().to_string(),
90 price_max: high.normalize().to_string(),
91 price_yesterday: first.price_yesterday.clone(),
92 price_first: first.price_first.clone(),
93 }
94}
95
96/// Group daily [`ClosingPrice`] rows into Jalali weekly or monthly OHLCV bars.
97///
98/// Rows should be sorted ascending by `deven`. For [`Period::Daily`] the input
99/// is returned unchanged (cloned). Rows whose `deven` can't be converted to the
100/// Jalali calendar are dropped from weekly/monthly output.
101///
102/// # Examples
103///
104/// ```
105/// use tse_client::{group, ClosingPrice, Period};
106///
107/// let daily = vec![/* ClosingPrice rows sorted by date */];
108/// let weekly = group(&daily, Period::Weekly);
109/// let monthly = group(&daily, Period::Monthly);
110/// let _ = (weekly, monthly);
111/// ```
112pub fn group(prices: &[ClosingPrice], period: Period) -> Vec<ClosingPrice> {
113 if period == Period::Daily || prices.is_empty() {
114 return prices.to_vec();
115 }
116
117 let key_of = |p: &ClosingPrice| -> Option<String> {
118 match period {
119 Period::Weekly => shamsi_week_key(&p.deven),
120 Period::Monthly => shamsi_month_key(&p.deven),
121 Period::Daily => unreachable!(),
122 }
123 };
124
125 let mut out: Vec<ClosingPrice> = Vec::new();
126 let mut current_key: Option<String> = None;
127 let mut bucket: Vec<ClosingPrice> = Vec::new();
128
129 for p in prices {
130 let Some(key) = key_of(p) else {
131 // Unconvertible date: skip the row rather than mis-group it.
132 continue;
133 };
134
135 match ¤t_key {
136 Some(k) if *k == key => bucket.push(p.clone()),
137 Some(_) => {
138 out.push(aggregate(&bucket));
139 bucket.clear();
140 bucket.push(p.clone());
141 current_key = Some(key);
142 }
143 None => {
144 current_key = Some(key);
145 bucket.push(p.clone());
146 }
147 }
148 }
149
150 if !bucket.is_empty() {
151 out.push(aggregate(&bucket));
152 }
153
154 out
155}