Skip to main content

kestrel_chartkit/indicator/
atr.rs

1use std::collections::HashMap;
2
3#[cfg(feature = "serde")]
4use serde::{Deserialize, Serialize};
5
6use crate::model::Bar;
7
8use super::smoothing::{crossed_over, crossed_under, Ema, Rma, Sma, Wma};
9use super::{Indicator, IndicatorAlert, IndicatorOutput};
10
11/// How the true range is averaged.
12///
13/// This is the ATR's own smoothing. The separate averaging of the percentage series into
14/// `extra["signal"]` is not affected — it stays Wilder's, so the alerts keep their meaning.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
16#[cfg_attr(
17    feature = "serde",
18    derive(Serialize, Deserialize),
19    serde(rename_all = "snake_case")
20)]
21pub enum TrueRangeSmoothing {
22    /// Wilder's smoothing, `alpha = 1/N`, seeded with the SMA of the first `N` true ranges. The
23    /// default, and the historical behaviour of this indicator.
24    #[default]
25    Rma,
26    /// Plain average of the last `N` true ranges.
27    Sma,
28    /// Exponential, `alpha = 2/(N+1)`, seeded with the first true range. Published from the
29    /// `N`-th bar on, so the output start does not depend on the method.
30    Ema,
31    /// Linearly weighted over the last `N` true ranges, heaviest on the most recent.
32    Wma,
33}
34
35/// The true-range average in whichever form was selected. Every mode publishes from the `N`-th
36/// true range on, so switching the method moves the values, never the first output.
37#[derive(Debug, Clone)]
38enum TrSmoother {
39    Rma(Rma),
40    Sma(Sma),
41    Ema { ema: Ema, len: usize, seen: usize },
42    Wma(Wma),
43}
44
45impl TrSmoother {
46    fn new(method: TrueRangeSmoothing, len: usize) -> Self {
47        match method {
48            TrueRangeSmoothing::Rma => Self::Rma(Rma::new(len)),
49            TrueRangeSmoothing::Sma => Self::Sma(Sma::new(len)),
50            TrueRangeSmoothing::Ema => Self::Ema {
51                ema: Ema::new(len),
52                len,
53                seen: 0,
54            },
55            TrueRangeSmoothing::Wma => Self::Wma(Wma::new(len)),
56        }
57    }
58
59    fn update(&mut self, tr: f64) -> Option<f64> {
60        match self {
61            Self::Rma(rma) => rma.update(tr),
62            Self::Sma(sma) => sma.update(tr),
63            Self::Ema { ema, len, seen } => {
64                let value = ema.update(tr)?;
65                *seen += 1;
66                (*seen >= *len).then_some(value)
67            }
68            Self::Wma(wma) => wma.update(tr),
69        }
70    }
71
72    fn reset(&mut self) {
73        match self {
74            Self::Rma(rma) => rma.reset(),
75            Self::Sma(sma) => sma.reset(),
76            Self::Ema { ema, seen, .. } => {
77                ema.reset();
78                *seen = 0;
79            }
80            Self::Wma(wma) => wma.reset(),
81        }
82    }
83}
84
85/// Average True Range, emitted in two units.
86///
87/// True range: `TR_1 = high - low`, then
88/// `TR_t = max(high - low, |high - close_{t-1}|, |low - close_{t-1}|)` — both gap terms are
89/// absent on the first bar because there is no previous close.
90///
91/// Averaged with [`TrueRangeSmoothing`], Wilder's by default: the seed is the SMA of the first
92/// `atr_len` true ranges, then `ATR_t = ATR_{t-1} + (TR_t - ATR_{t-1}) / atr_len`. Every method
93/// publishes from the `atr_len`-th true range on, so the choice changes the values but not when
94/// they start.
95///
96/// Per-bar outputs:
97/// - `value`: `100 * ATR / close`, in percent of the closing price (0 for `close <= 0`).
98/// - `extra["raw"]`: the same ATR in the series' price units — neither a second calculation nor
99///   a back-conversion from the percentage. A price distance, not money or contract risk: a
100///   monetary amount only follows from contract size and tick value (see [`crate::contract`]).
101/// - `extra["signal"]`: `Rma_{sig_len}` over the percentage series, in percent.
102///
103/// First output: with the `sig_len`-th percentage observation, i.e. after `atr_len + sig_len - 1`
104/// bars — there is no partial output before that, `raw` included. [`Indicator::reset`] clears the
105/// previous close, both smoothers and the alerts, so the next series starts deterministically.
106#[derive(Debug, Clone)]
107pub struct Atr {
108    atr_len: usize,
109    smoothing: TrueRangeSmoothing,
110    prev_close: Option<f64>,
111    tr_average: TrSmoother,
112    signal_rma: Rma,
113
114    prev_atr_disp: Option<f64>,
115    prev_signal: Option<f64>,
116    bars_seen: usize,
117    warmup_period: usize,
118
119    alerts: AtrAlerts,
120}
121
122#[derive(Debug, Clone, Copy, PartialEq, Default)]
123pub struct AtrAlerts {
124    pub expansion: bool,
125    pub contraction: bool,
126    pub regime_strength: f64,
127}
128
129impl Atr {
130    pub fn new(atr_len: usize, sig_len: usize) -> Self {
131        Self {
132            atr_len,
133            smoothing: TrueRangeSmoothing::Rma,
134            prev_close: None,
135            tr_average: TrSmoother::new(TrueRangeSmoothing::Rma, atr_len),
136            signal_rma: Rma::new(sig_len),
137            prev_atr_disp: None,
138            prev_signal: None,
139            bars_seen: 0,
140            warmup_period: atr_len + sig_len - 1,
141            alerts: AtrAlerts::default(),
142        }
143    }
144
145    pub fn with_defaults() -> Self {
146        // Matches the registry's "atr" catalog default (atr_len=14, sig_len=20) -- sig_len was
147        // previously 14 here, silently diverging from the registry-built default.
148        Self::new(14, 20)
149    }
150
151    pub fn with_period(atr_len: usize) -> Self {
152        Self::new(atr_len, 14)
153    }
154
155    /// Selects how the true range is averaged; see [`TrueRangeSmoothing`].
156    ///
157    /// Additive to the existing constructors, which keep Wilder's. The signal line stays
158    /// Wilder-smoothed either way — carrying this choice over to it would change the alerts
159    /// without anyone asking for that.
160    ///
161    /// Resets the true-range average, so this belongs before the first bar.
162    pub fn with_smoothing(mut self, method: TrueRangeSmoothing) -> Self {
163        self.smoothing = method;
164        self.tr_average = TrSmoother::new(method, self.atr_len);
165        self
166    }
167
168    pub fn smoothing(&self) -> TrueRangeSmoothing {
169        self.smoothing
170    }
171}
172
173impl Indicator for Atr {
174    fn name(&self) -> &str {
175        "atr"
176    }
177
178    fn warmup_period(&self) -> usize {
179        self.warmup_period
180    }
181
182    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
183        self.alerts = AtrAlerts::default();
184        self.bars_seen += 1;
185
186        let tr = match self.prev_close {
187            None => bar.high - bar.low,
188            Some(prev_close) => (bar.high - bar.low)
189                .max((bar.high - prev_close).abs())
190                .max((bar.low - prev_close).abs()),
191        };
192        self.prev_close = Some(bar.close);
193
194        let atr_raw = self.tr_average.update(tr)?;
195        let atr_disp = if bar.close > 0.0 {
196            100.0 * atr_raw / bar.close
197        } else {
198            0.0
199        };
200        let atr_signal = self.signal_rma.update(atr_disp)?;
201
202        if let (Some(prev_disp), Some(prev_sig)) = (self.prev_atr_disp, self.prev_signal) {
203            self.alerts.expansion = crossed_over(prev_disp, prev_sig, atr_disp, atr_signal);
204            self.alerts.contraction = crossed_under(prev_disp, prev_sig, atr_disp, atr_signal);
205            self.alerts.regime_strength = if atr_signal != 0.0 {
206                ((atr_disp - atr_signal) / atr_signal).abs().clamp(0.0, 1.0)
207            } else {
208                0.0
209            };
210        }
211        self.prev_atr_disp = Some(atr_disp);
212        self.prev_signal = Some(atr_signal);
213
214        let mut extra = HashMap::new();
215        extra.insert("signal".to_string(), atr_signal);
216        extra.insert("raw".to_string(), atr_raw);
217
218        Some(IndicatorOutput::with_extra(atr_disp, extra))
219    }
220
221    fn reset(&mut self) {
222        self.prev_close = None;
223        self.tr_average.reset();
224        self.signal_rma.reset();
225        self.prev_atr_disp = None;
226        self.prev_signal = None;
227        self.bars_seen = 0;
228        self.alerts = AtrAlerts::default();
229    }
230
231    fn alerts(&self) -> Vec<IndicatorAlert> {
232        let a = self.alerts;
233        let mut out = Vec::new();
234        if a.expansion {
235            out.push(IndicatorAlert {
236                kind: "expansion".to_string(),
237                note: "ATR · VOLA EXPANSION".to_string(),
238                strength: a.regime_strength,
239            });
240        }
241        if a.contraction {
242            out.push(IndicatorAlert {
243                kind: "contraction".to_string(),
244                note: "ATR · VOLA CONTRACTION".to_string(),
245                strength: a.regime_strength,
246            });
247        }
248        out
249    }
250}