feat: replace regime monitor with v2 methodology

This commit is contained in:
2026-07-15 09:02:56 +02:00
parent fd21067a40
commit 1d5b1489be
17 changed files with 1599 additions and 1535 deletions
+213 -436
View File
@@ -1,5 +1,5 @@
import { useState, lazy, Suspense, type ReactNode } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { lazy, Suspense, useState, type ReactNode } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { PageHeader } from '../components/ui/PageHeader';
import { Callout } from '../components/ui/Callout';
import { Disclosure } from '../components/ui/Disclosure';
@@ -7,44 +7,38 @@ import { Badge } from '../components/ui/Badge';
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
import { useAuthStore } from '../stores/authStore';
import {
getRegimeMonitor,
getRegimeConfig,
updateRegimeConfig,
getRegimeFundamentals,
updateRegimeFundamentals,
refreshRegimeFundamentals,
getEventStudy,
getRegimeConfig,
getRegimeFundamentals,
getRegimeMonitor,
refreshRegimeFundamentals,
updateRegimeConfig,
updateRegimeFundamentals,
} from '../api/regime';
// Lazy so recharts (heavy) ships in its own chunk, loaded only on this tab.
const ScoreHistoryChart = lazy(() => import('../components/regime/ScoreHistoryChart'));
const RegimeQuadrant = lazy(() => import('../components/regime/RegimeQuadrant'));
import type {
EventStudyReport,
RegimeBand,
RegimeSignal,
RegimeConfig,
RegimeFundamentals,
EventStudyReport,
EventStudyLeadStats,
EventStudyPerEvent,
RegimeReading,
} from '../lib/types';
const ScoreHistoryChart = lazy(() => import('../components/regime/ScoreHistoryChart'));
const RegimeQuadrant = lazy(() => import('../components/regime/RegimeQuadrant'));
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' },
watch: { text: 'text-amber-400', bar: 'bg-amber-400', ring: 'border-amber-400/30', label: 'Watch' },
elevated: { text: 'text-orange-400', bar: 'bg-orange-400', ring: 'border-orange-400/30', label: 'Elevated' },
breaking: { text: 'text-red-400', bar: 'bg-red-400', ring: 'border-red-400/30', label: 'Breaking' },
breaking: { text: 'text-red-400', bar: 'bg-red-400', ring: 'border-red-400/30', label: 'High stress' },
};
function TrendChip({ label, delta }: { label: string; delta: number | null | undefined }) {
if (delta == null) {
return <span className="rounded-lg bg-white/[0.04] px-2.5 py-1 text-xs text-gray-500">{label}: n/a</span>;
}
const rising = delta > 0;
const flat = delta === 0;
// Higher index = worse, so a rising score is the warning direction.
const color = flat ? 'text-gray-400' : rising ? 'text-red-400' : 'text-emerald-400';
const arrow = flat ? '→' : rising ? '↑' : '↓';
const color = delta === 0 ? 'text-gray-400' : delta > 0 ? 'text-red-400' : 'text-emerald-400';
const arrow = delta === 0 ? '→' : delta > 0 ? '↑' : '↓';
return (
<span className="rounded-lg bg-white/[0.04] px-2.5 py-1 text-xs text-gray-400">
{label}: <span className={`font-medium ${color}`}>{arrow} {delta > 0 ? '+' : ''}{delta}</span>
@@ -54,61 +48,51 @@ function TrendChip({ label, delta }: { label: string; delta: number | null | und
function ScoreGauge({
label,
score,
band,
trend,
threshold,
reading,
divider,
footnote,
size = 'lg',
}: {
label: string;
score: number | null | undefined;
band: RegimeBand | null | undefined;
trend?: { delta_7?: number | null; delta_30?: number | null };
threshold?: number;
footnote?: ReactNode;
size?: 'lg' | 'md';
reading: RegimeReading | undefined;
divider?: number;
footnote: ReactNode;
}) {
const naa = score == null;
const style = BAND_STYLES[(band ?? 'stable') as RegimeBand];
const s = score ?? 0;
const clamp = (v: number) => Math.min(100, Math.max(0, v));
const numCls = size === 'lg' ? 'text-6xl' : 'text-4xl';
const score = reading?.score;
const complete = reading?.band != null;
const style = complete ? BAND_STYLES[reading.band as RegimeBand] : null;
const position = Math.min(100, Math.max(0, score ?? 0));
return (
<div className={`glass border ${naa ? 'border-white/[0.06]' : style.ring} p-6`}>
<div className={`glass border p-6 ${style?.ring ?? 'border-white/[0.06]'}`}>
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<div className="text-[11px] uppercase tracking-wider text-gray-500">{label}</div>
<div className="mt-1 flex items-baseline gap-2">
<span className={`font-display font-bold ${numCls} ${naa ? 'text-gray-600' : style.text}`}>
{naa ? '—' : Math.round(s)}
<span className={`font-display text-6xl font-bold ${style?.text ?? 'text-gray-500'}`}>
{score == null ? '—' : Math.round(score)}
</span>
{!naa && <span className="text-sm text-gray-500">/ 100</span>}
{score != null && <span className="text-sm text-gray-500">/ 100</span>}
</div>
<div className="mt-1 flex flex-wrap items-center gap-2">
<span className={`text-sm font-medium ${style?.text ?? 'text-gray-500'}`}>
{style?.label ?? 'Incomplete'}
</span>
<span className="text-xs text-gray-600">coverage {Math.round(reading?.coverage ?? 0)}%</span>
</div>
{!naa && <p className={`mt-0.5 text-sm font-medium ${style.text}`}>{style.label}</p>}
</div>
{trend && (
<div className="flex flex-wrap gap-2">
<TrendChip label="7d" delta={trend.delta_7} />
<TrendChip label="30d" delta={trend.delta_30} />
</div>
)}
<div className="flex gap-2">
<TrendChip label="7d" delta={reading?.trend?.delta_7} />
<TrendChip label="30d" delta={reading?.trend?.delta_30} />
</div>
</div>
{!naa && (
{score != null && (
<>
{/* Band track with score (+ optional threshold) markers */}
<div className="relative mt-5 h-2 w-full rounded-full bg-gradient-to-r from-emerald-500/30 via-amber-500/30 to-red-500/40">
{threshold != null && (
<div
className="absolute -top-1 h-4 w-0.5 -translate-x-1/2 rounded bg-gray-300/80"
style={{ left: `${clamp(threshold)}%` }}
title={`Alert threshold ${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}`}
style={{ left: `${clamp(s)}%` }}
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}%` }}
/>
</div>
<div className="mt-1.5 flex justify-between text-[10px] uppercase tracking-wider text-gray-600">
@@ -116,68 +100,110 @@ function ScoreGauge({
</div>
</>
)}
{footnote && <p className="mt-4 text-xs leading-relaxed text-gray-500">{footnote}</p>}
<p className="mt-4 text-xs leading-relaxed text-gray-500">{footnote}</p>
</div>
);
}
function Breakdown({ breakdown }: { breakdown: RegimeSignal[] }) {
function PillarBreakdown({ title, reading }: { title: string; reading: RegimeReading }) {
return (
<div className="glass overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-3 font-medium">Signal</th>
<th className="px-4 py-3 font-medium">Sub-score</th>
<th className="px-4 py-3 text-right font-medium">Weight</th>
<th className="px-4 py-3 text-right font-medium">Contribution</th>
</tr>
</thead>
<tbody>
{breakdown.map((s) => (
<tr key={s.id} className="border-b border-white/[0.03] last:border-0">
<td className="px-4 py-3">
<span className="font-mono text-[10px] text-gray-600">{s.id}</span>{' '}
<span className="text-gray-300">{s.label}</span>
</td>
<td className="px-4 py-3">
{s.available && s.sub_score != null ? (
<div className="flex items-center gap-2">
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-white/[0.06]">
<div className="h-full rounded-full bg-blue-400/70" style={{ width: `${s.sub_score}%` }} />
</div>
<span className="num text-gray-300">{s.sub_score}</span>
</div>
) : (
<span className="text-xs text-gray-600">n/a</span>
)}
</td>
<td className="px-4 py-3 text-right num text-gray-400">{s.weight}</td>
<td className="px-4 py-3 text-right num text-gray-300">
{s.available ? s.contribution.toFixed(1) : '—'}
</td>
<Disclosure summary={`${title} pillars · ${Math.round(reading.coverage)}% coverage`}>
<div className="overflow-x-auto rounded-lg border border-white/[0.06]">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-xs uppercase tracking-wider text-gray-500">
<th className="px-4 py-3 font-medium">Pillar / sensor</th>
<th className="px-4 py-3 text-right font-medium">Score</th>
<th className="px-4 py-3 text-right font-medium">Weight</th>
<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>
</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 EventStudyBody({ report }: { report: EventStudyReport }) {
const metrics = report.metrics;
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-2">
<Badge label={report.evaluation ?? 'exploratory'} variant={report.evaluation === 'holdout' ? 'auto' : 'manual'} />
{report.generated_at && <span className="text-xs text-gray-500">generated {new Date(report.generated_at).toLocaleDateString()}</span>}
{report.sample && <span className="text-xs text-gray-500">test {report.sample.test_start} {report.sample.end}</span>}
</div>
<p className="text-sm leading-relaxed text-gray-300">{report.summary}</p>
{metrics && (
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{[
['Warned', `${metrics.events_warned}/${metrics.events}`],
['Missed', metrics.events_missed],
['False alarms/year', metrics.false_alarms_per_year.toFixed(1)],
['Median lead', metrics.median_lead_days == null ? '—' : `${metrics.median_lead_days}d`],
].map(([label, value]) => (
<div key={String(label)} className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2">
<div className="text-[11px] text-gray-500">{label}</div>
<div className="mt-0.5 text-lg font-semibold text-gray-200">{value}</div>
</div>
))}
</tbody>
</table>
</div>
)}
{report.events && report.events.length > 0 && (
<div className="overflow-x-auto rounded-lg border border-white/[0.06]">
<table className="w-full text-xs">
<thead><tr className="border-b border-white/[0.06] text-left text-gray-500">
<th className="px-3 py-2 font-medium">Correction</th>
<th className="px-3 py-2 text-right font-medium">Warned</th>
<th className="px-3 py-2 text-right font-medium">Lead</th>
</tr></thead>
<tbody>{report.events.map((event) => (
<tr key={event.date} className="border-b border-white/[0.03] last:border-0">
<td className="px-3 py-2 num text-gray-300">{event.date}</td>
<td className={`px-3 py-2 text-right ${event.warned ? 'text-emerald-400' : 'text-gray-500'}`}>{event.warned ? 'yes' : 'no'}</td>
<td className="px-3 py-2 text-right num text-gray-300">{event.lead_days == null ? '—' : `${event.lead_days}d`}</td>
</tr>
))}</tbody>
</table>
</div>
)}
<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>
);
}
function SliderRow({ label, value, onChange }: { label: string; value: number; onChange: (v: number) => void }) {
function EventStudyPanel() {
const study = useQuery({ queryKey: ['regime', 'event-study'], queryFn: getEventStudy });
return (
<label className="flex items-center gap-3 text-xs text-gray-400">
<span className="w-52 shrink-0">{label}</span>
<input
type="range"
min={0}
max={100}
value={value}
onChange={(e) => onChange(parseInt(e.target.value, 10))}
className="h-2 flex-1 cursor-pointer appearance-none rounded-lg bg-gray-700 accent-blue-500"
/>
<span className="w-8 text-right num text-gray-300">{value}</span>
</label>
<Disclosure summary="Warning study · chronological correction alarms">
{study.isLoading && <SkeletonCard className="h-24" />}
{study.data === null && <Callout variant="empty">Not run yet trigger Event Study in Admin Jobs.</Callout>}
{study.data && !study.data.available && <Callout variant="warning">{study.data.reason ?? 'No data'}</Callout>}
{study.data?.available && <EventStudyBody report={study.data} />}
</Disclosure>
);
}
@@ -194,379 +220,130 @@ function FundamentalsEditor({
saving: boolean;
refreshing: boolean;
}) {
const [f1, setF1] = useState(Math.round(data.f1_score));
const [f3, setF3] = useState(Math.round(data.f3_score));
const [f1, setF1] = useState(data.f1_score ?? 0);
const [f3, setF3] = useState(data.f3_score ?? 0);
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-500">
<span>Source: {data.source}</span>
{data.fetched_at && <span>· {new Date(data.fetched_at).toLocaleDateString()}</span>}
{data.fetched_at && <span>· fetched {new Date(data.fetched_at).toLocaleDateString()}</span>}
{data.effective_date && <span>· effective {data.effective_date}</span>}
{data.locked && <Badge label="locked" variant="manual" />}
</div>
{data.reasoning && <p className="text-xs leading-relaxed text-gray-400">{data.reasoning}</p>}
<SliderRow label="F1 · Hyperscaler capex guidance" value={f1} onChange={setF1} />
<SliderRow label="F3 · Good news, stock down" value={f3} onChange={setF3} />
<div className="flex flex-wrap gap-2 pt-1">
<button
className="btn-primary px-3 py-1.5 text-sm disabled:opacity-50"
disabled={saving}
onClick={() => onSave({ f1_score: f1, f3_score: f3, locked: true })}
>
Save override
</button>
<button
className="rounded-lg px-3 py-1.5 text-sm text-gray-400 hover:bg-white/[0.04] hover:text-gray-200 disabled:opacity-50"
disabled={refreshing}
onClick={onRefresh}
>
{refreshing ? 'Refreshing…' : 'Refresh via LLM'}
</button>
{data.locked && (
<button
className="rounded-lg px-3 py-1.5 text-sm text-gray-400 hover:bg-white/[0.04] hover:text-gray-200 disabled:opacity-50"
disabled={saving}
onClick={() => onSave({ locked: false })}
>
Unlock
</button>
)}
{[
['F1 · Capex cuts', f1, setF1],
['F3 · Good news, stock down', f3, setF3],
].map(([label, value, setter]) => (
<label key={String(label)} className="flex items-center gap-3 text-xs text-gray-400">
<span className="w-52 shrink-0">{String(label)}</span>
<input type="range" min={0} max={100} value={Number(value)} onChange={(event) => (setter as (v: number) => void)(Number(event.target.value))} className="h-2 flex-1 accent-blue-500" />
<span className="w-8 text-right num text-gray-300">{Number(value)}</span>
</label>
))}
<div className="flex flex-wrap gap-2">
<button className="btn-primary px-3 py-1.5 text-sm disabled:opacity-50" disabled={saving} onClick={() => onSave({ f1_score: f1, f3_score: f3, locked: true })}>Save override</button>
<button className="rounded-lg px-3 py-1.5 text-sm text-gray-400 hover:bg-white/[0.04] disabled:opacity-50" disabled={refreshing} onClick={onRefresh}>{refreshing ? 'Refreshing…' : 'Refresh via LLM'}</button>
{data.locked && <button className="rounded-lg px-3 py-1.5 text-sm text-gray-400 hover:bg-white/[0.04]" onClick={() => onSave({ locked: false })}>Unlock</button>}
</div>
</div>
);
}
function WeightsEditor({
data,
onSave,
saving,
}: {
data: RegimeConfig;
onSave: (updates: Partial<RegimeConfig>) => void;
saving: boolean;
}) {
const [weights, setWeights] = useState<Record<string, number>>(() => ({ ...data.weights }));
const [threshold, setThreshold] = useState<number>(data.alert_threshold);
const setWeight = (key: string, value: string) => {
const num = parseFloat(value);
setWeights((prev) => ({ ...prev, [key]: isNaN(num) ? 0 : num }));
};
function ConfigEditor({ data, onSave, saving }: { data: RegimeConfig; onSave: (updates: Partial<RegimeConfig>) => void; saving: boolean }) {
const [basket, setBasket] = useState(data.breadth_basket.join(', '));
const [staleness, setStaleness] = useState(data.fundamental_staleness_days);
const symbols = basket.split(/[\s,]+/).map((symbol) => symbol.trim().toUpperCase()).filter(Boolean);
return (
<div className="space-y-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{Object.keys(weights).map((key) => (
<label key={key} className="flex items-center justify-between gap-2 text-xs text-gray-400">
<span className="font-mono text-gray-500">{key}</span>
<input
type="number"
min={0}
value={weights[key]}
onChange={(e) => setWeight(key, e.target.value)}
className="w-16 rounded-md border border-white/[0.08] bg-white/[0.03] px-2 py-1 text-right num text-gray-200"
/>
</label>
))}
</div>
<label className="flex items-center gap-2 text-xs text-gray-400">
<span>Alert threshold</span>
<input
type="number"
min={0}
max={100}
value={threshold}
onChange={(e) => setThreshold(parseInt(e.target.value, 10) || 0)}
className="w-20 rounded-md border border-white/[0.08] bg-white/[0.03] px-2 py-1 text-right num text-gray-200"
/>
<label className="block text-xs text-gray-400">
<span>Fixed breadth basket · {symbols.length} symbols</span>
<textarea value={basket} onChange={(event) => setBasket(event.target.value)} rows={5} className="mt-1 w-full rounded-lg border border-white/[0.08] bg-white/[0.03] p-2 font-mono text-xs text-gray-200" />
</label>
<button
className="btn-primary px-3 py-1.5 text-sm disabled:opacity-50"
disabled={saving}
onClick={() => onSave({ weights, alert_threshold: threshold })}
>
Save weights
</button>
<label className="flex items-center gap-2 text-xs text-gray-400">
<span>Fundamental staleness</span>
<input type="number" min={30} max={180} value={staleness} onChange={(event) => setStaleness(Number(event.target.value))} className="w-20 rounded-md border border-white/[0.08] bg-white/[0.03] px-2 py-1 text-right num text-gray-200" />
<span>days</span>
</label>
<p className="text-[11px] text-gray-600">Changing the basket resets its freeze date and silently reseeds quadrant alerts.</p>
<button className="btn-primary px-3 py-1.5 text-sm disabled:opacity-50" disabled={saving || symbols.length < 20} onClick={() => onSave({ breadth_basket: symbols, fundamental_staleness_days: staleness })}>Save monitor settings</button>
</div>
);
}
function Sparkline({ values, color = '#60a5fa', height = 28 }: { values: number[]; color?: string; height?: number }) {
if (values.length < 2) return null;
const min = Math.min(...values);
const max = Math.max(...values);
const range = max - min || 1;
const w = 120;
const pts = values
.map((v, i) => `${(i / (values.length - 1)) * w},${height - ((v - min) / range) * height}`)
.join(' ');
return (
<svg width={w} height={height}>
<polyline points={pts} fill="none" stroke={color} strokeWidth={1.5} />
</svg>
);
}
function pctLabel(v: number | null): string {
return v == null ? '—' : `${Math.round(v * 100)}%`;
}
function leadLabel(v: number | null): string {
return v == null ? 'missed' : `${v}d`;
}
function bestPr(stats: EventStudyLeadStats) {
const rows = stats.signal.rows.filter((r) => r.precision != null && r.recall != null && r.recall > 0);
if (!rows.length) return null;
return rows.reduce((a, b) => ((b.precision ?? 0) > (a.precision ?? 0) ? b : a));
}
function LeadStat({ label, stats, highlight }: { label: string; stats: EventStudyLeadStats; highlight?: boolean }) {
const pr = bestPr(stats);
return (
<div className={`rounded-lg border px-3 py-2 ${highlight ? 'border-blue-400/30 bg-blue-400/[0.06]' : 'border-white/[0.06] bg-white/[0.02]'}`}>
<div className="text-xs text-gray-500">{label}</div>
<div className="mt-0.5 text-lg font-semibold text-gray-200">
{stats.median_lead_days != null ? `${stats.median_lead_days}d lead` : 'no signal'}
</div>
<div className="text-[11px] text-gray-600">
{stats.events_with_signal}/{stats.events_total} warned
{stats.warn_threshold != null ? ` · warn ≥ ${Math.round(stats.warn_threshold)}` : ''}
</div>
{pr && (
<div className="text-[11px] text-gray-600">
best P {pctLabel(pr.precision)} · R {pctLabel(pr.recall)} @ {pr.threshold}
</div>
)}
</div>
);
}
function PerEventTable({ rows }: { rows: EventStudyPerEvent[] }) {
return (
<div className="overflow-x-auto rounded-lg border border-white/[0.06]">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-white/[0.06] text-left uppercase tracking-wider text-gray-500">
<th className="px-3 py-2 font-medium">Drawdown</th>
<th className="px-3 py-2 text-right font-medium">Depth</th>
<th className="px-3 py-2 text-right font-medium">Breadth lead</th>
<th className="px-3 py-2 text-right font-medium">Coincident lead</th>
</tr>
</thead>
<tbody>
{rows.map((e) => {
const earlier = e.breadth_lead != null && (e.coincident_lead == null || e.breadth_lead > e.coincident_lead);
return (
<tr key={e.date} className="border-b border-white/[0.03] last:border-0">
<td className="px-3 py-2 num text-gray-300">{e.date}</td>
<td className="px-3 py-2 text-right num text-gray-400">{e.depth_pct}%</td>
<td className={`px-3 py-2 text-right num ${earlier ? 'text-emerald-400' : 'text-gray-300'}`}>
{leadLabel(e.breadth_lead)}
</td>
<td className="px-3 py-2 text-right num text-gray-300">{leadLabel(e.coincident_lead)}</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
function EventStudyBody({ report }: { report: EventStudyReport }) {
const bd = report.indicators!.breadth_divergence;
const cd = report.indicators!.coincident_price;
const recent = report.recent_breadth ?? [];
const breadthVals = recent.map((r) => r.breadth);
const divVals = recent.map((r) => r.divergence ?? 0);
const moreCoverage = bd.events_with_signal > cd.events_with_signal;
return (
<div className="space-y-4">
<p className="text-xs text-gray-500">
{report.events?.length ?? 0} drawdown events ({report.params?.event_threshold_pct}%) on{' '}
{report.params?.benchmark} over ~5y. With so few events, coverage (how many it warned before) matters
more than the median lead.
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<LeadStat label="Breadth divergence (leading candidate)" stats={bd} highlight={moreCoverage} />
<LeadStat label="Coincident price composite (baseline)" stats={cd} />
</div>
<p className="text-xs text-gray-400">
Breadth divergence warned before{' '}
<span className="font-medium text-emerald-400">{bd.events_with_signal}/{bd.events_total}</span> drawdowns
{bd.median_lead_days != null ? ` (median ${bd.median_lead_days}d lead)` : ''}; the coincident baseline only{' '}
<span className="font-medium text-gray-300">{cd.events_with_signal}/{cd.events_total}</span>. The median-lead
comparison is unreliable when coverage differs this much see per-drawdown below.
</p>
{report.per_event && report.per_event.length > 0 && (
<div className="space-y-1.5">
<div className="text-[11px] uppercase tracking-wider text-gray-500">Per drawdown (same events, both indicators)</div>
<PerEventTable rows={report.per_event} />
</div>
)}
{recent.length > 1 && (
<div className="flex flex-wrap items-end gap-6">
<div>
<div className="text-[11px] text-gray-500">Breadth (% &gt; 200d), last 90d</div>
<Sparkline values={breadthVals} color="#34d399" />
<div className="num text-xs text-gray-400">{breadthVals[breadthVals.length - 1]?.toFixed(0)}%</div>
</div>
<div>
<div className="text-[11px] text-gray-500">Divergence (fragility), last 90d</div>
<Sparkline values={divVals} color="#fb923c" />
<div className="num text-xs text-gray-400">{divVals[divVals.length - 1]?.toFixed(0)}</div>
</div>
</div>
)}
<p className="text-[11px] leading-relaxed text-gray-600">
Base rate {Math.round(bd.signal.base_rate * 100)}% · horizon {bd.signal.horizon_days}d. Few events in
5y noisy; treat lead time as an order of magnitude and don&apos;t overfit thresholds. Not yet wired
into the live score.
</p>
</div>
);
}
function EventStudyPanel() {
const study = useQuery({ queryKey: ['regime', 'event-study'], queryFn: getEventStudy });
return (
<Disclosure summary="Early-warning study — measured lead time vs. drawdowns">
{study.isLoading && <SkeletonCard className="h-24" />}
{study.data === null && (
<Callout variant="empty">Not run yet trigger the Event Study job in Admin Jobs.</Callout>
)}
{study.data && !study.data.available && (
<Callout variant="warning">{study.data.reason ?? 'No data'}</Callout>
)}
{study.data && study.data.available && <EventStudyBody report={study.data} />}
</Disclosure>
);
}
function AdminControls() {
const qc = useQueryClient();
const queryClient = useQueryClient();
const fundamentals = useQuery({ queryKey: ['regime', 'fundamentals'], queryFn: getRegimeFundamentals });
const config = useQuery({ queryKey: ['regime', 'config'], queryFn: getRegimeConfig });
const invalidate = () => qc.invalidateQueries({ queryKey: ['regime'] });
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['regime'] });
const refresh = useMutation({ mutationFn: refreshRegimeFundamentals, onSuccess: invalidate });
const saveFund = useMutation({ mutationFn: updateRegimeFundamentals, onSuccess: invalidate });
const saveFundamentals = useMutation({ mutationFn: updateRegimeFundamentals, onSuccess: invalidate });
const saveConfig = useMutation({ mutationFn: updateRegimeConfig, onSuccess: invalidate });
return (
<div className="space-y-3">
<Disclosure summary="Admin · Fundamentals (F1 / F3)">
{fundamentals.isLoading && <SkeletonCard className="h-24" />}
{fundamentals.data && (
<FundamentalsEditor
key={fundamentals.dataUpdatedAt}
data={fundamentals.data}
onSave={(body) => saveFund.mutate(body)}
onRefresh={() => refresh.mutate()}
saving={saveFund.isPending}
refreshing={refresh.isPending}
/>
)}
{refresh.isError && (
<Callout variant="error">Refresh failed: {(refresh.error as Error).message}</Callout>
)}
<Disclosure summary="Admin · Fundamental observations">
{fundamentals.data && <FundamentalsEditor key={fundamentals.dataUpdatedAt} data={fundamentals.data} onSave={(body) => saveFundamentals.mutate(body)} onRefresh={() => refresh.mutate()} saving={saveFundamentals.isPending} refreshing={refresh.isPending} />}
{refresh.isError && <Callout variant="error">Refresh failed: {(refresh.error as Error).message}</Callout>}
</Disclosure>
<Disclosure summary="Admin · Weights & threshold">
{config.isLoading && <SkeletonCard className="h-24" />}
{config.data && (
<WeightsEditor
key={config.dataUpdatedAt}
data={config.data}
onSave={(updates) => saveConfig.mutate(updates)}
saving={saveConfig.isPending}
/>
)}
<Disclosure summary="Admin · Fixed basket & freshness">
{config.data && <ConfigEditor key={config.dataUpdatedAt} data={config.data} onSave={(updates) => saveConfig.mutate(updates)} saving={saveConfig.isPending} />}
{saveConfig.isError && <Callout variant="error">Save failed: {(saveConfig.error as Error).message}</Callout>}
</Disclosure>
</div>
);
}
export default function RegimePage() {
const role = useAuthStore((s) => s.role);
const isAdmin = role === 'admin';
const isAdmin = useAuthStore((state) => state.role) === 'admin';
const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor });
const data = monitor.data;
return (
<div className="space-y-6 animate-slide-up">
<PageHeader
title="Regime Monitor"
subtitle="AI/Tech regime-change index — observational, feeds no trades"
/>
<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>
{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>
)}
{monitor.data && !monitor.data.available && (
<Callout variant="empty">
Not computed yet run the Regime Monitor job from Admin Jobs, or wait for the daily pipeline.
</Callout>
)}
{monitor.data && monitor.data.available && (
{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?.available && data.state && data.warning && (
<>
{(!data.data_quality?.is_fresh || data.state.band == null || data.warning.band == null) && (
<Callout variant="warning">
Reading is incomplete or stale. State coverage {Math.round(data.state.coverage)}%, Warning coverage {Math.round(data.warning.coverage)}%
{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="Regime index · coincident"
score={monitor.data.total_score}
band={monitor.data.band}
trend={monitor.data.trend}
threshold={monitor.data.alert_threshold}
footnote={
<>
An <span className="text-gray-400">index</span> (not a calibrated probability) of how far the AI/Tech
bull regime has deteriorated. Mostly coincident it shortens reaction time, it doesn&apos;t predict
the turn.
{monitor.data.date && <> As of {monitor.data.date}.</>}
{monitor.data.inputs && (monitor.data.inputs.vix != null || monitor.data.inputs.hy_oas != null) && (
<span className="ml-1 text-gray-600">
VIX {monitor.data.inputs.vix ?? '—'} · HY OAS {monitor.data.inputs.hy_oas ?? '—'}
</span>
)}
</>
}
label="State · current structural stress"
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 ?? '—'}.</>}
/>
<ScoreGauge
label="Early warning · breadth divergence"
score={monitor.data.early_warning?.score}
band={monitor.data.early_warning?.band}
trend={monitor.data.early_warning}
footnote={
<>
Breadth narrowing while price holds. In the event study it led ~6 weeks on 7/11 past drawdowns, but
it&apos;s noisy (2× base rate) and blind to shocks. Observational separate from the index, not
wired into trades.
</>
}
label="Warning · deterioration & divergence"
reading={data.warning}
divider={data.quadrant_config?.warning_divider}
footnote={<>Breadth divergence, SMH/SPY rollover, and point-in-time fundamental observations. Unknown or stale fundamentals reduce coverage; they never default to 50.</>}
/>
</div>
<Suspense fallback={<SkeletonCard className="h-80" />}>
<RegimeQuadrant />
</Suspense>
<Suspense fallback={<SkeletonCard className="h-72" />}>
<ScoreHistoryChart />
</Suspense>
{monitor.data.breakdown && <Breakdown breakdown={monitor.data.breakdown} />}
<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>
)}
</>
)}
<EventStudyPanel />
{isAdmin && <AdminControls />}
</div>
);