Skip to content

15 — Pathification of Probability Tables: Multi-Window Distribution + Four Path Shapes ​

Implementation: core/regime_probability.py (compute_regime_return_frame / get_path_profile / build_reentry_reference_text). Tests: tests/test_regime_probability.py. Introduced in: v0.6.0 (multi-window + three shapes) → v0.6.x (four shapes + regime persistence annotation).

0. One-liner ​

For every historical trading day in decades of OHLC data: use the production-grade classify_regime to determine the day's regime, then look forward 30/60/90 days to record the end-of-period return, the lowest/highest point along the way, their respective arrival times, and the actual number of consecutive days the regime persisted — aggregate all historical days with the "same regime as today" into a distribution, and inject it into the CIO brief. Pure arithmetic, 0 LLM tokens, computed in real-time at each committee meeting.

Design motivation: The committee's verdict direction prediction (~57% blind) cannot beat the base rate, but forward return differences between regimes are significant (see 12-verification.md). Therefore, direction is anchored by the probability table, with the LLM responsible for translation and contextualization; the pathification in this document upgrades the "30-day single-point distribution" to a "prediction with path shapes" — answering the user's real questions: when will it drop after selling, when will it rise, and will there be opportunities to buy the dip / get a higher selling price along the way.

1. Data and Daily Regime Classification (compute_regime_return_frame) ​

Input: MarketStore local OHLC full history (GC=F 2000→present ~6500 rows, NDQ.AX 2015→present ~2800 rows).

For each historical day i, compute point-in-time (using only data up to and including i, no look-ahead bias):

MetricAlgorithm
ma20 / ma12020/120-day rolling mean of close
return_30dclose[i]/close[i−30] − 1
rebound_off_30d_lowclose[i]/min close over last 30 days − 1
price_quantile_2yRolling 504 trading day window: (closes ≤ current close) / n, true percentile
atr_pctWilder TR = max(H−L, |H−prevC|, |L−prevC|), ewm(α=1/14) ÷ current close ×100; degrades to |ΔClose| if no H/L columns

Calls the production classify_regime(metrics, symbol) daily — the same function and same per-asset thresholds used by the live committee, ensuring the historical distribution is consistent with today's regime label. Insufficient warmup (first ~120 rows) → unknown, excluded from the distribution.

2. Forward Path Metrics (Calendar Day Alignment) ​

For each window w ∈ {30, 60, 90} calendar days (consistent with the "30d" semantics of verdict_review):

pos[i]      = index.searchsorted(date[i] + w days, side="left")   # End of window or first close after
fwd_w[i]    = close[pos]/close[i] − 1          # End-of-period return
min_w[i]    = min(close[i+1..pos])/close[i] − 1   # Maximum drawdown along the way
tmin_w[i]   = Trading day offset of argmin (starting from i+1)      # How many trading days to the trough ("when it drops")
max_w[i]    = max(close[i+1..pos])/close[i] − 1   # Maximum peak along the way (mirror of min)
tmax_w[i]   = Trading day offset of argmax                  # How many trading days to the peak ("when it rises")
persist_w[i]= min(forward consecutive regime run length, pos[i]−i+1) # Actual number of trading days the regime persists within the window
wdays_w[i]  = pos[i] − i + 1                       # Number of trading days in the window (denominator reference for persist)

Rows with insufficient lookahead for a full window → all NaN (never peek at a partial window). The consecutive run length for persist is obtained via a single reverse scan O(n) run-length calculation, precisely capped at the window end for each row, with no approximate magic numbers.

3. Aggregation (get_path_profile(asset, regime)) ​

Take all historical days from the frame where regime == current regime.

Per-window distribution (one set each for 30/60/90):

  • median_pct median return, p_below = P(end price < current price), p_down = P(drop > 5%)
  • p10_pct / p90_pct pessimistic/optimistic 10th percentile
  • downside_pct = 20th percentile (pessimistic scenario, converted to buyback reference price current price × (1+downside/100))
  • effective_n = n // window_days —— adjacent samples overlap by w−1 days; with n=1423, independent samples for the 90d window are only ≈15; effective_n < 10 → output flagged with ⚠Insufficient Samples. Honest discounting prevents overlapping samples from inflating confidence.

Path Shapes (90d window, four mutually exclusive and exhaustive categories):

Unit u = current day's atr_pct (self-calibrating, no absolute percentage magic numbers)
dipped = min_90d ≤ −1×u      # A "significant pullback" occurred along the way (≥1x its own daily volatility)
popped = max_90d ≥ +1×u      # A "significant high point" occurred along the way
up     = fwd_90d > 0

