Skip to main content

tse_client/
adjust.rs

1//! Price-adjustment logic, ported from the JS `adjust()` using `rust_decimal`
2//! for exact decimal arithmetic with banker's rounding (ROUND_HALF_EVEN).
3
4use std::collections::HashMap;
5use std::str::FromStr;
6
7use rust_decimal::{Decimal, RoundingStrategy};
8
9use crate::models::{ClosingPrice, Share};
10
11/// A single price-adjustment event (capital increase or dividend).
12#[derive(Debug, Clone)]
13pub struct AdjustEvent {
14    pub kind: String, // "capital increase" | "dividend"
15    pub increase_pct: Option<String>,
16    pub old_shares: Option<String>,
17    pub new_shares: Option<String>,
18    pub dividend: Option<String>,
19    pub price_before_event: String,
20    pub price_after_event: String,
21    pub date: String,
22}
23
24/// Adjustment info collected when `get_adjust_info(_only)` is set.
25#[derive(Debug, Clone, Default)]
26pub struct AdjustInfo {
27    pub events: Vec<AdjustEvent>,
28    pub valid_gpl_ratio: Option<bool>,
29}
30
31/// Result of `adjust`.
32#[derive(Debug, Clone)]
33pub struct AdjustResult {
34    pub prices: Option<Vec<ClosingPrice>>,
35    pub info: Option<AdjustInfo>,
36}
37
38fn dec(s: &str) -> Decimal {
39    Decimal::from_str(s).unwrap_or(Decimal::ZERO)
40}
41
42fn round_fixed(v: Decimal, places: u32) -> String {
43    // Matches `.toDecimalPlaces(n).toFixed(n)` — half-even then fixed format.
44    let r = v.round_dp_with_strategy(places, RoundingStrategy::MidpointNearestEven);
45    format!("{:.*}", places as usize, r)
46}
47
48fn round_int(v: Decimal) -> String {
49    let r = v.round_dp_with_strategy(0, RoundingStrategy::MidpointNearestEven);
50    // `.toDecimalPlaces(0).toString()` — integer string, no trailing dot.
51    r.normalize().to_string()
52}
53
54/// Port of the JS `adjust(cond, closingPrices, shares, getInfo, getInfoOnly)`.
55///
56/// `shares` maps a DEven string to the corresponding `Share` record.
57pub fn adjust(
58    cond: u8,
59    closing_prices: &[ClosingPrice],
60    shares: &HashMap<String, Share>,
61    get_info: bool,
62    get_info_only: bool,
63) -> AdjustResult {
64    let cp = closing_prices;
65    let len = cp.len();
66    let should_get_info = get_info || get_info_only;
67
68    let mut info = AdjustInfo::default();
69
70    // Default result mirrors the JS early/default `res`.
71    let default_prices = if get_info_only {
72        None
73    } else {
74        Some(cp.to_vec())
75    };
76    let mut result = AdjustResult {
77        prices: default_prices.clone(),
78        info: if should_get_info {
79            Some(info.clone())
80        } else {
81            None
82        },
83    };
84
85    if !((cond == 1 || cond == 2 || should_get_info) && len > 1) {
86        return result;
87    }
88
89    let mut gaps = Decimal::ZERO;
90    let mut coef = Decimal::ONE;
91    let mut adjusted: Vec<ClosingPrice> = Vec::with_capacity(len);
92    adjusted.push(cp[len - 1].clone());
93
94    if cond == 1 || should_get_info {
95        for i in (0..=len - 2).rev() {
96            let curr = &cp[i];
97            let next = &cp[i + 1];
98            if dec(&curr.pclosing) != dec(&next.price_yesterday) && curr.ins_code == next.ins_code {
99                gaps += Decimal::ONE;
100            }
101        }
102    }
103
104    let gaps_to_lifespan_ratio = gaps / Decimal::from(len as u64);
105    let has_valid_ratio = gaps_to_lifespan_ratio < dec("0.08");
106    info.valid_gpl_ratio = Some(has_valid_ratio);
107
108    if !((cond == 1 && has_valid_ratio) || cond == 2 || should_get_info) {
109        // Recompute info-only result so valid_gpl_ratio is surfaced.
110        result.info = if should_get_info { Some(info) } else { None };
111        return result;
112    }
113
114    for i in (0..=len - 2).rev() {
115        let curr = &cp[i];
116        let next = &cp[i + 1];
117        let prices_dont_match =
118            dec(&curr.pclosing) != dec(&next.price_yesterday) && curr.ins_code == next.ins_code;
119        let target_share = shares.get(&next.deven);
120
121        if should_get_info && prices_dont_match && (has_valid_ratio || target_share.is_some()) {
122            let price_before_event = curr.pclosing.clone();
123            let price_after_event = next.price_yesterday.clone();
124            let date = curr.deven.clone();
125
126            let event = if let Some(ts) = target_share {
127                let old_shares = Decimal::from(ts.number_of_share_old);
128                let new_shares = Decimal::from(ts.number_of_share_new);
129                let increase_pct = if old_shares.is_zero() {
130                    Decimal::ZERO
131                } else {
132                    (new_shares - old_shares) / old_shares
133                };
134                AdjustEvent {
135                    kind: "capital increase".to_string(),
136                    increase_pct: Some(increase_pct.normalize().to_string()),
137                    old_shares: Some(ts.number_of_share_old.to_string()),
138                    new_shares: Some(ts.number_of_share_new.to_string()),
139                    dividend: None,
140                    price_before_event,
141                    price_after_event,
142                    date,
143                }
144            } else {
145                let dividend = dec(&price_before_event) - dec(&price_after_event);
146                AdjustEvent {
147                    kind: "dividend".to_string(),
148                    increase_pct: None,
149                    old_shares: None,
150                    new_shares: None,
151                    dividend: Some(dividend.normalize().to_string()),
152                    price_before_event,
153                    price_after_event,
154                    date,
155                }
156            };
157            info.events.push(event);
158        }
159
160        if get_info_only {
161            continue;
162        }
163
164        if cond == 1 && prices_dont_match {
165            coef = coef * dec(&next.price_yesterday) / dec(&curr.pclosing);
166        } else if cond == 2 && prices_dont_match {
167            if let Some(ts) = target_share {
168                let old_shares = Decimal::from(ts.number_of_share_old);
169                let new_shares = Decimal::from(ts.number_of_share_new);
170                coef = coef * old_shares / new_shares;
171            }
172        }
173
174        let close = round_fixed(coef * dec(&curr.pclosing), 2);
175        let last = round_fixed(coef * dec(&curr.pdr_cot_val), 2);
176        let low = round_int(coef * dec(&curr.price_min));
177        let high = round_int(coef * dec(&curr.price_max));
178        let yday = round_int(coef * dec(&curr.price_yesterday));
179        let first = round_fixed(coef * dec(&curr.price_first), 2);
180
181        adjusted.push(ClosingPrice {
182            ins_code: curr.ins_code.clone(),
183            deven: curr.deven.clone(),
184            pclosing: close,
185            pdr_cot_val: last,
186            ztot_tran: curr.ztot_tran.clone(),
187            qtot_tran5j: curr.qtot_tran5j.clone(),
188            qtot_cap: curr.qtot_cap.clone(),
189            price_min: low,
190            price_max: high,
191            price_yesterday: yday,
192            price_first: first,
193        });
194    }
195
196    adjusted.reverse();
197
198    AdjustResult {
199        prices: if get_info_only { None } else { Some(adjusted) },
200        info: if should_get_info { Some(info) } else { None },
201    }
202}