kestrel_chartkit/indicator/
chandelier_exit.rs1use std::collections::HashMap;
16
17use crate::model::Bar;
18use crate::series::Series;
19
20use super::smoothing::Rma;
21use super::{Indicator, IndicatorAlert, IndicatorOutput};
22
23#[derive(Debug, Clone)]
26pub struct ChandelierExitEngine {
27 length: usize,
28 atr_mult: f64,
29 tr_rma: Rma,
30 prev_close: Option<f64>,
31 highs: Series<f64>,
32 lows: Series<f64>,
33 long_stop_prev: Option<f64>,
34 short_stop_prev: Option<f64>,
35 direction: i8,
36 alerts: Vec<IndicatorAlert>,
37}
38
39impl ChandelierExitEngine {
40 pub fn new(length: usize, atr_mult: f64) -> Self {
41 let length = length.max(1);
42 Self {
43 length,
44 atr_mult,
45 tr_rma: Rma::new(length),
46 prev_close: None,
47 highs: Series::new(length),
48 lows: Series::new(length),
49 long_stop_prev: None,
50 short_stop_prev: None,
51 direction: 1,
52 alerts: Vec::new(),
53 }
54 }
55
56 pub fn with_defaults() -> Self {
57 Self::new(22, 3.0)
58 }
59}
60
61impl Indicator for ChandelierExitEngine {
62 fn name(&self) -> &str {
63 "chandelier_exit"
64 }
65
66 fn warmup_period(&self) -> usize {
67 self.length
68 }
69
70 fn reset(&mut self) {
71 self.tr_rma.reset();
72 self.prev_close = None;
73 self.highs.reset();
74 self.lows.reset();
75 self.long_stop_prev = None;
76 self.short_stop_prev = None;
77 self.direction = 1;
78 self.alerts.clear();
79 }
80
81 fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
82 self.alerts.clear();
83
84 let tr = match self.prev_close {
85 Some(pc) => (bar.high - bar.low)
86 .max((bar.high - pc).abs())
87 .max((bar.low - pc).abs()),
88 None => bar.high - bar.low,
89 };
90
91 self.highs.push(bar.high);
92 self.lows.push(bar.low);
93 let atr = self.tr_rma.update(tr);
94
95 let (highest_high, lowest_low, atr) = match (
96 self.highs.highest(self.length),
97 self.lows.lowest(self.length),
98 atr,
99 ) {
100 (Some(hh), Some(ll), Some(a)) => (hh, ll, a),
101 _ => {
102 self.prev_close = Some(bar.close);
103 return None;
104 }
105 };
106
107 let raw_long_stop = highest_high - self.atr_mult * atr;
108 let raw_short_stop = lowest_low + self.atr_mult * atr;
109
110 let long_stop_prev = self.long_stop_prev.unwrap_or(raw_long_stop);
111 let short_stop_prev = self.short_stop_prev.unwrap_or(raw_short_stop);
112
113 let long_stop = match self.prev_close {
114 Some(pc) if pc > long_stop_prev => raw_long_stop.max(long_stop_prev),
115 _ => raw_long_stop,
116 };
117 let short_stop = match self.prev_close {
118 Some(pc) if pc < short_stop_prev => raw_short_stop.min(short_stop_prev),
119 _ => raw_short_stop,
120 };
121
122 let mut direction = self.direction;
123 if bar.close > short_stop_prev {
124 direction = 1;
125 } else if bar.close < long_stop_prev {
126 direction = -1;
127 }
128
129 if direction != self.direction {
130 let (kind, note) = if direction == 1 {
131 ("chandelier_flip_long", "Chandelier Exit flipped long")
132 } else {
133 ("chandelier_flip_short", "Chandelier Exit flipped short")
134 };
135 self.alerts.push(IndicatorAlert::new(kind, note, 1.0));
136 }
137
138 self.direction = direction;
139 self.long_stop_prev = Some(long_stop);
140 self.short_stop_prev = Some(short_stop);
141 self.prev_close = Some(bar.close);
142
143 let (stop_value, opposite_stop, state) = if direction == 1 {
144 (long_stop, short_stop, "long")
145 } else {
146 (short_stop, long_stop, "short")
147 };
148
149 let mut extra = HashMap::new();
150 extra.insert("long_stop".to_string(), long_stop);
151 extra.insert("short_stop".to_string(), short_stop);
152
153 Some(
154 IndicatorOutput::with_extra(stop_value, extra)
155 .with_secondary(opposite_stop)
156 .with_state(state),
157 )
158 }
159
160 fn alerts(&self) -> Vec<IndicatorAlert> {
161 self.alerts.clone()
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::*;
168
169 fn trending_up_bars(n: usize) -> Vec<Bar> {
170 (0..n)
171 .map(|i| {
172 let base = 100.0 + i as f64 * 2.0;
173 Bar::new(
174 i as i64 * 60,
175 base,
176 base + 3.0,
177 base - 3.0,
178 base + 1.0,
179 100.0,
180 )
181 })
182 .collect()
183 }
184
185 #[test]
186 fn test_warmup_returns_none_then_emits() {
187 let mut engine = ChandelierExitEngine::new(5, 3.0);
188 let bars = trending_up_bars(10);
189 let mut outputs = Vec::new();
190 for bar in &bars {
191 outputs.push(engine.on_bar(bar));
192 }
193 assert!(outputs[..4].iter().all(|o| o.is_none()));
194 assert!(outputs[4..].iter().all(|o| o.is_some()));
195 }
196
197 #[test]
198 fn test_stop_only_moves_favorably_while_trending() {
199 let mut engine = ChandelierExitEngine::new(5, 3.0);
200 let bars = trending_up_bars(20);
201 let mut long_stops = Vec::new();
202 for bar in &bars {
203 if let Some(out) = engine.on_bar(bar) {
204 if out.state.as_deref() == Some("long") {
205 long_stops.push(out.value);
206 }
207 }
208 }
209 assert!(long_stops.len() > 2);
210 for pair in long_stops.windows(2) {
211 assert!(
212 pair[1] >= pair[0] - 1e-9,
213 "long stop must never loosen while price keeps making new highs: {:?}",
214 pair
215 );
216 }
217 }
218
219 #[test]
220 fn test_direction_flips_on_stop_breach() {
221 let mut engine = ChandelierExitEngine::new(3, 1.0);
222 let mut bars = trending_up_bars(6);
223 bars.push(Bar::new(600, 90.0, 91.0, 60.0, 61.0, 100.0));
225
226 let mut flipped_short = false;
227 for bar in &bars {
228 if let Some(out) = engine.on_bar(bar) {
229 if out.state.as_deref() == Some("short") {
230 flipped_short = true;
231 }
232 }
233 }
234 assert!(flipped_short, "sharp reversal must flip direction to short");
235 }
236
237 #[test]
238 fn test_reset_clears_state() {
239 let mut engine = ChandelierExitEngine::new(3, 2.0);
240 for bar in trending_up_bars(6) {
241 engine.on_bar(&bar);
242 }
243 engine.reset();
244 assert_eq!(engine.on_bar(&trending_up_bars(1)[0]), None);
245 }
246}