The Warning study measured a fitted percentile crossing that nothing consumes. What reaches Telegram is a quadrant change: fixed 50/40 dividers, hysteresis, two-session confirmation, 3-day cooldown. Those thresholds are constants, not fits, so there is no training set to protect and all 11 detected corrections are evaluable instead of the 4 that fell in a holdout. Replaying it: 1/10 corrections, 0.9 false alarms/year. Random alarms at the same firing rate match or beat that in 65% of draws. The panel now carries ablations (does the quadrant machinery earn its place?), external baselines (does the score earn its complexity?), and that null, because a bare "2 of 4" was unreadable in either direction. Nothing in the alert path was retuned on the strength of it. Fundamentals become a third channel rather than a term in either score. v3 cut them arguing 12+8 of 100 points "could not change any published conclusion" -- true only when every technical sensor reads zero; weighted they moved the bar for the 40 divider from 40 to 25. But no fusion weight is measurable either: with ~10 events and no fundamental history, any weight is a policy preference presented as a measurement. So the read is a categorical state (supportive/neutral/adverse/ unknown) with an evidence grade, derived by fixed rules from stored facts, read by confluence. The LLM extracts and explains; it does not score. Absence stays absence throughout. `unknown` is unreachable by averaging, a stale or empty observation may display but never confirm, extraction failures map to `unknown` rather than `mixed`, and the study rows are coverage-matched and marked not-measurable until enough corrections are covered -- otherwise a fortnight of observations renders as 0/10 and reads as a failed test. Observations become a real time series (migration 033); they lived in a single overwritten settings slot, so no history existed to replay. Pre-rename snapshots are adapted rather than discarded. METHODOLOGY stays v4 -- no score changed -- so no reseed; STUDY_SCHEMA moves to 3 and discards the cached report. Post-deploy: re-run Event Study from Admin -> Jobs. The panel reads "not run yet" until then. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
894 lines
43 KiB
TypeScript
894 lines
43 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 { FUNDAMENTAL_VISUAL } from '../lib/regime';
|
|
import {
|
|
getEventStudy,
|
|
getRegimeConfig,
|
|
getRegimeFundamentals,
|
|
getRegimeMonitor,
|
|
refreshRegimeFundamentals,
|
|
updateRegimeConfig,
|
|
updateRegimeFundamentals,
|
|
} from '../api/regime';
|
|
import type {
|
|
CapexState,
|
|
FundamentalState,
|
|
EventStudyMetrics,
|
|
EventStudyReport,
|
|
GoodNewsReaction,
|
|
RegimeBand,
|
|
RegimeConfig,
|
|
RegimeFundamentalContext,
|
|
RegimeFundamentals,
|
|
RegimeFundamentalsUpdate,
|
|
RegimeMonitor,
|
|
RegimeReading,
|
|
} from '../lib/types';
|
|
|
|
const RegimeChart = lazy(() => import('../components/regime/RegimeChart'));
|
|
|
|
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-400">{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,
|
|
footnote,
|
|
}: {
|
|
label: string;
|
|
reading: RegimeReading | undefined;
|
|
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;
|
|
// No fallback ticks: the two axes have different thresholds, so guessing a
|
|
// shared set would mislabel one of them. Render none rather than wrong ones.
|
|
const ticks = bands ? [bands.watch, bands.elevated, bands.breaking] : [];
|
|
return (
|
|
<div className={`glass h-full border p-5 ${style?.ring ?? 'border-white/[0.06]'}`}>
|
|
<div className="flex flex-wrap items-end justify-between gap-3">
|
|
<div>
|
|
<div className="text-xs uppercase tracking-wider text-gray-400">{label}</div>
|
|
<div className="mt-1 flex items-baseline gap-2">
|
|
<span className={`font-display text-5xl font-bold ${style?.text ?? 'text-gray-500'}`}>
|
|
{score == null ? '—' : Math.round(score)}
|
|
</span>
|
|
{score != null && <span className="text-sm text-gray-400">/ 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-400">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 && (
|
|
<>
|
|
{/* The quadrant divider is each axis's watch/elevated boundary, so it
|
|
is already the middle tick below — drawing it again was two marks
|
|
for one 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">
|
|
<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-xs uppercase tracking-wider text-gray-400">
|
|
<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-400">{footnote}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Mirrors `_capex_signal`: holding is the neutral case, so it takes the neutral
|
|
* colour rather than an amber that reads as a third severity and sits close to
|
|
* the Warning channel's orange. */
|
|
const CAPEX_COLOR: Record<CapexState, string> = {
|
|
raising: FUNDAMENTAL_VISUAL.supportive.color,
|
|
holding: FUNDAMENTAL_VISUAL.neutral.color,
|
|
cutting: FUNDAMENTAL_VISUAL.adverse.color,
|
|
unknown: FUNDAMENTAL_VISUAL.unknown.color,
|
|
};
|
|
|
|
function sentenceCase(value: string): string {
|
|
const text = value.replace(/_/g, ' ');
|
|
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
}
|
|
|
|
function reactionReading(reaction: GoodNewsReaction | null): { label: string; color: string } {
|
|
switch (reaction) {
|
|
case 'yes':
|
|
return { label: 'Yes · good news sold', color: FUNDAMENTAL_VISUAL.adverse.color };
|
|
case 'no':
|
|
return { label: 'No · ordinary reactions', color: FUNDAMENTAL_VISUAL.supportive.color };
|
|
case 'mixed':
|
|
return { label: 'Mixed · no clear pattern', color: FUNDAMENTAL_VISUAL.neutral.color };
|
|
default:
|
|
return { label: 'Unknown · not observed', color: FUNDAMENTAL_VISUAL.unknown.color };
|
|
}
|
|
}
|
|
|
|
function FundamentalSummaryCard({ overlay }: { overlay: RegimeFundamentalContext }) {
|
|
const tone = FUNDAMENTAL_VISUAL[overlay.state] ?? FUNDAMENTAL_VISUAL.unknown;
|
|
const observed = overlay.observed ?? Boolean(overlay.fetched_at);
|
|
const status = !observed
|
|
? 'No usable observation. This channel remains Unknown.'
|
|
: overlay.pending
|
|
? `Collected now; enters the point-in-time record ${overlay.effective_date ?? 'next session'}.`
|
|
: overlay.stale
|
|
? 'The last state is retained for context, but stale evidence cannot confirm alerts.'
|
|
: !overlay.usable
|
|
? 'An observation was collected, but no signal could be determined.'
|
|
: null;
|
|
|
|
return (
|
|
<div className="glass h-full border p-5" style={{ borderColor: `${tone.color}33` }}>
|
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
<div className="text-[11px] uppercase tracking-wider text-gray-400">Fundamentals · context</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{overlay.pending && <Badge label="pending" variant="manual" />}
|
|
{overlay.stale && <Badge label="stale" variant="manual" />}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-2 flex flex-wrap items-baseline gap-3">
|
|
<span className="font-display text-4xl font-bold" style={{ color: tone.color }}>{tone.label}</span>
|
|
<span className="rounded-lg bg-white/[0.04] px-2.5 py-1 text-xs text-gray-300">
|
|
{sentenceCase(overlay.evidence_quality)} evidence
|
|
</span>
|
|
</div>
|
|
|
|
<div className="mt-4 grid grid-cols-2 gap-2 text-xs">
|
|
<div className="rounded-lg bg-white/[0.025] px-3 py-2">
|
|
<div className="text-gray-400">Capex</div>
|
|
<div className="mt-0.5 font-medium" style={{ color: FUNDAMENTAL_VISUAL[overlay.capex_signal].color }}>
|
|
{sentenceCase(overlay.capex_signal)}
|
|
</div>
|
|
</div>
|
|
<div className="rounded-lg bg-white/[0.025] px-3 py-2">
|
|
<div className="text-gray-400">Reaction</div>
|
|
<div className="mt-0.5 font-medium" style={{ color: FUNDAMENTAL_VISUAL[overlay.reaction_signal].color }}>
|
|
{sentenceCase(overlay.reaction_signal)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{status && <p className="mt-3 text-xs leading-relaxed text-gray-400">{status}</p>}
|
|
{(overlay.source || overlay.effective_date) && (
|
|
<p className="mt-3 text-[11px] text-gray-400">
|
|
{overlay.source ?? 'stored observation'}
|
|
{overlay.effective_date && ` · effective ${overlay.effective_date}`}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FundamentalEvidence({ overlay }: { overlay: RegimeFundamentalContext }) {
|
|
const observed = overlay.observed ?? Boolean(overlay.fetched_at);
|
|
if (!observed) return null;
|
|
const capex = overlay.capex ?? {};
|
|
const reaction = reactionReading(overlay.good_news_stock_down);
|
|
|
|
return (
|
|
<Disclosure summary="Fundamental evidence · capex and earnings reaction">
|
|
<div className="grid gap-5 pt-1 sm:grid-cols-2">
|
|
<div>
|
|
<div className="mb-2 text-xs font-medium text-gray-200">Hyperscaler capex guidance</div>
|
|
{Object.keys(capex).length === 0 ? (
|
|
<p className="text-xs text-gray-400">No company-level observation.</p>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
{Object.entries(capex).map(([symbol, state]) => (
|
|
<div key={symbol} className="flex items-center justify-between text-xs">
|
|
<span className="font-mono text-gray-300">{symbol}</span>
|
|
<span className="font-medium" style={{ color: CAPEX_COLOR[state] }}>{sentenceCase(state)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<div className="mb-2 text-xs font-medium text-gray-200">Good news, stock down</div>
|
|
<div className="text-sm font-medium" style={{ color: reaction.color }}>{reaction.label}</div>
|
|
<p className="mt-2 text-xs text-gray-400">
|
|
Derived context: capex {overlay.capex_signal} · reaction {overlay.reaction_signal}.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
{overlay.reasoning && (
|
|
<details className="mt-4 border-t border-white/[0.06] pt-3">
|
|
<summary className="cursor-pointer text-xs font-medium text-gray-400 hover:text-gray-200">
|
|
Source reasoning
|
|
</summary>
|
|
<p className="mt-2 text-xs leading-relaxed text-gray-300">{overlay.reasoning}</p>
|
|
</details>
|
|
)}
|
|
</Disclosure>
|
|
);
|
|
}
|
|
|
|
function ConfluenceStrip({ warning, context }: { warning: RegimeReading; context?: RegimeFundamentalContext }) {
|
|
const warningElevated = warning.band === 'elevated' || warning.band === 'breaking';
|
|
if (!warningElevated || !context?.usable || context.state !== 'adverse') return null;
|
|
|
|
return (
|
|
<div className="glass-sm relative overflow-hidden px-4 py-3" role="status">
|
|
<span className="absolute inset-y-0 left-0 w-1 bg-gradient-to-b from-orange-400 to-red-400" aria-hidden="true" />
|
|
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1 pl-1">
|
|
<span className="text-xs font-semibold uppercase tracking-wider text-orange-300">Confluence active</span>
|
|
<span className="text-sm text-gray-200">
|
|
Warning is {warning.band}; point-in-time fundamentals are adverse with {context.evidence_quality} evidence.
|
|
</span>
|
|
<span className="text-xs text-gray-400">Condition only · never a combined score</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** One table for both axes — they share a shape, and two panels invited
|
|
* comparing numbers that are not on the same scale. */
|
|
function PillarTable({ state, warning }: { state: RegimeReading; warning: RegimeReading }) {
|
|
const groups: { title: string; reading: RegimeReading }[] = [
|
|
{ title: 'State', reading: state },
|
|
{ title: 'Warning', reading: warning },
|
|
];
|
|
return (
|
|
<Disclosure summary="Pillars & sensors · what drives each score">
|
|
<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-400">
|
|
<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>
|
|
{groups.map(({ title, reading }) => (
|
|
<tbody key={title}>
|
|
<tr className="border-b border-white/[0.06] bg-white/[0.02]">
|
|
<td colSpan={4} className="px-4 py-2 text-[11px] uppercase tracking-wider text-gray-400">
|
|
{title}
|
|
<span className="ml-2 normal-case tracking-normal text-gray-400">
|
|
{reading.score ?? '—'} · {Math.round(reading.coverage)}% coverage
|
|
</span>
|
|
</td>
|
|
</tr>
|
|
{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-400">
|
|
<span className="font-mono text-gray-400">{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 MetaChip({ label, value, title }: { label: string; value: ReactNode; title?: string }) {
|
|
return (
|
|
<span className="rounded-lg bg-white/[0.03] px-2.5 py-1 text-xs text-gray-400" title={title}>
|
|
{label} <span className="num text-gray-400">{value}</span>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/** Provenance strip — replaces three separate prose blocks. */
|
|
function MetaStrip({ data }: { data: RegimeMonitor }) {
|
|
const quality = data.data_quality;
|
|
const basket = data.basket;
|
|
const days = (value: number | null | undefined) => (value == null ? '—' : `${value}d`);
|
|
return (
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<MetaChip label="as of" value={data.date ?? '—'} />
|
|
<MetaChip label="oldest input" value={days(quality?.oldest_market_input_age_days)} />
|
|
{basket && (
|
|
<MetaChip
|
|
label="basket"
|
|
value={`${basket.members_available ?? '—'}/${basket.members_expected} · frozen ${basket.basket_asof}`}
|
|
title={`hash ${basket.hash}`}
|
|
/>
|
|
)}
|
|
<MetaChip
|
|
label="credit history"
|
|
value={days(quality?.credit_history_days)}
|
|
title="Upstream span actually available. ICE caps the HY OAS series at 3 rolling years."
|
|
/>
|
|
<MetaChip label="VIX history" value={days(quality?.vix_history_days)} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatTiles({ metrics }: { metrics: EventStudyMetrics }) {
|
|
return (
|
|
<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-xs text-gray-400">{label}</div>
|
|
<div className="mt-0.5 text-lg font-semibold text-gray-200">{value}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function EventTable({ events }: { events: { date: string; warned: boolean; lead_days: number | null }[] }) {
|
|
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 text-gray-400">
|
|
<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>{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-400'}`}>{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>
|
|
);
|
|
}
|
|
|
|
/** Shipped rule against ablations, external baselines, and chance.
|
|
*
|
|
* The two kinds answer different questions and must not be read as one list:
|
|
* an ablation asks whether the quadrant machinery earns its place, a baseline
|
|
* asks whether the score earns its complexity.
|
|
*/
|
|
function ComparisonTable({ report }: { report: EventStudyReport }) {
|
|
const shipped = report.shipped;
|
|
if (!shipped || !report.comparison?.length) return null;
|
|
const rows = [
|
|
{
|
|
id: 'shipped',
|
|
label: 'Quadrant alert (shipped)',
|
|
kind: 'shipped' as const,
|
|
note: shipped.rule.entry,
|
|
measurable: true,
|
|
...shipped.metrics,
|
|
},
|
|
...report.comparison,
|
|
];
|
|
const KIND_LABEL: Record<string, string> = {
|
|
shipped: 'shipped',
|
|
ablation: 'ablation',
|
|
baseline: 'baseline',
|
|
fundamental: 'fundamental',
|
|
};
|
|
return (
|
|
<div className="space-y-2">
|
|
<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-400">
|
|
<th className="px-3 py-2 font-medium">Rule</th>
|
|
<th className="px-3 py-2 text-right font-medium">Warned</th>
|
|
<th className="px-3 py-2 text-right font-medium">FA/yr</th>
|
|
<th className="px-3 py-2 text-right font-medium">Median lead</th>
|
|
</tr></thead>
|
|
<tbody>{rows.map((row) => (
|
|
<tr
|
|
key={row.id}
|
|
className={`border-b border-white/[0.03] last:border-0 ${row.kind === 'shipped' ? 'bg-white/[0.03]' : ''}`}
|
|
title={row.note}
|
|
>
|
|
<td className={`px-3 py-2 ${row.kind === 'shipped' ? 'font-medium text-gray-200' : 'text-gray-400'}`}>
|
|
{row.label}
|
|
<span className="ml-2 text-xs uppercase tracking-wide text-gray-400">{KIND_LABEL[row.kind]}</span>
|
|
</td>
|
|
{/* A rule whose input does not exist yet scores 0/N, and printing
|
|
that would read as tested-and-failed. Say "not measurable". */}
|
|
{row.measurable === false ? (
|
|
<td className="px-3 py-2 text-right text-xs italic text-gray-400" colSpan={3}>
|
|
{/* Not "no observations yet": once some exist but fewer than
|
|
the minimum are covered, that is simply false. Matches the
|
|
callout below. */}
|
|
insufficient exposure — not measurable
|
|
</td>
|
|
) : (
|
|
<>
|
|
<td className="px-3 py-2 text-right num text-gray-300">{row.events_warned}/{row.events}</td>
|
|
<td className="px-3 py-2 text-right num text-gray-300">{row.false_alarms_per_year?.toFixed(1) ?? '—'}</td>
|
|
<td className="px-3 py-2 text-right num text-gray-300">{row.median_lead_days == null ? '—' : `${row.median_lead_days}d`}</td>
|
|
</>
|
|
)}
|
|
</tr>
|
|
))}
|
|
{report.null_model && (
|
|
<tr className="border-t border-white/[0.06] text-gray-400">
|
|
<td className="px-3 py-2">
|
|
Random alarms, same firing rate
|
|
<span className="ml-2 text-xs uppercase tracking-wide text-gray-400">null</span>
|
|
</td>
|
|
<td className="px-3 py-2 text-right num">
|
|
{report.null_model.mean_warned.toFixed(1)} ± {report.null_model.sd_warned.toFixed(1)}
|
|
</td>
|
|
<td className="px-3 py-2 text-right num">—</td>
|
|
<td className="px-3 py-2 text-right num">—</td>
|
|
</tr>
|
|
)}</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StudyVerdict({ report }: { report: EventStudyReport }) {
|
|
const model = report.null_model;
|
|
if (!model) return null;
|
|
const chancePct = (model.p_at_least_observed * 100).toFixed(0);
|
|
const indistinguishable = model.p_at_least_observed >= 0.1;
|
|
// The number carries the claim, not the adjective. At ~10 corrections a p of
|
|
// 0.09 is not evidence of anything, so "beats the null" would over-state a
|
|
// result this panel is otherwise careful never to over-state.
|
|
return (
|
|
<Callout variant={indistinguishable ? 'warning' : 'info'}>
|
|
<strong>
|
|
{indistinguishable
|
|
? `Not distinguishable from chance (p = ${model.p_at_least_observed.toFixed(2)}).`
|
|
: `Above the firing-rate null (p = ${model.p_at_least_observed.toFixed(2)}).`}
|
|
</strong>{' '}
|
|
Random alarms match or beat {model.observed_warned}/{model.events} warned corrections in {chancePct}% of{' '}
|
|
{model.draws} draws placing {model.alarms_per_draw} alarms over the same sessions. Corrections cluster and random
|
|
placement does not, so this is the floor, not the bar.
|
|
</Callout>
|
|
);
|
|
}
|
|
|
|
/** The credit sensor starts partway through, so Warning is a different
|
|
* construct either side of it. The share is derived, never asserted: if one era
|
|
* carries no corrections there is no comparison to draw and the per-era ratios
|
|
* would be noise dressed up as a finding. */
|
|
function EraDisclosure({
|
|
eras,
|
|
divider,
|
|
}: {
|
|
eras: NonNullable<NonNullable<EventStudyReport['shipped']>['by_era']>;
|
|
divider: number | undefined;
|
|
}) {
|
|
const { pre_credit: pre, full_coverage: full } = eras;
|
|
const total = pre.sessions + full.sessions;
|
|
const share = total > 0 ? Math.round((pre.sessions / total) * 100) : 0;
|
|
// An era holding one or two corrections has a recall of 0/1 or 1/2, which is
|
|
// not a rate. Below this the eras get their false-alarm rates compared and
|
|
// nothing else.
|
|
const comparable = pre.events >= 3 && full.events >= 3;
|
|
return (
|
|
<Disclosure summary={`Sensor-era caveat · ${share}% of sessions predate credit`}>
|
|
<p className="text-xs leading-relaxed text-gray-400">
|
|
<strong>{share}% of the evaluated sessions predate the credit sensor.</strong> W3 begins {eras.credit_from}, so
|
|
before that Warning renormalises to W1+W2 and the fixed {divider} divider is applied to a different construct
|
|
than it was reasoned about. Dropping the training split makes every correction evaluable; it does not make the
|
|
coverage gap go away, it moves it from the threshold to the score.
|
|
{comparable ? (
|
|
<>
|
|
{' '}Two sensors:{' '}
|
|
<strong className="text-gray-300">{pre.events_warned}/{pre.events}</strong> at{' '}
|
|
{pre.false_alarms_per_year?.toFixed(1) ?? '—'} FA/yr. All three:{' '}
|
|
<strong className="text-gray-300">{full.events_warned}/{full.events}</strong> at{' '}
|
|
{full.false_alarms_per_year?.toFixed(1) ?? '—'} FA/yr.
|
|
</>
|
|
) : (
|
|
<>
|
|
{' '}The corrections do not straddle that boundary ({pre.events} before, {full.events} after), so the two
|
|
eras cannot be compared on recall — only the false-alarm rates are meaningful ({pre.false_alarms_per_year?.toFixed(1) ?? '—'}{' '}
|
|
vs {full.false_alarms_per_year?.toFixed(1) ?? '—'} per year).
|
|
</>
|
|
)}
|
|
</p>
|
|
</Disclosure>
|
|
);
|
|
}
|
|
|
|
function EventStudyBody({ report }: { report: EventStudyReport }) {
|
|
const shipped = report.shipped;
|
|
const eras = shipped?.by_era;
|
|
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-400">generated {new Date(report.generated_at).toLocaleDateString()}</span>}
|
|
{report.sample && <span className="text-xs text-gray-400">{report.sample.evaluable_from} → {report.sample.end}</span>}
|
|
</div>
|
|
<StudyVerdict report={report} />
|
|
<p className="text-sm leading-relaxed text-gray-300">{report.summary}</p>
|
|
{shipped && <StatTiles metrics={shipped.metrics} />}
|
|
<ComparisonTable report={report} />
|
|
|
|
{shipped && shipped.events.length > 0 && (
|
|
<Disclosure summary={`Correction details · ${shipped.events.length} events`}>
|
|
<EventTable events={shipped.events} />
|
|
</Disclosure>
|
|
)}
|
|
|
|
{report.null_model && (
|
|
<Disclosure summary="Null-model interpretation">
|
|
<p className="text-xs leading-relaxed text-gray-400">
|
|
The null places {report.null_model.alarms_per_draw} alarms at random over the same sessions and at the
|
|
shipped rule's firing rate. Corrections cluster while random placement does not, so this is a floor rather
|
|
than a demanding benchmark: a clustering rule could beat it without genuine foresight.
|
|
</p>
|
|
</Disclosure>
|
|
)}
|
|
|
|
{report.fundamental_coverage && !report.fundamental_coverage.measurable && (
|
|
<Disclosure
|
|
summary={`Fundamental exposure · ${report.fundamental_coverage.events_covered}/${report.fundamental_coverage.events_evaluable} corrections covered`}
|
|
>
|
|
<p className="text-xs leading-relaxed text-gray-400">
|
|
<strong>Insufficient exposure — the fundamental rows are untested, not failed.</strong>{' '}
|
|
The channel had usable context on{' '}
|
|
<strong className="text-gray-300">
|
|
{report.fundamental_coverage.sessions_eligible} of{' '}
|
|
{report.fundamental_coverage.evaluable_sessions}
|
|
</strong>{' '}
|
|
evaluated sessions, covering{' '}
|
|
<strong className="text-gray-300">
|
|
{report.fundamental_coverage.events_covered} of{' '}
|
|
{report.fundamental_coverage.events_evaluable}
|
|
</strong>{' '}
|
|
corrections ({report.fundamental_coverage.minimum_events} needed;{' '}
|
|
{report.fundamental_coverage.observations} observation
|
|
{report.fundamental_coverage.observations === 1 ? '' : 's'} recorded). Those rows are
|
|
scored only on that window, never on the market rows' full sample — otherwise a
|
|
fortnight of data would render as a 0/10 and read as a failed test. Read the market rows
|
|
as a verdict on the technical sensors and the alert machinery only.
|
|
</p>
|
|
</Disclosure>
|
|
)}
|
|
|
|
{eras && <EraDisclosure eras={eras} divider={shipped?.rule.warning_divider} />}
|
|
|
|
{report.fitted && (
|
|
<Disclosure summary={`Fitted-threshold variant · ${report.fitted.metrics.events_warned}/${report.fitted.metrics.events} on the 30% holdout`}>
|
|
<div className="space-y-3 pt-1">
|
|
<p className="text-xs leading-relaxed text-gray-400">
|
|
The original study, kept because it is what the methodology document reports: an{' '}
|
|
{report.fitted.params.warn_percentile}th-percentile Warning threshold (
|
|
{report.fitted.params.warn_threshold}) frozen on the first{' '}
|
|
{(report.fitted.params.train_fraction * 100).toFixed(0)}% of sessions and measured on the rest. Nothing
|
|
consumes this rule — the shipped alert uses fixed dividers with hysteresis, confirmation and a cooldown.
|
|
</p>
|
|
<StatTiles metrics={report.fitted.metrics} />
|
|
{report.fitted.events.length > 0 && <EventTable events={report.fitted.events} />}
|
|
{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 holdout (
|
|
{report.reliability.minimum_events}+ needed). 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 threshold was frozen on a partly different construct than it is measured against.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</Callout>
|
|
)}
|
|
</div>
|
|
</Disclosure>
|
|
)}
|
|
</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 values = Object.values(capex);
|
|
const counts = {
|
|
cutting: values.filter((s) => s === 'cutting').length,
|
|
holding: values.filter((s) => s === 'holding').length,
|
|
raising: values.filter((s) => s === 'raising').length,
|
|
unknown: values.filter((s) => s === 'unknown').length,
|
|
};
|
|
// Mirrors _capex_signal: any cut is adverse on partial evidence, any hold is
|
|
// neutral, all-known-raising is supportive, nothing known is unknown. No
|
|
// average — an average would let cuts and unknowns land on "neutral".
|
|
const capexSignal: FundamentalState =
|
|
counts.cutting > 0 ? 'adverse'
|
|
: counts.holding > 0 ? 'neutral'
|
|
: counts.raising > 0 ? 'supportive'
|
|
: 'unknown';
|
|
const reactionSignal: FundamentalState =
|
|
reaction === 'yes' ? 'adverse' : reaction === 'no' ? 'supportive' : reaction === 'mixed' ? 'neutral' : 'unknown';
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-wrap items-center gap-2 text-xs text-gray-400">
|
|
<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">Capex guidance by hyperscaler</span>
|
|
<span style={{ color: FUNDAMENTAL_VISUAL[capexSignal].color }}>{capexSignal}</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-xs text-gray-400">
|
|
{counts.cutting} cutting · {counts.holding} holding · {counts.raising} raising · {counts.unknown} unknown.
|
|
Any cut reads adverse on partial evidence; supportive needs every known name raising.
|
|
</p>
|
|
</div>
|
|
<label className="flex items-center justify-between gap-3 text-xs text-gray-400">
|
|
<span>
|
|
<span className="font-medium text-gray-300">Good news, stock down</span>
|
|
<span className="ml-2" style={{ color: FUNDAMENTAL_VISUAL[reactionSignal].color }}>{reactionSignal}</span>
|
|
</span>
|
|
{/* "Mixed" is an observed mixed reaction; "unknown" is nobody looked or
|
|
the extraction failed. Collapsing them made a parse error read as
|
|
neutral evidence. */}
|
|
<select className={SELECT_CLASS} value={reaction} onChange={(event) => setReaction(event.target.value as GoodNewsReaction)}>
|
|
<option value="yes">Yes · good news sold</option>
|
|
<option value="no">No · reacting normally</option>
|
|
<option value="mixed">Mixed · observed, no clear pattern</option>
|
|
<option value="unknown">Unknown · not observed</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-xs text-gray-400">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-xs uppercase tracking-wider text-gray-400">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-xs uppercase tracking-wider text-gray-400">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;
|
|
const inputs = data?.inputs;
|
|
return (
|
|
<div className="space-y-6 animate-slide-up">
|
|
<PageHeader
|
|
title="AI/Tech Risk Monitor"
|
|
subtitle="Market stress, early warning, and fundamental context"
|
|
actions={
|
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
|
<Badge label="observational only" variant="default" />
|
|
{data?.date && <span className="num text-xs text-gray-400">as of {data.date}</span>}
|
|
{data?.available && (
|
|
<Badge
|
|
label={data.data_quality?.is_fresh ? 'fresh' : 'check data'}
|
|
variant={data.data_quality?.is_fresh ? 'auto' : 'manual'}
|
|
/>
|
|
)}
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
{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">Not computed yet — run “AI/Tech Risk 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-3">
|
|
<ScoreGauge
|
|
label="State · stress right now"
|
|
reading={data.state}
|
|
footnote={
|
|
<>
|
|
Price, breadth, credit and volatility levels · VIX{' '}
|
|
<span className="num text-gray-400">{inputs?.vix ?? '—'}</span> · HY OAS{' '}
|
|
<span className="num text-gray-400">{inputs?.hy_oas ?? '—'}</span> · breadth{' '}
|
|
<span className="num text-gray-400">
|
|
{inputs?.breadth_pct_above_200 == null ? '—' : `${inputs.breadth_pct_above_200}%`}
|
|
</span>
|
|
</>
|
|
}
|
|
/>
|
|
<ScoreGauge
|
|
label="Warning · deterioration & divergence"
|
|
reading={data.warning}
|
|
footnote={
|
|
<>
|
|
Breadth divergence · SMH/SPY rollover · HY credit impulse.
|
|
{data.warning.coverage < 100 && ' Missing sensors are omitted rather than filled.'}
|
|
</>
|
|
}
|
|
/>
|
|
{data.fundamental_live && <FundamentalSummaryCard overlay={data.fundamental_live} />}
|
|
</div>
|
|
|
|
<ConfluenceStrip warning={data.warning} context={data.fundamental_context} />
|
|
|
|
<Suspense fallback={<SkeletonCard className="h-80" />}><RegimeChart /></Suspense>
|
|
|
|
{data.fundamental_live && <FundamentalEvidence overlay={data.fundamental_live} />}
|
|
|
|
<PillarTable state={data.state} warning={data.warning} />
|
|
|
|
<Disclosure summary="Data provenance · coverage and history">
|
|
<MetaStrip data={data} />
|
|
</Disclosure>
|
|
</>
|
|
)}
|
|
|
|
<EventStudyPanel />
|
|
{isAdmin && <AdminControls />}
|
|
</div>
|
|
);
|
|
}
|