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