The v3 cutover run scored 2/4 corrections warned against v2's 3/4, which reads like a regression and is not one. Only 4 of the 11 detected corrections fall in the holdout, so recall is one event from a different headline -- and the event that flips is decided by threshold placement, not by what the score saw. "v3 without the credit sensor" catches 2025-02-21 at a *higher* threshold (35.5) than shipped v3 misses it at (32.3), because the alarm rule needs a rising edge and a lower threshold can fire outside the horizon then never reset below. Two caveats are now computed and surfaced rather than left for the reader to infer: - Holdout event count against MIN_EVENTS_FOR_CONFIDENCE. The summary sentence states how many of the detected corrections actually fall in the test period. - Warning-sensor coverage across the split. The score renormalises over what is available, so a training window predating a sensor's history freezes the threshold on a different construct than the holdout is measured against. At the cutover that is 39% of training sessions with all three sensors versus 100% of the test period, credit history beginning 2023-07-25. Restricting the threshold to sensor-matched training sessions was tested and rejected: those sessions are a calm recent stretch, so the threshold falls from 32.3 to 22.5 and false alarms rise from 3.3 to 8.6/yr. It swaps a coverage bias for a regime-selection bias. The report states its limits instead. _warning_series now returns per-session sensor counts alongside the scores. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
511 lines
27 KiB
TypeScript
511 lines
27 KiB
TypeScript
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';
|
|
import { Badge } from '../components/ui/Badge';
|
|
import { SkeletonCard, SkeletonTable } from '../components/ui/Skeleton';
|
|
import { useAuthStore } from '../stores/authStore';
|
|
import {
|
|
getEventStudy,
|
|
getRegimeConfig,
|
|
getRegimeFundamentals,
|
|
getRegimeMonitor,
|
|
refreshRegimeFundamentals,
|
|
updateRegimeConfig,
|
|
updateRegimeFundamentals,
|
|
} from '../api/regime';
|
|
import type {
|
|
CapexState,
|
|
EventStudyReport,
|
|
GoodNewsReaction,
|
|
RegimeBand,
|
|
RegimeConfig,
|
|
RegimeFundamentalOverlay,
|
|
RegimeFundamentals,
|
|
RegimeFundamentalsUpdate,
|
|
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: '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 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>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function ScoreGauge({
|
|
label,
|
|
reading,
|
|
divider,
|
|
footnote,
|
|
}: {
|
|
label: string;
|
|
reading: RegimeReading | undefined;
|
|
divider?: number;
|
|
footnote: ReactNode;
|
|
}) {
|
|
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));
|
|
const bands = reading?.bands;
|
|
const ticks = bands ? [bands.watch, bands.elevated, bands.breaking] : [30, 60, 80];
|
|
return (
|
|
<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 text-6xl font-bold ${style?.text ?? 'text-gray-500'}`}>
|
|
{score == null ? '—' : Math.round(score)}
|
|
</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>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<TrendChip label="7d" delta={reading?.trend?.delta_7} />
|
|
<TrendChip label="30d" delta={reading?.trend?.delta_30} />
|
|
</div>
|
|
</div>
|
|
{score != null && (
|
|
<>
|
|
<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 ?? 'bg-gray-500'}`}
|
|
style={{ left: `${position}%` }}
|
|
/>
|
|
</div>
|
|
{/* Thresholds come from the reading: the two axes no longer share them. */}
|
|
<div className="relative mt-1.5 h-4 text-[10px] uppercase tracking-wider text-gray-600">
|
|
<span className="absolute left-0">0</span>
|
|
{ticks.map((tick) => (
|
|
<span key={tick} className="absolute -translate-x-1/2 num" style={{ left: `${tick}%` }}>
|
|
{tick}
|
|
</span>
|
|
))}
|
|
<span className="absolute right-0">100</span>
|
|
</div>
|
|
</>
|
|
)}
|
|
<p className="mt-4 text-xs leading-relaxed text-gray-500">{footnote}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const CAPEX_TONE: Record<CapexState, string> = {
|
|
raising: 'text-emerald-400',
|
|
holding: 'text-amber-400',
|
|
cutting: 'text-red-400',
|
|
unknown: 'text-gray-500',
|
|
};
|
|
|
|
function FundamentalOverlayCard({ overlay }: { overlay: RegimeFundamentalOverlay }) {
|
|
const capex = overlay.capex ?? {};
|
|
const reaction = overlay.good_news_stock_down;
|
|
return (
|
|
<div className="glass border border-white/[0.06] p-5">
|
|
<div className="flex flex-wrap items-baseline justify-between gap-2">
|
|
<div className="text-[11px] uppercase tracking-wider text-gray-500">
|
|
Fundamental overlay · context, not scored
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2 text-[11px] text-gray-500">
|
|
{overlay.source && <span>{overlay.source}</span>}
|
|
{overlay.effective_date && <span>· effective {overlay.effective_date}</span>}
|
|
{overlay.pending && <Badge label="pending" variant="manual" />}
|
|
{overlay.stale && <Badge label="stale" variant="manual" />}
|
|
</div>
|
|
</div>
|
|
|
|
{overlay.pending ? (
|
|
<p className="mt-3 text-xs leading-relaxed text-amber-400/90">
|
|
A newer observation was collected but is not effective until {overlay.effective_date ?? 'the next session'}.
|
|
Observations are never backdated, so the reading below appears from that session onward.
|
|
</p>
|
|
) : (
|
|
<>
|
|
<div className="mt-4 grid gap-4 sm:grid-cols-2">
|
|
<div>
|
|
<div className="mb-2 flex items-baseline justify-between text-xs">
|
|
<span className="font-medium text-gray-300">Hyperscaler capex guidance</span>
|
|
<span className="num text-gray-500">{overlay.capex_stress ?? 'n/a'}</span>
|
|
</div>
|
|
<div className="space-y-1">
|
|
{Object.entries(capex).map(([symbol, state]) => (
|
|
<div key={symbol} className="flex items-center justify-between text-xs">
|
|
<span className="font-mono text-gray-400">{symbol}</span>
|
|
<span className={CAPEX_TONE[state] ?? 'text-gray-500'}>{state}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="mb-2 flex items-baseline justify-between text-xs">
|
|
<span className="font-medium text-gray-300">Good news, stock down</span>
|
|
<span className="num text-gray-500">{overlay.earnings_stress ?? 'n/a'}</span>
|
|
</div>
|
|
<div className={`text-sm font-medium ${reaction === 'yes' ? 'text-red-400' : reaction === 'no' ? 'text-emerald-400' : 'text-gray-500'}`}>
|
|
{reaction === 'yes' ? 'Yes — beats sold into' : reaction === 'no' ? 'No — ordinary reactions' : 'Mixed'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{overlay.reasoning && (
|
|
<p className="mt-4 text-xs leading-relaxed text-gray-400">{overlay.reasoning}</p>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
<p className="mt-4 text-[11px] leading-relaxed text-gray-600">
|
|
These observations are qualitative, refreshed roughly quarterly, and deliberately excluded from State and
|
|
Warning. In v2 they carried 20 of 100 Warning points — not enough to cross the study's alarm threshold even
|
|
when both were pegged — so they are reported here rather than diluted into a daily score.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PillarBreakdown({ title, reading }: { title: string; reading: RegimeReading }) {
|
|
return (
|
|
<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>
|
|
))}
|
|
</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>
|
|
)}
|
|
{report.reliability && (report.reliability.underpowered || report.reliability.sensor_coverage_mismatch) && (
|
|
<Callout variant="warning">
|
|
<div className="space-y-1.5">
|
|
{report.reliability.underpowered && (
|
|
<p>
|
|
<strong>Underpowered.</strong> Only {report.reliability.events_in_holdout} of{' '}
|
|
{report.reliability.events_detected} detected corrections fall in the test period (
|
|
{report.reliability.minimum_events}+ needed). Recall is one event away from a materially
|
|
different headline, and which events flip is usually decided by where the frozen threshold
|
|
lands rather than by what the score saw. Read the direction, not the ratio.
|
|
</p>
|
|
)}
|
|
{report.reliability.sensor_coverage_mismatch && (
|
|
<p>
|
|
<strong>Sensor coverage differs across the split.</strong>{' '}
|
|
{report.reliability.train_full_sensor_share}% of training sessions had all{' '}
|
|
{report.reliability.sensors_expected} Warning sensors versus{' '}
|
|
{report.reliability.holdout_full_sensor_share}% of test sessions
|
|
{report.params?.credit_sensor_from && ` — credit history begins ${report.params.credit_sensor_from}`}
|
|
. The score renormalises over what is available, so the threshold was frozen on a partly
|
|
different construct than it is measured against.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</Callout>
|
|
)}
|
|
<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 EventStudyPanel() {
|
|
const study = useQuery({ queryKey: ['regime', 'event-study'], queryFn: getEventStudy });
|
|
return (
|
|
<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>
|
|
);
|
|
}
|
|
|
|
const SELECT_CLASS = 'rounded-md border border-white/[0.08] bg-white/[0.03] px-2 py-1.5 text-xs text-gray-200';
|
|
|
|
const CAPEX_OPTIONS: { value: CapexState; label: string }[] = [
|
|
{ value: 'raising', label: 'Raising' },
|
|
{ value: 'holding', label: 'Holding' },
|
|
{ value: 'cutting', label: 'Cutting' },
|
|
{ value: 'unknown', label: 'Unknown' },
|
|
];
|
|
|
|
function FundamentalsEditor({
|
|
data,
|
|
onSave,
|
|
onRefresh,
|
|
saving,
|
|
refreshing,
|
|
}: {
|
|
data: RegimeFundamentals;
|
|
onSave: (body: RegimeFundamentalsUpdate) => void;
|
|
onRefresh: () => void;
|
|
saving: boolean;
|
|
refreshing: boolean;
|
|
}) {
|
|
const [capex, setCapex] = useState<Record<string, CapexState>>(() => ({ ...data.capex }));
|
|
const [reaction, setReaction] = useState<GoodNewsReaction>(data.good_news_stock_down);
|
|
const knownCapex = Object.values(capex).filter((state) => state !== 'unknown');
|
|
// Mirrors _CAPEX_STATE_SCORES: raising 0, holding 50, cutting 100. Holding is
|
|
// the deceleration case and used to score identically to raising.
|
|
const capexPoints = knownCapex.reduce((sum, state) => sum + (state === 'cutting' ? 100 : state === 'holding' ? 50 : 0), 0);
|
|
const derivedF1 = knownCapex.length >= 3 ? Math.round((capexPoints / knownCapex.length) * 10) / 10 : null;
|
|
const derivedF3 = reaction === 'yes' ? 100 : reaction === 'no' ? 0 : null;
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-500">
|
|
<span>Source: {data.source}</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>}
|
|
<div>
|
|
<div className="mb-2 flex items-center justify-between gap-3 text-xs">
|
|
<span className="font-medium text-gray-300">F1 · Capex guidance by hyperscaler</span>
|
|
<span className="num text-gray-500">score {derivedF1 ?? 'n/a'}</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{Object.entries(capex).map(([symbol, state]) => (
|
|
<label key={symbol} className="flex items-center justify-between gap-2 rounded-md bg-white/[0.02] px-2.5 py-2 text-xs text-gray-400">
|
|
<span className="font-mono text-gray-300">{symbol}</span>
|
|
<select
|
|
className={SELECT_CLASS}
|
|
value={state}
|
|
onChange={(event) => setCapex((current) => ({ ...current, [symbol]: event.target.value as CapexState }))}
|
|
>
|
|
{CAPEX_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
|
</select>
|
|
</label>
|
|
))}
|
|
</div>
|
|
<p className="mt-1.5 text-[11px] text-gray-600">Raising = 0, holding = 50, cutting = 100; at least three known names required. Display only — this does not enter Warning.</p>
|
|
</div>
|
|
<label className="flex items-center justify-between gap-3 text-xs text-gray-400">
|
|
<span>
|
|
<span className="font-medium text-gray-300">F3 · Good news, stock down</span>
|
|
<span className="ml-2 num text-gray-600">score {derivedF3 ?? 'n/a'}</span>
|
|
</span>
|
|
<select className={SELECT_CLASS} value={reaction} onChange={(event) => setReaction(event.target.value as GoodNewsReaction)}>
|
|
<option value="yes">Yes · stress</option>
|
|
<option value="no">No · ordinary</option>
|
|
<option value="mixed">Mixed · unavailable</option>
|
|
</select>
|
|
</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({ capex, good_news_stock_down: reaction, locked: true })}>Save observations</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 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">
|
|
<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>
|
|
<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 basket & freshness</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AdminControls() {
|
|
const queryClient = useQueryClient();
|
|
const fundamentals = useQuery({ queryKey: ['regime', 'fundamentals'], queryFn: getRegimeFundamentals });
|
|
const config = useQuery({ queryKey: ['regime', 'config'], queryFn: getRegimeConfig });
|
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['regime'] });
|
|
const refresh = useMutation({ mutationFn: refreshRegimeFundamentals, onSuccess: invalidate });
|
|
const saveFundamentals = useMutation({ mutationFn: updateRegimeFundamentals, onSuccess: invalidate });
|
|
const saveConfig = useMutation({ mutationFn: updateRegimeConfig, onSuccess: invalidate });
|
|
return (
|
|
<Disclosure summary="Admin · Monitor settings">
|
|
<div className="grid gap-5 xl:grid-cols-2 xl:gap-6">
|
|
<section className="border-b border-white/[0.06] pb-5 xl:border-b-0 xl:border-r xl:pb-0 xl:pr-6">
|
|
<div className="mb-3 text-[11px] uppercase tracking-wider text-gray-500">Fundamental observations</div>
|
|
{fundamentals.isLoading && <SkeletonCard className="h-36" />}
|
|
{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>}
|
|
</section>
|
|
<section>
|
|
<div className="mb-3 text-[11px] uppercase tracking-wider text-gray-500">Fixed basket & freshness</div>
|
|
{config.isLoading && <SkeletonCard className="h-36" />}
|
|
{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>}
|
|
</section>
|
|
</div>
|
|
</Disclosure>
|
|
);
|
|
}
|
|
|
|
export default function RegimePage() {
|
|
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 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>}
|
|
{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="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="Warning · deterioration & divergence"
|
|
reading={data.warning}
|
|
divider={data.quadrant_config?.warning_divider}
|
|
footnote={<>Breadth divergence, SMH/SPY rollover, and HY credit impulse. Breadth loss counts fully when price masks it and partially when price confirms it. Missing sensors reduce coverage; they never default to 50.</>}
|
|
/>
|
|
</div>
|
|
{data.fundamental_context && <FundamentalOverlayCard overlay={data.fundamental_context} />}
|
|
<p className="text-xs text-gray-600">
|
|
Data quality · oldest market input:{' '}
|
|
{data.data_quality?.oldest_market_input_age_days == null
|
|
? 'unavailable'
|
|
: `${data.data_quality.oldest_market_input_age_days}d`}
|
|
</p>
|
|
|
|
<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>
|
|
);
|
|
}
|