use std::collections::HashMap;
use crate::model::Bar;
use crate::series::Series;
use super::smoothing::Rma;
use super::{Indicator, IndicatorAlert, IndicatorOutput};
#[derive(Debug, Clone)]
pub struct ChandelierExitEngine {
length: usize,
atr_mult: f64,
tr_rma: Rma,
prev_close: Option<f64>,
highs: Series<f64>,
lows: Series<f64>,
long_stop_prev: Option<f64>,
short_stop_prev: Option<f64>,
direction: i8,
alerts: Vec<IndicatorAlert>,
}
impl ChandelierExitEngine {
pub fn new(length: usize, atr_mult: f64) -> Self {
let length = length.max(1);
Self {
length,
atr_mult,
tr_rma: Rma::new(length),
prev_close: None,
highs: Series::new(length),
lows: Series::new(length),
long_stop_prev: None,
short_stop_prev: None,
direction: 1,
alerts: Vec::new(),
}
}
pub fn with_defaults() -> Self {
Self::new(22, 3.0)
}
}
impl Indicator for ChandelierExitEngine {
fn name(&self) -> &str {
"chandelier_exit"
}
fn warmup_period(&self) -> usize {
self.length
}
fn reset(&mut self) {
self.tr_rma.reset();
self.prev_close = None;
self.highs.reset();
self.lows.reset();
self.long_stop_prev = None;
self.short_stop_prev = None;
self.direction = 1;
self.alerts.clear();
}
fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
self.alerts.clear();
let tr = match self.prev_close {
Some(pc) => (bar.high - bar.low)
.max((bar.high - pc).abs())
.max((bar.low - pc).abs()),
None => bar.high - bar.low,
};
self.highs.push(bar.high);
self.lows.push(bar.low);
let atr = self.tr_rma.update(tr);
let (highest_high, lowest_low, atr) = match (
self.highs.highest(self.length),
self.lows.lowest(self.length),
atr,
) {
(Some(hh), Some(ll), Some(a)) => (hh, ll, a),
_ => {
self.prev_close = Some(bar.close);
return None;
}
};
let raw_long_stop = highest_high - self.atr_mult * atr;
let raw_short_stop = lowest_low + self.atr_mult * atr;
let long_stop_prev = self.long_stop_prev.unwrap_or(raw_long_stop);
let short_stop_prev = self.short_stop_prev.unwrap_or(raw_short_stop);
let long_stop = match self.prev_close {
Some(pc) if pc > long_stop_prev => raw_long_stop.max(long_stop_prev),
_ => raw_long_stop,
};
let short_stop = match self.prev_close {
Some(pc) if pc < short_stop_prev => raw_short_stop.min(short_stop_prev),
_ => raw_short_stop,
};
let mut direction = self.direction;
if bar.close > short_stop_prev {
direction = 1;
} else if bar.close < long_stop_prev {
direction = -1;
}
if direction != self.direction {
let (kind, note) = if direction == 1 {
("chandelier_flip_long", "Chandelier Exit flipped long")
} else {
("chandelier_flip_short", "Chandelier Exit flipped short")
};
self.alerts.push(IndicatorAlert::new(kind, note, 1.0));
}
self.direction = direction;
self.long_stop_prev = Some(long_stop);
self.short_stop_prev = Some(short_stop);
self.prev_close = Some(bar.close);
let (stop_value, opposite_stop, state) = if direction == 1 {
(long_stop, short_stop, "long")
} else {
(short_stop, long_stop, "short")
};
let mut extra = HashMap::new();
extra.insert("long_stop".to_string(), long_stop);
extra.insert("short_stop".to_string(), short_stop);
Some(
IndicatorOutput::with_extra(stop_value, extra)
.with_secondary(opposite_stop)
.with_state(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 = ChandelierExitEngine::new(5, 3.0);
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_stop_only_moves_favorably_while_trending() {
let mut engine = ChandelierExitEngine::new(5, 3.0);
let bars = trending_up_bars(20);
let mut long_stops = Vec::new();
for bar in &bars {
if let Some(out) = engine.on_bar(bar) {
if out.state.as_deref() == Some("long") {
long_stops.push(out.value);
}
}
}
assert!(long_stops.len() > 2);
for pair in long_stops.windows(2) {
assert!(
pair[1] >= pair[0] - 1e-9,
"long stop must never loosen while price keeps making new highs: {:?}",
pair
);
}
}
#[test]
fn test_direction_flips_on_stop_breach() {
let mut engine = ChandelierExitEngine::new(3, 1.0);
let mut bars = trending_up_bars(6);
bars.push(Bar::new(600, 90.0, 91.0, 60.0, 61.0, 100.0));
let mut flipped_short = false;
for bar in &bars {
if let Some(out) = engine.on_bar(bar) {
if out.state.as_deref() == Some("short") {
flipped_short = true;
}
}
}
assert!(flipped_short, "sharp reversal must flip direction to short");
}
#[test]
fn test_reset_clears_state() {
let mut engine = ChandelierExitEngine::new(3, 2.0);
for bar in trending_up_bars(6) {
engine.on_bar(&bar);
}
engine.reset();
assert_eq!(engine.on_bar(&trending_up_bars(1)[0]), None);
}
}