kestrel_chartkit/indicator/
chandelier_flip_radar.rs1use 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
28pub 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 #[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 pub fn with_defaults() -> Self {
121 Self::new(30, 4.5, true, false, 0.80, 0.35, 0.75)
122 }
123}
124
125fn 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 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}