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>
309 lines
13 KiB
TypeScript
309 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|