Skip to main content

kestrel_chartkit/indicator/
chandelier_exit.rs

1//! Chandelier Exit: an ATR-trailing stop that flips direction when price closes beyond the
2//! opposite stop, ratcheting only in the favorable direction while a position is held. A
3//! reusable engine instead of the ad-hoc, per-script duplication of this formula.
4//!
5//! ```text
6//! long_stop  = highest(high, length) - mult * atr(length)
7//! short_stop = lowest(low, length)   + mult * atr(length)
8//! // stops only ever tighten (never loosen) while the position direction is unchanged
9//! long_stop  := close[1] > long_stop[1]  ? max(long_stop,  long_stop[1])  : long_stop
10//! short_stop := close[1] < short_stop[1] ? min(short_stop, short_stop[1]) : short_stop
11//! direction flips to long when close > short_stop[1], to short when close < long_stop[1]
12//! ```
13
14use std::collections::HashMap;
15
16use crate::model::Bar;
17use crate::series::Series;
18
19use super::smoothing::Rma;
20use super::{Indicator, IndicatorAlert, IndicatorOutput};
21
22/// Reusable Chandelier Exit engine. `length` sizes both the highest-high/lowest-low lookback and
23/// the internal ATR; `atr_mult` scales the ATR offset from those extremes.
24///
25/// The ATR is Wilder's ([`Rma`], first true range `high - low`). A stop only tightens while the
26/// previous close was on its side of the previous stop. The direction starts long, turns long on a
27/// close above the previous short stop and short on a close below the previous long stop.
28/// `value` is the stop of the current direction, `secondary` the other one; `extra["long_stop"]`
29/// and `extra["short_stop"]` carry both, `state` is `long` or `short`. First output: with the
30/// `length`-th bar. [`Indicator::reset`] clears all state.
31#[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        // Sharp reversal candle far below the recent range breaches the long stop.
230        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}