Files
signal-platform/frontend/src/components/regime/RegimeChart.tsx
T
dennisthiessenandClaude Opus 5 3143477a62 feat(regime): cut the risk monitor to v4 — desaturate VIX and the trend break
Two sensors saturated in exactly the range where resolution matters, and the top
State band had no headroom. Calibrated with scripts/run_regime_monitor_calibration.py
over the 408 sessions ending 2026-07-24; the shipped code reproduces that run's
band shares exactly (78.9 / 13.0 / 4.7 / 3.4).

V1 read VIX 30, 50 and 82 as an identical 100 — the same defect v3 had just
removed from P3, left in place one sensor over. In the window it flattened five
distinct April-2025 prints (52.33, 46.98, 45.31, 40.72, 38.57) into one value.
Now an anchor table reaching full scale at 55, not at 2020's ~82: anchoring the
top at a once-in-a-generation print would make VIX 50 read only ~70. Pegged on
14 of 408 sessions before; none now.

_under_200 returned a bare 0/100, so P1 printed 100 the moment SMH and QQQ were
both under their average — and since the price pillar takes max(P1, P2, P3),
that pinned the pillar and stopped P3's ladder resolving for the whole of a
selloff. Now graded by depth below the 200-DMA, with a deliberate floor of 20 at
the crossing: the break is a genuine binary event, only its depth is graded.
Pegged on 46 of 408 sessions before; none now. A 2% break reads ~30, not 100.

max() was KEPT — the defect was the step function feeding it, not the vote, and
v3's "one capped vote for correlated reads" rationale still holds. P1 is the sole
price argmax on 17 of 408 sessions (4.2%), so the P1_SCORE_CAP fallback drafted
during design was measured as unnecessary and not shipped.

STATE_BANDS breaking 80 -> 65, and only that threshold. Credit returns 0.0 (not
None) when calm, so it holds its 20 points pinned at zero and price + breadth +
volatility at literal maximum summed to exactly 80.0 — v3's threshold to the
decimal, with nothing above it. The sensor is deliberately unchanged: a
calm-credit selloff genuinely is less stressed. What was stale is the band, fit
on v2 while credit's since-removed percentile leg still contributed. A
2022-style AI/tech drawdown with calm credit computes to 70.3 (no death cross) or
74.0 (with one); 70 would have left 0.33 points of headroom, reproducing the
defect. Chosen by scenario arithmetic, and the realized breaking share then lands
on 3.4% — the same as v3's, arrived at independently.

"v4" added to CATEGORICAL_FUNDAMENTAL_METHODOLOGIES in this same commit, which is
load-bearing: that set is checked against the STORED blob, so bumping without it
discards the collected observation on first write, leaving fetched_at null and
locked false — and update_regime_monitor then fires a paid LLM refresh on every
run, forever. Now guarded by a test parametrised over v2 and v3 stored blobs.

SENSOR_REVISION deliberately stays 2: a METHODOLOGY change already forces a full
reseed via _parse_snapshot, and bumping both would imply the reseed was
revision-driven.

QUADRANT_STATE_DIVIDER stays 50 because only breaking moved, so alert_service,
RegimeChart and the quadrant tests need no change. A new test enforces
divider == band boundary on both axes, which nothing did before.

Doc renamed to regime-monitor-v4.md with a tombstone at the old path (commit
messages cite it), the three open questions converted to resolved with the
reasoning that closed them, and indexed in docs/research/README.md for the first
time. The P2 limit is stated honestly: _death_cross pegs at a -5% MA gap, so a
deep selloff still reaches 100 via P2 — v4 repairs the shallow-to-moderate break,
not "the price pillar no longer pegs".

DEPLOY: the first run reseeds ~464 sessions. Expect one phantom quadrant alert
(the dedup key carries basket_hash, not methodology) and re-run the Event Study
manually — its cached report self-invalidates but does not self-regenerate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 20:34:25 +02:00

309 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 shipped 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>
);
}