Skip to main content

kestrel_chartkit/analytics/
regime.rs

1//! Three-measure trend/range vote (ADX, unbounded Choppiness, Kaufman efficiency).
2//! Distinct from the four-state market regime classifier: a flat efficiency window is unavailable.
3
4#[cfg(feature = "serde")]
5use serde::Serialize;
6
7use super::{efficiency_ratio, true_range};
8use crate::indicator::adx::Adx;
9use crate::Bar;
10use crate::Indicator;
11
12/// Standard trending/ranging thresholds for each component (widely used
13/// defaults; see the module doc). A component votes "trending" when it clears
14/// its threshold.
15const ADX_TREND: f64 = 25.0;
16const CHOP_TREND: f64 = 38.2; // below → trending, above ~61.8 → ranging
17const EFFICIENCY_TREND: f64 = 0.5;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20#[cfg_attr(feature = "serde", derive(Serialize))]
21#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
22pub enum RegimeState {
23    Trending,
24    Ranging,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq)]
28#[cfg_attr(feature = "serde", derive(Serialize))]
29pub struct RegimeReading {
30    pub state: RegimeState,
31    pub adx: f64,
32    pub choppiness: f64,
33    pub efficiency: f64,
34    /// How many of the three components voted "trending" (0..=3).
35    pub trend_votes: u8,
36}
37
38/// Classifies the regime from `bars` (oldest first). `None` until enough bars
39/// exist to seed all three measures. `adx_len` also drives the ADX smoothing
40/// window (Wilder default: both 14); `window` is the lookback for Choppiness
41/// and the Efficiency Ratio.
42///
43/// - `adx`: the last value of [`Adx::new`]`(adx_len, adx_len, 3, 20)` fed from the first bar;
44/// - `choppiness = 100 · log10(sum TR / (highest high - lowest low)) / log10(window)` over the
45///   last `window` bars, the first true range against the close before them, so `window + 1`
46///   bars are needed; `None` for a window without range. Not clamped to `0..=100`;
47/// - `efficiency = |close_t - close_(t-window)| / sum |close changes|` over the last
48///   `window + 1` closes, `None` when nothing moved.
49///
50/// They vote "trending" at `adx >= 25`, `choppiness <= 38.2` and `efficiency >= 0.5`; two or
51/// more votes make the state [`RegimeState::Trending`].
52pub fn classify_regime(bars: &[Bar], adx_len: usize, window: usize) -> Option<RegimeReading> {
53    if window < 2 || adx_len < 1 {
54        return None;
55    }
56    // ADX needs the most warmup (di_len + adx_smooth); the two window measures
57    // need `window`+1 bars for their true-range / return terms.
58    let adx = latest_adx(bars, adx_len)?;
59    let choppiness = choppiness(bars, window)?;
60    let efficiency = efficiency_ratio(bars, window)?;
61
62    let trend_votes = (adx >= ADX_TREND) as u8
63        + (choppiness <= CHOP_TREND) as u8
64        + (efficiency >= EFFICIENCY_TREND) as u8;
65    let state = if trend_votes >= 2 {
66        RegimeState::Trending
67    } else {
68        RegimeState::Ranging
69    };
70
71    Some(RegimeReading {
72        state,
73        adx,
74        choppiness,
75        efficiency,
76        trend_votes,
77    })
78}
79
80/// Feeds every bar through `Adx` and returns its last value, so the vote uses
81/// the same Wilder DMI/ADX as the indicator rather than re-deriving it here.
82fn latest_adx(bars: &[Bar], adx_len: usize) -> Option<f64> {
83    let mut adx = Adx::new(adx_len, adx_len, 3, 20.0);
84    let mut last = None;
85    for bar in bars {
86        if let Some(out) = adx.on_bar(bar) {
87            last = Some(out.value);
88        }
89    }
90    last
91}
92
93/// Choppiness Index over the last `n` bars. True range of the first bar in
94/// the window references the close just before it, so `n`+1 bars are needed.
95fn choppiness(bars: &[Bar], n: usize) -> Option<f64> {
96    if bars.len() < n + 1 {
97        return None;
98    }
99    let window = &bars[bars.len() - n..];
100    let prev_close = bars[bars.len() - n - 1].close;
101
102    let mut tr_sum = 0.0;
103    let mut prev = Some(prev_close);
104    let mut highest = f64::NEG_INFINITY;
105    let mut lowest = f64::INFINITY;
106    for bar in window {
107        tr_sum += true_range(bar, prev);
108        highest = highest.max(bar.high);
109        lowest = lowest.min(bar.low);
110        prev = Some(bar.close);
111    }
112    let range = highest - lowest;
113    if range <= 0.0 || tr_sum <= 0.0 {
114        return None;
115    }
116    Some(100.0 * (tr_sum / range).log10() / (n as f64).log10())
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    fn bar(o: f64, h: f64, l: f64, c: f64) -> Bar {
124        Bar {
125            timestamp: 0,
126            open: o,
127            high: h,
128            low: l,
129            close: c,
130            volume: 0.0,
131        }
132    }
133
134    /// A clean monotonic ramp is maximally efficient (ER = 1) and minimally
135    /// choppy.
136    #[test]
137    fn straight_trend_reads_as_trending() {
138        let bars: Vec<Bar> = (0..60)
139            .map(|i| {
140                let c = 100.0 + i as f64;
141                bar(c - 0.5, c + 0.2, c - 0.7, c)
142            })
143            .collect();
144        let r = classify_regime(&bars, 14, 20).expect("enough bars");
145        assert!((r.efficiency - 1.0).abs() < 1e-9, "ramp ER should be 1");
146        assert_eq!(r.state, RegimeState::Trending);
147        assert!(r.trend_votes >= 2);
148    }
149
150    /// A flat zig-zag oscillating around a level nets ~zero travel → low
151    /// efficiency, high choppiness → ranging.
152    #[test]
153    fn oscillation_reads_as_ranging() {
154        let bars: Vec<Bar> = (0..60)
155            .map(|i| {
156                let c = if i % 2 == 0 { 100.0 } else { 101.0 };
157                bar(c, c + 0.5, c - 0.5, c)
158            })
159            .collect();
160        let r = classify_regime(&bars, 14, 20).expect("enough bars");
161        assert!(
162            r.efficiency < 0.3,
163            "zig-zag ER should be low: {}",
164            r.efficiency
165        );
166        assert_eq!(r.state, RegimeState::Ranging);
167    }
168
169    #[test]
170    fn insufficient_bars_is_none() {
171        let bars: Vec<Bar> = (0..5).map(|_| bar(100.0, 101.0, 99.0, 100.0)).collect();
172        assert!(classify_regime(&bars, 14, 20).is_none());
173    }
174
175    #[test]
176    fn efficiency_ratio_half_on_one_retrace() {
177        // up 10 then down 5: net 5, path 15 → ER = 1/3.
178        let bars = vec![
179            bar(100.0, 100.0, 100.0, 100.0),
180            bar(110.0, 110.0, 110.0, 110.0),
181            bar(105.0, 105.0, 105.0, 105.0),
182        ];
183        let er = efficiency_ratio(&bars, 2).unwrap();
184        assert!((er - (5.0 / 15.0)).abs() < 1e-9);
185    }
186}