feat: replace regime monitor with v2 methodology

This commit is contained in:
2026-07-15 09:02:56 +02:00
parent fd21067a40
commit 1d5b1489be
17 changed files with 1599 additions and 1535 deletions
@@ -13,15 +13,13 @@ import {
ReferenceLine,
ReferenceArea,
} from 'recharts';
import { getRegimeHistory } from '../../api/regime';
import { getRegimeHistory, getRegimeMonitor } from '../../api/regime';
import { Callout } from '../ui/Callout';
import { SkeletonCard } from '../ui/Skeleton';
// Lazy-loaded (see RegimePage) so recharts stays in the regime-tab chunk.
// Quadrant dividers. Regime < 40 ≈ intact; early-warning > 60 ≈ elevated.
const X_DIV = 40; // regime index
const Y_DIV = 60; // early warning
// Quadrant boundaries come from the backend v2 methodology response.
const TRAIL = 60; // sessions shown
interface QPoint {
@@ -64,7 +62,7 @@ function QuadrantTip({ active, payload }: { active?: boolean; payload?: { payloa
<div className="glass px-2.5 py-1.5 text-[11px]">
<div className="text-gray-300">{p.date}</div>
<div className="text-gray-400">
Regime <span className="text-blue-300">{Math.round(p.x)}</span> · Early warning{' '}
State <span className="text-blue-300">{Math.round(p.x)}</span> · Warning{' '}
<span className="text-orange-300">{Math.round(p.y)}</span>
</div>
</div>
@@ -72,14 +70,17 @@ function QuadrantTip({ active, payload }: { active?: boolean; payload?: { payloa
}
export default function RegimeQuadrant() {
const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(400) });
const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(800) });
const monitor = useQuery({ queryKey: ['regime', 'monitor'], queryFn: getRegimeMonitor });
const xDiv = monitor.data?.quadrant_config?.state_divider ?? 60;
const yDiv = monitor.data?.quadrant_config?.warning_divider ?? 60;
const points = useMemo<QPoint[]>(() => {
const data = history.data ?? [];
return data
.filter((p) => p.early_warning != null)
.filter((p) => p.state != null && p.warning != null)
.slice(-TRAIL)
.map((p) => ({ x: p.index, y: p.early_warning as number, date: p.date }));
.map((p) => ({ x: p.state as number, y: p.warning as number, date: p.date }));
}, [history.data]);
const trail = useMemo(() => smoothTrail(points), [points]);
@@ -89,11 +90,11 @@ export default function RegimeQuadrant() {
<div className="glass p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-[11px] uppercase tracking-wider text-gray-500">
Regime quadrant last {TRAIL} sessions
State × Warning quadrant last {TRAIL} sessions
</div>
{latest && (
<div className="text-[11px] text-gray-500">
now: regime <span className="text-blue-300">{Math.round(latest.x)}</span> · warning{' '}
now: State <span className="text-blue-300">{Math.round(latest.x)}</span> · Warning{' '}
<span className="text-orange-300">{Math.round(latest.y)}</span>
</div>
)}
@@ -103,7 +104,7 @@ export default function RegimeQuadrant() {
<SkeletonCard className="mt-3 h-72" />
) : !points.length ? (
<Callout variant="empty">
Not enough history yet the early-warning fills in as the daily job runs.
Not enough coverage-qualified v2 history yet.
</Callout>
) : (
<>
@@ -111,13 +112,13 @@ export default function RegimeQuadrant() {
<ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 10, right: 16, bottom: 22, left: 0 }}>
{/* Quadrant shading (drawn first, behind everything) */}
<ReferenceArea x1={0} x2={X_DIV} y1={Y_DIV} y2={100} fill="#f59e0b" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={X_DIV} x2={100} y1={Y_DIV} y2={100} fill="#f97316" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={0} x2={X_DIV} y1={0} y2={Y_DIV} fill="#10b981" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={X_DIV} x2={100} y1={0} y2={Y_DIV} fill="#ef4444" fillOpacity={0.08} stroke="none" />
<ReferenceArea x1={0} x2={xDiv} y1={yDiv} y2={100} fill="#f59e0b" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={xDiv} x2={100} y1={yDiv} y2={100} fill="#f97316" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={0} x2={xDiv} y1={0} y2={yDiv} fill="#10b981" fillOpacity={0.07} stroke="none" />
<ReferenceArea x1={xDiv} x2={100} y1={0} y2={yDiv} fill="#ef4444" fillOpacity={0.08} stroke="none" />
<CartesianGrid stroke="rgba(255,255,255,0.04)" />
<ReferenceLine x={X_DIV} stroke="rgba(255,255,255,0.12)" />
<ReferenceLine y={Y_DIV} stroke="rgba(255,255,255,0.12)" />
<ReferenceLine x={xDiv} stroke="rgba(255,255,255,0.12)" />
<ReferenceLine y={yDiv} stroke="rgba(255,255,255,0.12)" />
<XAxis
type="number"
dataKey="x"
@@ -126,7 +127,7 @@ export default function RegimeQuadrant() {
tick={{ fill: '#6b7280', fontSize: 10 }}
tickLine={false}
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
label={{ value: 'Regime index →', position: 'insideBottom', offset: -12, fill: '#6b7280', fontSize: 10 }}
label={{ value: 'State →', position: 'insideBottom', offset: -12, fill: '#6b7280', fontSize: 10 }}
/>
<YAxis
type="number"
@@ -137,7 +138,7 @@ export default function RegimeQuadrant() {
width={30}
tickLine={false}
axisLine={false}
label={{ value: 'Early warning', angle: -90, position: 'insideLeft', fill: '#6b7280', fontSize: 10 }}
label={{ value: 'Warning', angle: -90, position: 'insideLeft', fill: '#6b7280', fontSize: 10 }}
/>
<ZAxis range={[13, 13]} />
<Tooltip cursor={{ strokeDasharray: '3 3', stroke: 'rgba(255,255,255,0.2)' }} content={<QuadrantTip />} />
@@ -166,15 +167,15 @@ export default function RegimeQuadrant() {
</div>
<div className="mt-2 grid grid-cols-1 gap-x-4 gap-y-1 text-[11px] text-gray-500 sm:grid-cols-2">
<span><span className="text-amber-400"> Hot &amp; brittle</span> narrow melt-up, shakeout risk</span>
<span><span className="text-orange-400"> Transition</span> break may be starting</span>
<span><span className="text-emerald-400"> Healthy &amp; broad</span> calm uptrend</span>
<span><span className="text-red-400"> Real downturn</span> regime breaking, broad</span>
<span><span className="text-amber-400">Early warning</span> state calm, fragility rising</span>
<span><span className="text-orange-400">Active stress</span> damaged and deteriorating</span>
<span><span className="text-emerald-400">Healthy</span> calm and broadly supported</span>
<span><span className="text-red-400">Stressed / stabilizing</span> damage remains, warning lower</span>
</div>
<p className="mt-2 text-[11px] leading-relaxed text-gray-600">
White dot = today; the trail fades from muted (older) to bright blue (newer) over the last {TRAIL}{' '}
sessions, smoothed. The tell isn&apos;t a single spot but the move (early warning rolling over while
the regime index climbs = divergence resolving downward). Observational not wired into trades.
sessions, smoothed. The path matters more than a single point. Risk thermometer not an entry, exit,
or sizing signal.
</p>
</>
)}