use crate::model::Bar;
use crate::series::Series;
use super::smoothing::{Ema, Rma, Sma};
use super::{Indicator, IndicatorAlert, IndicatorOutput};
use std::collections::HashMap;
pub struct ChandelierFlipRadarEngine {
length: usize,
atr_mult: f64,
use_close_extremes: bool,
simple_adaptive: bool,
body_filter_atr: f64,
danger_dist_atr: f64,
warn_dist_atr: f64,
tr_rma: Rma,
atr_sma: Sma,
ema5: Ema,
highs: Series<f64>,
lows: Series<f64>,
prev_close: Option<f64>,
prev_long_stop: Option<f64>,
prev_short_stop: Option<f64>,
direction: i8,
prev_direction: i8,
alerts: Vec<IndicatorAlert>,
}
impl ChandelierFlipRadarEngine {
#[allow(clippy::too_many_arguments)]
pub fn new(
length: usize,
atr_mult: f64,
use_close_extremes: bool,
simple_adaptive: bool,
body_filter_atr: f64,
danger_dist_atr: f64,
warn_dist_atr: f64,
) -> Self {
let length = length.max(1);
Self {
length,
atr_mult,
use_close_extremes,
simple_adaptive,
body_filter_atr,
danger_dist_atr,
warn_dist_atr,
tr_rma: Rma::new(length),
atr_sma: Sma::new(100),
ema5: Ema::new(5),
highs: Series::new(length),
lows: Series::new(length),
prev_close: None,
prev_long_stop: None,
prev_short_stop: None,
direction: 1,
prev_direction: 1,
alerts: Vec::new(),
}
}
pub fn with_defaults() -> Self {
Self::new(30, 4.5, true, false, 0.80, 0.35, 0.75)
}
}
fn state_label(state: i32) -> &'static str {
match state {
3 => "long_healthy",
2 => "long_caution",
1 => "long_danger",
-1 => "short_danger",
-2 => "short_caution",
_ => "short_healthy",
}
}
impl Indicator for ChandelierFlipRadarEngine {
fn name(&self) -> &str {
"chandelier_flip_radar"
}
fn warmup_period(&self) -> usize {
self.length
}
fn reset(&mut self) {
self.tr_rma.reset();
self.atr_sma.reset();
self.ema5.reset();
self.highs.reset();
self.lows.reset();
self.prev_close = None;
self.prev_long_stop = None;
self.prev_short_stop = None;
self.direction = 1;
self.prev_direction = 1;
self.alerts.clear();
}
fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
self.alerts.clear();
let tr = match self.prev_close {
None => bar.high - bar.low,
Some(prev_close) => (bar.high - bar.low)
.max((bar.high - prev_close).abs())
.max((bar.low - prev_close).abs()),
};
let atr_raw = self.tr_rma.update(tr);
let atr_avg = self.atr_sma.update(atr_raw.unwrap_or(tr));
let high_src = if self.use_close_extremes {
bar.close
} else {
bar.high
};
let low_src = if self.use_close_extremes {
bar.close
} else {
bar.low
};
self.highs.push(high_src);
self.lows.push(low_src);
let ch_hi = self.highs.highest(self.length);
let ch_lo = self.lows.lowest(self.length);
let ema5 = self.ema5.update(bar.close)?;
let (atr_raw, ch_hi, ch_lo) = match (atr_raw, ch_hi, ch_lo) {
(Some(atr), Some(hi), Some(lo)) => (atr, hi, lo),
_ => {
self.prev_close = Some(bar.close);
return None;
}
};
let dyn_mult = if self.simple_adaptive {
let atr_avg = atr_avg.unwrap_or(atr_raw);
let vol_ratio = if atr_avg != 0.0 {
atr_raw / atr_avg
} else {
1.0
};
if vol_ratio > 1.3 {
self.atr_mult * 1.2
} else if vol_ratio < 0.8 {
self.atr_mult * 0.85
} else {
self.atr_mult
}
} else {
self.atr_mult
};
let atr = dyn_mult * atr_raw;
let long_stop_raw = ch_hi - atr;
let short_stop_raw = ch_lo + atr;
let long_stop_prev = self.prev_long_stop.unwrap_or(long_stop_raw);
let short_stop_prev = self.prev_short_stop.unwrap_or(short_stop_raw);
let long_stop = if self.prev_direction == 1 {
long_stop_raw.max(long_stop_prev)
} else {
long_stop_raw
};
let short_stop = if self.prev_direction == -1 {
short_stop_raw.min(short_stop_prev)
} else {
short_stop_raw
};
let body_ok = self.body_filter_atr <= 0.0
|| (bar.close - bar.open).abs() >= atr_raw * self.body_filter_atr;
let flip_long = bar.close > short_stop_prev && body_ok;
let flip_short = bar.close < long_stop_prev && body_ok;
let old_direction = self.direction;
self.direction = if flip_long {
1
} else if flip_short {
-1
} else {
self.direction
};
let buy_signal = self.direction == 1 && old_direction == -1;
let sell_signal = self.direction == -1 && old_direction == 1;
let weak_long = self.direction == -1 && bar.close > short_stop_prev && !body_ok;
let weak_short = self.direction == 1 && bar.close < long_stop_prev && !body_ok;
let trigger_stop = if self.direction == 1 {
long_stop_prev
} else {
short_stop_prev
};
let dist_atr = if atr_raw != 0.0 {
(bar.close - trigger_stop).abs() / atr_raw
} else {
0.0
};
let warn_level = self.warn_dist_atr.max(self.danger_dist_atr + 0.05);
let danger = dist_atr < self.danger_dist_atr;
let warn = !danger && dist_atr < warn_level;
let pullback_long = self.direction == 1 && bar.close < ema5;
let pullback_short = self.direction == -1 && bar.close > ema5;
let state: i32 = if self.direction == 1 {
if danger {
1
} else if warn || pullback_long {
2
} else {
3
}
} else if danger {
-1
} else if warn || pullback_short {
-2
} else {
-3
};
let bull_trap =
self.direction == -1 && bar.high > short_stop_prev && bar.close <= short_stop_prev;
let bear_trap =
self.direction == 1 && bar.low < long_stop_prev && bar.close >= long_stop_prev;
if buy_signal {
self.alerts.push(IndicatorAlert::new(
"bull_flip",
"Chandelier Flip Radar: flipped long",
1.0,
));
}
if sell_signal {
self.alerts.push(IndicatorAlert::new(
"bear_flip",
"Chandelier Flip Radar: flipped short",
1.0,
));
}
if weak_long {
self.alerts.push(IndicatorAlert::new(
"bull_weak_flip",
"Chandelier Flip Radar: weak long flip (body filter not met)",
1.0,
));
}
if weak_short {
self.alerts.push(IndicatorAlert::new(
"bear_weak_flip",
"Chandelier Flip Radar: weak short flip (body filter not met)",
1.0,
));
}
if bull_trap {
self.alerts.push(IndicatorAlert::new(
"bear_bull_trap",
"Chandelier Flip Radar: bull trap (wick above short stop, close back inside)",
(1.0 - dist_atr).clamp(0.0, 1.0),
));
}
if bear_trap {
self.alerts.push(IndicatorAlert::new(
"bull_bear_trap",
"Chandelier Flip Radar: bear trap (wick below long stop, close back inside)",
(1.0 - dist_atr).clamp(0.0, 1.0),
));
}
self.prev_direction = self.direction;
self.prev_long_stop = Some(long_stop);
self.prev_short_stop = Some(short_stop);
self.prev_close = Some(bar.close);
let active_stop = if self.direction == 1 {
long_stop
} else {
short_stop
};
let mut extra = HashMap::new();
extra.insert("long_stop".to_string(), long_stop);
extra.insert("short_stop".to_string(), short_stop);
extra.insert("dist_atr".to_string(), dist_atr);
extra.insert("multiplier".to_string(), dyn_mult);
extra.insert("risk_state".to_string(), state as f64);
Some(IndicatorOutput::with_extra(active_stop, extra).with_state(state_label(state)))
}
fn alerts(&self) -> Vec<IndicatorAlert> {
self.alerts.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn trending_up_bars(n: usize) -> Vec<Bar> {
(0..n)
.map(|i| {
let base = 100.0 + i as f64 * 2.0;
Bar::new(
i as i64 * 60,
base,
base + 3.0,
base - 3.0,
base + 1.0,
100.0,
)
})
.collect()
}
#[test]
fn test_warmup_returns_none_then_emits() {
let mut engine = ChandelierFlipRadarEngine::new(5, 3.0, true, false, 0.8, 0.35, 0.75);
let bars = trending_up_bars(10);
let mut outputs = Vec::new();
for bar in &bars {
outputs.push(engine.on_bar(bar));
}
assert!(outputs[..4].iter().all(|o| o.is_none()));
assert!(outputs[4..].iter().all(|o| o.is_some()));
}
#[test]
fn test_reset_clears_state() {
let mut engine = ChandelierFlipRadarEngine::new(5, 3.0, true, false, 0.8, 0.35, 0.75);
for bar in trending_up_bars(6) {
engine.on_bar(&bar);
}
engine.reset();
assert_eq!(engine.on_bar(&trending_up_bars(1)[0]), None);
}
}