feat: separate live early-warning + combined score on the regime tab
The event study showed the breadth-divergence signal genuinely leads (warned before 7/11 drawdowns, ~6 weeks median, where the coincident baseline almost never did). Surface it live to observe before deciding how to embed it — kept separate from the index, not folded into its weights. - regime_monitor daily job now computes breadth-divergence live and attaches a separate early_warning score plus a combined blend (weighted mean, default 0.6/0.4, configurable via combined_weights) to each snapshot, including the backfill so the 7/30-day trends populate immediately. Stored in breakdown_json — no schema change. Best-effort: a breadth failure can't break the index. - get_regime_monitor returns the index, early_warning, and combined scores each with 7/30-day deltas. - Regime tab shows three gauges (generalized ScoreGauge): coincident index, early warning, and a compact combined blend. Stale snapshots render "—". Note: the daily regime job now also does a universe-wide breadth scan. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { PageHeader } from '../components/ui/PageHeader';
|
||||
import { Callout } from '../components/ui/Callout';
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
} from '../api/regime';
|
||||
import type {
|
||||
RegimeBand,
|
||||
RegimeMonitor,
|
||||
RegimeSignal,
|
||||
RegimeConfig,
|
||||
RegimeFundamentals,
|
||||
@@ -49,54 +48,71 @@ function TrendChip({ label, delta }: { label: string; delta: number | null | und
|
||||
);
|
||||
}
|
||||
|
||||
function Gauge({ data }: { data: RegimeMonitor }) {
|
||||
const band = (data.band ?? 'stable') as RegimeBand;
|
||||
const style = BAND_STYLES[band];
|
||||
const score = data.total_score ?? 0;
|
||||
const threshold = data.alert_threshold ?? 65;
|
||||
function ScoreGauge({
|
||||
label,
|
||||
score,
|
||||
band,
|
||||
trend,
|
||||
threshold,
|
||||
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';
|
||||
}) {
|
||||
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';
|
||||
return (
|
||||
<div className={`glass border ${style.ring} p-6`}>
|
||||
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||
<div className={`glass border ${naa ? 'border-white/[0.06]' : style.ring} p-6`}>
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className={`font-display text-6xl font-bold ${style.text}`}>{Math.round(score)}</span>
|
||||
<span className="text-sm text-gray-500">/ 100</span>
|
||||
<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>
|
||||
{!naa && <span className="text-sm text-gray-500">/ 100</span>}
|
||||
</div>
|
||||
<p className={`mt-1 text-sm font-medium ${style.text}`}>{style.label}</p>
|
||||
{!naa && <p className={`mt-0.5 text-sm font-medium ${style.text}`}>{style.label}</p>}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TrendChip label="7d" delta={data.trend?.delta_7} />
|
||||
<TrendChip label="30d" delta={data.trend?.delta_30} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Band track with score + threshold markers */}
|
||||
<div className="relative mt-6 h-2 w-full rounded-full bg-gradient-to-r from-emerald-500/30 via-amber-500/30 to-red-500/40">
|
||||
<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={`absolute -top-1.5 h-5 w-5 -translate-x-1/2 rounded-full border-2 border-white/70 ${style.bar}`}
|
||||
style={{ left: `${clamp(score)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-between text-[10px] uppercase tracking-wider text-gray-600">
|
||||
<span>0</span><span>30</span><span>60</span><span>80</span><span>100</span>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-xs leading-relaxed text-gray-500">
|
||||
An <span className="text-gray-400">index</span> (not a calibrated probability) of how far the AI/Tech bull regime
|
||||
has deteriorated. Mostly coincident signals — it shortens reaction time, it doesn't predict the exact turn.
|
||||
{data.date && <> As of {data.date}.</>}
|
||||
{data.inputs && (data.inputs.vix != null || data.inputs.hy_oas != null) && (
|
||||
<span className="ml-1 text-gray-600">
|
||||
VIX {data.inputs.vix ?? '—'} · HY OAS {data.inputs.hy_oas ?? '—'}
|
||||
</span>
|
||||
{trend && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<TrendChip label="7d" delta={trend.delta_7} />
|
||||
<TrendChip label="30d" delta={trend.delta_30} />
|
||||
</div>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!naa && (
|
||||
<>
|
||||
{/* 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={`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)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1.5 flex justify-between text-[10px] uppercase tracking-wider text-gray-600">
|
||||
<span>0</span><span>30</span><span>60</span><span>80</span><span>100</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{footnote && <p className="mt-4 text-xs leading-relaxed text-gray-500">{footnote}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -501,7 +517,52 @@ export default function RegimePage() {
|
||||
|
||||
{monitor.data && monitor.data.available && (
|
||||
<>
|
||||
<Gauge data={monitor.data} />
|
||||
<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'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>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<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's noisy (≈2× base rate) and blind to shocks. Observational — separate from the index, not
|
||||
wired into trades.
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ScoreGauge
|
||||
label="Combined · observational blend"
|
||||
score={monitor.data.combined?.score}
|
||||
band={monitor.data.combined?.band}
|
||||
trend={monitor.data.combined}
|
||||
size="md"
|
||||
footnote={
|
||||
<>A weighted mean of the index and the early warning — for observation only. Tune the mix via the
|
||||
regime config.</>
|
||||
}
|
||||
/>
|
||||
{monitor.data.breakdown && <Breakdown breakdown={monitor.data.breakdown} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user