refactor(regime): collapse the monitor page, fix the OAS rebuild window

The page had twelve stacked blocks, several of them different views of the
same numbers. The quadrant plot and the score-history chart drew the same two
series from the same query key, which read as two datasets; they are now one
card with a Time | Path toggle. The two pillar disclosures become one grouped
table, and three prose blocks (data quality, basket, coverage) become one
provenance chip strip. Page text is now limited to what changes how the reader
interprets today's number; the rest moved to the methodology doc.

Removes three stale-threshold bugs of one class. The quadrant fell back to v2's
60/60 dividers when quadrant_config was absent -- the real values are 50/40 and
they feed alert_service, so the chart could disagree with what actually fires.
The gauge fell back to v2's 30/60/80 band ticks, and drew a divider line that
always landed on its own "elevated" tick. The time series' reference lines were
at 30/60/80, which correspond to nothing in v3; they are now per-axis dashed
lines read from the same quadrant_config. Rendering also surfaced a live
clipping bug inherited from the old chart: margin.left -18 against YAxis
width 28 left ~10px for a 3-digit label, so every Y tick was cut off.

HY_OAS_WINDOW_DAYS was 400 *calendar* days while a rebuild replays
REBUILD_SESSIONS = 400 *trading* sessions (~579 calendar days), so the oldest
~180 days of any rebuild got no OAS at all and both credit sensors returned
None. State then lands at 80% coverage and Warning at exactly MIN_COVERAGE, so
both still publish bands -- a series that looks homogeneous while its oldest
rows were scored without credit. Widened to 700. This needs no methodology
bump: C1 reads [-1] and W3 reads [-21], both from the end, so widening only
prepends and every live score is bit-identical. Sequenced deliberately, since
acting on the open findings below bumps METHODOLOGY and fires the rebuild.

A just-collected fundamental observation was hidden until its effective date --
one day, three over a weekend -- because the live reading called the
point-in-time function, so refreshing appeared to do nothing. That was the
opposite of what the doc claimed. fundamental_overlay stays the gated record
(it runs for every replayed date during a rebuild); current_observation is the
live reading and reports the effective date instead of blanking the content.
Nothing in the overlay is scored, so showing it early cannot reach a published
number.

