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:
@@ -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 (% > 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'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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user