10. One bar through SD, end to end
I'm working on SD itself · 3/6
Chapter 3 explained what SD v2 measures and why. This
chapter follows one bar through the code, file by file, in the order the
computer runs it. Keep it open next to src/sd2/ and you can trace any SD
number back to the line that produced it.
Our bar: the close of a quiet day for a stock that has spent twelve days coiling and fired three days ago.
bars ──▶ primitives.rs ──▶ features.rs ──▶ squeeze.rs ──▶ features.rs ──▶ engine.rs ──▶ Sd2Reading
(rolling tools) (q21, q10, (the clock: (energy, (expected move,
tiers, θ, τ, fired, momentum, intensity, lean,
momentum) active) decay) SD)
▲
sd2_model.json ─────┘ (frozen fit)
#Stop 1: primitives.rs: the rolling tools
Everything starts with small trailing-window functions: true_range, atr,
rolling_std, highest, lowest, linreg_value, rsi, macd_hist.
Each takes a slice and returns a column of the same length, with NaN
until enough history exists.
Rule for this file: a value at index i may read 0..=i only. If you
write a new tool, test that changing bar i+1 cannot change output i.
squash(x, s) = tanh(x / s) lives here too. It maps any number into (−1, 1)
so one wild day can't dominate a blend.
#Stop 2: features.rs: compression
For our bar, FeatureFrame::compute measures squeeze depth twice:
q21 = 2·σ(close, 21) / ATR(21) the paper's gauge
q10 = 2·σ(close, 10) / ATR(10) the fast gauge
tier(q) turns depth into 3 / 2 / 1 / 0 using the paper's Keltner
multipliers (1.0, 1.5, 2.0). In MultiScale mode the fast gauge may raise
the tier, but only if the slow gauge already sees some compression, or the
fast gauge alone sees tier 3. The result, comp[i], is one small integer
per bar.
#Stop 3: squeeze.rs: the clock
run_clock(comp, mom_flip) is the paper's state machine, shared by v1 and
v2. It walks bars once and keeps, per bar:
| Column | Our bar |
|---|---|
bars_since_start | 15 (12 coiling + 3 since fire) |
highest_comp | the deepest tier reached in this squeeze |
is_fired | true: the tier dropped below its highest |
bars_since_fired (τ) | 3 |
theta (θ) | average squeeze length over the last 125 bars (per tier where there is enough data), here say 9 |
is_active | true while τ ≤ ⌈θ⌉ and momentum hasn't flipped since the fire |
θ uses prefix sums, so the whole clock is O(n). That matters: the backtester runs it on 125 symbols × 6,000 bars × many configurations.
mom_flip is how momentum can end a fire early. In v2 it is the
BlendedPersistent rule: the blended momentum must change sign and stay
changed for two bars. One noisy bar no longer kills a fire.
#Stop 4: features.rs: energy, momentum, decay
Back in features.rs, with the clock in hand:
- Energy. While compressed,
energy += (2 − q21) + ½(2 − q10); frozen at the fire; reset when a new squeeze starts or 3θ after the fire. Our bar carries the twelve days of winding. - Momentum panel. Eight gauges (AO, TTM linreg, MACD histogram, ROC10,
RSI14, SMF EMA-8, EMA8 − EMA21, 126-day trend), each scaled to its natural
unit and squashed.
blendis their average;agreethe share pointing the same way. - Decay.
phase = τ/θ = 3/9 = 0.33.decay_kernel(0.33) = 1.0: the fire is young. It would stay 1.0 until phase 1.5 and then halve every further θ.
Also computed here: vol20, vol126, vol_robust (5-day overlapping
returns ÷ √5 over 60 bars), width_pct (where bandwidth ranks in the last
year), stretch, semi.
#Stop 5: engine.rs: from measurements to SD
reading_at(f, i, model) does the rest, in six lines that matter:
let expected = model.magnitude.predict(&magnitude_inputs(f, i)).max(0.0); // in σ·√21 units
let intensity = model.intensity(expected); // 10th pct → 0, 90th pct → 1
let strength = time_strength(f, i); // decay for a live fire
let lean = (2.0 * f.blend[i]).tanh() * strength;
let sd = (0.5 + 0.5 * lean * intensity).clamp(0.0, 1.0);
let pct = expected * move_vol(f, i) * (21f64).sqrt();
- Expected move. The ridge model in
sd2_model.jsonmaps eleven direction-free inputs (research.rs::MAGNITUDE_NAMES) to an expected 21-day absolute move, measured in units of normal 21-day travel. - Intensity. Where that expectation ranks among all pre-2019 readings, squeezed into 0–1.
- Strength.
time_strength: decay if the fire is live, 0.5 in a coil, 0.25 × decay after a fire has ended, 0.25 with no squeeze at all. - Lean. The side, from blended momentum, softened by strength.
- SD. Distance from 0.5 = lean × intensity. A big expected move with a fresh, strong lean pushes SD towards 0 or 1; anything missing pulls it back to 0.5.
- Percent move. Back from σ units with the noise guard,
move_vol = min(vol20, vol_robust).
The reading also carries calibrated_up_probability: the share of past
readings in this SD decile that rose. It hovers near 58% everywhere. That is
SD saying, honestly, that most of the "up" is market drift.
#Stop 6: where the reading goes
| Consumer | File | Uses |
|---|---|---|
| Command line | bin/sd2.rs | prints the reading, --json, --csv |
| Options backtester and strategies | strategy/mod.rs, bin/sd_options.rs | expected_move_pct vs implied move → vol_edge; in_squeeze; lean |
| Thinkorswim | Indicator/SahastaDirectionalityV2STUDY.ts | the same arithmetic, constants pasted from the JSON |
| Thatha Stocks | portfolio_analyzer (separate repo) | v1's Extended SD today; v2 is available |
#Where the frozen numbers come from
sd2_model.json is written by sd_backtest fit: ridge weights fitted on
bars before 2019, the 10th/90th percentile of expected moves (intensity
range), the SD-decile calibration table, and the IV proxy coefficients. It
is embedded at compile time (include_str!), so the CLI, the backtests and
any binary that links the library agree to the last decimal. If you
change an input in magnitude_inputs, you must refit, or the weights will
be applied to the wrong columns.
#Recipe: change one stage
| You want to change… | Edit | Then run |
|---|---|---|
| a rolling tool | primitives.rs (+ a test) | cargo test |
| how a squeeze is detected | features.rs compression block | sd_backtest anatomy, magnitude |
| when a fire ends | squeeze.rs or the flip rule in features.rs | anatomy, verify |
| the decay curve | decay_kernel | anatomy (does it match the measured profile?) |
| what the SD number means | engine.rs::reading_at | verify, noise, sd_options |
| the expected-move inputs | research.rs::magnitude_inputs + MAGNITUDE_NAMES | magnitude, then fit and rebuild |
Next on this journey: direction.