feat: replace regime monitor with v2 methodology
This commit is contained in:
@@ -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 & 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 & 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'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
@@ -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
@@ -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 (% > 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 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'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'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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user