Dip-then-Rise   = dipped  ∧ up     # Can buy back after a pullback (for holders: don't panic on shallow dips)
Direct Rise     = ¬dipped ∧ up     # No dip-buying opportunity (waiting for a pullback leads to missing out)
Rise-then-Fall  = popped  ∧ ¬up    # Gave a higher selling point first, then closed lower — core reading for selling timing
Continuous Fall = ¬popped ∧ ¬up    # No decent exit point was given throughout the entire journey

Why four categories instead of three (specification correction from 2026-06): The original three categories (Dip-then-Rise / Direct Rise / Fall) only used the "lowest point along the way" as the classification axis. It asked "did it drop along the way?" but not "did it rise along the way?" — thus "continuous decline" and "first surged +8% then reversed" were lumped into the same "Fall" category, even though the latter is precisely the shape most relevant to selling decisions. The fix = add the mirror max axis, splitting the ¬up branch by popped. Empirical test: NDQ uptrend old "Fall 27%" = Rise-then-Fall 19% + Continuous Fall 8% — 70% of the Fall branch "gave a higher selling point."

Plus accompanying statistics (all percentages converted to current price levels):

  • Peak: pop_median_pct / pop_p75_pct (upper quartile) + days_to_peak_median
  • Trough: dip_median_pct / dip_p25_pct (lower quartile) + days_to_trough_median
  • regime_persist_median_days / window_median_days: Median actual persistence of the regime within the window

Why mark persist (2026-06 interpretation safeguard): Shape distributions are bucketed by the "regime at the window's start," but the regime may switch within the window — "Dip-then-Rise 49%" might include cases where "the regime switched away early, and that rise wasn't given by this regime." A low ratio of persist median / window median → shape proportions mainly describe post-switch behavior, and interpretation weight should be discounted. Empirical test: NDQ uptrend 45/64 (mostly effective within the window); GC=F downtrend 13/63 — gold's downtrend median persists for only 13 trading days before switching, so its 90d shapes are largely post-switch behavior (this also explains the "counter-intuitive" 90d median return of +3.0% under downtrend).

4. Output and Integration into Decision (build_reentry_reference_text) ​

After a single OHLC load, compute the three windows + shapes (get_path_profile reuses the frame in one call), and inject the text into the === Post-Sell Path / Buyback Reference === section of the CIO brief. Real example (2026-06-11):

# Path Reference (regime=uptrend historical forward path distribution; TRIM buyback point + holding path expectation):
- Current Price: ¥60.73
- 30d (n=1423, independent overlapping windows≈47): Probability of falling below current price 35%, probability of drop>5% 9%; Pessimistic scenario (20th percentile) -2.1% → ¥59.46; Median +1.4%
- 60d (n=1423, independent overlapping windows≈23): Probability of falling below current price 32%, probability of drop>5% 14%; Pessimistic scenario (20th percentile) -2.9% → ¥58.95; Median +2.7%
- 90d (n=1423, independent overlapping windows≈15): Probability of falling below current price 27%, probability of drop>5% 15%; Pessimistic scenario (20th percentile) -2.6% → ¥59.15; Median +4.3%
- 90d Path Shapes (n=1423, independent overlapping windows≈15): Dip-then-Rise 49% / Direct Rise (no significant pullback) 23% / Rise-then-Fall 19% / Continuous Fall 8% ("Significant" = intra-path amplitude ≥1× daily ATR; Rise-then-Fall = gave a higher selling point first, then closed lower)
- 90d Maximum Peak Along the Way: Median +7.1% (→ ¥65.04), Upper Quartile +10.5% (→ ¥67.12); Median 48 trading days to peak
- 90d Maximum Trough Along the Way: Median -3.3% (→ ¥58.72), Lower Quartile -6.9% (→ ¥56.53); Median 18 trading days to trough
- 90d Median Regime Persistence within Window: 45/64 trading days — a low persistence ratio means shape proportions include regime-switching components, discount interpretation weight

Constraint Closure:

  • CIO SKILL (agents/skills/cio/SKILL.md) mandates EXPECTED_PATH to reference these numbers (shape proportions/pullback depth/trough timing), prohibiting fabricated paths
  • TRIM's REENTRY_PRICE ≥ current price is still deterministically rejected by parse_cio_memo Sanity 5, returning HOLD
  • Consistent probability source: regime_brief's STRATEGY_HINT (30d neutral probability) and this path reference come from the same compute_regime_return_frame, ensuring no internal contradictions

