Skip to main content

portfolio_analyzer/sd2/
engine.rs

1//! # Engine: from measurements to the SD number
2//!
3//! This is the file to open if you want to know what the number on the chart
4//! *means*.  Everything upstream measures; this file decides.
5//!
6//! ## What the backtests taught us (and why the engine looks like this)
7//!
8//! `sd_backtest study` and `sd_backtest grid` asked, over 125 symbols and
9//! 25 years, trained before 2014 and graded after 2019:
10//!
11//! 1. **Can squeeze + momentum features call direction?**  Barely.  Every
12//!    feature, alone or learned in combination, lands within a point or two
13//!    of a coin flip once the market's upward drift is removed.  Classic SD's
14//!    `+`/`-` calls hit 50.5%, while "always up" hit 54-61%.
15//! 2. **Can they call the size of the move?**  Yes.  Predicted versus
16//!    realized move size has a rank correlation of about 0.22 out of
17//!    sample, and the effect is monotone in squeeze tier, stored energy and
18//!    squeeze duration in *both* eras.
19//!
20//! So SD v2 is built around the thing it can actually do.  The SD number
21//! keeps its familiar shape (0 = bearish, 0.5 = nothing, 1 = bullish), but:
22//!
23//! * **how far** SD sits from 0.5 is set by the *expected movement*
24//!   (bigger expected move → further from 0.5), and
25//! * **which side** of 0.5 it sits on is the best direction call the
26//!   walk-forward search (`sd_direction`, chapter 8) could find: P(up) from
27//!   drift plus the market's weather.  That is right about 58% of the time
28//!   at 21 bars, the same as "always up", because nothing beat drift.  The
29//!   momentum lean is still reported, but it no longer picks the side.
30//!
31//! ```text
32//!   SD = 0.5 + 0.5 · side · strength · intensity
33//!        side      ∈ {-1, +1}  sign of P(up) − 0.5
34//!        strength  ∈ [ 0, 1]   time-decay strength of the squeeze
35//!        intensity ∈ [ 0, 1]   where this bar's expected move ranks
36//! ```
37
38use super::features::{decay_kernel, FeatureConfig, FeatureFrame};
39use super::model::{sigmoid, Logistic, Ridge};
40use super::primitives::OHLCV;
41use super::squeeze::group_c;
42use crate::research::{magnitude_inputs, MAGNITUDE_NAMES};
43use serde::{Deserialize, Serialize};
44
45/// The frozen, fitted pieces SD v2 needs at run time.  Produced by
46/// `sd_backtest fit` and embedded in the binary from `sd2_model.json`.
47#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct Sd2Model {
49    /// Horizon, in bars, that `magnitude` was trained for.
50    pub horizon: usize,
51    /// Predicts |move| / (σ·√h) from [`MAGNITUDE_NAMES`] inputs.
52    pub magnitude: Ridge,
53    /// Expected-move values (σ units) at the 10th and 90th percentile of the
54    /// training data; intensity maps this range onto [0, 1].
55    pub intensity_lo: f64,
56    pub intensity_hi: f64,
57    /// Observed share of up moves by SD bucket (0.0-0.1, …, 0.9-1.0) on the
58    /// training data.  This is the *honest* probability to show a person.
59    pub calibration: Vec<f64>,
60    /// Implied-volatility proxy for symbols without a listed vol index:
61    /// `ln IV = a + b·ln RV20 + c·ln RV126` (annualized), fitted on SPY,
62    /// QQQ, DIA, GLD and USO against their Cboe indices.
63    pub iv_proxy: [f64; 3],
64    pub feature_names: Vec<String>,
65    pub fitted_on: String,
66    /// Which side of 0.5 SD sits on: P(close higher in `horizon` bars) from
67    /// the market's weather (SPY trend, momentum, vol ratio, 5- and 21-bar
68    /// return; see `direction.rs`).  Written by `sd_direction --ship`.
69    #[serde(default)]
70    pub side: Option<Logistic>,
71}
72
73impl Sd2Model {
74    /// The model shipped with this crate.
75    pub fn embedded() -> Self {
76        serde_json::from_str(include_str!("sd2_model.json")).expect("valid embedded sd2_model.json")
77    }
78
79    pub fn intensity(&self, expected_sigma: f64) -> f64 {
80        ((expected_sigma - self.intensity_lo) / (self.intensity_hi - self.intensity_lo)).clamp(0.0, 1.0)
81    }
82
83    /// P(up) from market weather; without weather, the average day's answer.
84    pub fn p_up(&self, weather: Option<&[f64]>) -> f64 {
85        match (&self.side, weather) {
86            (Some(m), Some(w)) if w.iter().all(|v| v.is_finite()) => m.predict(w),
87            (Some(m), _) => sigmoid(m.intercept),
88            (None, _) => 0.5,
89        }
90    }
91
92    pub fn calibrated_up(&self, sd: f64) -> f64 {
93        let b = ((sd * 10.0).floor() as usize).min(self.calibration.len().saturating_sub(1));
94        self.calibration.get(b).copied().unwrap_or(0.5)
95    }
96
97    /// Implied-volatility proxy (annualized) from daily realized vols.
98    pub fn iv_proxy(&self, vol20_daily: f64, vol126_daily: f64) -> f64 {
99        let a = 252f64.sqrt();
100        let [c0, c1, c2] = self.iv_proxy;
101        (c0 + c1 * (vol20_daily * a).ln() + c2 * (vol126_daily * a).ln()).exp()
102    }
103}
104
105/// One SD v2 reading.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct Sd2Reading {
108    /// The SD number, `[0, 1]`.
109    pub sd: f64,
110    /// Momentum lean after time decay, `[-1, 1]`.  Shown for context; it
111    /// no longer sets SD's side (it calls direction at coin-flip rates).
112    pub lean: f64,
113    /// Probability of a higher close in `horizon` bars (market weather + drift).
114    pub p_up: f64,
115    /// SD's side: +1 above 0.5, −1 below (from `p_up`).
116    pub side: f64,
117    /// Expected-move rank, `[0, 1]`.
118    pub intensity: f64,
119    /// Expected |move| over `horizon` bars, in σ·√h units (≈0.8 is ordinary).
120    pub expected_move_sigma: f64,
121    /// The same, as a fraction of price (0.05 = 5%).
122    pub expected_move_pct: f64,
123    pub horizon: usize,
124    /// Historical share of up moves for readings like this one.
125    pub calibrated_up_probability: f64,
126    /// Time-decay strength of the current fire, `[0, 1]`.
127    pub decay_strength: f64,
128    /// τ/θ, or `None` if no squeeze has fired.
129    pub phase: Option<f64>,
130    pub compression: u8,
131    pub energy: f64,
132    pub group_c: u8,
133    pub is_active_fire: bool,
134    pub in_squeeze: bool,
135    /// Daily volatility (20 and 126 bars) at this bar.
136    pub vol20: f64,
137    pub vol126: f64,
138    pub close: f64,
139    /// How far price is from its 21-bar EMA, squashed to (−1, 1).
140    #[serde(default)]
141    pub stretch: f64,
142}
143
144/// How much to trust the momentum lean at this point in the squeeze's life.
145///
146/// * A live fire gets the full decay curve: 1.0 while young, fading after.
147/// * Inside a squeeze (not yet fired), direction is genuinely unknown; the
148///   lean is shown at half strength so the chart still hints which way
149///   momentum is leaning while the coil builds.
150/// * Otherwise the lean is shown at a quarter strength: momentum without a
151///   squeeze behind it is the weakest kind of evidence.
152pub fn time_strength(f: &FeatureFrame, i: usize) -> f64 {
153    if f.clock.is_active[i] {
154        decay_kernel(f.clock.bars_since_fired[i] as f64 / f.clock.theta[i].max(1.0))
155    } else if f.comp[i] > 0 {
156        0.5
157    } else if f.clock.is_fired[i] {
158        0.25 * decay_kernel(f.clock.bars_since_fired[i] as f64 / f.clock.theta[i].max(1.0))
159    } else {
160        0.25
161    }
162}
163
164/// Which daily volatility turns σ-units into a % move.
165///
166/// Measurement noise can only *inflate* the 20-bar estimate, so taking the
167/// smaller of it and the noise-robust estimate guards against bad prints.
168/// `SD_VOL=vol20` / `SD_VOL=robust` force one estimator for A/B tests.
169fn move_vol(f: &FeatureFrame, i: usize) -> f64 {
170    let robust = f.vol_robust[i];
171    match std::env::var("SD_VOL").as_deref() {
172        _ if !robust.is_finite() => f.vol20[i],
173        Ok("vol20") => f.vol20[i],
174        Ok("robust") => robust,
175        _ => f.vol20[i].min(robust),
176    }
177}
178
179/// The SD number: side × how much to trust it now × how big the move will be.
180pub fn side_sd(side: f64, strength: f64, intensity: f64) -> f64 {
181    (0.5 + 0.5 * side * strength * intensity).clamp(0.0, 1.0)
182}
183
184/// Re-decide a reading's side with today's market weather (SPY row from
185/// [`crate::direction::MarketWeather`]; the first five columns).
186pub fn apply_weather(r: &mut Sd2Reading, model: &Sd2Model, weather: Option<&[f64]>) {
187    r.p_up = model.p_up(weather);
188    r.side = if r.p_up >= 0.5 { 1.0 } else { -1.0 };
189    r.sd = side_sd(r.side, r.decay_strength, r.intensity);
190    r.calibrated_up_probability = r.p_up;
191}
192
193/// SD v2 for every bar, with the market's weather deciding the side.
194pub fn sd2_series_market(
195    bars: &[OHLCV],
196    dates: &[chrono::NaiveDate],
197    model: &Sd2Model,
198    mkt: &crate::direction::MarketWeather,
199) -> Vec<Option<Sd2Reading>> {
200    let mut out = sd2_series(bars, model);
201    for (r, d) in out.iter_mut().zip(dates) {
202        if let Some(r) = r {
203            let w = mkt.at(*d).map(|(_, row)| row[..5].to_vec());
204            apply_weather(r, model, w.as_deref());
205        }
206    }
207    out
208}
209
210/// Compute SD v2 for every bar of `bars`.
211pub fn sd2_series(bars: &[OHLCV], model: &Sd2Model) -> Vec<Option<Sd2Reading>> {
212    let f = FeatureFrame::compute(bars, FeatureConfig::default());
213    (0..f.n).map(|i| reading_at(&f, i, model)).collect()
214}
215
216/// Compute SD v2 for the last bar only.
217pub fn sd2_latest(bars: &[OHLCV], model: &Sd2Model) -> Option<Sd2Reading> {
218    let f = FeatureFrame::compute(bars, FeatureConfig::default());
219    reading_at(&f, f.n.checked_sub(1)?, model)
220}
221
222pub fn reading_at(f: &FeatureFrame, i: usize, model: &Sd2Model) -> Option<Sd2Reading> {
223    if i < 140 || !f.atr[i].is_finite() || !f.vol20[i].is_finite() || !f.vol126[i].is_finite() {
224        return None;
225    }
226    let mut mag_x = magnitude_inputs(f, i);
227    // Under a year of history the bandwidth percentile is unknown; use the
228    // neutral middle rather than refusing to answer.
229    if !mag_x[8].is_finite() {
230        mag_x[8] = 0.5;
231    }
232    if mag_x.iter().any(|v| !v.is_finite()) || mag_x.len() != MAGNITUDE_NAMES.len() {
233        return None;
234    }
235    let expected = model.magnitude.predict(&mag_x).max(0.0);
236    let intensity = model.intensity(expected);
237    let strength = time_strength(f, i);
238    let lean = (2.0 * f.blend[i]).tanh() * strength;
239    let p_up = model.p_up(None);
240    let side = if p_up >= 0.5 { 1.0 } else { -1.0 };
241    let sd = side_sd(side, strength, intensity);
242    let active = f.clock.is_active[i];
243    let eff = if active { f.clock.highest_comp[i] } else { f.comp[i] };
244    Some(Sd2Reading {
245        sd,
246        lean,
247        p_up,
248        side,
249        intensity,
250        expected_move_sigma: expected,
251        expected_move_pct: expected * move_vol(f, i) * (model.horizon as f64).sqrt(),
252        horizon: model.horizon,
253        calibrated_up_probability: model.calibrated_up(sd),
254        decay_strength: strength,
255        phase: f.clock.is_fired[i].then(|| f.clock.bars_since_fired[i] as f64 / f.clock.theta[i].max(1.0)),
256        compression: f.comp[i],
257        energy: f.energy[i],
258        group_c: group_c(eff, f.comp[i]),
259        is_active_fire: active,
260        in_squeeze: f.comp[i] > 0,
261        vol20: f.vol20[i],
262        vol126: f.vol126[i],
263        close: f.close[i],
264        stretch: f.stretch[i],
265    })
266}