diff --git a/app/services/regime_monitor_service.py b/app/services/regime_monitor_service.py
index da91233..5b3e1d3 100644
--- a/app/services/regime_monitor_service.py
+++ b/app/services/regime_monitor_service.py
@@ -81,7 +81,16 @@ HY_OAS_STRESSED = 7.0
# of stress at 3.5 -- the level these anchors call "mild". The anchors already
# encode the long-run distribution, so the credit *level* is now purely anchored
# and credit *dynamics* live in W3 on the Warning axis where they belong.
-HY_OAS_WINDOW_DAYS = 400 # only W3's lookback plus slack is needed now
+# Calendar days, and it must cover the oldest date a rebuild replays -- not just
+# W3's lookback. REBUILD_SESSIONS is 400 *trading* sessions (~579 calendar
+# days), so a 400-calendar-day fetch left the oldest ~180 days of a rebuild with
+# no OAS at all: C1 and W3 both returned None, State landed at 80% coverage and
+# Warning at exactly MIN_COVERAGE, and *both still published bands* -- a series
+# that looks homogeneous while its oldest rows were scored without credit.
+# Widening only prepends older observations; C1 reads [-1] and W3 reads [-21], so
+# live scores are unchanged and this needs no methodology bump. Stays under
+# ICE's ~3-year cap so FRED still honours the request.
+HY_OAS_WINDOW_DAYS = 700
W3_OAS_LOOKBACK = 20
W3_OAS_FULL_SCALE_PCT = 35.0
@@ -477,17 +486,29 @@ def _fundamental_effective_date(overrides: dict) -> date | None:
return _next_weekday(fetched) if fetched else None
+def _overlay_timing(
+ overrides: dict, config: dict, as_of: date
+) -> tuple[date | None, bool, int | None, bool]:
+ """Shared effective-date arithmetic: (effective, pending, age_days, stale)."""
+ effective = _fundamental_effective_date(overrides)
+ pending = effective is None or as_of < effective
+ age = None if pending else (as_of - effective).days
+ stale = bool(age is not None and age > int(config.get("fundamental_staleness_days", 80)))
+ return effective, pending, age, stale
+
+
def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict:
"""Point-in-time qualitative overlay. Never feeds State or Warning in v3.
The effective-date gate stays even though nothing is scored from this: the
400-session rebuild replays historical dates, and stamping today's LLM read
onto 2024 snapshots would be plain lookahead in the stored record.
+
+ This is the *record*. For "what do we know right now", use
+ ``current_observation`` -- do not add a bypass flag here, because this runs
+ for every replayed date during a rebuild.
"""
- effective = _fundamental_effective_date(overrides)
- pending = effective is None or as_of < effective
- age = None if pending else (as_of - effective).days
- stale = bool(age is not None and age > int(config.get("fundamental_staleness_days", 80)))
+ effective, pending, age, stale = _overlay_timing(overrides, config, as_of)
return {
"available": not pending and not stale,
"pending": pending,
@@ -504,6 +525,35 @@ def fundamental_overlay(overrides: dict, config: dict, as_of: date) -> dict:
}
+def current_observation(overrides: dict, config: dict, as_of: date) -> dict:
+ """The observation as it stands now, for the live reading only.
+
+ Same shape as ``fundamental_overlay``, but the effective date is *reported*
+ rather than used to blank the content. A refresh stamps
+ ``_next_weekday(today)``, so gating the live card hid a just-collected read
+ for one day -- three over a weekend -- and refreshing appeared to do
+ nothing. Nothing here is scored, so showing it early cannot leak into a
+ published number; the stored snapshot keeps the gate.
+ """
+ effective, pending, age, stale = _overlay_timing(overrides, config, as_of)
+ return {
+ # Live availability is about usefulness, not effectiveness: a pending
+ # observation is the freshest thing we have.
+ "available": not stale,
+ "pending": pending,
+ "stale": stale,
+ "effective_date": effective.isoformat() if effective else None,
+ "age_days": age,
+ "capex": overrides.get("capex"),
+ "good_news_stock_down": overrides.get("good_news_stock_down"),
+ "capex_stress": overrides.get("f1_score"),
+ "earnings_stress": overrides.get("f3_score"),
+ "reasoning": overrides.get("reasoning"),
+ "source": overrides.get("source"),
+ "fetched_at": overrides.get("fetched_at"),
+ }
+
+
def _basket_hash(symbols: list[str]) -> str:
canonical = ",".join(sorted({s.strip().upper() for s in symbols if s.strip()}))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12]
@@ -1042,7 +1092,7 @@ def _delta(current: dict, previous: dict | None) -> float | None:
async def get_regime_monitor(db: AsyncSession) -> dict:
latest = await _latest_snapshot_row(db)
if latest is None:
- return {"available": False, "reason": "v2 not computed yet"}
+ return {"available": False, "reason": "not computed yet"}
row, result = latest
basket_hash = (result.get("basket") or {}).get("hash")
previous_7 = await _result_at_or_before(
@@ -1071,7 +1121,9 @@ async def get_regime_monitor(db: AsyncSession) -> dict:
# session, because otherwise refreshing it looks like it did nothing.
config = await get_regime_config(db)
overrides = await get_fundamental_overrides(db)
- live = fundamental_overlay(overrides, config, date.today())
+ live = current_observation(overrides, config, date.today())
+ # Deliberately reads the *snapshot's* overlay, not the live one: this is how
+ # the reader tells "shown here" from "in the stored record".
live["observed_in_snapshot"] = bool((result.get("fundamental_overlay") or {}).get("available"))
result["fundamental_context"] = live
result["available"] = True
diff --git a/docs/research/regime-monitor-v3.md b/docs/research/regime-monitor-v3.md
index f1d9443..e97945b 100644
--- a/docs/research/regime-monitor-v3.md
+++ b/docs/research/regime-monitor-v3.md
@@ -140,13 +140,34 @@ The fundamental overlay keeps its effective date (normally the next session afte
collection) and is never replayed backward, so a rebuild cannot stamp today's
observation onto historical snapshots. Because the observation is stored in a
single slot, a refresh replaces the previously effective record: the snapshot
-therefore reports the overlay as `pending` until the new effective date, and the
-live reading additionally carries `fundamental_context` so a just-collected
-observation is visible immediately rather than appearing to have done nothing.
+therefore reports the overlay as `pending` until the new effective date.
+
+Two functions, deliberately: `fundamental_overlay` is the **record** and keeps
+the gate — it runs for every replayed date during a rebuild, so it must never
+grow a bypass flag. `current_observation` is the **live reading** behind
+`fundamental_context`, and *reports* the effective date instead of blanking the
+content.
+
+Until 2026-08-07 the live reading called the gated function, so a just-collected
+observation stayed hidden until the next weekday — three days over a weekend —
+and refreshing appeared to do nothing. That was the opposite of what this section
+already claimed. Showing it early cannot leak into a published number, because
+nothing in the overlay is scored (see "Fundamentals left the score").
Each snapshot stores the fixed basket symbols, hash, and freeze date.
Reconstructed history before that freeze date is retrospective/exploratory.
+## Presentation
+
+The page is deliberately thin: two gauges, one chart card, one pillar table, the
+overlay, and a provenance strip. Time and Path are two projections of the same
+snapshot series and share one card and one query key — they were previously two
+panels, which read as two datasets. Methodology rationale lives in this document,
+not on the page; page text is limited to what changes how the reader interprets
+today's number. The quadrant dividers rendered in Path view come from
+`quadrant_config` and are the same constants the alert path consumes
+(`alert_service`), so the chart cannot drift from what actually fires.
+
## Warning study
The study calls the outcome a **10% correction**, not a regime break. The first
@@ -194,6 +215,72 @@ coverage bias for a regime-selection bias. The honest position is that the
threshold is hypersensitive to window choice at this sample size; the report
states its limits rather than pretending to a precision it does not have.
+## Open calibration questions
+
+Raised 2026-08-07 during the page refactor. **None are implemented.** Each one
+changes a published score, so acting on any of them means cutting `METHODOLOGY`
+to v4 — which reseeds 400 sessions and discards the cached event study. They are
+recorded here rather than hand-patched into v3.
+
+**1. State's top band is a credit-event band.** `f2_credit_spreads` returns
+`0.0` — not `None` — for any OAS below the 3.5 mild anchor, so credit stays
+*available* at weight 20 and is not renormalized out. It is simply pinned at
+zero. Verified: with price, breadth and volatility all pegged at 100 and OAS at
+the cutover's 2.77, State computes to exactly **80.0** at 100% coverage — the
+"breaking" threshold to the decimal. So the top State band requires either a
+credit event or all three remaining pillars simultaneously at maximum. A pure
+AI/Tech drawdown with calm credit — the scenario this monitor exists to
+measure — cannot print it with anything to spare. Anchors-only credit was
+nonzero on 27 of 408 calibration sessions, so that 20-point weight sits at zero
+roughly 93% of the time. This is structurally the same defect v3 corrected on
+the Warning axis ("the upper half of the Warning axis was unreachable"), and it
+means the State bands were fit against a v2 credit distribution that v3 no
+longer produces.
+
+**2. V1 saturates at VIX 30.** `(vix - 15) / 15 * 100` reaches 100 at VIX 30 and
+has no resolution above it: VIX 30, 50 and 82 all score identically. That is the
+same failure mode, at a similar percentile, as the `dd_pct * 5` formula this
+version replaced for pegging at a 20% drawdown. If addressed, it should get an
+anchor table in the P3 style rather than a rescaled slope.
+
+**3. `max(P1, P2, P3)` defeats P3's anchoring.** The `max` is deliberate ("one
+capped vote for correlated reads"), but `_under_200` is binary, so P1 prints 100
+whenever SMH and QQQ are both below their 200-DMA. P3's anchor ladder therefore
+only resolves anything while price is *above* the 200-DMA — that is, before the
+drawdown it measures is underway. Note also that "P3's realized share of State
+falls from 65% to 40%" is argmax-share accounting, which is a slippery statistic
+under `max()`.
+
+## Fixed 2026-08-07: the OAS fetch window did not cover a rebuild
+
+`HY_OAS_WINDOW_DAYS` was 400 **calendar** days, but a rebuild replays
+`leader_series[-REBUILD_SESSIONS:]` — 400 **trading** sessions, about 579
+calendar days. The oldest ~180 calendar days of any rebuild therefore got no OAS
+data at all, so `f2_credit_spreads` and `w3_credit_impulse` both returned `None`.
+Verified: State then lands at 80% coverage and Warning at exactly 75.0% —
+`MIN_COVERAGE` — so **both still publish bands**. The rebuilt series would look
+homogeneous while its oldest rows had been scored without credit, the tell being
+a null `data_quality.credit_history_days` on exactly those rows.
+
+The window is now 700 days: it must cover the oldest replayed date (~579) plus
+W3's lookback and slack, while staying under ICE's ~3-year cap so FRED still
+honours the request. This required **no methodology bump** — C1 reads
+`oas_values[-1]` and W3 reads `oas_values[-21]`, both indexed from the end, so
+widening only prepends older observations and every live score is bit-identical.
+Confirmed by evaluating both windows against a varying synthetic series: today's
+C1/W3 match exactly, while the oldest rebuild row goes from `None`/`None` to real
+values.
+
+Expect `credit_history_days` on new snapshots to rise from ~400 to ~700. That is
+the widened request, not new upstream history — and it makes the chip a better
+truncation canary, since a 700-day request returning ~1095 days' worth is now
+the visible ceiling.
+
+The fix was sequenced deliberately: acting on items 1–3 above bumps
+`METHODOLOGY`, which fires `rebuilding`, which would have baked the credit-less
+rows into the fresh series. Fixing the window afterwards would mean reseeding
+twice.
+
## Operator rule
Quadrant alerts default off for new/reset configurations. When enabled they
diff --git a/frontend/src/components/regime/RegimeChart.tsx b/frontend/src/components/regime/RegimeChart.tsx
new file mode 100644
index 0000000..ae40380
--- /dev/null
+++ b/frontend/src/components/regime/RegimeChart.tsx
@@ -0,0 +1,308 @@
+import { useMemo, useState } from 'react';
+import { useQuery } from '@tanstack/react-query';
+import {
+ CartesianGrid,
+ Cell,
+ Line,
+ LineChart,
+ ReferenceArea,
+ ReferenceLine,
+ ResponsiveContainer,
+ Scatter,
+ ScatterChart,
+ Tooltip,
+ XAxis,
+ YAxis,
+ ZAxis,
+} from 'recharts';
+import { getRegimeHistory, getRegimeMonitor } from '../../api/regime';
+import { Callout } from '../ui/Callout';
+import { SkeletonCard } from '../ui/Skeleton';
+import { formatDate } from '../../lib/format';
+
+// Lazy-loaded (see RegimePage) so recharts stays in the regime-tab chunk.
+// Time and Path are two projections of one series, so they share a card and a
+// query rather than sitting in two panels that look like different data.
+
+const VIEWS = ['Time', 'Path'] as const;
+type View = (typeof VIEWS)[number];
+
+const RANGES = [
+ { key: '1M', days: 30 },
+ { key: '3M', days: 90 },
+ { key: '6M', days: 182 },
+ { key: 'All', days: Number.POSITIVE_INFINITY },
+] as const;
+type RangeKey = (typeof RANGES)[number]['key'];
+
+/** Sessions drawn in Path view. The full series is unreadable as a path. */
+const PATH_TRAIL = 60;
+
+const STATE_COLOR = '#60a5fa';
+const WARNING_COLOR = '#fb923c';
+
+// Fall back to the v3 constants, not v2's shared 60/60, so a missing
+// quadrant_config cannot draw dividers that disagree with the alert path.
+const DEFAULT_STATE_DIVIDER = 50;
+const DEFAULT_WARNING_DIVIDER = 40;
+
+interface PathPoint {
+ x: number;
+ y: number;
+ date: string;
+}
+
+/** Centered moving average to de-noise the path; today (last) kept exact. */
+function smoothTrail(points: PathPoint[], half = 2): PathPoint[] {
+ const n = points.length;
+ return points.map((p, i) => {
+ if (i === n - 1) return { ...p };
+ let sx = 0;
+ let sy = 0;
+ let c = 0;
+ for (let j = Math.max(0, i - half); j <= Math.min(n - 1, i + half); j++) {
+ sx += points[j].x;
+ sy += points[j].y;
+ c += 1;
+ }
+ return { x: sx / c, y: sy / c, date: p.date };
+ });
+}
+
+/** Recency gradient: 0 = oldest (muted slate), 1 = newest (bright blue). */
+function recencyColor(t: number): string {
+ const lerp = (a: number, b: number) => Math.round(a + (b - a) * t);
+ return `rgba(${lerp(71, 96)}, ${lerp(85, 165)}, ${lerp(105, 250)}, ${(0.3 + 0.7 * t).toFixed(2)})`;
+}
+
+function SegmentedControl
+ History before {basketAsOf} is reconstructed against today's basket — retrospective, not a live record.
+
- White dot = today; the trail fades from muted (older) to bright blue (newer) over the last {TRAIL}{' '}
- sessions, smoothed. The path matters more than a single point. Risk thermometer — not an entry, exit,
- or sizing signal.
- {footnote} {footnote}
- A newer observation was collected but is not effective until {overlay.effective_date ?? 'the next session'}.
- Observations are never backdated, so the reading below appears from that session onward.
+ {/* A pending observation is still shown — it is the freshest read we
+ have, and nothing here is scored. The date says when the stored
+ point-in-time record picks it up. */}
+ {overlay.pending && (
+
+ Shown as collected. The point-in-time record picks it up{' '}
+ {overlay.effective_date ?? 'next session'} — observations are never backdated.
{overlay.reasoning}
- These observations are qualitative, refreshed roughly quarterly, and deliberately excluded from State and
- Warning. In v2 they carried 20 of 100 Warning points — not enough to cross the study's alarm threshold even
- when both were pegged — so they are reported here rather than diluted into a daily score.
- {overlay.reasoning}
Underpowered. Only {report.reliability.events_in_holdout} of{' '}
{report.reliability.events_detected} detected corrections fall in the test period (
- {report.reliability.minimum_events}+ needed). Recall is one event away from a materially
- different headline, and which events flip is usually decided by where the frozen threshold
- lands rather than by what the score saw. Read the direction, not the ratio.
+ {report.reliability.minimum_events}+ needed). Read the direction, not the ratio.
@@ -202,32 +201,78 @@ function PillarBreakdown({ title, reading }: { title: string; reading: RegimeRea
Contribution
-
- {reading.pillars.map((pillar) => (
-
-
+
-
+
- ))}
-
+ {reading.pillars.map((pillar) => (
+
+ {title}
+
+ {reading.score ?? '—'} · {Math.round(reading.coverage)}% coverage
+
- {pillar.score ?? '—'}
- {pillar.weight}
- {pillar.available ? pillar.contribution.toFixed(1) : '—'}
+
+ ))}
+
+ ))}
+
+ {pillar.score ?? '—'}
+ {pillar.weight}
+
+ {pillar.available ? pillar.contribution.toFixed(1) : '—'}
+
+
- The threshold is frozen on the training period and measured on the chronological test period. Reconstructed - pre-freeze basket history remains exploratory. -
); } @@ -375,7 +413,7 @@ function FundamentalsEditor({ ))} -Raising = 0, holding = 50, cutting = 100; at least three known names required. Display only — this does not enter Warning.
+Raising = 0, holding = 50, cutting = 100; at least three known names required.