Documents four calculation findings. Three are not implemented, since each
changes a published score and so requires a v4 cut: State's top band is a
credit-event band (credit returns 0.0 rather than None below the 3.5 anchor, so
it is pinned at zero at weight 20 -- with everything else pegged State computes
to exactly 80.0, the breaking threshold); V1 saturates at VIX 30; and the
deliberate max(P1,P2,P3) defeats P3's anchoring because P1 is binary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 17:53:45 +02:00
co-authored by Claude Opus 5
parent 23de8c9540
commit 46ace501a2
8 changed files with 646 additions and 434 deletions
+59 -7
View File
@@ -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
+90 -3
View File
@@ -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 13 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
@@ -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<T extends string>({
options,
value,
onChange,
label,
}: {
options: readonly T[];
value: T;
onChange: (next: T) => void;
label: string;
}) {
return (
<div className="flex gap-1" role="group" aria-label={label}>
{options.map((option) => (
<button
key={option}
type="button"
aria-pressed={value === option}
onClick={() => onChange(option)}
className={`rounded px-2 py-1 text-[11px] font-medium tabular-nums transition-colors ${
value === option ? 'bg-white/10 text-blue-300' : 'text-gray-500 hover:text-gray-300'
}`}
>
{option}
</button>
))}
</div>
);
}
function PathTip({ active, payload }: { active?: boolean; payload?: { payload: PathPoint }[] }) {
if (!active || !payload?.length) return null;
const p = payload[0].payload;
return (
<div className="glass px-2.5 py-1.5 text-[11px]">
<div className="text-gray-300">{formatDate(p.date)}</div>
<div className="text-gray-400">
State <span style={{ color: STATE_COLOR }}>{Math.round(p.x)}</span> · Warning{' '}
<span style={{ color: WARNING_COLOR }}>{Math.round(p.y)}</span>
</div>
</div>
);
}
export default function RegimeChart() {
const [view, setView] = useState<View>('Time');
const [range, setRange] = useState<RangeKey>('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<PathPoint[]>(
() => 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 (
<div className="glass p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<span className="text-[11px] uppercase tracking-wider text-gray-500">
{view === 'Time' ? 'State & Warning over time' : `State × Warning path · last ${PATH_TRAIL} sessions`}
</span>
<SegmentedControl options={VIEWS} value={view} onChange={setView} label="Chart view" />
</div>
{view === 'Time' ? (
<SegmentedControl options={RANGES.map((r) => r.key)} value={range} onChange={setRange} label="Time range" />
) : (
latest && (
<span className="text-[11px] text-gray-500">
now: State <span style={{ color: STATE_COLOR }}>{Math.round(latest.x)}</span> · Warning{' '}
<span style={{ color: WARNING_COLOR }}>{Math.round(latest.y)}</span>
</span>
)
)}
</div>
{history.isLoading ? (
<SkeletonCard className="mt-3 h-72" />
) : !enoughData ? (
<Callout variant="empty">Not enough coverage-qualified history yet it accumulates as the daily job runs.</Callout>
) : (
<>
<div className="mt-3 h-72">
<ResponsiveContainer width="100%" height="100%">
{view === 'Time' ? (
<LineChart data={series} margin={{ top: 6, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid stroke="rgba(255,255,255,0.05)" vertical={false} />
<XAxis
dataKey="date"
tick={{ fill: '#6b7280', fontSize: 10 }}
tickFormatter={(d) => 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. */}
<YAxis
domain={[0, 100]}
ticks={[0, 25, 50, 75, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
width={34}
tickLine={false}
axisLine={false}
/>
{/* The two axes have different thresholds, so each divider is
drawn in its series' colour rather than as shared gridlines. */}
<ReferenceLine y={xDiv} stroke={STATE_COLOR} strokeOpacity={0.25} strokeDasharray="4 4" />
<ReferenceLine y={yDiv} stroke={WARNING_COLOR} strokeOpacity={0.25} strokeDasharray="4 4" />
<Tooltip
contentStyle={{
background: 'rgba(17,24,39,0.95)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 8,
fontSize: 12,
}}
labelStyle={{ color: '#9ca3af' }}
labelFormatter={(l) => formatDate(String(l))}
formatter={(value) => (value == null ? '—' : Math.round(Number(value)))}
/>
<Line type="monotone" dataKey="state" name="State" stroke={STATE_COLOR} dot={false} strokeWidth={1.5} isAnimationActive={false} />
<Line type="monotone" dataKey="warning" name="Warning" stroke={WARNING_COLOR} dot={false} strokeWidth={1.5} isAnimationActive={false} />
</LineChart>
) : (
<ScatterChart margin={{ top: 10, right: 16, bottom: 22, left: 0 }}>
<ReferenceArea x1={0} x2={xDiv} y1={yDiv} y2={100} fill="#f59e0b" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={xDiv} x2={100} y1={yDiv} y2={100} fill="#f97316" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={0} x2={xDiv} y1={0} y2={yDiv} fill="#10b981" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={xDiv} x2={100} y1={0} y2={yDiv} fill="#ef4444" fillOpacity={0.08} stroke="none" />
<CartesianGrid stroke="rgba(255,255,255,0.04)" />
<ReferenceLine x={xDiv} stroke="rgba(255,255,255,0.12)" />
<ReferenceLine y={yDiv} stroke="rgba(255,255,255,0.12)" />
<XAxis
type="number"
dataKey="x"
domain={[0, 100]}
ticks={[0, 20, 40, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
tickLine={false}
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
label={{ value: 'State →', position: 'insideBottom', offset: -12, fill: '#6b7280', fontSize: 10 }}
/>
<YAxis
type="number"
dataKey="y"
domain={[0, 100]}
ticks={[0, 20, 40, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
width={30}
tickLine={false}
axisLine={false}
label={{ value: 'Warning', angle: -90, position: 'insideLeft', fill: '#6b7280', fontSize: 10 }}
/>
<ZAxis range={[13, 13]} />
<Tooltip cursor={{ strokeDasharray: '3 3', stroke: 'rgba(255,255,255,0.2)' }} content={<PathTip />} />
<Scatter data={trail} line={{ stroke: 'rgba(96,165,250,0.18)', strokeWidth: 1.5 }} isAnimationActive={false}>
{trail.map((_, i) => (
<Cell key={i} fill={recencyColor(trail.length <= 1 ? 1 : i / (trail.length - 1))} />
))}
</Scatter>
{latest && (
<Scatter
data={[latest]}
isAnimationActive={false}
shape={(props: { cx?: number; cy?: number }) => (
<circle cx={props.cx} cy={props.cy} r={6} fill="#ffffff" stroke={STATE_COLOR} strokeWidth={2} />
)}
/>
)}
</ScatterChart>
)}
</ResponsiveContainer>
</div>
{view === 'Time' ? (
<div className="mt-2 flex flex-wrap items-center gap-4 text-[11px] text-gray-400">
<span className="flex items-center gap-1.5">
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: STATE_COLOR }} />
State
</span>
<span className="flex items-center gap-1.5">
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: WARNING_COLOR }} />
Warning
</span>
<span className="text-gray-600">dashed = each axis's elevated threshold ({xDiv} / {yDiv})</span>
</div>
) : (
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-[11px] text-gray-500 sm:grid-cols-2">
<span><span className="text-amber-400">Early warning</span> calm, fragility rising</span>
<span><span className="text-orange-400">Active stress</span> damaged and deteriorating</span>
<span><span className="text-emerald-400">Healthy</span> calm, broadly supported</span>
<span><span className="text-red-400">Stabilizing</span> damage remains, warning lower</span>
<span className="text-gray-600 sm:col-span-2">White dot = today; trail brightens toward the present, smoothed.</span>
</div>
)}
{crossesFreeze && (
<p className="mt-2 text-[11px] text-gray-600">
History before {basketAsOf} is reconstructed against today's basket retrospective, not a live record.
</p>
)}
</>
)}
</div>
);
}
@@ -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 (
<div className="glass px-2.5 py-1.5 text-[11px]">
<div className="text-gray-300">{p.date}</div>
<div className="text-gray-400">
State <span className="text-blue-300">{Math.round(p.x)}</span> · Warning{' '}
<span className="text-orange-300">{Math.round(p.y)}</span>
</div>
</div>
);
}
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<QPoint[]>(() => {
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 (
<div className="glass p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[11px] uppercase tracking-wider text-gray-500">
State × Warning quadrant last {TRAIL} sessions
</div>
{latest && (
<div className="text-[11px] text-gray-500">
now: State <span className="text-blue-300">{Math.round(latest.x)}</span> · Warning{' '}
<span className="text-orange-300">{Math.round(latest.y)}</span>
</div>
)}
</div>
{history.isLoading ? (
<SkeletonCard className="mt-3 h-72" />
) : !points.length ? (
<Callout variant="empty">
Not enough coverage-qualified v2 history yet.
</Callout>
) : (
<>
<div className="mt-3 h-80">
<ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 10, right: 16, bottom: 22, left: 0 }}>
{/* Quadrant shading (drawn first, behind everything) */}
<ReferenceArea x1={0} x2={xDiv} y1={yDiv} y2={100} fill="#f59e0b" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={xDiv} x2={100} y1={yDiv} y2={100} fill="#f97316" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={0} x2={xDiv} y1={0} y2={yDiv} fill="#10b981" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={xDiv} x2={100} y1={0} y2={yDiv} fill="#ef4444" fillOpacity={0.08} stroke="none" />
<CartesianGrid stroke="rgba(255,255,255,0.04)" />
<ReferenceLine x={xDiv} stroke="rgba(255,255,255,0.12)" />
<ReferenceLine y={yDiv} stroke="rgba(255,255,255,0.12)" />
<XAxis
type="number"
dataKey="x"
domain={[0, 100]}
ticks={[0, 20, 40, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
tickLine={false}
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
label={{ value: 'State →', position: 'insideBottom', offset: -12, fill: '#6b7280', fontSize: 10 }}
/>
<YAxis
type="number"
dataKey="y"
domain={[0, 100]}
ticks={[0, 20, 40, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
width={30}
tickLine={false}
axisLine={false}
label={{ value: 'Warning', angle: -90, position: 'insideLeft', fill: '#6b7280', fontSize: 10 }}
/>
<ZAxis range={[13, 13]} />
<Tooltip cursor={{ strokeDasharray: '3 3', stroke: 'rgba(255,255,255,0.2)' }} content={<QuadrantTip />} />
{/* Smoothed trail with a recency gradient (old → new) */}
<Scatter
data={trail}
line={{ stroke: 'rgba(96,165,250,0.18)', strokeWidth: 1.5 }}
isAnimationActive={false}
>
{trail.map((_, i) => (
<Cell key={i} fill={recencyColor(trail.length <= 1 ? 1 : i / (trail.length - 1))} />
))}
</Scatter>
{/* Today */}
{latest && (
<Scatter
data={[latest]}
isAnimationActive={false}
shape={(props: { cx?: number; cy?: number }) => (
<circle cx={props.cx} cy={props.cy} r={6} fill="#ffffff" stroke="#60a5fa" strokeWidth={2} />
)}
/>
)}
</ScatterChart>
</ResponsiveContainer>
</div>
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-[11px] text-gray-500 sm:grid-cols-2">
<span><span className="text-amber-400">Early warning</span> state calm, fragility rising</span>
<span><span className="text-orange-400">Active stress</span> damaged and deteriorating</span>
<span><span className="text-emerald-400">Healthy</span> calm and broadly supported</span>
<span><span className="text-red-400">Stressed / stabilizing</span> damage remains, warning lower</span>
</div>
<p className="mt-2 text-[11px] leading-relaxed text-gray-600">
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.
</p>
</>
)}
</div>
);
}
@@ -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<HistoryRange>('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 (
<div className="glass p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[11px] uppercase tracking-wider text-gray-500">Score history</div>
<div className="flex gap-1">
{HISTORY_RANGES.map((r) => (
<button
key={r.key}
type="button"
onClick={() => setRange(r.key)}
className={`rounded px-2 py-1 text-[11px] font-medium tabular-nums transition-colors ${
range === r.key ? 'bg-white/10 text-blue-300' : 'text-gray-500 hover:text-gray-300'
}`}
>
{r.key}
</button>
))}
</div>
</div>
{history.isLoading ? (
<SkeletonCard className="mt-3 h-56" />
) : filtered.length < 2 ? (
<Callout variant="empty">Not enough history yet it accumulates as the daily job runs.</Callout>
) : (
<>
<div className="mt-3 h-60">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={filtered} margin={{ top: 6, right: 8, left: -18, bottom: 0 }}>
<CartesianGrid stroke="rgba(255,255,255,0.05)" vertical={false} />
<XAxis
dataKey="date"
tick={{ fill: '#6b7280', fontSize: 10 }}
tickFormatter={(d) => formatDate(String(d))}
minTickGap={28}
tickLine={false}
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
/>
<YAxis
domain={[0, 100]}
ticks={[0, 30, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
width={28}
tickLine={false}
axisLine={false}
/>
<ReferenceLine y={30} stroke="rgba(255,255,255,0.06)" />
<ReferenceLine y={60} stroke="rgba(255,255,255,0.06)" />
<ReferenceLine y={80} stroke="rgba(255,255,255,0.06)" />
<Tooltip
contentStyle={{
background: 'rgba(17,24,39,0.95)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 8,
fontSize: 12,
}}
labelStyle={{ color: '#9ca3af' }}
labelFormatter={(l) => formatDate(String(l))}
formatter={(value) => (value == null ? '—' : Math.round(Number(value)))}
/>
{HISTORY_SERIES.map((s) => (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
name={s.label}
stroke={s.color}
dot={false}
strokeWidth={1.5}
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
<div className="mt-2 flex flex-wrap gap-4">
{HISTORY_SERIES.map((s) => (
<span key={s.key} className="flex items-center gap-1.5 text-[11px] text-gray-400">
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: s.color }} />
{s.label}
</span>
))}
</div>
</>
)}
</div>
);
}
+3
View File
@@ -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 };
}
+145 -107
View File
@@ -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<RegimeBand, { text: string; bar: string; ring: string; label: string }> = {
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 (
<div className={`glass border p-6 ${style?.ring ?? 'border-white/[0.06]'}`}>
<div className="flex flex-wrap items-end justify-between gap-3">
@@ -92,10 +92,10 @@ function ScoreGauge({
</div>
{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. */}
<div className="relative mt-5 h-2 rounded-full bg-gradient-to-r from-emerald-500/30 via-amber-500/30 to-red-500/40">
{divider != null && (
<div className="absolute -top-1 h-4 w-0.5 bg-gray-300/70" style={{ left: `${divider}%` }} />
)}
<div
className={`absolute -top-1.5 h-5 w-5 -translate-x-1/2 rounded-full border-2 border-white/70 ${style?.bar ?? 'bg-gray-500'}`}
style={{ left: `${position}%` }}
@@ -113,7 +113,7 @@ function ScoreGauge({
</div>
</>
)}
<p className="mt-4 text-xs leading-relaxed text-gray-500">{footnote}</p>
<p className="mt-4 text-xs text-gray-500">{footnote}</p>
</div>
);
}
@@ -136,62 +136,61 @@ function FundamentalOverlayCard({ overlay }: { overlay: RegimeFundamentalOverlay
</div>
<div className="flex flex-wrap items-center gap-2 text-[11px] text-gray-500">
{overlay.source && <span>{overlay.source}</span>}
{overlay.effective_date && <span>· effective {overlay.effective_date}</span>}
{/* When pending, the line below is the single carrier of this date. */}
{overlay.effective_date && !overlay.pending && <span>· effective {overlay.effective_date}</span>}
{overlay.pending && <Badge label="pending" variant="manual" />}
{overlay.stale && <Badge label="stale" variant="manual" />}
</div>
</div>
{overlay.pending ? (
<p className="mt-3 text-xs leading-relaxed text-amber-400/90">
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 && (
<p className="mt-3 text-xs text-amber-400/90">
Shown as collected. The point-in-time record picks it up{' '}
{overlay.effective_date ?? 'next session'} observations are never backdated.
</p>
) : (
<>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<div className="mb-2 flex items-baseline justify-between text-xs">
<span className="font-medium text-gray-300">Hyperscaler capex guidance</span>
<span className="num text-gray-500">{overlay.capex_stress ?? 'n/a'}</span>
</div>
<div className="space-y-1">
{Object.entries(capex).map(([symbol, state]) => (
<div key={symbol} className="flex items-center justify-between text-xs">
<span className="font-mono text-gray-400">{symbol}</span>
<span className={CAPEX_TONE[state] ?? 'text-gray-500'}>{state}</span>
</div>
))}
</div>
</div>
<div>
<div className="mb-2 flex items-baseline justify-between text-xs">
<span className="font-medium text-gray-300">Good news, stock down</span>
<span className="num text-gray-500">{overlay.earnings_stress ?? 'n/a'}</span>
</div>
<div className={`text-sm font-medium ${reaction === 'yes' ? 'text-red-400' : reaction === 'no' ? 'text-emerald-400' : 'text-gray-500'}`}>
{reaction === 'yes' ? 'Yes — beats sold into' : reaction === 'no' ? 'No — ordinary reactions' : 'Mixed'}
</div>
</div>
</div>
{overlay.reasoning && (
<p className="mt-4 text-xs leading-relaxed text-gray-400">{overlay.reasoning}</p>
)}
</>
)}
<p className="mt-4 text-[11px] leading-relaxed text-gray-600">
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.
</p>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<div className="mb-2 flex items-baseline justify-between text-xs">
<span className="font-medium text-gray-300">Hyperscaler capex guidance</span>
<span className="num text-gray-500">{overlay.capex_stress ?? 'n/a'}</span>
</div>
<div className="space-y-1">
{Object.entries(capex).map(([symbol, state]) => (
<div key={symbol} className="flex items-center justify-between text-xs">
<span className="font-mono text-gray-400">{symbol}</span>
<span className={CAPEX_TONE[state] ?? 'text-gray-500'}>{state}</span>
</div>
))}
</div>
</div>
<div>
<div className="mb-2 flex items-baseline justify-between text-xs">
<span className="font-medium text-gray-300">Good news, stock down</span>
<span className="num text-gray-500">{overlay.earnings_stress ?? 'n/a'}</span>
</div>
<div className={`text-sm font-medium ${reaction === 'yes' ? 'text-red-400' : reaction === 'no' ? 'text-emerald-400' : 'text-gray-500'}`}>
{reaction === 'yes' ? 'Yes — beats sold into' : reaction === 'no' ? 'No — ordinary reactions' : 'Mixed'}
</div>
</div>
</div>
{overlay.reasoning && <p className="mt-4 text-xs leading-relaxed text-gray-400">{overlay.reasoning}</p>}
</div>
);
}
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 (
<Disclosure summary={`${title} pillars · ${Math.round(reading.coverage)}% coverage`}>
<Disclosure summary="Pillars & sensors · what drives each score">
<div className="overflow-x-auto rounded-lg border border-white/[0.06]">
<table className="w-full text-sm">
<thead>
@@ -202,32 +201,78 @@ function PillarBreakdown({ title, reading }: { title: string; reading: RegimeRea
<th className="px-4 py-3 text-right font-medium">Contribution</th>
</tr>
</thead>
<tbody>
{reading.pillars.map((pillar) => (
<tr key={pillar.id} className="border-b border-white/[0.04] align-top last:border-0">
<td className="px-4 py-3">
<div className="font-medium text-gray-200">{pillar.label}</div>
<div className="mt-1 space-y-0.5">
{pillar.sensors.map((sensor) => (
<div key={sensor.id} className="text-xs text-gray-500">
<span className="font-mono text-gray-600">{sensor.id}</span> {sensor.label}:{' '}
<span className="num text-gray-400">{sensor.score == null ? 'n/a' : sensor.score}</span>
</div>
))}
</div>
{groups.map(({ title, reading }) => (
<tbody key={title}>
<tr className="border-b border-white/[0.06] bg-white/[0.02]">
<td colSpan={4} className="px-4 py-2 text-[11px] uppercase tracking-wider text-gray-400">
{title}
<span className="ml-2 normal-case tracking-normal text-gray-600">
{reading.score ?? '—'} · {Math.round(reading.coverage)}% coverage
</span>
</td>
<td className="px-4 py-3 text-right num text-gray-300">{pillar.score ?? '—'}</td>
<td className="px-4 py-3 text-right num text-gray-400">{pillar.weight}</td>
<td className="px-4 py-3 text-right num text-gray-300">{pillar.available ? pillar.contribution.toFixed(1) : '—'}</td>
</tr>
))}
</tbody>
{reading.pillars.map((pillar) => (
<tr key={pillar.id} className="border-b border-white/[0.04] align-top last:border-0">
<td className="px-4 py-3">
<div className="font-medium text-gray-200">{pillar.label}</div>
<div className="mt-1 space-y-0.5">
{pillar.sensors.map((sensor) => (
<div key={sensor.id} className="text-xs text-gray-500">
<span className="font-mono text-gray-600">{sensor.id}</span> {sensor.label}:{' '}
<span className="num text-gray-400">{sensor.score == null ? 'n/a' : sensor.score}</span>
</div>
))}
</div>
</td>
<td className="px-4 py-3 text-right num text-gray-300">{pillar.score ?? '—'}</td>
<td className="px-4 py-3 text-right num text-gray-400">{pillar.weight}</td>
<td className="px-4 py-3 text-right num text-gray-300">
{pillar.available ? pillar.contribution.toFixed(1) : '—'}
</td>
</tr>
))}
</tbody>
))}
</table>
</div>
</Disclosure>
);
}
function MetaChip({ label, value, title }: { label: string; value: ReactNode; title?: string }) {
return (
<span className="rounded-lg bg-white/[0.03] px-2.5 py-1 text-[11px] text-gray-500" title={title}>
{label} <span className="num text-gray-400">{value}</span>
</span>
);
}
/** 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 (
<div className="flex flex-wrap items-center gap-2">
<MetaChip label="as of" value={data.date ?? '—'} />
<MetaChip label="oldest input" value={days(quality?.oldest_market_input_age_days)} />
{basket && (
<MetaChip
label="basket"
value={`${basket.members_available ?? '—'}/${basket.members_expected} · frozen ${basket.basket_asof}`}
title={`hash ${basket.hash}`}
/>
)}
<MetaChip
label="credit history"
value={days(quality?.credit_history_days)}
title="Upstream span actually available. ICE caps the HY OAS series at 3 rolling years."
/>
<MetaChip label="VIX history" value={days(quality?.vix_history_days)} />
</div>
);
}
function EventStudyBody({ report }: { report: EventStudyReport }) {
const metrics = report.metrics;
return (
@@ -278,9 +323,7 @@ function EventStudyBody({ report }: { report: EventStudyReport }) {
<p>
<strong>Underpowered.</strong> 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.
</p>
)}
{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.
</p>
)}
</div>
</Callout>
)}
<p className="text-[11px] leading-relaxed text-gray-600">
The threshold is frozen on the training period and measured on the chronological test period. Reconstructed
pre-freeze basket history remains exploratory.
</p>
</div>
);
}
@@ -375,7 +413,7 @@ function FundamentalsEditor({
</label>
))}
</div>
<p className="mt-1.5 text-[11px] text-gray-600">Raising = 0, holding = 50, cutting = 100; at least three known names required. Display only this does not enter Warning.</p>
<p className="mt-1.5 text-[11px] text-gray-600">Raising = 0, holding = 50, cutting = 100; at least three known names required.</p>
</div>
<label className="flex items-center justify-between gap-3 text-xs text-gray-400">
<span>
@@ -450,14 +488,17 @@ export default function RegimePage() {
const isAdmin = useAuthStore((state) => state.role) === 'admin';
const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor });
const data = monitor.data;
const inputs = data?.inputs;
return (
<div className="space-y-6 animate-slide-up">
<PageHeader title="Regime Monitor" subtitle="AI/Tech risk thermometer · State and Warning · feeds no trades" />
<Callout variant="info"><strong>Risk thermometer not an entry, exit, or sizing signal.</strong> State measures current stress; Warning measures deterioration and divergence.</Callout>
<PageHeader
title="Regime Monitor"
subtitle="AI/Tech risk thermometer — observational only, feeds no entry, exit, or sizing decision"
/>
{monitor.isLoading && <><SkeletonCard className="h-44" /><SkeletonTable rows={6} cols={4} /></>}
{monitor.isError && <Callout variant="error" onRetry={() => monitor.refetch()}>Failed to load: {(monitor.error as Error).message}</Callout>}
{data && !data.available && <Callout variant="empty">V2 is not computed yet run Regime Monitor from Admin Jobs or wait for the daily pipeline.</Callout>}
{data && !data.available && <Callout variant="empty">Not computed yet run Regime Monitor from Admin Jobs or wait for the daily pipeline.</Callout>}
{data?.available && data.state && data.warning && (
<>
@@ -467,39 +508,36 @@ export default function RegimePage() {
{data.data_quality?.stale_inputs?.length ? ` · stale: ${data.data_quality.stale_inputs.join(', ')}` : ''}.
</Callout>
)}
<div className="grid gap-4 lg:grid-cols-2">
<ScoreGauge
label="State · current structural stress"
label="State · stress right now"
reading={data.state}
divider={data.quadrant_config?.state_divider}
footnote={<>One capped price vote plus fixed-basket breadth, HY credit, and volatility. As of {data.date}. VIX {data.inputs?.vix ?? '—'} · HY OAS {data.inputs?.hy_oas ?? '—'}.</>}
footnote={
<>
Price, breadth, credit and volatility levels · VIX{' '}
<span className="num text-gray-400">{inputs?.vix ?? '—'}</span> · HY OAS{' '}
<span className="num text-gray-400">{inputs?.hy_oas ?? '—'}</span> · breadth{' '}
<span className="num text-gray-400">
{inputs?.breadth_pct_above_200 == null ? '—' : `${inputs.breadth_pct_above_200}%`}
</span>
</>
}
/>
<ScoreGauge
label="Warning · deterioration & divergence"
reading={data.warning}
divider={data.quadrant_config?.warning_divider}
footnote={<>Breadth divergence, SMH/SPY rollover, and HY credit impulse. Breadth loss counts fully when price masks it and partially when price confirms it. Missing sensors reduce coverage; they never default to 50.</>}
footnote="Breadth divergence, SMH/SPY rollover, and HY credit impulse. Missing sensors reduce coverage; they never default to 50."
/>
</div>
{data.fundamental_context && <FundamentalOverlayCard overlay={data.fundamental_context} />}
<p className="text-xs text-gray-600">
Data quality · oldest market input:{' '}
{data.data_quality?.oldest_market_input_age_days == null
? 'unavailable'
: `${data.data_quality.oldest_market_input_age_days}d`}
</p>
<Suspense fallback={<SkeletonCard className="h-80" />}><RegimeQuadrant /></Suspense>
<Suspense fallback={<SkeletonCard className="h-72" />}><ScoreHistoryChart /></Suspense>
<div className="grid gap-3 lg:grid-cols-2">
<PillarBreakdown title="State" reading={data.state} />
<PillarBreakdown title="Warning" reading={data.warning} />
</div>
{data.basket && (
<p className="text-xs leading-relaxed text-gray-600">
Fixed basket {data.basket.members_available ?? '—'}/{data.basket.members_expected} available · hash {data.basket.hash} · frozen {data.basket.basket_asof}. History reconstructed before the freeze date is retrospective/exploratory; readings after it form the trustworthy forward series.
</p>
)}
<Suspense fallback={<SkeletonCard className="h-80" />}><RegimeChart /></Suspense>
<PillarTable state={data.state} warning={data.warning} />
{data.fundamental_context && <FundamentalOverlayCard overlay={data.fundamental_context} />}
<MetaStrip data={data} />
</>
)}
+41
View File
@@ -27,6 +27,7 @@ from app.services.regime_monitor_service import (
breadth_level_score,
drawdown_pct,
f2_credit_spreads,
current_observation,
fundamental_overlay,
p1_trend_break,
p2_death_cross,
@@ -238,6 +239,46 @@ def test_fundamental_overlay_never_replays_before_effective_date_and_expires():
assert expired["available"] is False
def test_live_observation_is_visible_before_its_effective_date():
"""Refreshing must not look like it did nothing.
The stored snapshot keeps the effective-date gate so a rebuild cannot
backdate an observation, but the live card reports that date instead of
blanking the content -- otherwise a Friday refresh stays invisible until
Monday.
"""
overrides = {
"f1_score": 50.0,
"f3_score": 100.0,
"capex": {"GOOGL": "holding"},
"good_news_stock_down": "yes",
"reasoning": "fresh read",
"fetched_at": "2026-06-01T10:00:00+00:00",
"effective_date": "2026-06-02",
}
config = {**DEFAULT_CONFIG, "fundamental_staleness_days": 80}
before = date(2026, 6, 1)
record = fundamental_overlay(overrides, config, before)
now = current_observation(overrides, config, before)
# Same day, same observation: the record hides it, the live reading shows it.
assert record["capex"] is None and record["reasoning"] is None
assert now["capex"] == {"GOOGL": "holding"}
assert now["reasoning"] == "fresh read"
assert now["capex_stress"] == 50.0
assert now["earnings_stress"] == 100.0
# ...while still reporting when the stored record picks it up.
assert now["pending"] is True
assert now["effective_date"] == "2026-06-02"
assert now["available"] is True
# Staleness still expires the live reading.
assert current_observation(overrides, config, date(2026, 8, 22))["stale"] is True
assert current_observation(overrides, config, date(2026, 8, 22))["available"] is False
def test_fundamentals_do_not_move_the_warning_score():
"""The v3 complaint: a maxed-out LLM read must not silently do nothing.