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):
| Metric | Algorithm |
|---|---|
| ma20 / ma120 | 20/120-day rolling mean of close |
| return_30d | close[i]/close[i−30] − 1 |
| rebound_off_30d_low | close[i]/min close over last 30 days − 1 |
| price_quantile_2y | Rolling 504 trading day window: (closes ≤ current close) / n, true percentile |
| atr_pct | Wilder 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_pctmedian return,p_below= P(end price < current price),p_down= P(drop > 5%)p10_pct / p90_pctpessimistic/optimistic 10th percentiledownside_pct= 20th percentile (pessimistic scenario, converted to buyback reference pricecurrent 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 journeyWhy 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 weightConstraint Closure:
- CIO SKILL (
agents/skills/cio/SKILL.md) mandatesEXPECTED_PATHto reference these numbers (shape proportions/pullback depth/trough timing), prohibiting fabricated paths - TRIM's
REENTRY_PRICE ≥ current priceis 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 samecompute_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-sincehistorically 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):
- 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.
- 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.
- Shape distribution has marginal information content (0.29>0.25) but is far from precise; trough timing MAE ~19 days ≈ low information.
Improvement directionAlready 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_profilewith 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.pypre-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
uv run pytest tests/test_regime_probability.py -q # Numerical accuracy + shape completenessKey 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):
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']))"