kestrel_chartkit/analytics/
regime.rs1#[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
12const ADX_TREND: f64 = 25.0;
16const CHOP_TREND: f64 = 38.2; const 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 pub trend_votes: u8,
36}
37
38pub fn classify_regime(bars: &[Bar], adx_len: usize, window: usize) -> Option<RegimeReading> {
53 if window < 2 || adx_len < 1 {
54 return None;
55 }
56 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
80fn 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
93fn 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 #[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 #[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 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}