feat: breadth-divergence early-warning indicator + event study

Adds a leading-by-construction candidate and the harness to measure whether it
actually leads regime breaks, before any of it earns weight in the live index.

- breadth_service: % of the stored universe above its own 200-DMA + a divergence
  score (benchmark price up while breadth falls, nudged by low breadth). Genuinely
  leading because it keys on divergence, not level. Not wired into the live score.
- event_study_service: detect drawdown events on the benchmark, then measure each
  indicator's median lead time (event-centered) and precision/recall vs. the base
  rate (signal-centered). Compares breadth-divergence against the deterministic
  coincident price composite (reuses the regime price sub-scores). Price/breadth
  only — reproducible, no LLM/FRED.
- Manual "Event Study" job (Admin → Jobs), GET /regime/event-study, and an
  inline early-warning panel on the Regime tab with an honest small-sample caveat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 14:08:52 +02:00
parent ebff19940b
commit 824c15cf69
10 changed files with 719 additions and 2 deletions
+10 -1
View File
@@ -1,10 +1,19 @@
import apiClient from './client';
import type { RegimeMonitor, RegimeConfig, RegimeFundamentals } from '../lib/types';
import type {
RegimeMonitor,
RegimeConfig,
RegimeFundamentals,
EventStudyReport,
} from '../lib/types';
export function getRegimeMonitor() {
return apiClient.get<RegimeMonitor>('regime/monitor').then((r) => r.data);
}
export function getEventStudy() {
return apiClient.get<EventStudyReport | null>('regime/event-study').then((r) => r.data);
}
export function getRegimeConfig() {
return apiClient.get<RegimeConfig>('regime/config').then((r) => r.data);
}
+32
View File
@@ -311,6 +311,38 @@ export interface RegimeConfig {
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;
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 EventStudyReport {
available: boolean;
reason?: string;
generated_at?: string;
params?: {
benchmark: string;
event_threshold_pct: number;
horizon_days: number;
warn_threshold: number;
};
events?: { date: string; index: number; depth_pct: number }[];
indicators?: {
breadth_divergence: EventStudyLeadStats;
coincident_price: EventStudyLeadStats;
};
lead_delta_days?: number | null;
recent_breadth?: { date: string; breadth: number; divergence: number | null }[];
}
export interface AlertConfig {
enabled: boolean;
telegram_chat_id: string;
+100
View File
@@ -13,6 +13,7 @@ import {
getRegimeFundamentals,
updateRegimeFundamentals,
refreshRegimeFundamentals,
getEventStudy,
} from '../api/regime';
import type {
RegimeBand,
@@ -20,6 +21,8 @@ import type {
RegimeSignal,
RegimeConfig,
RegimeFundamentals,
EventStudyReport,
EventStudyLeadStats,
} from '../lib/types';
const BAND_STYLES: Record<RegimeBand, { text: string; bar: string; ring: string; label: string }> = {
@@ -266,6 +269,101 @@ function WeightsEditor({
);
}
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 LeadStat({ label, stats, highlight }: { label: string; stats: EventStudyLeadStats; highlight?: boolean }) {
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} events warned
</div>
</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 lead = report.lead_delta_days;
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. Higher median lead = earlier warning.
</p>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<LeadStat label="Breadth divergence (leading candidate)" stats={bd} highlight={lead != null && lead > 0} />
<LeadStat label="Coincident price composite (baseline)" stats={cd} />
</div>
{lead != null && (
<p className="text-xs text-gray-400">
Breadth divergence warned a median{' '}
<span className={`font-medium ${lead > 0 ? 'text-emerald-400' : 'text-amber-400'}`}>
{lead > 0 ? '+' : ''}{lead} days
</span>{' '}
{lead >= 0 ? 'earlier' : 'later'} than the coincident baseline.
</p>
)}
{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 fundamentals = useQuery({ queryKey: ['regime', 'fundamentals'], queryFn: getRegimeFundamentals });
@@ -348,6 +446,8 @@ export default function RegimePage() {
</>
)}
<EventStudyPanel />
{isAdmin && <AdminControls />}
</div>
);