Skip to main content

egui_charts/studies/builtin/
chandelier_exit.rs

1use super::clamp_param;
2use crate::model::Bar;
3use crate::studies::palette;
4use crate::studies::stepper::Stepper;
5use crate::studies::{ATR, Indicator, IndicatorValue, IndicatorValues};
6use egui::Color32;
7
8/// Chandelier Exit
9/// Trailing stop-loss indicator: the highest high or lowest low of the last `period` bars, minus
10/// or plus `multiplier` times Wilder's ATR over `atr_period`. A value needs a full window as well
11/// as an ATR value.
12/// Developed by Chuck LeBeau
13///
14/// The `period` input sets both lengths, as studies saved with a single length expect;
15/// `atr_period`, which follows it in `inputs()`, then sets the ATR length alone.
16#[derive(Clone)]
17pub struct ChandelierExit {
18    /// Lookback period (typically 22)
19    period: usize,
20    /// ATR period (typically 22)
21    atr_period: usize,
22    /// ATR multiplier (typically 3.0)
23    multiplier: f64,
24    values: IndicatorValues,
25    atr: ATR,
26    stepper: Stepper<(usize, std::collections::VecDeque<[f64; 2]>)>,
27    /// Colors: [Long exit, Short exit]
28    colors: Vec<Color32>,
29    visible: bool,
30}
31
32impl ChandelierExit {
33    /// `multiplier` is clamped into 0 to `f64::MAX`, NaN to 0.
34    pub fn new(period: usize, multiplier: f64) -> Self {
35        Self {
36            period: period.max(1),
37            atr_period: period.max(1),
38            multiplier: clamp_param(multiplier, 0.0, f64::MAX),
39            values: IndicatorValues::new(),
40            atr: ATR::new(period),
41            stepper: Stepper::default(),
42            colors: vec![
43                palette::BLUE,   // Long exit
44                palette::ORANGE, // Short exit
45            ],
46            visible: true,
47        }
48    }
49
50    /// Set the length of the ATR, clamped to at least one. `new` sets it to the lookback period.
51    pub fn with_atr_period(mut self, period: usize) -> Self {
52        self.atr_period = period.max(1);
53        self.atr = ATR::new(self.atr_period);
54        self
55    }
56
57    /// Set plot colors in the order returned by [`Indicator::line_names`].
58    pub fn with_colors(mut self, colors: Vec<Color32>) -> Self {
59        self.colors = colors;
60        self
61    }
62}
63
64impl Default for ChandelierExit {
65    fn default() -> Self {
66        Self::new(22, 3.0)
67    }
68}
69
70impl Indicator for ChandelierExit {
71    crate::studies::inputs::study_inputs!(self;
72        period: length("Lookback period", 1, usize::MAX),
73        atr_period: length("ATR period", 1, usize::MAX),
74        multiplier: number("Multiplier", 0.0, f64::MAX)
75    ; changed => {
76        // Before the ATR input existed, `period` set both lengths; saved studies still do.
77        if changed == "period" {
78            self.atr_period = self.period;
79        }
80    });
81
82    fn name(&self) -> &str {
83        "Chandelier"
84    }
85
86    fn desc(&self) -> &str {
87        "Chandelier Exit - ATR-based trailing stop"
88    }
89
90    fn calculate(&mut self, data: &[Bar]) {
91        self.update(data, 0);
92    }
93
94    fn update(&mut self, data: &[Bar], from: usize) {
95        if from == 0 {
96            self.atr = ATR::new(self.atr_period);
97        }
98        self.atr.update(data, from);
99        self.stepper
100            .run(data, from, &mut self.values, |state, bar| {
101                let index = state.0;
102                state.0 += 1;
103                if ![bar.high, bar.low, bar.close].iter().all(|x| x.is_finite()) {
104                    return IndicatorValue::None;
105                }
106                state.1.push_back([bar.high, bar.low]);
107                if state.1.len() > self.period {
108                    state.1.pop_front();
109                }
110                if state.1.len() < self.period {
111                    return IndicatorValue::None;
112                }
113                let Some(atr) = self.atr.values().value(0, index) else {
114                    return IndicatorValue::None;
115                };
116                let high = state
117                    .1
118                    .iter()
119                    .map(|v| v[0])
120                    .fold(f64::NEG_INFINITY, f64::max);
121                let low = state.1.iter().map(|v| v[1]).fold(f64::INFINITY, f64::min);
122                IndicatorValue::Multiple(vec![
123                    high - atr * self.multiplier,
124                    low + atr * self.multiplier,
125                ])
126            });
127    }
128
129    fn is_incremental(&self) -> bool {
130        true
131    }
132
133    fn values(&self) -> &IndicatorValues {
134        &self.values
135    }
136
137    fn colors(&self) -> Vec<Color32> {
138        self.colors.clone()
139    }
140
141    fn set_colors(&mut self, colors: Vec<Color32>) {
142        if colors.len() >= 2 {
143            self.colors = colors;
144        }
145    }
146
147    fn is_overlay(&self) -> bool {
148        true // Overlay on price chart
149    }
150
151    fn is_visible(&self) -> bool {
152        self.visible
153    }
154
155    fn set_visible(&mut self, visible: bool) {
156        self.visible = visible;
157    }
158
159    fn clone_box(&self) -> Box<dyn Indicator> {
160        Box::new(self.clone())
161    }
162
163    fn line_count(&self) -> usize {
164        2
165    }
166
167    fn line_names(&self) -> Vec<String> {
168        let lengths = if self.atr_period == self.period {
169            self.period.to_string()
170        } else {
171            format!("{},{}", self.period, self.atr_period)
172        };
173        vec![
174            format!("Long Exit({lengths},{})", self.multiplier),
175            format!("Short Exit({lengths},{})", self.multiplier),
176        ]
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183    use crate::studies::test_support::make_range_bar as make_bar;
184
185    /// Reference values from LeBeau's definition over Wilder's ATR, computed
186    /// independently: highest high or lowest low of the last `period` bars,
187    /// minus or plus `multiplier` times ATR(`period`). The gap on bar 4 makes
188    /// Wilder's smoothing differ from a simple average of true range.
189    #[test]
190    fn matches_reference_values() {
191        let trend = [
192            (102.0, 98.0, 100.0),
193            (104.0, 99.0, 103.0),
194            (106.0, 101.0, 105.0),
195            (108.0, 103.0, 107.0),
196            (118.0, 112.0, 116.0),
197            (117.0, 113.0, 114.0),
198            (115.0, 109.0, 110.0),
199            (112.0, 108.0, 111.0),
200        ];
201        let trend_expected = [
202            (96.66666666666667, 107.33333333333333),
203            (98.44444444444444, 108.55555555555556),
204            (104.29629629629629, 114.70370370370371),
205            (106.19753086419753, 114.80246913580247),
206            (106.13168724279835, 120.86831275720165),
207            (106.42112482853224, 118.57887517146776),
208        ];
209        let flat = [(101.0, 99.0, 100.0); 5];
210        let flat_expected = [(97.0, 103.0); 3];
211        let cases = [
212            ("trend with gap", &trend[..], &trend_expected[..]),
213            ("flat", &flat[..], &flat_expected[..]),
214        ];
215
216        let mut failures = Vec::new();
217        for (label, bars, expected) in cases {
218            let data: Vec<Bar> = bars.iter().map(|&(h, l, c)| make_bar(h, l, c)).collect();
219            let mut ce = ChandelierExit::new(3, 2.0);
220            ce.calculate(&data);
221
222            let got = ce.values().to_rows();
223            let warm_up = got
224                .iter()
225                .take(2)
226                .all(|v| matches!(v, IndicatorValue::None));
227            if got.len() != data.len() || !warm_up {
228                failures.push(format!("{label}: warm-up or length wrong: {got:?}"));
229                continue;
230            }
231            for (i, &(long, short)) in expected.iter().enumerate() {
232                let ok = matches!(&got[i + 2], IndicatorValue::Multiple(v)
233                    if (v[0] - long).abs() < 1e-9 && (v[1] - short).abs() < 1e-9);
234                if !ok {
235                    failures.push(format!(
236                        "{label}: bar {} is {:?}, expected [{long}, {short}]",
237                        i + 2,
238                        got[i + 2]
239                    ));
240                }
241            }
242        }
243        assert!(failures.is_empty(), "{}", failures.join("\n"));
244    }
245}