Skip to main content

kestrel_chartkit/indicator/
chandelier_flip_radar.rs

1//! Chandelier Flip Radar: a Chandelier-Exit-style ATR ratchet stop extended with an adaptive
2//! volatility-regime multiplier, a body-filtered distinction between a "real" direction flip and
3//! a "weak" one, bull/bear trap detection (a wick breaches the opposite stop without a
4//! confirming close), and a continuous `-3..3` risk state — a richer risk-management layer on
5//! top of [`super::chandelier_exit::ChandelierExitEngine`]'s base ratchet primitive.
6//!
7//! Kept as a separate engine rather than folded into [`super::chandelier_exit::
8//! ChandelierExitEngine`], matching the precedent already set by the `volume_profile`/
9//! `volume_profile_extended`/`volume_profile_persistent` family (a richer variant lives
10//! alongside the base engine, not inside it) — the adaptive multiplier in particular has to sit
11//! *between* the raw-ATR computation and the stop computation on the same bar, which isn't a
12//! seam the base engine exposes, so this reimplements the shared ratchet arithmetic rather than
13//! wrapping it. Note this engine's ratchet condition (`prev_direction == 1` gates whether
14//! `long_stop` may only tighten) differs slightly from `ChandelierExitEngine`'s
15//! (`prev_close > long_stop_prev`); both are readings of the same ratchet idiom.
16//!
17//! Scope: the signal core only — no clustering-based conviction mode, chart objects or
18//! multi-timeframe confluence.
19
20use crate::model::Bar;
21use crate::series::Series;
22
23use super::smoothing::{Ema, Rma, Sma};
24use super::{Indicator, IndicatorAlert, IndicatorOutput};
25
26use std::collections::HashMap;
27
28/// Reusable Chandelier Flip Radar engine — see the module doc comment for how this relates to
29/// [`super::chandelier_exit::ChandelierExitEngine`].
30///
31/// Per bar: `atr_raw` is Wilder's average of the true range over `length` (the first bar's
32/// `high - low`), and `hi`/`lo` are the highest/lowest of the last `length` closes — highs and
33/// lows with `use_close_extremes = false`. The multiplier is `atr_mult`, or in adaptive mode 1.2
34/// or 0.85 times it when `atr_raw` is above 1.3 or below 0.8 times the plain mean of the last 100
35/// raw ATR values (the true range standing in before the ATR exists, and the raw ATR itself as the
36/// mean before 100 values exist). With `atr = multiplier · atr_raw`, `long_stop = hi - atr` and
37/// `short_stop = lo + atr`; while the previous direction is long the long stop only rises, while
38/// it is short the short stop only falls.
39///
40/// Against the previous bar's stops: a close above the short stop flips long, a close below the
41/// long stop flips short — but only when `|close - open| >= body_filter_atr · atr_raw` (a filter
42/// of 0 always passes); a crossing that fails the filter is a weak flip. The distance
43/// `|close - stop| / atr_raw` to the previous stop of the current direction sets the risk state:
44/// ±1 below `danger_dist_atr`, ±2 below `max(warn_dist_atr, danger_dist_atr + 0.05)` or on a
45/// pullback through the first-sample EMA(5) of the close, ±3 otherwise — positive when long. A
46/// wick through that stop with the close back at or inside it is a trap.
47///
48/// `value`: the current stop of the current direction; `extra`: `long_stop`, `short_stop`,
49/// `dist_atr`, `multiplier`, `risk_state`; `state`: the risk state's label. First output with bar
50/// `length`.
51pub struct ChandelierFlipRadarEngine {
52    length: usize,
53    atr_mult: f64,
54    use_close_extremes: bool,
55    simple_adaptive: bool,
56    body_filter_atr: f64,
57    danger_dist_atr: f64,
58    warn_dist_atr: f64,
59
60    tr_rma: Rma,
61    atr_sma: Sma,
62    ema5: Ema,
63    highs: Series<f64>,
64    lows: Series<f64>,
65
66    prev_close: Option<f64>,
67    prev_long_stop: Option<f64>,
68    prev_short_stop: Option<f64>,
69    direction: i8,
70    prev_direction: i8,
71
72    alerts: Vec<IndicatorAlert>,
73}
74
75impl ChandelierFlipRadarEngine {
76    /// `length` sizes the highest-high/lowest-low lookback and the internal ATR. `atr_mult`
77    /// scales the ATR offset from those extremes. `use_close_extremes` swaps the highest/lowest
78    /// source from bar high/low to bar close (default: close-based). `simple_adaptive`
79    /// scales `atr_mult` by 1.2x/0.85x when the current raw ATR is >30% above/>20% below its own
80    /// 100-bar SMA (a volatility-regime signal). `body_filter_atr` gates a "real" direction flip
81    /// from a "weak" one: a flip only counts if `|close - open| >= atr_raw * body_filter_atr`
82    /// (`0.0` disables the filter — every structural flip counts as real). `danger_dist_atr`/
83    /// `warn_dist_atr` bucket the continuous risk state by how many ATRs price sits from the
84    /// active stop.
85    #[allow(clippy::too_many_arguments)]
86    pub fn new(
87        length: usize,
88        atr_mult: f64,
89        use_close_extremes: bool,
90        simple_adaptive: bool,
91        body_filter_atr: f64,
92        danger_dist_atr: f64,
93        warn_dist_atr: f64,
94    ) -> Self {
95        let length = length.max(1);
96        Self {
97            length,
98            atr_mult,
99            use_close_extremes,
100            simple_adaptive,
101            body_filter_atr,
102            danger_dist_atr,
103            warn_dist_atr,
104            tr_rma: Rma::new(length),
105            atr_sma: Sma::new(100),
106            ema5: Ema::new(5),
107            highs: Series::new(length),
108            lows: Series::new(length),
109            prev_close: None,
110            prev_long_stop: None,
111            prev_short_stop: None,
112            direction: 1,
113            prev_direction: 1,
114            alerts: Vec::new(),
115        }
116    }
117
118    /// Defaults: `length=30`, `atr_mult=4.5`, close-based extremes, adaptive mode off,
119    /// `body_filter_atr=0.80`, `danger_dist_atr=0.35`, `warn_dist_atr=0.75`.
120    pub fn with_defaults() -> Self {
121        Self::new(30, 4.5, true, false, 0.80, 0.35, 0.75)
122    }
123}
124
125/// Maps the engine's signed `-3..3` risk-state number to a machine-readable label for
126/// [`IndicatorOutput::state`]: `1..3` long (danger/caution/healthy), `-1..-3` short.
127fn state_label(state: i32) -> &'static str {
128    match state {
129        3 => "long_healthy",
130        2 => "long_caution",
131        1 => "long_danger",
132        -1 => "short_danger",
133        -2 => "short_caution",
134        _ => "short_healthy",
135    }
136}
137
138impl Indicator for ChandelierFlipRadarEngine {
139    fn name(&self) -> &str {
140        "chandelier_flip_radar"
141    }
142
143    fn warmup_period(&self) -> usize {
144        self.length
145    }
146
147    fn reset(&mut self) {
148        self.tr_rma.reset();
149        self.atr_sma.reset();
150        self.ema5.reset();
151        self.highs.reset();
152        self.lows.reset();
153        self.prev_close = None;
154        self.prev_long_stop = None;
155        self.prev_short_stop = None;
156        self.direction = 1;
157        self.prev_direction = 1;
158        self.alerts.clear();
159    }
160
161    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
162        self.alerts.clear();
163
164        let tr = match self.prev_close {
165            None => bar.high - bar.low,
166            Some(prev_close) => (bar.high - bar.low)
167                .max((bar.high - prev_close).abs())
168                .max((bar.low - prev_close).abs()),
169        };
170        let atr_raw = self.tr_rma.update(tr);
171        let atr_avg = self.atr_sma.update(atr_raw.unwrap_or(tr));
172
173        let high_src = if self.use_close_extremes {
174            bar.close
175        } else {
176            bar.high
177        };
178        let low_src = if self.use_close_extremes {
179            bar.close
180        } else {
181            bar.low
182        };
183        self.highs.push(high_src);
184        self.lows.push(low_src);
185        let ch_hi = self.highs.highest(self.length);
186        let ch_lo = self.lows.lowest(self.length);
187        let ema5 = self.ema5.update(bar.close)?;
188
189        let (atr_raw, ch_hi, ch_lo) = match (atr_raw, ch_hi, ch_lo) {
190            (Some(atr), Some(hi), Some(lo)) => (atr, hi, lo),
191            _ => {
192                self.prev_close = Some(bar.close);
193                return None;
194            }
195        };
196
197        let dyn_mult = if self.simple_adaptive {
198            let atr_avg = atr_avg.unwrap_or(atr_raw);
199            let vol_ratio = if atr_avg != 0.0 {
200                atr_raw / atr_avg
201            } else {
202                1.0
203            };
204            if vol_ratio > 1.3 {
205                self.atr_mult * 1.2
206            } else if vol_ratio < 0.8 {
207                self.atr_mult * 0.85
208            } else {
209                self.atr_mult
210            }
211        } else {
212            self.atr_mult
213        };
214        let atr = dyn_mult * atr_raw;
215
216        let long_stop_raw = ch_hi - atr;
217        let short_stop_raw = ch_lo + atr;
218        let long_stop_prev = self.prev_long_stop.unwrap_or(long_stop_raw);
219        let short_stop_prev = self.prev_short_stop.unwrap_or(short_stop_raw);
220
221        let long_stop = if self.prev_direction == 1 {
222            long_stop_raw.max(long_stop_prev)
223        } else {
224            long_stop_raw
225        };
226        let short_stop = if self.prev_direction == -1 {
227            short_stop_raw.min(short_stop_prev)
228        } else {
229            short_stop_raw
230        };
231
232        let body_ok = self.body_filter_atr <= 0.0
233            || (bar.close - bar.open).abs() >= atr_raw * self.body_filter_atr;
234        let flip_long = bar.close > short_stop_prev && body_ok;
235        let flip_short = bar.close < long_stop_prev && body_ok;
236
237        let old_direction = self.direction;
238        self.direction = if flip_long {
239            1
240        } else if flip_short {
241            -1
242        } else {
243            self.direction
244        };
245
246        let buy_signal = self.direction == 1 && old_direction == -1;
247        let sell_signal = self.direction == -1 && old_direction == 1;
248        let weak_long = self.direction == -1 && bar.close > short_stop_prev && !body_ok;
249        let weak_short = self.direction == 1 && bar.close < long_stop_prev && !body_ok;
250
251        let trigger_stop = if self.direction == 1 {
252            long_stop_prev
253        } else {
254            short_stop_prev
255        };
256        let dist_atr = if atr_raw != 0.0 {
257            (bar.close - trigger_stop).abs() / atr_raw
258        } else {
259            0.0
260        };
261        let warn_level = self.warn_dist_atr.max(self.danger_dist_atr + 0.05);
262        let danger = dist_atr < self.danger_dist_atr;
263        let warn = !danger && dist_atr < warn_level;
264        let pullback_long = self.direction == 1 && bar.close < ema5;
265        let pullback_short = self.direction == -1 && bar.close > ema5;
266        let state: i32 = if self.direction == 1 {
267            if danger {
268                1
269            } else if warn || pullback_long {
270                2
271            } else {
272                3
273            }
274        } else if danger {
275            -1
276        } else if warn || pullback_short {
277            -2
278        } else {
279            -3
280        };
281
282        let bull_trap =
283            self.direction == -1 && bar.high > short_stop_prev && bar.close <= short_stop_prev;
284        let bear_trap =
285            self.direction == 1 && bar.low < long_stop_prev && bar.close >= long_stop_prev;
286
287        if buy_signal {
288            self.alerts.push(IndicatorAlert::new(
289                "bull_flip",
290                "Chandelier Flip Radar: flipped long",
291                1.0,
292            ));
293        }
294        if sell_signal {
295            self.alerts.push(IndicatorAlert::new(
296                "bear_flip",
297                "Chandelier Flip Radar: flipped short",
298                1.0,
299            ));
300        }
301        if weak_long {
302            self.alerts.push(IndicatorAlert::new(
303                "bull_weak_flip",
304                "Chandelier Flip Radar: weak long flip (body filter not met)",
305                1.0,
306            ));
307        }
308        if weak_short {
309            self.alerts.push(IndicatorAlert::new(
310                "bear_weak_flip",
311                "Chandelier Flip Radar: weak short flip (body filter not met)",
312                1.0,
313            ));
314        }
315        if bull_trap {
316            self.alerts.push(IndicatorAlert::new(
317                "bear_bull_trap",
318                "Chandelier Flip Radar: bull trap (wick above short stop, close back inside)",
319                (1.0 - dist_atr).clamp(0.0, 1.0),
320            ));
321        }
322        if bear_trap {
323            self.alerts.push(IndicatorAlert::new(
324                "bull_bear_trap",
325                "Chandelier Flip Radar: bear trap (wick below long stop, close back inside)",
326                (1.0 - dist_atr).clamp(0.0, 1.0),
327            ));
328        }
329
330        self.prev_direction = self.direction;
331        self.prev_long_stop = Some(long_stop);
332        self.prev_short_stop = Some(short_stop);
333        self.prev_close = Some(bar.close);
334
335        let active_stop = if self.direction == 1 {
336            long_stop
337        } else {
338            short_stop
339        };
340
341        let mut extra = HashMap::new();
342        extra.insert("long_stop".to_string(), long_stop);
343        extra.insert("short_stop".to_string(), short_stop);
344        extra.insert("dist_atr".to_string(), dist_atr);
345        extra.insert("multiplier".to_string(), dyn_mult);
346        extra.insert("risk_state".to_string(), state as f64);
347
348        Some(IndicatorOutput::with_extra(active_stop, extra).with_state(state_label(state)))
349    }
350
351    fn alerts(&self) -> Vec<IndicatorAlert> {
352        self.alerts.clone()
353    }
354}
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    // Reference values: `tests/golden_reference_volatility.rs`, derived in `reference/`.
361
362    fn trending_up_bars(n: usize) -> Vec<Bar> {
363        (0..n)
364            .map(|i| {
365                let base = 100.0 + i as f64 * 2.0;
366                Bar::new(
367                    i as i64 * 60,
368                    base,
369                    base + 3.0,
370                    base - 3.0,
371                    base + 1.0,
372                    100.0,
373                )
374            })
375            .collect()
376    }
377
378    #[test]
379    fn test_warmup_returns_none_then_emits() {
380        let mut engine = ChandelierFlipRadarEngine::new(5, 3.0, true, false, 0.8, 0.35, 0.75);
381        let bars = trending_up_bars(10);
382        let mut outputs = Vec::new();
383        for bar in &bars {
384            outputs.push(engine.on_bar(bar));
385        }
386        assert!(outputs[..4].iter().all(|o| o.is_none()));
387        assert!(outputs[4..].iter().all(|o| o.is_some()));
388    }
389
390    #[test]
391    fn test_reset_clears_state() {
392        let mut engine = ChandelierFlipRadarEngine::new(5, 3.0, true, false, 0.8, 0.35, 0.75);
393        for bar in trending_up_bars(6) {
394            engine.on_bar(&bar);
395        }
396        engine.reset();
397        assert_eq!(engine.on_bar(&trending_up_bars(1)[0]), None);
398    }
399}