SD book

10. One bar through SD, end to end

I'm working on SD itself · 3/6

6 min read

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:

ColumnOur bar
bars_since_start15 (12 coiling + 3 since fire)
highest_compthe deepest tier reached in this squeeze
is_firedtrue: 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_activetrue 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. blend is their average; agree the 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();
  1. Expected move. The ridge model in sd2_model.json maps eleven direction-free inputs (research.rs::MAGNITUDE_NAMES) to an expected 21-day absolute move, measured in units of normal 21-day travel.
  2. Intensity. Where that expectation ranks among all pre-2019 readings, squeezed into 0–1.
  3. 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.
  4. Lean. The side, from blended momentum, softened by strength.
  5. 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.
  6. 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

ConsumerFileUses
Command linebin/sd2.rsprints the reading, --json, --csv
Options backtester and strategiesstrategy/mod.rs, bin/sd_options.rsexpected_move_pct vs implied move → vol_edge; in_squeeze; lean
ThinkorswimIndicator/SahastaDirectionalityV2STUDY.tsthe same arithmetic, constants pasted from the JSON
Thatha Stocksportfolio_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…EditThen run
a rolling toolprimitives.rs (+ a test)cargo test
how a squeeze is detectedfeatures.rs compression blocksd_backtest anatomy, magnitude
when a fire endssqueeze.rs or the flip rule in features.rsanatomy, verify
the decay curvedecay_kernelanatomy (does it match the measured profile?)
what the SD number meansengine.rs::reading_atverify, noise, sd_options
the expected-move inputsresearch.rs::magnitude_inputs + MAGNITUDE_NAMESmagnitude, then fit and rebuild

Next on this journey: direction.