Skip to main content

kestrel_chartkit/indicator/
chande_kroll.rs

1use std::collections::HashMap;
2
3use crate::model::Bar;
4
5use super::smoothing::{ExtremeWindow, Rma};
6use super::{Indicator, IndicatorOutput};
7
8/// Chande Kroll Stop: two stop levels built from window extremes and a smoothed true range, then
9/// passed through a second extreme window.
10///
11/// First stage, over `atr_len` bars:
12///
13/// ```text
14/// preliminary_long  = highest(high, atr_len) - mult * ATR(atr_len)
15/// preliminary_short = lowest(low,  atr_len) + mult * ATR(atr_len)
16/// ```
17///
18/// Second stage, over `stop_len` values of those series:
19///
20/// ```text
21/// stop_long  = highest(preliminary_long,  stop_len)
22/// stop_short = lowest(preliminary_short, stop_len)
23/// ```
24///
25/// The names follow the economic role: `stop_long` is the line *below* price, where a long
26/// position would be given up, and `stop_short` the line *above* price for a short one. Other
27/// implementations pair the names with the opposite extremes; the mapping here is the one pinned
28/// by this crate's reference fixture, and no parity with any other implementation is claimed.
29///
30/// The true range is smoothed the Wilder way ([`Rma`]), the same convention this crate's ATR and
31/// Chandelier Exit use. A simple moving average over the true range is a different default found
32/// elsewhere and would produce different levels under the same name.
33///
34/// This is not a Chandelier Exit: that one ratchets a single stop and flips direction. Here both
35/// lines are published as they come out of the formula. They can cross — in a narrow range the
36/// two stops can end up on the wrong side of each other — and that is left visible: sorting,
37/// ratcheting or clamping them would add a position decision this indicator does not make.
38///
39/// Per-bar outputs, all in the price units of the series:
40/// - `value` and `extra["stop_long"]`: the long stop.
41/// - `extra["stop_short"]`: the short stop.
42///
43/// First output: with bar `atr_len + stop_len - 1` — the first stage has its first value with bar
44/// `atr_len` (the Wilder seed takes `atr_len` true ranges, the first bar's being its
45/// `high - low`), and the second stage needs `stop_len` of those values. [`Indicator::reset`]
46/// clears both stages, so the next series starts deterministically.
47#[derive(Debug, Clone)]
48pub struct ChandeKrollStop {
49    atr_len: usize,
50    stop_len: usize,
51    mult: f64,
52    prev_close: Option<f64>,
53    tr_rma: Rma,
54    high_window: ExtremeWindow,
55    low_window: ExtremeWindow,
56    long_window: ExtremeWindow,
57    short_window: ExtremeWindow,
58}
59
60impl ChandeKrollStop {
61    pub fn new(atr_len: usize, stop_len: usize, mult: f64) -> Self {
62        let atr_len = atr_len.max(1);
63        let stop_len = stop_len.max(1);
64        Self {
65            atr_len,
66            stop_len,
67            mult,
68            prev_close: None,
69            tr_rma: Rma::new(atr_len),
70            high_window: ExtremeWindow::new(atr_len),
71            low_window: ExtremeWindow::new(atr_len),
72            long_window: ExtremeWindow::new(stop_len),
73            short_window: ExtremeWindow::new(stop_len),
74        }
75    }
76
77    pub fn with_defaults() -> Self {
78        Self::new(10, 9, 3.0)
79    }
80}
81
82impl Indicator for ChandeKrollStop {
83    fn name(&self) -> &str {
84        "chande_kroll"
85    }
86
87    fn warmup_period(&self) -> usize {
88        self.atr_len + self.stop_len
89    }
90
91    fn on_bar(&mut self, bar: &Bar) -> Option<IndicatorOutput> {
92        let tr = match self.prev_close {
93            None => bar.high - bar.low,
94            Some(prev_close) => (bar.high - bar.low)
95                .max((bar.high - prev_close).abs())
96                .max((bar.low - prev_close).abs()),
97        };
98        self.prev_close = Some(bar.close);
99
100        let highest_high = self.high_window.push(bar.high).map(|(_, high)| high);
101        let lowest_low = self.low_window.push(bar.low).map(|(low, _)| low);
102        let atr = self.tr_rma.update(tr);
103
104        let (Some(highest_high), Some(lowest_low), Some(atr)) = (highest_high, lowest_low, atr)
105        else {
106            return None;
107        };
108
109        let preliminary_long = highest_high - self.mult * atr;
110        let preliminary_short = lowest_low + self.mult * atr;
111
112        let stop_long = self
113            .long_window
114            .push(preliminary_long)
115            .map(|(_, high)| high);
116        let stop_short = self
117            .short_window
118            .push(preliminary_short)
119            .map(|(low, _)| low);
120
121        let (Some(stop_long), Some(stop_short)) = (stop_long, stop_short) else {
122            return None;
123        };
124
125        let extra = HashMap::from([
126            ("stop_long".to_string(), stop_long),
127            ("stop_short".to_string(), stop_short),
128        ]);
129        Some(IndicatorOutput::with_extra(stop_long, extra))
130    }
131
132    fn reset(&mut self) {
133        self.prev_close = None;
134        self.tr_rma.reset();
135        self.high_window.reset();
136        self.low_window.reset();
137        self.long_window.reset();
138        self.short_window.reset();
139    }
140}