Skip to main content

portfolio_analyzer/sd2/
features.rs

1//! # Features: what SD v2 looks at
2//!
3//! Classic SD looked at the market through three keyholes: one compression
4//! measurement (21 bars), one momentum gauge (the Awesome Oscillator), and a
5//! stopwatch that either said "active" or "expired".  Each keyhole is where
6//! one of the known weaknesses came from:
7//!
8//! | Weakness | Keyhole | What v2 adds |
9//! | --- | --- | --- |
10//! | Short squeezes are missed | A 21-bar Bollinger band needs ~21 quiet bars before it can shrink | A second, 10-bar compression gauge, plus a *continuous* squeeze depth `q` |
11//! | Momentum is one-dimensional | AO only, and a single AO colour change ends the trade | A panel of seven momentum gauges, blended, and a flip that must persist |
12//! | No time decay | Active is a yes/no switch | A smooth strength curve over the life of the fire, and stored squeeze *energy* |
13//!
14//! This module only *measures*.  It does not decide anything; `model.rs`
15//! learns how much each measurement is worth, and `engine.rs` turns that
16//! into the SD number.
17//!
18//! ## Squeeze depth `q`
19//!
20//! The classic tiers compare `SMA + 2σ` with `SMA + k·ATR`.  The SMAs cancel,
21//! so the test is really `2σ ≤ k·ATR`, i.e. `q = 2σ/ATR ≤ k`.  Writing it this
22//! way gives one continuous number: `q < 1` is a high squeeze, `q < 1.5` mid,
23//! `q < 2` low, and *how far* below 2 tells you how tightly coiled price is.
24
25use super::primitives::*;
26use super::squeeze::{run_clock, ClockSeries};
27use crate::indicators::smf_derivative;
28
29/// Which compression gauge drives the squeeze clock.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum CompressionMode {
32    /// The paper: 21-bar bands only.
33    Classic,
34    /// Also listen to a 10-bar gauge, so a brief, deep squeeze registers its
35    /// true (higher) tier instead of being averaged away by 21 bars.
36    MultiScale,
37}
38
39/// How the clock decides "momentum flipped".
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum FlipMode {
42    /// The paper: any one-bar AO colour change.
43    AoColour,
44    /// The blended momentum changes sign *and stays changed for 2 bars*.
45    BlendedPersistent,
46}
47
48/// Knobs that change how features are measured (not how they are weighted).
49#[derive(Debug, Clone, Copy)]
50pub struct FeatureConfig {
51    pub compression: CompressionMode,
52    pub flip: FlipMode,
53}
54
55impl Default for FeatureConfig {
56    fn default() -> Self {
57        Self { compression: CompressionMode::MultiScale, flip: FlipMode::BlendedPersistent }
58    }
59}
60
61/// Names of the model inputs, in column order.  Keep in sync with
62/// [`FeatureFrame::row`].
63pub const FEATURE_NAMES: &[&str] = &[
64    // Momentum panel (each squashed to (-1, 1)).
65    "m_ao", "m_ttm", "m_macd", "m_roc", "m_rsi", "m_smf8", "m_trend", "m_tsmom",
66    // Momentum blend and agreement.
67    "m_blend", "m_agree",
68    // The same blend, switched on only while a fired squeeze is still alive,
69    // and shaped by the time-decay kernel.
70    "fire_x_blend", "fire_x_decay_blend",
71    // Squeeze structure (direction-free: they tell the model *how much*
72    // to trust momentum, through the interactions below).
73    "energy_x_blend", "depth_x_blend",
74    // Mean-reversion / exhaustion: price far from its 21 EMA.
75    "stretch",
76    // Group B structure, kept from v1.
77    "semi",
78];
79
80/// Equal default weights for the momentum blend, before any learning.
81pub const DEFAULT_BLEND: [f64; 8] = [0.125; 8];
82
83/// All measurements for one symbol, one entry per bar.
84pub struct FeatureFrame {
85    pub n: usize,
86    pub close: Vec<f64>,
87    pub atr: Vec<f64>,
88    /// Daily volatility of log returns (20-bar), used to put moves in σ units.
89    pub vol20: Vec<f64>,
90    /// Noise-robust daily volatility: variance of overlapping 5-bar returns
91    /// over 60 bars, ÷ 5.  A bad print adds the same error to a 1-bar and a
92    /// 5-bar return, but the 5-bar return carries 5× the real variance, so
93    /// the noise share drops ~5×.
94    pub vol_robust: Vec<f64>,
95    /// Six-month volatility: the level short-term volatility drifts back to.
96    pub vol126: Vec<f64>,
97    pub q21: Vec<f64>,
98    pub q10: Vec<f64>,
99    pub comp: Vec<u8>,
100    pub clock: ClockSeries,
101    /// Integrated squeeze depth over the current lifecycle (held after firing).
102    pub energy: Vec<f64>,
103    /// 0..1 percentile of 21-bar bandwidth within the last 252 bars (low = coiled).
104    pub width_pct: Vec<f64>,
105    /// Momentum panel, squashed, `[bar][component]`.
106    pub mom: Vec<[f64; 8]>,
107    pub blend: Vec<f64>,
108    pub agree: Vec<f64>,
109    /// Time-decay strength of the current fire, 0..1.
110    pub decay: Vec<f64>,
111    pub stretch: Vec<f64>,
112    pub semi: Vec<f64>,
113}
114
115/// Classic tier from a continuous depth.
116pub fn tier(q: f64) -> u8 {
117    if !q.is_finite() {
118        0
119    } else if q <= 1.0 {
120        3
121    } else if q <= 1.5 {
122        2
123    } else if q <= 2.0 {
124        1
125    } else {
126        0
127    }
128}
129
130/// The time-decay kernel.
131///
132/// `phase = τ / θ` is how far through its expected life the move is.  The
133/// curve stays at 1 for the first 1.5 expected lifetimes and then halves
134/// every further θ bars (0.5 at phase 2.5, 0.25 at 3.5).  The shape is not a
135/// guess: it is the measured profile of post-fire move size.
136pub fn decay_kernel(phase: f64) -> f64 {
137    if !phase.is_finite() || phase < 0.0 {
138        return 0.0;
139    }
140    // Measured (see `sd_backtest anatomy`): move size is flat for the first
141    // ~1.5 expected lifetimes and then fades.  Half-life after that: ~1θ.
142    let x = (phase - 1.5).max(0.0);
143    (-x * std::f64::consts::LN_2).exp()
144}
145
146impl FeatureFrame {
147    pub fn compute(bars: &[OHLCV], cfg: FeatureConfig) -> Self {
148        let n = bars.len();
149        let close: Vec<f64> = bars.iter().map(|b| b.close).collect();
150        let hl2: Vec<f64> = bars.iter().map(|b| (b.high + b.low) / 2.0).collect();
151        let high: Vec<f64> = bars.iter().map(|b| b.high).collect();
152        let low: Vec<f64> = bars.iter().map(|b| b.low).collect();
153        let atr21 = atr(bars, 21);
154        let atr10 = atr(bars, 10);
155        let std21 = rolling_std(&close, 21);
156        let std10 = rolling_std(&close, 10);
157        let q21: Vec<f64> = (0..n).map(|i| 2.0 * std21[i] / atr21[i]).collect();
158        let q10: Vec<f64> = (0..n).map(|i| 2.0 * std10[i] / atr10[i]).collect();
159        let comp: Vec<u8> = (0..n)
160            .map(|i| match cfg.compression {
161                CompressionMode::Classic => tier(q21[i]),
162                // The 10-bar gauge may *raise* the tier only while the 21-bar
163                // gauge already sees some compression, or when it sees a deep
164                // (high) squeeze on its own.  That keeps one quiet week from
165                // inventing squeezes in a volatile market.
166                CompressionMode::MultiScale => {
167                    let (slow, fast) = (tier(q21[i]), tier(q10[i]));
168                    if slow > 0 || fast == 3 { slow.max(fast) } else { slow }
169                }
170            })
171            .collect();
172
173        // ── Momentum panel ────────────────────────────────────────────────
174        let ao: Vec<f64> = sma(&hl2, 5).iter().zip(sma(&hl2, 34)).map(|(a, b)| a - b).collect();
175        let sma20 = sma(&close, 20);
176        let (hh, ll) = (highest(&high, 20), lowest(&low, 20));
177        let ttm_src: Vec<f64> =
178            (0..n).map(|i| close[i] - ((hh[i] + ll[i]) / 2.0 + sma20[i]) / 2.0).collect();
179        let ttm = linreg_value(&ttm_src, 20);
180        let macd = macd_hist(&close);
181        let roc = diff(&close, 10);
182        let rsi14 = rsi(&close, 14);
183        let ema8 = ema(&close, 8);
184        let ema21 = ema(&close, 21);
185        let d = |x: &[f64]| -> Vec<f64> {
186            std::iter::once(0.0).chain(x.windows(2).map(|w| w[1] - w[0])).collect()
187        };
188        let smf8 = smf_derivative(&d(&ema8), 21);
189        let smf_price = smf_derivative(&d(&close), 21);
190        let smf21 = smf_derivative(&d(&ema21), 21);
191        let logret: Vec<f64> = (0..n)
192            .map(|i| if i == 0 { 0.0 } else { (close[i] / close[i - 1]).ln() })
193            .collect();
194        let vol20 = rolling_std(&logret, 20);
195        let ret5: Vec<f64> = (0..n)
196            .map(|i| if i >= 5 { (close[i] / close[i - 5]).ln() } else { f64::NAN })
197            .collect();
198        let vol_robust: Vec<f64> = rolling_std(&ret5, 60).iter().map(|v| v / 5f64.sqrt()).collect();
199        let vol126 = rolling_std(&logret, 126);
200
201        let mut mom = vec![[0.0; 8]; n];
202        for i in 0..n {
203            let a = atr21[i];
204            let tsm = if i >= 126 && vol126[i] > 0.0 {
205                (close[i] / close[i - 126]).ln() / (vol126[i] * 126f64.sqrt())
206            } else {
207                f64::NAN
208            };
209            // Each scale is roughly "one typical day's worth" for that gauge,
210            // so a squashed value of ±0.76 means "one typical unit".
211            mom[i] = [
212                squash(ao[i] / a, 1.5),
213                squash(ttm[i] / a, 1.0),
214                squash(macd[i] / a, 0.25),
215                squash(roc[i] / (a * 10f64.sqrt()), 0.8),
216                squash((rsi14[i] - 50.0) / 50.0, 0.3),
217                squash(smf8[i] / a, 0.15),
218                squash((ema8[i] - ema21[i]) / a, 1.0),
219                squash(tsm, 1.0),
220            ];
221        }
222        let blend: Vec<f64> =
223            mom.iter().map(|m| m.iter().zip(DEFAULT_BLEND).map(|(x, w)| x * w).sum()).collect();
224        let agree: Vec<f64> = mom
225            .iter()
226            .map(|m| m.iter().map(|x| x.signum()).sum::<f64>() / m.len() as f64)
227            .collect();
228
229        // ── Squeeze clock ─────────────────────────────────────────────────
230        let flip: Vec<bool> = match cfg.flip {
231            FlipMode::AoColour => {
232                let up = |i: usize| i >= 1 && ao[i] > ao[i - 1];
233                let dn = |i: usize| i >= 1 && ao[i] < ao[i - 1];
234                (0..n).map(|i| i >= 1 && ((up(i) && !up(i - 1)) || (dn(i) && !dn(i - 1)))).collect()
235            }
236            // A flip counts only once the blend has held its new sign for two
237            // bars; a single noisy bar no longer ends the trade.
238            FlipMode::BlendedPersistent => (0..n)
239                .map(|i| {
240                    i >= 2
241                        && blend[i].signum() == blend[i - 1].signum()
242                        && blend[i - 1].signum() != blend[i - 2].signum()
243                })
244                .collect(),
245        };
246        let clock = run_clock(&comp, &flip);
247
248        // Stored energy: integrate (2 - q) over the squeeze, freeze it once
249        // the squeeze fires, forget it when a new squeeze starts or the fire
250        // is long over.
251        let mut energy = vec![0.0; n];
252        for i in 0..n {
253            let prev = if i > 0 { energy[i - 1] } else { 0.0 };
254            let depth = (2.0 - q21[i]).max(0.0).min(2.0) + 0.5 * (2.0 - q10[i]).max(0.0).min(2.0);
255            energy[i] = if clock.sq_start[i] {
256                depth
257            } else if clock.is_fired[i] {
258                if clock.bars_since_fired[i] as f64 > 3.0 * clock.theta[i].max(1.0) { 0.0 } else { prev }
259            } else if comp[i] > 0 {
260                prev + depth
261            } else {
262                0.0
263            };
264        }
265
266        let width: Vec<f64> = (0..n).map(|i| std21[i] / close[i]).collect();
267        let width_pct: Vec<f64> = (0..n)
268            .map(|i| {
269                if i < 252 || !width[i].is_finite() {
270                    return f64::NAN;
271                }
272                let w = &width[i - 251..=i];
273                w.iter().filter(|v| **v < width[i]).count() as f64 / 251.0
274            })
275            .collect();
276
277        let decay: Vec<f64> = (0..n)
278            .map(|i| {
279                if !clock.is_fired[i] {
280                    return 0.0;
281                }
282                decay_kernel(clock.bars_since_fired[i] as f64 / clock.theta[i].max(1.0))
283            })
284            .collect();
285
286        let stretch: Vec<f64> = (0..n).map(|i| squash((close[i] - ema21[i]) / atr21[i], 3.0)).collect();
287        let semi: Vec<f64> = (0..n)
288            .map(|i| {
289                let dist = (close[i] - ema21[i]).abs() / ema21[i];
290                let exhausted = dist >= 0.005 && smf21[i] <= smf_price[i];
291                if exhausted && close[i] < ema21[i] {
292                    1.0
293                } else if exhausted && close[i] > ema21[i] {
294                    -1.0
295                } else {
296                    0.0
297                }
298            })
299            .collect();
300
301        Self {
302            n,
303            close,
304            atr: atr21,
305            vol20,
306            vol_robust,
307            vol126,
308            q21,
309            q10,
310            comp,
311            clock,
312            energy,
313            width_pct,
314            mom,
315            blend,
316            agree,
317            decay,
318            stretch,
319            semi,
320        }
321    }
322
323    /// Bars before this index have unreliable warm-up values.
324    pub const WARMUP: usize = 260;
325
326    /// Is a fired squeeze still alive on bar `i`?
327    pub fn fire_alive(&self, i: usize) -> bool {
328        self.clock.is_active[i]
329    }
330
331    /// The model input row for bar `i`, in [`FEATURE_NAMES`] order.
332    pub fn row(&self, i: usize) -> Vec<f64> {
333        let m = &self.mom[i];
334        let b = self.blend[i];
335        let alive = if self.fire_alive(i) { 1.0 } else { 0.0 };
336        let fired = if self.clock.is_fired[i] { 1.0 } else { 0.0 };
337        let energy = squash(self.energy[i], 8.0);
338        let depth = squash((2.0 - self.q21[i]).max(0.0), 1.0);
339        let mut r = Vec::with_capacity(FEATURE_NAMES.len());
340        r.extend_from_slice(m);
341        r.push(b);
342        r.push(self.agree[i]);
343        r.push(alive * b);
344        r.push(fired * self.decay[i] * b);
345        r.push(energy * b);
346        r.push(depth * b);
347        r.push(self.stretch[i]);
348        r.push(self.semi[i]);
349        r
350    }
351
352    pub fn row_is_valid(&self, i: usize) -> bool {
353        i >= Self::WARMUP && self.row(i).iter().all(|v| v.is_finite()) && self.atr[i] > 0.0
354    }
355}