Skip to main content

indicators/
vol_regime.rs

1//! Volume-regime helpers: rolling percentile tracker, volatility regime classifier,
2//! and a simple MA-slope market regime classifier.
3//!
4//! These are ported from the Python `VolatilityPercentile`, `PercentileTracker`,
5//! and `MarketRegime` classes in `indicators.py`.
6//!
7//! Note: `MarketRegimeTracker` is distinct from the statistical `MarketRegime` enum
8//! in `types.rs` — it is a simpler slope-based classifier used by the signal engine.
9
10use std::collections::VecDeque;
11
12// ── PercentileTracker ─────────────────────────────────────────────────────────
13
14/// Rolling percentile calculator over a fixed-size window.
15pub struct PercentileTracker {
16    buf: VecDeque<f64>,
17}
18
19impl PercentileTracker {
20    pub fn new(maxlen: usize) -> Self {
21        Self {
22            buf: VecDeque::with_capacity(maxlen),
23        }
24    }
25
26    /// Seed the buffer with alternating `lo` / `hi` values so it is never empty.
27    pub fn seeded(maxlen: usize, seed_lo: f64, seed_hi: f64) -> Self {
28        let mut t = Self::new(maxlen);
29        for i in 0..(maxlen / 2) {
30            t.buf.push_back(if i % 2 == 0 { seed_lo } else { seed_hi });
31        }
32        t
33    }
34
35    pub fn push(&mut self, val: f64) {
36        if self.buf.len() == self.buf.capacity() {
37            self.buf.pop_front();
38        }
39        self.buf.push_back(val);
40    }
41
42    /// Fraction of buffered values strictly less than `val`.
43    pub fn pct(&self, val: f64) -> f64 {
44        let n = self.buf.len();
45        if n == 0 {
46            return 0.5;
47        }
48        self.buf.iter().filter(|&&v| v < val).count() as f64 / n as f64
49    }
50}
51
52// ── VolatilityPercentile ──────────────────────────────────────────────────────
53
54/// Classifies ATR into a volatility regime by comparing the current ATR to its
55/// own rolling percentile history.
56pub struct VolatilityPercentile {
57    tracker: PercentileTracker,
58    pub vol_pct: f64,
59    pub vol_regime: &'static str,
60    pub vol_mult: f64,
61    /// Confidence score adjustment applied to `conf_min_score`.
62    pub conf_adj: f64,
63}
64
65impl VolatilityPercentile {
66    pub fn new(maxlen: usize) -> Self {
67        let tracker = PercentileTracker::seeded(maxlen, 20.0, 200.0);
68        Self {
69            tracker,
70            vol_pct: 0.5,
71            vol_regime: "MED",
72            vol_mult: 1.2,
73            conf_adj: 1.0,
74        }
75    }
76
77    pub fn update(&mut self, atr: Option<f64>) {
78        let Some(v) = atr else { return };
79        if v <= 0.0 {
80            return;
81        }
82        self.tracker.push(v);
83        let p = self.tracker.pct(v);
84        self.vol_pct = p;
85        (self.vol_regime, self.vol_mult, self.conf_adj) = if p >= 0.8 {
86            ("VERY HIGH", 1.8, 1.15)
87        } else if p >= 0.6 {
88            ("HIGH", 1.5, 1.05)
89        } else if p <= 0.2 {
90            ("VERY LOW", 0.8, 0.9)
91        } else if p <= 0.4 {
92            ("LOW", 1.0, 0.95)
93        } else {
94            ("MED", 1.2, 1.0)
95        };
96    }
97}
98
99// ── MarketRegimeTracker ───────────────────────────────────────────────────────
100
101/// Simple slope + volatility regime tracker (ported from Python `MarketRegime` class).
102///
103/// Uses a 200-bar MA slope and return volatility to classify as:
104/// `"TRENDING↑"`, `"TRENDING↓"`, `"VOLATILE"`, `"RANGING"`, or `"NEUTRAL"`.
105pub struct MarketRegimeTracker {
106    closes: VecDeque<f64>,
107    ma200_hist: VecDeque<f64>,
108    ret_hist: VecDeque<f64>,
109
110    pub regime: &'static str,
111    pub is_trending_u: bool,
112    pub is_trending_d: bool,
113    pub is_ranging: bool,
114    pub is_volatile: bool,
115}
116
117impl MarketRegimeTracker {
118    pub fn new() -> Self {
119        Self {
120            closes: VecDeque::with_capacity(220),
121            ma200_hist: VecDeque::with_capacity(120),
122            ret_hist: VecDeque::with_capacity(110),
123            regime: "NEUTRAL",
124            is_trending_u: false,
125            is_trending_d: false,
126            is_ranging: false,
127            is_volatile: false,
128        }
129    }
130
131    pub fn update(&mut self, close: f64) {
132        let prev_cl = self.closes.back().copied().unwrap_or(close);
133
134        if self.closes.len() == 220 {
135            self.closes.pop_front();
136        }
137        self.closes.push_back(close);
138
139        if self.closes.len() < 200 {
140            return;
141        }
142
143        // 200-bar SMA
144        let ma200: f64 = self.closes.iter().rev().take(200).sum::<f64>() / 200.0;
145
146        if self.ma200_hist.len() == 120 {
147            self.ma200_hist.pop_front();
148        }
149        self.ma200_hist.push_back(ma200);
150
151        let ret = if prev_cl != 0.0 {
152            (close - prev_cl) / prev_cl
153        } else {
154            0.0
155        };
156        if self.ret_hist.len() == 110 {
157            self.ret_hist.pop_front();
158        }
159        self.ret_hist.push_back(ret);
160
161        if self.ma200_hist.len() < 21 || self.ret_hist.len() < 51 {
162            return;
163        }
164
165        // Slope of MA200 over last 20 bars, normalised by average MA change
166        let ma_arr: Vec<f64> = self.ma200_hist.iter().copied().collect();
167        let diffs: Vec<f64> = ma_arr.windows(2).map(|w| (w[1] - w[0]).abs()).collect();
168        let avg_chg = if diffs.is_empty() {
169            1e-9
170        } else {
171            let tail: Vec<f64> = diffs.iter().rev().take(100).copied().collect();
172            tail.iter().sum::<f64>() / tail.len() as f64
173        };
174        let slope_n = if avg_chg > 0.0 {
175            (ma200 - ma_arr[ma_arr.len() - 21]) / (avg_chg * 20.0)
176        } else {
177            0.0
178        };
179
180        // Return volatility
181        let ret_arr: Vec<f64> = self.ret_hist.iter().copied().collect();
182        let tail100: Vec<f64> = ret_arr.iter().rev().take(100).copied().collect();
183        let ret_s = std_dev(&tail100);
184        let tail50: Vec<f64> = ret_arr.iter().rev().take(50).map(|r| r.abs()).collect();
185        let ret_sma = if tail50.is_empty() {
186            ret_s.max(1e-9)
187        } else {
188            (tail50.iter().sum::<f64>() / tail50.len() as f64).max(1e-9)
189        };
190        let vol_n = ret_s / ret_sma;
191
192        self.regime = if slope_n > 1.0 {
193            "TRENDING↑"
194        } else if slope_n < -1.0 {
195            "TRENDING↓"
196        } else if vol_n > 1.5 {
197            "VOLATILE"
198        } else if vol_n < 0.8 {
199            "RANGING"
200        } else {
201            "NEUTRAL"
202        };
203
204        self.is_trending_u = self.regime == "TRENDING↑";
205        self.is_trending_d = self.regime == "TRENDING↓";
206        self.is_ranging = self.regime == "RANGING";
207        self.is_volatile = self.regime == "VOLATILE";
208    }
209}
210
211impl Default for MarketRegimeTracker {
212    fn default() -> Self {
213        Self::new()
214    }
215}
216
217// ── helpers ───────────────────────────────────────────────────────────────────
218
219fn std_dev(data: &[f64]) -> f64 {
220    if data.len() < 2 {
221        return 0.0;
222    }
223    let mean = data.iter().sum::<f64>() / data.len() as f64;
224    let var = data.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / data.len() as f64;
225    var.sqrt()
226}