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({ + options, + value, + onChange, + label, +}: { + options: readonly T[]; + value: T; + onChange: (next: T) => void; + label: string; +}) { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} + +function PathTip({ active, payload }: { active?: boolean; payload?: { payload: PathPoint }[] }) { + if (!active || !payload?.length) return null; + const p = payload[0].payload; + return ( +
+
{formatDate(p.date)}
+
+ State {Math.round(p.x)} · Warning{' '} + {Math.round(p.y)} +
+
+ ); +} + +export default function RegimeChart() { + const [view, setView] = useState('Time'); + const [range, setRange] = useState('3M'); + const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(800) }); + const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor }); + + const xDiv = monitor.data?.quadrant_config?.state_divider ?? DEFAULT_STATE_DIVIDER; + const yDiv = monitor.data?.quadrant_config?.warning_divider ?? DEFAULT_WARNING_DIVIDER; + const basketAsOf = monitor.data?.basket?.basket_asof; + + const series = useMemo(() => { + const data = history.data ?? []; + if (view === 'Path') { + return data + .filter((p) => p.state != null && p.warning != null) + .slice(-PATH_TRAIL); + } + const days = RANGES.find((r) => r.key === range)!.days; + if (!Number.isFinite(days)) return data; + const cutoff = new Date(); + cutoff.setDate(cutoff.getDate() - days); + return data.filter((p) => new Date(p.date) >= cutoff); + }, [history.data, view, range]); + + const pathPoints = useMemo( + () => series.map((p) => ({ x: p.state as number, y: p.warning as number, date: p.date })), + [series], + ); + const trail = useMemo(() => (view === 'Path' ? smoothTrail(pathPoints) : []), [pathPoints, view]); + const latest = view === 'Path' && pathPoints.length ? pathPoints[pathPoints.length - 1] : null; + + // Only warn about pre-freeze history when the drawn window actually reaches + // back past the freeze date. + const crossesFreeze = Boolean(basketAsOf && series.length && series[0].date < basketAsOf); + const enoughData = view === 'Path' ? pathPoints.length > 0 : series.length >= 2; + + return ( +
+
+
+ + {view === 'Time' ? 'State & Warning over time' : `State × Warning path · last ${PATH_TRAIL} sessions`} + + +
+ {view === 'Time' ? ( + r.key)} value={range} onChange={setRange} label="Time range" /> + ) : ( + latest && ( + + now: State {Math.round(latest.x)} · Warning{' '} + {Math.round(latest.y)} + + ) + )} +
+ + {history.isLoading ? ( + + ) : !enoughData ? ( + Not enough coverage-qualified history yet — it accumulates as the daily job runs. + ) : ( + <> +
+ + {view === 'Time' ? ( + + + formatDate(String(d))} + minTickGap={28} + tickLine={false} + axisLine={{ stroke: 'rgba(255,255,255,0.08)' }} + /> + {/* width must clear a 3-digit label: the old chart paired + width 28 with margin.left -18 and clipped every tick. */} + + {/* The two axes have different thresholds, so each divider is + drawn in its series' colour rather than as shared gridlines. */} + + + formatDate(String(l))} + formatter={(value) => (value == null ? '—' : Math.round(Number(value)))} + /> + + + + ) : ( + + + + + + + + + + + + } /> + + {trail.map((_, i) => ( + + ))} + + {latest && ( + ( + + )} + /> + )} + + )} + +
+ + {view === 'Time' ? ( +
+ + + State + + + + Warning + + dashed = each axis's elevated threshold ({xDiv} / {yDiv}) +
+ ) : ( +
+ Early warning — calm, fragility rising + Active stress — damaged and deteriorating + Healthy — calm, broadly supported + Stabilizing — damage remains, warning lower + White dot = today; trail brightens toward the present, smoothed. +
+ )} + + {crossesFreeze && ( +

+ History before {basketAsOf} is reconstructed against today's basket — retrospective, not a live record. +

+ )} + + )} +
+ ); +} diff --git a/frontend/src/components/regime/RegimeQuadrant.tsx b/frontend/src/components/regime/RegimeQuadrant.tsx deleted file mode 100644 index 3fa9724..0000000 --- a/frontend/src/components/regime/RegimeQuadrant.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { - ScatterChart, - Scatter, - Cell, - XAxis, - YAxis, - ZAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - ReferenceLine, - ReferenceArea, -} from 'recharts'; -import { getRegimeHistory, getRegimeMonitor } from '../../api/regime'; -import { Callout } from '../ui/Callout'; -import { SkeletonCard } from '../ui/Skeleton'; - -// Lazy-loaded (see RegimePage) so recharts stays in the regime-tab chunk. - -// Quadrant boundaries come from the backend v2 methodology response. -const TRAIL = 60; // sessions shown - -interface QPoint { - x: number; - y: number; - date: string; -} - -/** Centered moving average to de-noise the path; today (last) kept exact. */ -function smoothTrail(points: QPoint[], half = 2): QPoint[] { - 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); - const r = lerp(71, 96); - const g = lerp(85, 165); - const b = lerp(105, 250); - const alpha = (0.3 + 0.7 * t).toFixed(2); - return `rgba(${r}, ${g}, ${b}, ${alpha})`; -} - -function QuadrantTip({ active, payload }: { active?: boolean; payload?: { payload: QPoint }[] }) { - if (!active || !payload?.length) return null; - const p = payload[0].payload; - return ( -
-
{p.date}
-
- State {Math.round(p.x)} · Warning{' '} - {Math.round(p.y)} -
-
- ); -} - -export default function RegimeQuadrant() { - const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(800) }); - const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor }); - const xDiv = monitor.data?.quadrant_config?.state_divider ?? 60; - const yDiv = monitor.data?.quadrant_config?.warning_divider ?? 60; - - const points = useMemo(() => { - const data = history.data ?? []; - return data - .filter((p) => p.state != null && p.warning != null) - .slice(-TRAIL) - .map((p) => ({ x: p.state as number, y: p.warning as number, date: p.date })); - }, [history.data]); - - const trail = useMemo(() => smoothTrail(points), [points]); - const latest = points.length ? points[points.length - 1] : null; - - return ( -
-
-
- State × Warning quadrant — last {TRAIL} sessions -
- {latest && ( -
- now: State {Math.round(latest.x)} · Warning{' '} - {Math.round(latest.y)} -
- )} -
- - {history.isLoading ? ( - - ) : !points.length ? ( - - Not enough coverage-qualified v2 history yet. - - ) : ( - <> -
- - - {/* Quadrant shading (drawn first, behind everything) */} - - - - - - - - - - - } /> - {/* Smoothed trail with a recency gradient (old → new) */} - - {trail.map((_, i) => ( - - ))} - - {/* Today */} - {latest && ( - ( - - )} - /> - )} - - -
- -
- Early warning — state calm, fragility rising - Active stress — damaged and deteriorating - Healthy — calm and broadly supported - Stressed / stabilizing — damage remains, warning lower -
-

- 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. -

- - )} -
- ); -} diff --git a/frontend/src/components/regime/ScoreHistoryChart.tsx b/frontend/src/components/regime/ScoreHistoryChart.tsx deleted file mode 100644 index 8c3df78..0000000 --- a/frontend/src/components/regime/ScoreHistoryChart.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { useState, useMemo } from 'react'; -import { useQuery } from '@tanstack/react-query'; -import { - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - ReferenceLine, -} from 'recharts'; -import { getRegimeHistory } from '../../api/regime'; -import { Callout } from '../ui/Callout'; -import { SkeletonCard } from '../ui/Skeleton'; -import { formatDate } from '../../lib/format'; - -// Lazy-loaded (see RegimePage) so recharts only ships in the regime-tab chunk. - -const HISTORY_RANGES = [ - { key: '1M', days: 30 }, - { key: '3M', days: 90 }, - { key: '6M', days: 182 }, - { key: 'All', days: 100000 }, -] as const; -type HistoryRange = (typeof HISTORY_RANGES)[number]['key']; - -const HISTORY_SERIES = [ - { key: 'state', label: 'State', color: '#60a5fa' }, - { key: 'warning', label: 'Warning', color: '#fb923c' }, -] as const; - -export default function ScoreHistoryChart() { - const [range, setRange] = useState('3M'); - const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(800) }); - - const filtered = useMemo(() => { - const data = history.data ?? []; - const days = HISTORY_RANGES.find((r) => r.key === range)!.days; - if (range === 'All') return data; - const cutoff = new Date(); - cutoff.setDate(cutoff.getDate() - days); - return data.filter((p) => new Date(p.date) >= cutoff); - }, [history.data, range]); - - return ( -
-
-
Score history
-
- {HISTORY_RANGES.map((r) => ( - - ))} -
-
- - {history.isLoading ? ( - - ) : filtered.length < 2 ? ( - Not enough history yet — it accumulates as the daily job runs. - ) : ( - <> -
- - - - formatDate(String(d))} - minTickGap={28} - tickLine={false} - axisLine={{ stroke: 'rgba(255,255,255,0.08)' }} - /> - - - - - formatDate(String(l))} - formatter={(value) => (value == null ? '—' : Math.round(Number(value)))} - /> - {HISTORY_SERIES.map((s) => ( - - ))} - - -
-
- {HISTORY_SERIES.map((s) => ( - - - {s.label} - - ))} -
- - )} -
- ); -} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index f5ee7b2..d30e49b 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -549,6 +549,9 @@ export interface RegimeMonitor { inputs_fresh: boolean; snapshot_age_days?: number; is_fresh?: boolean; + /** Upstream history spans, so a silently truncated series is visible. */ + credit_history_days?: number | null; + vix_history_days?: number | null; }; quadrant_config?: { state_divider: number; warning_divider: number; margin: number }; } diff --git a/frontend/src/pages/RegimePage.tsx b/frontend/src/pages/RegimePage.tsx index 42af9c5..2d36c83 100644 --- a/frontend/src/pages/RegimePage.tsx +++ b/frontend/src/pages/RegimePage.tsx @@ -24,11 +24,11 @@ import type { RegimeFundamentalOverlay, RegimeFundamentals, RegimeFundamentalsUpdate, + RegimeMonitor, RegimeReading, } from '../lib/types'; -const ScoreHistoryChart = lazy(() => import('../components/regime/ScoreHistoryChart')); -const RegimeQuadrant = lazy(() => import('../components/regime/RegimeQuadrant')); +const RegimeChart = lazy(() => import('../components/regime/RegimeChart')); const BAND_STYLES: Record = { stable: { text: 'text-emerald-400', bar: 'bg-emerald-400', ring: 'border-emerald-400/30', label: 'Stable' }, @@ -53,12 +53,10 @@ function TrendChip({ label, delta }: { label: string; delta: number | null | und function ScoreGauge({ label, reading, - divider, footnote, }: { label: string; reading: RegimeReading | undefined; - divider?: number; footnote: ReactNode; }) { const score = reading?.score; @@ -66,7 +64,9 @@ function ScoreGauge({ const style = complete ? BAND_STYLES[reading.band as RegimeBand] : null; const position = Math.min(100, Math.max(0, score ?? 0)); const bands = reading?.bands; - const ticks = bands ? [bands.watch, bands.elevated, bands.breaking] : [30, 60, 80]; + // No fallback ticks: the two axes have different thresholds, so guessing a + // shared set would mislabel one of them. Render none rather than wrong ones. + const ticks = bands ? [bands.watch, bands.elevated, bands.breaking] : []; return (
@@ -92,10 +92,10 @@ function ScoreGauge({
{score != null && ( <> + {/* The quadrant divider is each axis's watch/elevated boundary, so it + is already the middle tick below — drawing it again was two marks + for one threshold. */}
- {divider != null && ( -
- )}
)} -

{footnote}

+

{footnote}

); } @@ -136,62 +136,61 @@ function FundamentalOverlayCard({ overlay }: { overlay: RegimeFundamentalOverlay
{overlay.source && {overlay.source}} - {overlay.effective_date && · effective {overlay.effective_date}} + {/* When pending, the line below is the single carrier of this date. */} + {overlay.effective_date && !overlay.pending && · effective {overlay.effective_date}} {overlay.pending && } {overlay.stale && }
- {overlay.pending ? ( -

- 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.

- ) : ( - <> -
-
-
- Hyperscaler capex guidance - {overlay.capex_stress ?? 'n/a'} -
-
- {Object.entries(capex).map(([symbol, state]) => ( -
- {symbol} - {state} -
- ))} -
-
-
-
- Good news, stock down - {overlay.earnings_stress ?? 'n/a'} -
-
- {reaction === 'yes' ? 'Yes — beats sold into' : reaction === 'no' ? 'No — ordinary reactions' : 'Mixed'} -
-
-
- {overlay.reasoning && ( -

{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. -

+
+
+
+ Hyperscaler capex guidance + {overlay.capex_stress ?? 'n/a'} +
+
+ {Object.entries(capex).map(([symbol, state]) => ( +
+ {symbol} + {state} +
+ ))} +
+
+
+
+ Good news, stock down + {overlay.earnings_stress ?? 'n/a'} +
+
+ {reaction === 'yes' ? 'Yes — beats sold into' : reaction === 'no' ? 'No — ordinary reactions' : 'Mixed'} +
+
+
+ {overlay.reasoning &&

{overlay.reasoning}

}
); } -function PillarBreakdown({ title, reading }: { title: string; reading: RegimeReading }) { +/** One table for both axes — they share a shape, and two panels invited + * comparing numbers that are not on the same scale. */ +function PillarTable({ state, warning }: { state: RegimeReading; warning: RegimeReading }) { + const groups: { title: string; reading: RegimeReading }[] = [ + { title: 'State', reading: state }, + { title: 'Warning', reading: warning }, + ]; return ( - +
@@ -202,32 +201,78 @@ function PillarBreakdown({ title, reading }: { title: string; reading: RegimeRea - - {reading.pillars.map((pillar) => ( - - + + - - - - ))} - + {reading.pillars.map((pillar) => ( + + + + + + + ))} + + ))}
Contribution
-
{pillar.label}
-
- {pillar.sensors.map((sensor) => ( -
- {sensor.id} {sensor.label}:{' '} - {sensor.score == null ? 'n/a' : sensor.score} -
- ))} -
+ {groups.map(({ title, reading }) => ( +
+ {title} + + {reading.score ?? '—'} · {Math.round(reading.coverage)}% coverage + {pillar.score ?? '—'}{pillar.weight}{pillar.available ? pillar.contribution.toFixed(1) : '—'}
+
{pillar.label}
+
+ {pillar.sensors.map((sensor) => ( +
+ {sensor.id} {sensor.label}:{' '} + {sensor.score == null ? 'n/a' : sensor.score} +
+ ))} +
+
{pillar.score ?? '—'}{pillar.weight} + {pillar.available ? pillar.contribution.toFixed(1) : '—'} +
); } +function MetaChip({ label, value, title }: { label: string; value: ReactNode; title?: string }) { + return ( + + {label} {value} + + ); +} + +/** Provenance strip — replaces three separate prose blocks. */ +function MetaStrip({ data }: { data: RegimeMonitor }) { + const quality = data.data_quality; + const basket = data.basket; + const days = (value: number | null | undefined) => (value == null ? '—' : `${value}d`); + return ( +
+ + + {basket && ( + + )} + + +
+ ); +} + function EventStudyBody({ report }: { report: EventStudyReport }) { const metrics = report.metrics; return ( @@ -278,9 +323,7 @@ function EventStudyBody({ report }: { report: EventStudyReport }) {

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.

)} {report.reliability.sensor_coverage_mismatch && ( @@ -290,17 +333,12 @@ function EventStudyBody({ report }: { report: EventStudyReport }) { {report.reliability.sensors_expected} Warning sensors versus{' '} {report.reliability.holdout_full_sensor_share}% of test sessions {report.params?.credit_sensor_from && ` — credit history begins ${report.params.credit_sensor_from}`} - . The score renormalises over what is available, so the threshold was frozen on a partly - different construct than it is measured against. + . The threshold was frozen on a partly different construct than it is measured against.

)} )} -

- 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.