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 across the Pine
4//! catalog.
5//!
6//! ```text
7//! long_stop  = highest(high, length) - mult * atr(length)
8//! short_stop = lowest(low, length)   + mult * atr(length)
9//! // stops only ever tighten (never loosen) while the position direction is unchanged
10//! long_stop  := close[1] > long_stop[1]  ? max(long_stop,  long_stop[1])  : long_stop
11//! short_stop := close[1] < short_stop[1] ? min(short_stop, short_stop[1]) : short_stop
12//! direction flips to long when close > short_stop[1], to short when close < long_stop[1]
13//! ```
14
15use std::collections::HashMap;
16
17use crate::model::Bar;
18use crate::series::Series;
19
20use super::smoothing::Rma;
21use super::{Indicator, IndicatorAlert, IndicatorOutput};
22
23/// Reusable Chandelier Exit engine. `length` sizes both the highest-high/lowest-low lookback and
24/// the internal ATR; `atr_mult` scales the ATR offset from those extremes.
25#[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        // Sharp reversal candle far below the recent range breaches the long stop.
224        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}