kestrel_chartkit/indicator/
atr.rs1use 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 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}