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
+1 -1
View File
@@ -11,7 +11,7 @@ export function getRegimeMonitor() {
return apiClient.get<RegimeMonitor>('regime/monitor').then((r) => r.data);
}
export function getRegimeHistory(days = 400) {
export function getRegimeHistory(days = 800) {
return apiClient
.get<RegimeHistoryPoint[]>('regime/history', { params: { days } })
.then((r) => r.data);
@@ -13,15 +13,13 @@ import {
ReferenceLine,
ReferenceArea,
} from 'recharts';
import { getRegimeHistory } from '../../api/regime';
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 dividers. Regime < 40 ≈ intact; early-warning > 60 ≈ elevated.
const X_DIV = 40; // regime index
const Y_DIV = 60; // early warning
// Quadrant boundaries come from the backend v2 methodology response.
const TRAIL = 60; // sessions shown
interface QPoint {
@@ -64,7 +62,7 @@ function QuadrantTip({ active, payload }: { active?: boolean; payload?: { payloa
<div className="glass px-2.5 py-1.5 text-[11px]">
<div className="text-gray-300">{p.date}</div>
<div className="text-gray-400">
Regime <span className="text-blue-300">{Math.round(p.x)}</span> · Early warning{' '}
State <span className="text-blue-300">{Math.round(p.x)}</span> · Warning{' '}
<span className="text-orange-300">{Math.round(p.y)}</span>
</div>
</div>
@@ -72,14 +70,17 @@ function QuadrantTip({ active, payload }: { active?: boolean; payload?: { payloa
}
export default function RegimeQuadrant() {
const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(400) });
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.early_warning != null)
.filter((p) => p.state != null && p.warning != null)
.slice(-TRAIL)
.map((p) => ({ x: p.index, y: p.early_warning as number, date: p.date }));
.map((p) => ({ x: p.state as number, y: p.warning as number, date: p.date }));
}, [history.data]);
const trail = useMemo(() => smoothTrail(points), [points]);
@@ -89,11 +90,11 @@ export default function RegimeQuadrant() {
<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">
Regime quadrant last {TRAIL} sessions
State × Warning quadrant last {TRAIL} sessions
</div>
{latest && (
<div className="text-[11px] text-gray-500">
now: regime <span className="text-blue-300">{Math.round(latest.x)}</span> · warning{' '}
now: State <span className="text-blue-300">{Math.round(latest.x)}</span> · Warning{' '}
<span className="text-orange-300">{Math.round(latest.y)}</span>
</div>
)}
@@ -103,7 +104,7 @@ export default function RegimeQuadrant() {
<SkeletonCard className="mt-3 h-72" />
) : !points.length ? (
<Callout variant="empty">
Not enough history yet the early-warning fills in as the daily job runs.
Not enough coverage-qualified v2 history yet.
</Callout>
) : (
<>
@@ -111,13 +112,13 @@ export default function RegimeQuadrant() {
<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={X_DIV} y1={Y_DIV} y2={100} fill="#f59e0b" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={X_DIV} x2={100} y1={Y_DIV} y2={100} fill="#f97316" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={0} x2={X_DIV} y1={0} y2={Y_DIV} fill="#10b981" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={X_DIV} x2={100} y1={0} y2={Y_DIV} fill="#ef4444" fillOpacity={0.08} stroke="none" />
<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={X_DIV} stroke="rgba(255,255,255,0.12)" />
<ReferenceLine y={Y_DIV} stroke="rgba(255,255,255,0.12)" />
<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"
@@ -126,7 +127,7 @@ export default function RegimeQuadrant() {
tick={{ fill: '#6b7280', fontSize: 10 }}
tickLine={false}
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
label={{ value: 'Regime index →', position: 'insideBottom', offset: -12, fill: '#6b7280', fontSize: 10 }}
label={{ value: 'State →', position: 'insideBottom', offset: -12, fill: '#6b7280', fontSize: 10 }}
/>
<YAxis
type="number"
@@ -137,7 +138,7 @@ export default function RegimeQuadrant() {
width={30}
tickLine={false}
axisLine={false}
label={{ value: 'Early warning', angle: -90, position: 'insideLeft', fill: '#6b7280', fontSize: 10 }}
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 />} />
@@ -166,15 +167,15 @@ export default function RegimeQuadrant() {
</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"> Hot &amp; brittle</span> narrow melt-up, shakeout risk</span>
<span><span className="text-orange-400"> Transition</span> break may be starting</span>
<span><span className="text-emerald-400"> Healthy &amp; broad</span> calm uptrend</span>
<span><span className="text-red-400"> Real downturn</span> regime breaking, broad</span>
<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 tell isn&apos;t a single spot but the move (early warning rolling over while
the regime index climbs = divergence resolving downward). Observational not wired into trades.
sessions, smoothed. The path matters more than a single point. Risk thermometer not an entry, exit,
or sizing signal.
</p>
</>
)}
@@ -26,14 +26,13 @@ const HISTORY_RANGES = [
type HistoryRange = (typeof HISTORY_RANGES)[number]['key'];
const HISTORY_SERIES = [
{ key: 'index', label: 'Index', color: '#60a5fa' },
{ key: 'early_warning', label: 'Early warning', color: '#fb923c' },
{ key: 'combined', label: 'Combined', color: '#a78bfa' },
{ 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(400) });
const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(800) });
const filtered = useMemo(() => {
const data = history.data ?? [];
@@ -113,7 +112,6 @@ export default function ScoreHistoryChart() {
stroke={s.color}
dot={false}
strokeWidth={1.5}
connectNulls
isAnimationActive={false}
/>
))}
+81 -53
View File
@@ -439,106 +439,134 @@ export type RegimeBand = 'stable' | 'watch' | 'elevated' | 'breaking';
export interface RegimeSignal {
id: string;
label: string;
sub_score: number | null;
weight: number;
score: number | null;
available: boolean;
contribution: number;
details?: Record<string, unknown>;
}
export interface RegimeSubScore {
export interface RegimePillar {
id: string;
label: string;
score: number | null;
weight: number;
contribution: number;
available: boolean;
sensors: RegimeSignal[];
}
export interface RegimeReading {
score: number | null;
band: RegimeBand | null;
delta_7?: number | null;
delta_30?: number | null;
coverage: number;
minimum_coverage: number;
available_pillars: string[];
pillars: RegimePillar[];
trend?: { delta_7: number | null; delta_30: number | null };
}
export interface RegimeHistoryPoint {
date: string;
index: number;
early_warning: number | null;
combined: number | null;
state: number | null;
warning: number | null;
state_coverage: number | null;
warning_coverage: number | null;
basket_hash: string | null;
}
export interface RegimeMonitor {
available: boolean;
reason?: string;
methodology?: string;
date?: string;
total_score?: number;
band?: RegimeBand;
alert_threshold?: number;
breakdown?: RegimeSignal[];
state?: RegimeReading;
warning?: RegimeReading;
inputs?: {
vix: number | null;
vix_date: string | null;
hy_oas: number | null;
hy_oas_date: string | null;
breadth_pct_above_200: number | null;
breadth_date: string | null;
fundamentals_fetched_at: string | null;
fundamentals_effective_date: string | null;
fundamentals_age_days: number | null;
};
trend?: { delta_7: number | null; delta_30: number | null };
// Separate, observational early-warning score (breadth divergence) + a small
// combined blend. Decoupled from the index above.
early_warning?: RegimeSubScore;
combined?: RegimeSubScore;
basket?: {
symbols: string[];
hash: string;
basket_asof: string;
members_available: number | null;
members_expected: number;
history_kind: 'forward' | 'retrospective';
};
data_quality?: {
minimum_coverage: number;
oldest_market_input_age_days: number | null;
stale_inputs: string[];
inputs_fresh: boolean;
snapshot_age_days?: number;
is_fresh?: boolean;
};
quadrant_config?: { state_divider: number; warning_divider: number; margin: number };
}
export interface RegimeFundamentals {
f1_score: number;
f3_score: number;
f1_score: number | null;
f3_score: number | null;
locked: boolean;
reasoning: string | null;
fetched_at: string | null;
effective_date: string | null;
source: string;
capex?: Record<string, string>;
good_news_stock_down?: string | null;
}
export interface RegimeConfig {
weights: Record<string, number>;
alert_threshold: number;
tickers: Record<string, unknown>;
leader_weight: number;
rs_lookback: number;
breadth_basket: string[];
basket_asof: string;
fundamental_staleness_days: number;
}
// Event study — measured lead time of early-warning indicators vs. drawdowns
export interface EventStudyLeadStats {
median_lead_days: number | null;
events_with_signal: number;
events_total: number;
warn_threshold: number;
mean_path: { rel_day: number; value: number }[];
signal: {
base_rate: number;
horizon_days: number;
rows: { threshold: number; precision: number | null; recall: number | null; alarms: number }[];
};
}
export interface EventStudyPerEvent {
date: string;
depth_pct: number;
breadth_lead: number | null;
coincident_lead: number | null;
}
export interface EventStudyReport {
available: boolean;
reason?: string;
methodology?: string;
generated_at?: string;
evaluation?: 'exploratory' | 'holdout';
summary?: string;
params?: {
benchmark: string;
outcome: string;
event_threshold_pct: number;
cooldown_days: number;
event_cooldown_days: number;
horizon_days: number;
train_fraction: number;
warn_percentile: number;
warn_threshold: number;
basket_hash: string;
basket_asof: string;
};
events?: { date: string; index: number; depth_pct: number }[];
indicators?: {
breadth_divergence: EventStudyLeadStats;
coincident_price: EventStudyLeadStats;
sample?: {
start: string;
end: string;
train_end: string;
test_start: string;
sessions: number;
holdout_sessions: number;
};
per_event?: EventStudyPerEvent[];
lead_delta_days?: number | null;
recent_breadth?: { date: string; breadth: number; divergence: number | null }[];
metrics?: {
events: number;
events_warned: number;
events_missed: number;
alarm_episodes: number;
false_alarms: number;
false_alarms_per_year: number;
median_lead_days: number | null;
};
events?: { date: string; warned: boolean; lead_days: number | null }[];
recent_breadth?: { date: string; breadth: number; warning: number | null }[];
}
export interface AlertConfig {
+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>
);