Skip to main content

kestrel_chartkit/indicator/
atr.rs

1use std::collections::HashMap;
2
3use crate::model::Bar;
4
5use super::smoothing::{crossed_over, crossed_under, Rma};
6use super::{Indicator, IndicatorAlert, IndicatorOutput};
7
8#[derive(Debug, Clone)]
9pub struct Atr {
10    prev_close: Option<f64>,
11    tr_rma: Rma,
12    signal_rma: Rma,
13
14    prev_atr_disp: Option<f64>,
15    prev_signal: Option<f64>,
16    bars_seen: usize,
17    warmup_period: usize,
18
19    alerts: AtrAlerts,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Default)]
23pub struct AtrAlerts {
24    pub expansion: bool,
25    pub contraction: bool,
26    pub regime_strength: f64,
27}
28
29impl Atr {
30    pub fn new(atr_len: usize, sig_len: usize) -> Self {
31        Self {
32            prev_close: None,
33            tr_rma: Rma::new(atr_len),
34            signal_rma: Rma::new(sig_len),
35            prev_atr_disp: None,
36            prev_signal: None,
37            bars_seen: 0,
38            warmup_period: atr_len + sig_len - 1,
39            alerts: AtrAlerts::default(),
40        }
41    }
42
43    pub fn with_defaults() -> Self {
44        // Matches the registry's "atr" catalog default (atr_len=14, sig_len=20) -- sig_len was
45        // previously 14 here, silently diverging from the registry-built default.
46        Self::new(14, 20)
47    }
48
49    pub fn with_period(atr_len: usize) -> Self {
50        Self::new(atr_len, 14)
51    }
52}
53
54impl Indicator for Atr {
55    fn name(&self) -> &str {
56        "atr"
57    }
58
59    fn warmup_period(&self) -> usize {
60        self.warmup_period
61    }
62
63    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
64        self.alerts = AtrAlerts::default();
65        self.bars_seen += 1;
66
67        let tr = match self.prev_close {
68            None => bar.high - bar.low,
69            Some(prev_close) => (bar.high - bar.low)
70                .max((bar.high - prev_close).abs())
71                .max((bar.low - prev_close).abs()),
72        };
73        self.prev_close = Some(bar.close);
74
75        let atr_raw = self.tr_rma.update(tr)?;
76        let atr_disp = if bar.close > 0.0 {
77            100.0 * atr_raw / bar.close
78        } else {
79            0.0
80        };
81        let atr_signal = self.signal_rma.update(atr_disp)?;
82
83        if let (Some(prev_disp), Some(prev_sig)) = (self.prev_atr_disp, self.prev_signal) {
84            self.alerts.expansion = crossed_over(prev_disp, prev_sig, atr_disp, atr_signal);
85            self.alerts.contraction = crossed_under(prev_disp, prev_sig, atr_disp, atr_signal);
86            self.alerts.regime_strength = if atr_signal != 0.0 {
87                ((atr_disp - atr_signal) / atr_signal).abs().clamp(0.0, 1.0)
88            } else {
89                0.0
90            };
91        }
92        self.prev_atr_disp = Some(atr_disp);
93        self.prev_signal = Some(atr_signal);
94
95        let mut extra = HashMap::new();
96        extra.insert("signal".to_string(), atr_signal);
97
98        Some(IndicatorOutput::with_extra(atr_disp, extra))
99    }
100
101    fn reset(&mut self) {
102        self.prev_close = None;
103        self.tr_rma.reset();
104        self.signal_rma.reset();
105        self.prev_atr_disp = None;
106        self.prev_signal = None;
107        self.bars_seen = 0;
108        self.alerts = AtrAlerts::default();
109    }
110
111    fn alerts(&self) -> Vec<IndicatorAlert> {
112        let a = self.alerts;
113        let mut out = Vec::new();
114        if a.expansion {
115            out.push(IndicatorAlert {
116                kind: "expansion".to_string(),
117                note: "ATR · VOLA EXPANSION".to_string(),
118                strength: a.regime_strength,
119            });
120        }
121        if a.contraction {
122            out.push(IndicatorAlert {
123                kind: "contraction".to_string(),
124                note: "ATR · VOLA CONTRACTION".to_string(),
125                strength: a.regime_strength,
126            });
127        }
128        out
129    }
130}