5. Boundaries and Known Limitations ​

  • Graceful Degradation: Any market data/computation failure → reentry_reference degrades to an empty string, does not block the committee
  • Conditional Distribution Bucketed by Starting Regime: Intra-window switching is a real phenomenon (the persist row makes it assessable), the classification itself does not filter for "same regime throughout the window" — that would introduce survivorship bias (only counting "uptrends that lasted 90 days" = selecting the strongest samples)
  • Honest Small Sample Labeling: GC=F downtrend 90d independent samples ≈4 → ⚠Insufficient Samples; no pretense of precision
  • 1×ATR is a Definition, Not a Tuned Parameter: It only affects the display boundary for "significant pullback/peak," does not modify any deterministic verdict; sweeping it requires first establishing a validation framework (same discipline as EVENT_STANCE weights, see precedent of scripts/eval_event_stance.py's INSUFFICIENT_DATA gate)

6. Calibration Loop (path_review) and Walk-Forward Baseline ​

Path predictions are verifiable — jobs/path_review.py (following the verdict_review pattern):

  • Snapshot at Decision Time: committee_runner writes the same structured profile seen by the CIO to memory/.committee/<date>/<sym>_path.json (prevents ground truth contamination from code specification drift)
  • Reconciliation After Maturity: Actual fwd 30/60/90, intra-window min/max/arrival times, same-methodology shape classification
  • Calibration Metrics: P10-P90 coverage rate (target ~80%), Brier score for p_below vs. base rate Brier, shape probability score vs. uniform 0.25, MAE for trough timing
  • Recompute Mode: The predictor is purely deterministic → --recompute-weekly-since historically recalculates "what would have been predicted at that time" (get_path_profile(asof=...) with zero look-ahead), without waiting 90 days to accumulate samples

Walk-Forward Calibration Baseline (2026-06-11, every Monday from 2024-01 × 2 assets, n=388):

| Window | n | Coverage (target 0.8) | p_below Brier | Base Rate Brier | Median |Error| | Median Bias | |---|---|---|---|---|---|---|---| | 30d | 388 | 66% | 0.248 | 0.220 | 4.4pp | +0.5pp | | 60d | 357 | 62% | 0.275 | 0.220 | 7.2pp | +2.1pp | | 90d | 335 | 56% | 0.240 | 0.185 | 9.6pp | +1.6pp |

Shapes: Probability score 0.29 (uniform 0.25), top1 hit rate 34%, trough timing MAE 19.1 trading days.

Honest Interpretation (single bull market window, same limitation as Chapter 16):

  1. Prediction bands are systematically too narrow — actual volatility in 2024-26 (especially gold's surge) frequently falls outside the historical conditional distribution's P10-P90. When reading "pessimistic percentile/band" in the brief, be aware it underestimates tails.
  2. p_below as a probability underperforms the base rate in this window — the per-regime conditional distribution's directional probability is less calibrated than the unconditional base rate (mutually confirming Chapter 16's "cannot beat the base rate"). Positive median bias = predictions are conservative.
  3. Shape distribution has marginal information content (0.29>0.25) but is far from precise; trough timing MAE ~19 days ≈ low information.
  4. Improvement direction Already Implemented (2026-06-11, calibration layer): Expanded window to 2007-2026 (n=1579) with era bucketing to confirm both defects are structural (coverage in 6 out of 7 eras <80%; worst in small-sample buckets — GC 07-09 downtrend coverage 7%) → calibrate_profile with two fixes: Small-sample shrinkage (λ=eff_n/(eff_n+k), conditional distribution shrinks towards the unconditional distribution of the same asset) + Bandwidth expansion (P10/P90/downside scaled around the median by ×γ). scripts/fit_path_calibration.py pre-registered fit(2007-17)/OOS(2018-26): OOS three-window coverage 76/73/70% → 81/80/76% all within [75,85], Brier improved across all windows → PASS, k=80 / γ=1.1 written into defaults (path section). Caveat: p_below gap with "ex-post base rate" narrowed to 0.5-1.5% but not reversed (ex-post base rate is unknowable, for reference only). Refitting process: walk-forward recompute → re-run fit script (recompute snapshots always store the uncalibrated raw distribution; calibration is only applied at the production output — fit data is never contaminated by itself). After live snapshots accumulate to n≥30, enable the weekly path_review job for continuous tracking.

7. How to Verify ​

bash
uv run pytest tests/test_regime_probability.py -q     # Numerical accuracy + shape completeness

Key tests: precise per-element assertions for min/max/tmin/tmax/persist on monotonic sequences; four category proportions sum to 1; monotonic uptrend → Direct Rise 100% and persist==window length; synthetic sawtooth (first +8% then drop below) → Rise-then-Fall dominates the Fall branch. Sub-second real spot-check (no committee rerun needed):

bash
uv run python -c "
from utils.market_metrics import compute_metrics
from utils.exchange_fee import get_history_data
from core.regime import classify_regime
from core.regime_probability import build_reentry_reference_text
df = get_history_data('NDQ.AX', '2y'); m = compute_metrics(df)
rg = classify_regime(m, symbol='NDQ.AX')['regime']
print(build_reentry_reference_text('NDQ.AX', rg, m['current_price']))"