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}