feat(risk-monitor): measure the rule that fires, and give fundamentals their own channel
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>
This commit is contained in:
@@ -2,7 +2,6 @@ import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Line,
|
||||
LineChart,
|
||||
ReferenceArea,
|
||||
@@ -19,6 +18,8 @@ import { getRegimeHistory, getRegimeMonitor } from '../../api/regime';
|
||||
import { Callout } from '../ui/Callout';
|
||||
import { SkeletonCard } from '../ui/Skeleton';
|
||||
import { formatDate } from '../../lib/format';
|
||||
import { FUNDAMENTAL_VISUAL, QUADRANT_WASH, REGIME_VISUAL } from '../../lib/regime';
|
||||
import type { EvidenceQuality, FundamentalState } from '../../lib/types';
|
||||
|
||||
// Lazy-loaded (see RegimePage) so recharts stays in the regime-tab chunk.
|
||||
// Time and Path are two projections of one series, so they share a card and a
|
||||
@@ -38,8 +39,14 @@ type RangeKey = (typeof RANGES)[number]['key'];
|
||||
/** Sessions drawn in Path view. The full series is unreadable as a path. */
|
||||
const PATH_TRAIL = 60;
|
||||
|
||||
const STATE_COLOR = '#60a5fa';
|
||||
const WARNING_COLOR = '#fb923c';
|
||||
const STATE_COLOR = REGIME_VISUAL.state;
|
||||
const WARNING_COLOR = REGIME_VISUAL.warning;
|
||||
const FUNDAMENTAL_SYMBOL: Record<FundamentalState, string> = {
|
||||
supportive: '▲',
|
||||
neutral: '●',
|
||||
adverse: '◆',
|
||||
unknown: '○',
|
||||
};
|
||||
|
||||
// Fall back to the shipped constants, not v2's shared 60/60, so a missing
|
||||
// quadrant_config cannot draw dividers that disagree with the alert path.
|
||||
@@ -50,13 +57,19 @@ interface PathPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
date: string;
|
||||
/** The third channel as recorded that day. Colours the dot; never moves it. */
|
||||
fundamental: FundamentalState;
|
||||
evidence: EvidenceQuality;
|
||||
/** Raw dated observations are interactive dots; the smoothed copy is line-only. */
|
||||
raw: boolean;
|
||||
recency: number;
|
||||
}
|
||||
|
||||
/** Centered moving average to de-noise the path; today (last) kept exact. */
|
||||
function smoothTrail(points: PathPoint[], half = 2): PathPoint[] {
|
||||
const n = points.length;
|
||||
return points.map((p, i) => {
|
||||
if (i === n - 1) return { ...p };
|
||||
if (i === n - 1) return { ...p, raw: false };
|
||||
let sx = 0;
|
||||
let sy = 0;
|
||||
let c = 0;
|
||||
@@ -65,14 +78,58 @@ function smoothTrail(points: PathPoint[], half = 2): PathPoint[] {
|
||||
sy += points[j].y;
|
||||
c += 1;
|
||||
}
|
||||
return { x: sx / c, y: sy / c, date: p.date };
|
||||
return { ...p, x: sx / c, y: sy / c, raw: false };
|
||||
});
|
||||
}
|
||||
|
||||
/** Recency gradient: 0 = oldest (muted slate), 1 = newest (bright blue). */
|
||||
function recencyColor(t: number): string {
|
||||
const lerp = (a: number, b: number) => Math.round(a + (b - a) * t);
|
||||
return `rgba(${lerp(71, 96)}, ${lerp(85, 165)}, ${lerp(105, 250)}, ${(0.3 + 0.7 * t).toFixed(2)})`;
|
||||
function FundamentalGlyph({
|
||||
cx,
|
||||
cy,
|
||||
state,
|
||||
size,
|
||||
opacity = 1,
|
||||
}: {
|
||||
cx: number;
|
||||
cy: number;
|
||||
state: FundamentalState;
|
||||
size: number;
|
||||
opacity?: number;
|
||||
}) {
|
||||
const visual = FUNDAMENTAL_VISUAL[state] ?? FUNDAMENTAL_VISUAL.unknown;
|
||||
const common = { fill: visual.color, opacity, stroke: '#11131c', strokeWidth: 1 };
|
||||
if (visual.glyph === 'up') {
|
||||
return <polygon points={`${cx},${cy - size} ${cx - size},${cy + size} ${cx + size},${cy + size}`} {...common} />;
|
||||
}
|
||||
if (visual.glyph === 'diamond') {
|
||||
return <polygon points={`${cx},${cy - size} ${cx - size},${cy} ${cx},${cy + size} ${cx + size},${cy}`} {...common} />;
|
||||
}
|
||||
if (visual.glyph === 'ring') {
|
||||
return <circle cx={cx} cy={cy} r={size - 0.5} fill="transparent" opacity={opacity} stroke={visual.color} strokeWidth={1.5} />;
|
||||
}
|
||||
return <circle cx={cx} cy={cy} r={size - 0.5} {...common} />;
|
||||
}
|
||||
|
||||
function PathPointShape({ cx = 0, cy = 0, payload }: { cx?: number; cy?: number; payload?: PathPoint }) {
|
||||
if (!payload) return <g />;
|
||||
return (
|
||||
<FundamentalGlyph
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
state={payload.fundamental}
|
||||
size={3.25 + payload.recency * 1.25}
|
||||
opacity={0.58 + payload.recency * 0.42}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LatestPointShape({ cx = 0, cy = 0, payload }: { cx?: number; cy?: number; payload?: PathPoint }) {
|
||||
if (!payload) return <g />;
|
||||
return (
|
||||
<g>
|
||||
<circle cx={cx} cy={cy} r={7} fill="transparent" stroke="#ffffff" strokeWidth={1.75} />
|
||||
<FundamentalGlyph cx={cx} cy={cy} state={payload.fundamental} size={4.5} />
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
function SegmentedControl<T extends string>({
|
||||
@@ -94,8 +151,8 @@ function SegmentedControl<T extends string>({
|
||||
type="button"
|
||||
aria-pressed={value === option}
|
||||
onClick={() => onChange(option)}
|
||||
className={`rounded px-2 py-1 text-[11px] font-medium tabular-nums transition-colors ${
|
||||
value === option ? 'bg-white/10 text-blue-300' : 'text-gray-500 hover:text-gray-300'
|
||||
className={`min-h-9 rounded px-3 py-2 text-xs font-medium tabular-nums transition-colors ${
|
||||
value === option ? 'bg-white/10 text-blue-300' : 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{option}
|
||||
@@ -107,14 +164,19 @@ function SegmentedControl<T extends string>({
|
||||
|
||||
function PathTip({ active, payload }: { active?: boolean; payload?: { payload: PathPoint }[] }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
const p = payload[0].payload;
|
||||
const p = payload.find((item) => item.payload.raw)?.payload ?? payload[0].payload;
|
||||
const visual = FUNDAMENTAL_VISUAL[p.fundamental] ?? FUNDAMENTAL_VISUAL.unknown;
|
||||
const evidence = p.evidence === 'unavailable' ? 'Unavailable' : `${p.evidence.replace(/_/g, ' ')} evidence`;
|
||||
return (
|
||||
<div className="glass px-2.5 py-1.5 text-[11px]">
|
||||
<div className="glass px-3 py-2 text-xs">
|
||||
<div className="text-gray-300">{formatDate(p.date)}</div>
|
||||
<div className="text-gray-400">
|
||||
State <span style={{ color: STATE_COLOR }}>{Math.round(p.x)}</span> · Warning{' '}
|
||||
<span style={{ color: WARNING_COLOR }}>{Math.round(p.y)}</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-gray-400">
|
||||
Fundamentals <span style={{ color: visual.color }}>{visual.label}</span> · {evidence}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -144,7 +206,15 @@ export default function RegimeChart() {
|
||||
}, [history.data, view, range]);
|
||||
|
||||
const pathPoints = useMemo<PathPoint[]>(
|
||||
() => series.map((p) => ({ x: p.state as number, y: p.warning as number, date: p.date })),
|
||||
() => series.map((p, index, points) => ({
|
||||
x: p.state as number,
|
||||
y: p.warning as number,
|
||||
date: p.date,
|
||||
fundamental: p.fundamental_state ?? 'unknown',
|
||||
evidence: p.evidence_quality ?? 'unavailable',
|
||||
raw: true,
|
||||
recency: points.length <= 1 ? 1 : index / (points.length - 1),
|
||||
})),
|
||||
[series],
|
||||
);
|
||||
const trail = useMemo(() => (view === 'Path' ? smoothTrail(pathPoints) : []), [pathPoints, view]);
|
||||
@@ -159,7 +229,7 @@ export default function RegimeChart() {
|
||||
<div className="glass p-5">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[11px] uppercase tracking-wider text-gray-500">
|
||||
<span className="text-xs uppercase tracking-wider text-gray-400">
|
||||
{view === 'Time' ? 'State & Warning over time' : `State × Warning path · last ${PATH_TRAIL} sessions`}
|
||||
</span>
|
||||
<SegmentedControl options={VIEWS} value={view} onChange={setView} label="Chart view" />
|
||||
@@ -168,7 +238,7 @@ export default function RegimeChart() {
|
||||
<SegmentedControl options={RANGES.map((r) => r.key)} value={range} onChange={setRange} label="Time range" />
|
||||
) : (
|
||||
latest && (
|
||||
<span className="text-[11px] text-gray-500">
|
||||
<span className="text-xs text-gray-400">
|
||||
now: State <span style={{ color: STATE_COLOR }}>{Math.round(latest.x)}</span> · Warning{' '}
|
||||
<span style={{ color: WARNING_COLOR }}>{Math.round(latest.y)}</span>
|
||||
</span>
|
||||
@@ -182,14 +252,18 @@ export default function RegimeChart() {
|
||||
<Callout variant="empty">Not enough coverage-qualified history yet — it accumulates as the daily job runs.</Callout>
|
||||
) : (
|
||||
<>
|
||||
<div className="mt-3 h-72">
|
||||
<div
|
||||
className="mt-3 h-72"
|
||||
role="img"
|
||||
aria-label={view === 'Time' ? 'State and Warning scores over time' : 'State by Warning path with fundamental context symbols'}
|
||||
>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
{view === 'Time' ? (
|
||||
<LineChart data={series} margin={{ top: 6, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid stroke="rgba(255,255,255,0.05)" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
||||
tick={{ fill: '#9aa0b0', fontSize: 10 }}
|
||||
tickFormatter={(d) => formatDate(String(d))}
|
||||
minTickGap={28}
|
||||
tickLine={false}
|
||||
@@ -200,7 +274,7 @@ export default function RegimeChart() {
|
||||
<YAxis
|
||||
domain={[0, 100]}
|
||||
ticks={[0, 25, 50, 75, 100]}
|
||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
||||
tick={{ fill: '#9aa0b0', fontSize: 10 }}
|
||||
width={34}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
@@ -225,10 +299,13 @@ export default function RegimeChart() {
|
||||
</LineChart>
|
||||
) : (
|
||||
<ScatterChart margin={{ top: 10, right: 16, bottom: 22, left: 0 }}>
|
||||
<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" />
|
||||
{/* One neutral at four opacities: denser = more axes elevated.
|
||||
Hue here would collide with the fundamental glyphs drawn
|
||||
on top of it — see QUADRANT_WASH. */}
|
||||
<ReferenceArea x1={0} x2={xDiv} y1={yDiv} y2={100} fill="#ffffff" fillOpacity={QUADRANT_WASH.early_warning} stroke="none" />
|
||||
<ReferenceArea x1={xDiv} x2={100} y1={yDiv} y2={100} fill="#ffffff" fillOpacity={QUADRANT_WASH.active_stress} stroke="none" />
|
||||
<ReferenceArea x1={0} x2={xDiv} y1={0} y2={yDiv} fill="#ffffff" fillOpacity={QUADRANT_WASH.healthy} stroke="none" />
|
||||
<ReferenceArea x1={xDiv} x2={100} y1={0} y2={yDiv} fill="#ffffff" fillOpacity={QUADRANT_WASH.stabilizing} stroke="none" />
|
||||
<CartesianGrid stroke="rgba(255,255,255,0.04)" />
|
||||
<ReferenceLine x={xDiv} stroke="rgba(255,255,255,0.12)" />
|
||||
<ReferenceLine y={yDiv} stroke="rgba(255,255,255,0.12)" />
|
||||
@@ -237,36 +314,37 @@ export default function RegimeChart() {
|
||||
dataKey="x"
|
||||
domain={[0, 100]}
|
||||
ticks={[0, 20, 40, 60, 80, 100]}
|
||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
||||
tick={{ fill: '#9aa0b0', fontSize: 10 }}
|
||||
tickLine={false}
|
||||
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
|
||||
label={{ value: 'State →', position: 'insideBottom', offset: -12, fill: '#6b7280', fontSize: 10 }}
|
||||
label={{ value: 'State →', position: 'insideBottom', offset: -12, fill: '#9aa0b0', fontSize: 10 }}
|
||||
/>
|
||||
<YAxis
|
||||
type="number"
|
||||
dataKey="y"
|
||||
domain={[0, 100]}
|
||||
ticks={[0, 20, 40, 60, 80, 100]}
|
||||
tick={{ fill: '#6b7280', fontSize: 10 }}
|
||||
tick={{ fill: '#9aa0b0', fontSize: 10 }}
|
||||
width={30}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
label={{ value: 'Warning', angle: -90, position: 'insideLeft', fill: '#6b7280', fontSize: 10 }}
|
||||
label={{ value: 'Warning', angle: -90, position: 'insideLeft', fill: '#9aa0b0', fontSize: 10 }}
|
||||
/>
|
||||
<ZAxis range={[13, 13]} />
|
||||
<ZAxis range={[18, 18]} />
|
||||
<Tooltip cursor={{ strokeDasharray: '3 3', stroke: 'rgba(255,255,255,0.2)' }} content={<PathTip />} />
|
||||
<Scatter data={trail} line={{ stroke: 'rgba(96,165,250,0.18)', strokeWidth: 1.5 }} isAnimationActive={false}>
|
||||
{trail.map((_, i) => (
|
||||
<Cell key={i} fill={recencyColor(trail.length <= 1 ? 1 : i / (trail.length - 1))} />
|
||||
))}
|
||||
</Scatter>
|
||||
<Scatter
|
||||
data={trail}
|
||||
line={{ stroke: 'rgba(255,255,255,0.18)', strokeWidth: 1.5 }}
|
||||
shape={(props: { cx?: number; cy?: number }) => <circle cx={props.cx} cy={props.cy} r={0} />}
|
||||
tooltipType="none"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
<Scatter data={pathPoints} shape={<PathPointShape />} isAnimationActive={false} />
|
||||
{latest && (
|
||||
<Scatter
|
||||
data={[latest]}
|
||||
isAnimationActive={false}
|
||||
shape={(props: { cx?: number; cy?: number }) => (
|
||||
<circle cx={props.cx} cy={props.cy} r={6} fill="#ffffff" stroke={STATE_COLOR} strokeWidth={2} />
|
||||
)}
|
||||
shape={<LatestPointShape />}
|
||||
/>
|
||||
)}
|
||||
</ScatterChart>
|
||||
@@ -275,7 +353,7 @@ export default function RegimeChart() {
|
||||
</div>
|
||||
|
||||
{view === 'Time' ? (
|
||||
<div className="mt-2 flex flex-wrap items-center gap-4 text-[11px] text-gray-400">
|
||||
<div className="mt-2 flex flex-wrap items-center gap-4 text-xs text-gray-400">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: STATE_COLOR }} />
|
||||
State
|
||||
@@ -284,20 +362,43 @@ export default function RegimeChart() {
|
||||
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: WARNING_COLOR }} />
|
||||
Warning
|
||||
</span>
|
||||
<span className="text-gray-600">dashed = each axis's elevated threshold ({xDiv} / {yDiv})</span>
|
||||
<span className="text-gray-400">dashed = each axis's elevated threshold ({xDiv} / {yDiv})</span>
|
||||
</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">Early warning</span> — 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, broadly supported</span>
|
||||
<span><span className="text-red-400">Stabilizing</span> — damage remains, warning lower</span>
|
||||
<span className="text-gray-600 sm:col-span-2">White dot = today; trail brightens toward the present, smoothed.</span>
|
||||
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-xs text-gray-400 sm:grid-cols-2">
|
||||
{/* Swatches, not coloured words: the quadrant names used the
|
||||
fundamental channel's colours, so "Stabilizing" was rendered in
|
||||
the adverse hue while meaning damage receding. */}
|
||||
{([
|
||||
['active_stress', 'Active stress', 'damaged and deteriorating'],
|
||||
['early_warning', 'Early warning', 'calm, fragility rising'],
|
||||
['stabilizing', 'Stabilizing', 'damage remains, warning lower'],
|
||||
['healthy', 'Healthy', 'calm, broadly supported'],
|
||||
] as const).map(([key, name, gloss]) => (
|
||||
<span key={key} className="flex items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="inline-block h-3 w-3 shrink-0 rounded-sm border border-white/10"
|
||||
style={{ background: `rgba(255,255,255,${QUADRANT_WASH[key] * 4})` }}
|
||||
/>
|
||||
<span className="text-gray-300">{name}</span> — {gloss}
|
||||
</span>
|
||||
))}
|
||||
<span className="text-gray-400 sm:col-span-2">Raw dated points grow toward today; the connecting line is smoothed. White ring = today.</span>
|
||||
<span className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-gray-400 sm:col-span-2">
|
||||
<span>symbol + colour = fundamentals:</span>
|
||||
{(['supportive', 'neutral', 'adverse', 'unknown'] as const).map((state) => (
|
||||
<span key={state} className="inline-flex items-center gap-1.5">
|
||||
<span aria-hidden="true" style={{ color: FUNDAMENTAL_VISUAL[state].color }}>{FUNDAMENTAL_SYMBOL[state]}</span>
|
||||
{FUNDAMENTAL_VISUAL[state].label}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{crossesFreeze && (
|
||||
<p className="mt-2 text-[11px] text-gray-600">
|
||||
<p className="mt-2 text-xs text-gray-400">
|
||||
History before {basketAsOf} is reconstructed against today's basket — retrospective, not a live record.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -9,7 +9,7 @@ interface DisclosureProps {
|
||||
export function Disclosure({ summary, children }: DisclosureProps) {
|
||||
return (
|
||||
<details className="glass-sm group">
|
||||
<summary className="flex cursor-pointer select-none items-center gap-2 px-4 py-2.5 text-xs font-medium text-gray-400 transition-colors hover:text-gray-200 [&::-webkit-details-marker]:hidden">
|
||||
<summary className="flex min-h-11 cursor-pointer select-none items-center gap-2 px-4 py-2.5 text-xs font-medium text-gray-400 transition-colors hover:text-gray-200 [&::-webkit-details-marker]:hidden">
|
||||
<span className="inline-block transition-transform duration-200 group-open:rotate-90">▸</span>
|
||||
{summary}
|
||||
</summary>
|
||||
|
||||
@@ -1,4 +1,68 @@
|
||||
import type { MarketRegime } from './types';
|
||||
import type { FundamentalState, MarketRegime } from './types';
|
||||
|
||||
/** One visual vocabulary for the three-channel regime monitor. Keep chart SVG
|
||||
* literals and DOM text in sync rather than letting Tailwind aliases and
|
||||
* hard-coded colours describe the same state differently.
|
||||
*
|
||||
* **Hue identifies the channel, and only the channel.** Two collisions made
|
||||
* that false and both are fixed here:
|
||||
*
|
||||
* - `supportive` was literally `state`, so teal meant "the State score" in the
|
||||
* Time view and "fundamentals supportive" in the Path view of the same card.
|
||||
* - `adverse` sat 19 degrees from `warning`, which is inside deuteranope
|
||||
* confusion range for two channels that appear on adjacent tooltip lines.
|
||||
*
|
||||
* The market pair now sits at 27/190 degrees and the fundamental pair at
|
||||
* 0/158, so every *cross-channel* pair is at least 27 degrees apart. All six
|
||||
* clear 4.5:1 against `--surface`. Fundamentals additionally carry a glyph, so
|
||||
* colour is never the sole encoding for the categorical channel.
|
||||
*
|
||||
* `neutral` and `unknown` are deliberately the same hue: they are two states of
|
||||
* one channel, both meaning "no directional signal", separated by lightness
|
||||
* (7.1:1 vs 5.4:1) and by glyph (filled circle vs ring). Do not "fix" their
|
||||
* proximity by giving `unknown` a hue — that would make an absence of evidence
|
||||
* look like a reading.
|
||||
*
|
||||
* These deliberately do *not* reuse `--up-text`/`--down-text`: those are the
|
||||
* app's directional tokens, and `--up-text` is already this chart's State
|
||||
* colour, which is how the first collision happened.
|
||||
*/
|
||||
export const REGIME_VISUAL = {
|
||||
// Market channels — continuous scores, drawn as lines and positions.
|
||||
state: '#6ec9db',
|
||||
warning: '#fb923c',
|
||||
// Fundamental channel — categorical, drawn as glyphs.
|
||||
supportive: '#34d399',
|
||||
neutral: '#9aa0b0',
|
||||
adverse: '#f87171',
|
||||
unknown: '#848a9c',
|
||||
} as const;
|
||||
|
||||
/** Market quadrant severity as an opacity ramp on one neutral — never a hue.
|
||||
*
|
||||
* The quadrants are a State x Warning construct, so colouring them borrowed
|
||||
* hues that already meant something else: "Healthy" was painted in the
|
||||
* fundamental supportive colour and "Stabilizing" in the adverse one, which put
|
||||
* an adverse glyph on an adverse-coloured background while meaning roughly the
|
||||
* opposite (damage receding). Opacity carries how many axes are elevated, the
|
||||
* position and labels carry which, and hue stays free to mean channel.
|
||||
*/
|
||||
export const QUADRANT_WASH = {
|
||||
healthy: 0.015,
|
||||
early_warning: 0.05,
|
||||
stabilizing: 0.05,
|
||||
active_stress: 0.085,
|
||||
} as const;
|
||||
|
||||
export const FUNDAMENTAL_VISUAL: Record<
|
||||
FundamentalState,
|
||||
{ label: string; color: string; glyph: 'up' | 'circle' | 'diamond' | 'ring' }
|
||||
> = {
|
||||
supportive: { label: 'Supportive', color: REGIME_VISUAL.supportive, glyph: 'up' },
|
||||
neutral: { label: 'Neutral', color: REGIME_VISUAL.neutral, glyph: 'circle' },
|
||||
adverse: { label: 'Adverse', color: REGIME_VISUAL.adverse, glyph: 'diamond' },
|
||||
unknown: { label: 'Unknown', color: REGIME_VISUAL.unknown, glyph: 'ring' },
|
||||
};
|
||||
|
||||
export function regimeDot(label: MarketRegime['label']): string {
|
||||
switch (label) {
|
||||
|
||||
+109
-23
@@ -509,9 +509,24 @@ export interface RegimeReading {
|
||||
trend?: { delta_7: number | null; delta_30: number | null };
|
||||
}
|
||||
|
||||
/** Qualitative capex / earnings-reaction context. Not part of either score. */
|
||||
export interface RegimeFundamentalOverlay {
|
||||
export type FundamentalState = 'supportive' | 'neutral' | 'adverse' | 'unknown';
|
||||
export type EvidenceQuality = 'complete' | 'partial' | 'stale' | 'manual' | 'unavailable';
|
||||
|
||||
/** The third channel: capex / earnings-reaction context, read alongside State
|
||||
* and Warning by confluence. Deliberately never a term in either score — see
|
||||
* the methodology doc on why no fusion weight is measurable yet. */
|
||||
export interface RegimeFundamentalContext {
|
||||
/** Derived from the stored facts by fixed rules, not by an LLM's judgement. */
|
||||
state: FundamentalState;
|
||||
evidence_quality: EvidenceQuality;
|
||||
capex_signal: FundamentalState;
|
||||
reaction_signal: FundamentalState;
|
||||
/** Timing only: there is an effective, non-stale record to display. */
|
||||
available: boolean;
|
||||
/** Content too: it is available *and* actually determined something. A
|
||||
* collected observation whose extraction failed is available but not usable,
|
||||
* and only `usable` may confirm anything or count as study exposure. */
|
||||
usable: boolean;
|
||||
pending: boolean;
|
||||
stale: boolean;
|
||||
effective_date: string | null;
|
||||
@@ -524,7 +539,7 @@ export interface RegimeFundamentalOverlay {
|
||||
source: string | null;
|
||||
fetched_at: string | null;
|
||||
/** Whether anything was actually collected. Live reading only; the snapshot's
|
||||
* point-in-time overlay omits it. */
|
||||
* point-in-time record omits it. */
|
||||
observed?: boolean;
|
||||
observed_in_snapshot?: boolean;
|
||||
}
|
||||
@@ -533,6 +548,11 @@ export interface RegimeHistoryPoint {
|
||||
date: string;
|
||||
state: number | null;
|
||||
warning: number | null;
|
||||
/** The fundamental channel as recorded that day — drives the Path dot colour.
|
||||
* Rows written before the channel existed read as "unknown", which is correct:
|
||||
* nothing was observed then either. */
|
||||
fundamental_state: FundamentalState;
|
||||
evidence_quality: EvidenceQuality;
|
||||
state_coverage: number | null;
|
||||
warning_coverage: number | null;
|
||||
basket_hash: string | null;
|
||||
@@ -545,10 +565,12 @@ export interface RegimeMonitor {
|
||||
date?: string;
|
||||
state?: RegimeReading;
|
||||
warning?: RegimeReading;
|
||||
/** Point-in-time overlay recorded in the snapshot. */
|
||||
fundamental_overlay?: RegimeFundamentalOverlay;
|
||||
/** Current observation, even when it is not effective until the next session. */
|
||||
fundamental_context?: RegimeFundamentalOverlay;
|
||||
/** The channel as recorded in the snapshot — point-in-time, effective-date gated. */
|
||||
fundamental_context?: RegimeFundamentalContext;
|
||||
/** What we know right now, even when it is not effective until the next
|
||||
* session. Separate from the above so a just-collected observation cannot
|
||||
* look as though it had been backdated into the record. */
|
||||
fundamental_live?: RegimeFundamentalContext;
|
||||
inputs?: {
|
||||
vix: number | null;
|
||||
vix_date: string | null;
|
||||
@@ -596,7 +618,7 @@ export interface RegimeFundamentals {
|
||||
}
|
||||
|
||||
export type CapexState = 'raising' | 'holding' | 'cutting' | 'unknown';
|
||||
export type GoodNewsReaction = 'yes' | 'no' | 'mixed';
|
||||
export type GoodNewsReaction = 'yes' | 'no' | 'mixed' | 'unknown';
|
||||
|
||||
export interface RegimeFundamentalsUpdate {
|
||||
capex?: Record<string, CapexState>;
|
||||
@@ -611,9 +633,22 @@ export interface RegimeConfig {
|
||||
}
|
||||
|
||||
// Event study — measured lead time of early-warning indicators vs. drawdowns
|
||||
export interface EventStudyMetrics {
|
||||
events: number;
|
||||
events_warned: number;
|
||||
events_missed: number;
|
||||
alarm_episodes: number;
|
||||
false_alarms: number;
|
||||
/** null when the rule had no eligible sessions — undefined, not zero. */
|
||||
false_alarms_per_year: number | null;
|
||||
median_lead_days: number | null;
|
||||
}
|
||||
|
||||
export interface EventStudyReport {
|
||||
available: boolean;
|
||||
reason?: string;
|
||||
/** Report shape, independent of methodology. Mismatched reports are discarded. */
|
||||
schema?: number;
|
||||
methodology?: string;
|
||||
generated_at?: string;
|
||||
evaluation?: 'exploratory' | 'holdout';
|
||||
@@ -624,14 +659,11 @@ export interface EventStudyReport {
|
||||
event_threshold_pct: number;
|
||||
event_cooldown_days: number;
|
||||
horizon_days: number;
|
||||
train_fraction: number;
|
||||
warn_percentile: number;
|
||||
warn_threshold: number;
|
||||
basket_hash: string;
|
||||
basket_asof: string;
|
||||
credit_sensor_from?: string | null;
|
||||
};
|
||||
/** How far the headline metrics can be trusted. See _reliability(). */
|
||||
/** How far the *fitted* variant's metrics can be trusted. See _reliability(). */
|
||||
reliability?: {
|
||||
events_detected: number;
|
||||
events_in_holdout: number;
|
||||
@@ -645,21 +677,75 @@ export interface EventStudyReport {
|
||||
sample?: {
|
||||
start: string;
|
||||
end: string;
|
||||
train_end: string;
|
||||
test_start: string;
|
||||
/** Where the quadrant baseline seeds — not a holdout boundary. */
|
||||
evaluable_from: string;
|
||||
sessions: number;
|
||||
holdout_sessions: number;
|
||||
evaluable_sessions: number;
|
||||
events_detected: number;
|
||||
events_evaluable: number;
|
||||
};
|
||||
metrics?: {
|
||||
/** The quadrant-change rule that actually reaches Telegram. The headline. */
|
||||
shipped?: {
|
||||
rule: {
|
||||
state_divider: number;
|
||||
warning_divider: number;
|
||||
margin: number;
|
||||
confirm_sessions: number;
|
||||
cooldown_days: number;
|
||||
entry: string;
|
||||
};
|
||||
metrics: EventStudyMetrics;
|
||||
events: { date: string; warned: boolean; lead_days: number | null }[];
|
||||
quadrant_changes: number;
|
||||
/** Debugging payload: every change the replay would have alerted on. Not rendered. */
|
||||
fires: { index: number; date: string; from: string; to: string; state: number; warning: number }[];
|
||||
/** Credit history starts partway through, so Warning is W1+W2 before it. */
|
||||
by_era?: {
|
||||
credit_from: string;
|
||||
pre_credit: EventStudyMetrics & { label: string; start: string; end: string; sessions: number };
|
||||
full_coverage: EventStudyMetrics & { label: string; start: string; end: string; sessions: number };
|
||||
} | null;
|
||||
};
|
||||
/** The fundamental channel's actual exposure — its rows are scored on this
|
||||
* window, not on the market rows' full sample. */
|
||||
fundamental_coverage?: {
|
||||
observations: number;
|
||||
/** Sessions with usable (observed, effective, non-stale) context. */
|
||||
sessions_eligible: number;
|
||||
evaluable_sessions: number;
|
||||
/** Corrections whose warning horizon had usable context. */
|
||||
events_covered: number;
|
||||
events_evaluable: number;
|
||||
minimum_events: number;
|
||||
/** False until enough corrections are covered: the fundamental rows are
|
||||
* untested, not failed, and must not render as a 0/N result. */
|
||||
measurable: boolean;
|
||||
};
|
||||
/** Ablations, external baselines, and the fundamental channel — all on fixed
|
||||
* (unfitted) rules, so every row is scored on the same events. */
|
||||
comparison?: (EventStudyMetrics & {
|
||||
id: string;
|
||||
label: string;
|
||||
kind: 'ablation' | 'baseline' | 'fundamental';
|
||||
note: string;
|
||||
measurable: boolean;
|
||||
})[];
|
||||
null_model?: {
|
||||
draws: number;
|
||||
alarms_per_draw: number;
|
||||
events: number;
|
||||
events_warned: number;
|
||||
events_missed: number;
|
||||
alarm_episodes: number;
|
||||
false_alarms: number;
|
||||
false_alarms_per_year: number;
|
||||
median_lead_days: number | null;
|
||||
mean_warned: number;
|
||||
sd_warned: number;
|
||||
observed_warned: number;
|
||||
p_at_least_observed: number;
|
||||
} | null;
|
||||
/** The original 70/30 fitted-threshold study, kept for continuity. */
|
||||
fitted?: {
|
||||
params: { train_fraction: number; warn_percentile: number; warn_threshold: number };
|
||||
sample: { train_end: string; test_start: string; holdout_sessions: number };
|
||||
metrics: EventStudyMetrics;
|
||||
events: { date: string; warned: boolean; lead_days: number | null }[];
|
||||
};
|
||||
events?: { date: string; warned: boolean; lead_days: number | null }[];
|
||||
recent_breadth?: { date: string; breadth: number; warning: number | null }[];
|
||||
}
|
||||
|
||||
|
||||
+481
-151
@@ -6,6 +6,7 @@ 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,
|
||||
@@ -17,11 +18,13 @@ import {
|
||||
} from '../api/regime';
|
||||
import type {
|
||||
CapexState,
|
||||
FundamentalState,
|
||||
EventStudyMetrics,
|
||||
EventStudyReport,
|
||||
GoodNewsReaction,
|
||||
RegimeBand,
|
||||
RegimeConfig,
|
||||
RegimeFundamentalOverlay,
|
||||
RegimeFundamentalContext,
|
||||
RegimeFundamentals,
|
||||
RegimeFundamentalsUpdate,
|
||||
RegimeMonitor,
|
||||
@@ -39,7 +42,7 @@ const BAND_STYLES: Record<RegimeBand, { text: string; bar: string; ring: string;
|
||||
|
||||
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>;
|
||||
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 ? '↑' : '↓';
|
||||
@@ -68,21 +71,21 @@ function ScoreGauge({
|
||||
// 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 border p-6 ${style?.ring ?? 'border-white/[0.06]'}`}>
|
||||
<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-[11px] uppercase tracking-wider text-gray-500">{label}</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-6xl font-bold ${style?.text ?? 'text-gray-500'}`}>
|
||||
<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-500">/ 100</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-600">coverage {Math.round(reading?.coverage ?? 0)}%</span>
|
||||
<span className="text-xs text-gray-400">coverage {Math.round(reading?.coverage ?? 0)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
@@ -102,7 +105,7 @@ function ScoreGauge({
|
||||
/>
|
||||
</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">
|
||||
<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}%` }}>
|
||||
@@ -113,86 +116,153 @@ function ScoreGauge({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<p className="mt-4 text-xs text-gray-500">{footnote}</p>
|
||||
<p className="mt-4 text-xs leading-relaxed text-gray-400">{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',
|
||||
/** 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,
|
||||
};
|
||||
|
||||
const OVERLAY_TITLE = 'Fundamental overlay · context, not scored';
|
||||
function sentenceCase(value: string): string {
|
||||
const text = value.replace(/_/g, ' ');
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
function FundamentalOverlayCard({ overlay }: { overlay: RegimeFundamentalOverlay }) {
|
||||
const capex = overlay.capex ?? {};
|
||||
const reaction = overlay.good_news_stock_down;
|
||||
|
||||
// Nothing collected: the stored default is "unknown" for every hyperscaler
|
||||
// and "mixed" for the reaction, which are placeholders, not a reading.
|
||||
if (overlay.observed === false) {
|
||||
return (
|
||||
<div className="glass border border-white/[0.06] p-5">
|
||||
<div className="text-[11px] uppercase tracking-wider text-gray-500">{OVERLAY_TITLE}</div>
|
||||
<p className="mt-3 text-xs text-gray-500">
|
||||
No observation collected yet. An admin can collect one under Admin · Monitor settings. It is
|
||||
context only — it never enters State or Warning.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
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 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">{OVERLAY_TITLE}</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px] text-gray-500">
|
||||
{overlay.source && <span>{overlay.source}</span>}
|
||||
{/* When pending, the line below is the single carrier of this date. */}
|
||||
{overlay.effective_date && !overlay.pending && <span>· effective {overlay.effective_date}</span>}
|
||||
<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>
|
||||
|
||||
{/* A pending observation is still shown — it is the freshest read we
|
||||
have, and nothing here is scored. The date says when the stored
|
||||
point-in-time record picks it up. */}
|
||||
{overlay.pending && (
|
||||
<p className="mt-3 text-xs text-amber-400/90">
|
||||
Shown as collected. The point-in-time record picks it up{' '}
|
||||
{overlay.effective_date ?? 'next session'} — observations are never backdated.
|
||||
</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 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>
|
||||
<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 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>
|
||||
{overlay.reasoning && <p className="mt-4 text-xs leading-relaxed text-gray-400">{overlay.reasoning}</p>}
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -209,7 +279,7 @@ function PillarTable({ state, warning }: { state: RegimeReading; warning: Regime
|
||||
<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">
|
||||
<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>
|
||||
@@ -221,7 +291,7 @@ function PillarTable({ state, warning }: { state: RegimeReading; warning: Regime
|
||||
<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-600">
|
||||
<span className="ml-2 normal-case tracking-normal text-gray-400">
|
||||
{reading.score ?? '—'} · {Math.round(reading.coverage)}% coverage
|
||||
</span>
|
||||
</td>
|
||||
@@ -232,8 +302,8 @@ function PillarTable({ state, warning }: { state: RegimeReading; warning: Regime
|
||||
<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}:{' '}
|
||||
<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>
|
||||
))}
|
||||
@@ -256,7 +326,7 @@ function PillarTable({ state, warning }: { state: RegimeReading; warning: Regime
|
||||
|
||||
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-[11px] text-gray-500" title={title}>
|
||||
<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>
|
||||
);
|
||||
@@ -288,71 +358,291 @@ function MetaStrip({ data }: { data: RegimeMonitor }) {
|
||||
);
|
||||
}
|
||||
|
||||
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 metrics = report.metrics;
|
||||
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-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>}
|
||||
{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>
|
||||
{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>
|
||||
{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.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.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.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). 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>
|
||||
|
||||
{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>
|
||||
</Callout>
|
||||
</Disclosure>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -394,15 +684,26 @@ function FundamentalsEditor({
|
||||
}) {
|
||||
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;
|
||||
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-500">
|
||||
<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>}
|
||||
@@ -411,8 +712,8 @@ function FundamentalsEditor({
|
||||
{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>
|
||||
<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]) => (
|
||||
@@ -428,17 +729,24 @@ function FundamentalsEditor({
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1.5 text-[11px] text-gray-600">Raising = 0, holding = 50, cutting = 100; at least three known names required.</p>
|
||||
<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">F3 · Good news, stock down</span>
|
||||
<span className="ml-2 num text-gray-600">score {derivedF3 ?? 'n/a'}</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 · stress</option>
|
||||
<option value="no">No · ordinary</option>
|
||||
<option value="mixed">Mixed · unavailable</option>
|
||||
<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">
|
||||
@@ -465,7 +773,7 @@ function ConfigEditor({ data, onSave, saving }: { data: RegimeConfig; onSave: (u
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
@@ -483,13 +791,13 @@ function AdminControls() {
|
||||
<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>
|
||||
<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-[11px] uppercase tracking-wider text-gray-500">Fixed basket & freshness</div>
|
||||
<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>}
|
||||
@@ -508,7 +816,19 @@ export default function RegimePage() {
|
||||
<div className="space-y-6 animate-slide-up">
|
||||
<PageHeader
|
||||
title="AI/Tech Risk Monitor"
|
||||
subtitle="AI/Tech risk thermometer — observational only, feeds no entry, exit, or sizing decision"
|
||||
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} /></>}
|
||||
@@ -524,7 +844,7 @@ export default function RegimePage() {
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<ScoreGauge
|
||||
label="State · stress right now"
|
||||
reading={data.state}
|
||||
@@ -542,17 +862,27 @@ export default function RegimePage() {
|
||||
<ScoreGauge
|
||||
label="Warning · deterioration & divergence"
|
||||
reading={data.warning}
|
||||
footnote="Breadth divergence, SMH/SPY rollover, and HY credit impulse. Missing sensors reduce coverage; they never default to 50."
|
||||
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} />
|
||||
|
||||
{data.fundamental_context && <FundamentalOverlayCard overlay={data.fundamental_context} />}
|
||||
|
||||
<MetaStrip data={data} />
|
||||
<Disclosure summary="Data provenance · coverage and history">
|
||||
<MetaStrip data={data} />
|
||||
</Disclosure>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -42,6 +42,14 @@
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
/* --ink, not --up-text: a focus ring must not carry a semantic colour. The
|
||||
directional token reads as "up/positive" and lands at poor contrast on the
|
||||
controls that are already that colour. */
|
||||
:where(button, a, input, select, textarea, summary, [tabindex]):focus-visible {
|
||||
outline: 2px solid var(--ink);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
/* Atmosphere: faint starfield + soft rim-cyan / ember glows + film grain */
|
||||
#root {
|
||||
position: relative;
|
||||
@@ -83,6 +91,17 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Mars horizon — fixed at the viewport bottom, atmosphere only, never data */
|
||||
.app-horizon {
|
||||
|
||||
Reference in New Issue
Block a user