3. SD v2
I'm new here · 3/6 I'm working on SD itself · 2/6 I want to change the indicator · 2/4 I want to change a trading strategy · 1/5
SD v2 keeps the paper's squeeze clock and changes what feeds it, how it ages, and what the final number means. This chapter walks the pipeline from raw bars to the SD number, in the order the code runs:
bars ─▶ primitives.rs ─▶ features.rs ─────────────▶ engine.rs ─▶ Sd2Reading
(ATR, σ, RSI, (compression depth q, (lean × intensity
MACD, linreg) tiers, clock, energy, → SD, expected
momentum panel, decay) move, probability)
│
└─▶ model.rs (fit once, frozen in sd2_model.json)
#1. Multi-scale compression
Problem: a short, deep squeeze is blurred by the 21-bar bands (chapter 2).
Fix (features.rs, CompressionMode::MultiScale): measure the depth
twice, q21 = 2σ₂₁/ATR₂₁ and q10 = 2σ₁₀/ATR₁₀. The 10-bar gauge may
raise the tier, but only when the 21-bar gauge already sees some
compression, or when the 10-bar gauge alone sees a tier-3 squeeze.
| Choice | Why |
|---|---|
| A second gauge, not a shorter main gauge | 10-bar bands are jumpy; as the only gauge they invent squeezes in every quiet week |
| It can only raise the tier | the paper's "highest tier reached" logic is untouched, so short squeezes now fire from their true height |
| A gate: 21-bar must agree, unless the fast gauge sees tier 3 | one calm week in a volatile market is not a coil |
#2. Stored energy
Idea: a squeeze is a spring. Depth × duration is how hard it has been wound.
energy += (2 − q21) + ½·(2 − q10) every bar the squeeze is compressed
energy = frozen after it fires
energy = 0 when a new squeeze starts, or 3θ after the fire
Measured: move size rises steadily from the lowest energy quartile to the highest, in both eras.
#3. Blended momentum
Problem: AO alone, and one noisy bar ends a fire.
Fix (features.rs, the momentum panel): eight gauges, each divided by
ATR (or its own natural scale) and squashed with tanh into (−1, 1) so no
single wild bar dominates:
| Gauge | Measures |
|---|---|
| AO | 5 vs 34 bar midpoint averages (v1's gauge) |
| TTM linreg | slope-adjusted end of a regression line through price vs its 20-bar center |
| MACD histogram | fast trend pulling away from slow, and accelerating |
| ROC(10) | ten-day change, in units of normal ten-day travel |
| RSI(14) | share of recent movement that was up |
| SMF EMA-8 | v1's smoothed derivative of the 8 EMA |
| EMA-8 − EMA-21 | short trend vs medium trend |
| 6-month trend | 126-day return in units of 126-day volatility |
blend is their average; agree is the share pointing the same way.
Flip rule (FlipMode::BlendedPersistent): a fire ends on a momentum
change only when the blend changes sign and stays changed for two bars.
Measured: none of this predicts direction better than a coin flip (chapter 2). The blend is kept because it is a steadier description of momentum than AO alone: fewer false flips, and a lean that people can read. It is not sold as an edge.
#4. Time decay
strength(phase) = 1 while phase ≤ 1.5
= 2^−(phase − 1.5) after (halves every θ bars)
phase = τ / θ
engine.rs::time_strength uses that curve for an active fire. A squeeze
that is still coiling gets 0.5: momentum hints at a direction while the
spring winds, but nothing has happened yet. With no squeeze behind it,
momentum gets 0.25, the weakest kind of evidence.
Why this shape: it is the measured profile of post-fire move size
(chapter 2's table), not a guess. Change it, and re-run
sd_backtest anatomy to see whether your curve fits the data better.
#5. Expected movement
model.rs holds a ridge regression (a straight-line fit with a penalty that
keeps weights small unless the data insists). It predicts:
expected |move over 21 bars| ÷ (σ₂₀ · √21)
from eleven direction-free inputs (research.rs::MAGNITUDE_NAMES): two
volatility ratios, energy, both depths, tier, squeeze duration, decay,
bandwidth percentile, and the strength (not sign) of momentum. It is fitted
once on bars before 2019 and frozen into src/sd2/sd2_model.json, so the
live indicator, the backtests and ThinkScript all use the same numbers.
The result is scaled back to percent with a noise-guarded volatility:
min(σ₂₀, σ_robust). Here σ_robust is the volatility of overlapping
5-day returns ÷ √5 over 60 bars. Chapter 4 tells the story of why: bad
prints inflate one-day volatility, and the guard stopped SD from mistaking
noise for movement.
#6. The SD number
lean = tanh(2 · blend) × strength ∈ [−1, 1]
intensity = rank of expected move (10th → 90th pct) ∈ [ 0, 1]
SD = 0.5 + 0.5 · lean · intensity
| SD reads | Means |
|---|---|
| 0.50 | nothing to say: small expected move, or no lean |
| 0.60 / 0.40 | a moderate move expected, momentum leaning up / down |
| 0.85 / 0.15 | a large move expected, with a strong, fresh lean |
Alongside the number, Sd2Reading carries the expected move in percent,
the fire phase, the energy, the tier, and calibrated_up_probability: the
share of past readings in the same SD decile that rose. That probability is
about 58% everywhere, which is SD's honest admission that the side is mostly
drift. Show it to people.
Measured on 2019–2026 (sd_backtest verify):
| Classic SD | SD v2 | |
|---|---|---|
| ρ(|SD − 0.5|, |21-day move|) | +0.003 | +0.171 |
| Direction hit rate | 50.9% | 50.4% |
| "Always up" | 56.9% | 56.9% |
#Where to find it
| Piece | File |
|---|---|
| Rolling tools | src/sd2/primitives.rs |
| Squeeze clock (shared with v1) | src/sd2/squeeze.rs |
| v1, every bar | src/sd2/classic.rs |
| Features: compression, energy, momentum, decay | src/sd2/features.rs |
| Logistic and ridge models | src/sd2/model.rs |
| The SD number | src/sd2/engine.rs |
| Frozen model | src/sd2/sd2_model.json (regenerate: sd_backtest fit) |
| Command line | src/bin/sd2.rs |
| ThinkScript | Indicator/SahastaDirectionalityV2STUDY.ts (constants copied from the JSON) |
Next: the steady backtester, the machine that produced every number above.