feat: regime quadrant plot in place of the combined gauge
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 42s
Deploy / deploy (push) Successful in 25s

The combined score collapsed two distinct signals into one not-very-meaningful
number. Replace its gauge with a quadrant scatter that shows both axes directly:
x = regime index (coincident), y = early warning (breadth divergence), with a
trail of the last 60 sessions and today highlighted.

The four quadrants make the readings legible — ① hot & brittle (narrow melt-up,
shakeout risk), ② transition, ③ healthy & broad, ④ real downturn — and the trail
surfaces the actual tell: the ①→④ move (early warning rolling over as the regime
index climbs = divergence resolving downward). Combined still shows as a line in
the score-history chart. Frontend-only; reuses the history endpoint. Lazy-loaded.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 16:14:32 +02:00
parent 66444af65c
commit a07bfee6e6
2 changed files with 150 additions and 11 deletions
@@ -0,0 +1,146 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
ScatterChart,
Scatter,
XAxis,
YAxis,
ZAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
ReferenceArea,
} from 'recharts';
import { getRegimeHistory } 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
const TRAIL = 60; // sessions shown
interface QPoint {
x: number;
y: number;
date: string;
}
function QuadrantTip({ active, payload }: { active?: boolean; payload?: { payload: QPoint }[] }) {
if (!active || !payload?.length) return null;
const p = payload[0].payload;
return (
<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{' '}
<span className="text-orange-300">{Math.round(p.y)}</span>
</div>
</div>
);
}
export default function RegimeQuadrant() {
const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(400) });
const points = useMemo<QPoint[]>(() => {
const data = history.data ?? [];
return data
.filter((p) => p.early_warning != null)
.slice(-TRAIL)
.map((p) => ({ x: p.index, y: p.early_warning as number, date: p.date }));
}, [history.data]);
const latest = points.length ? points[points.length - 1] : null;
return (
<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
</div>
{latest && (
<div className="text-[11px] text-gray-500">
now: regime <span className="text-blue-300">{Math.round(latest.x)}</span> · warning{' '}
<span className="text-orange-300">{Math.round(latest.y)}</span>
</div>
)}
</div>
{history.isLoading ? (
<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.
</Callout>
) : (
<>
<div className="mt-3 h-80">
<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" />
<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)" />
<XAxis
type="number"
dataKey="x"
domain={[0, 100]}
ticks={[0, 20, 40, 60, 80, 100]}
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 }}
/>
<YAxis
type="number"
dataKey="y"
domain={[0, 100]}
ticks={[0, 20, 40, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
width={30}
tickLine={false}
axisLine={false}
label={{ value: 'Early warning', angle: -90, position: 'insideLeft', fill: '#6b7280', fontSize: 10 }}
/>
<ZAxis range={[16, 16]} />
<Tooltip cursor={{ strokeDasharray: '3 3', stroke: 'rgba(255,255,255,0.2)' }} content={<QuadrantTip />} />
{/* Trail (chronological path) */}
<Scatter data={points} fill="#60a5fa" fillOpacity={0.5} line={{ stroke: 'rgba(96,165,250,0.3)' }} isAnimationActive={false} />
{/* Today */}
{latest && (
<Scatter
data={[latest]}
isAnimationActive={false}
shape={(props: { cx?: number; cy?: number }) => (
<circle cx={props.cx} cy={props.cy} r={6} fill="#ffffff" stroke="#60a5fa" strokeWidth={2} />
)}
/>
)}
</ScatterChart>
</ResponsiveContainer>
</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>
</div>
<p className="mt-2 text-[11px] leading-relaxed text-gray-600">
White dot = today; trail = path over the last {TRAIL} sessions. 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.
</p>
</>
)}
</div>
);
}
+4 -11
View File
@@ -18,6 +18,7 @@ import {
// Lazy so recharts (heavy) ships in its own chunk, loaded only on this tab.
const ScoreHistoryChart = lazy(() => import('../components/regime/ScoreHistoryChart'));
const RegimeQuadrant = lazy(() => import('../components/regime/RegimeQuadrant'));
import type {
RegimeBand,
RegimeSignal,
@@ -554,17 +555,9 @@ export default function RegimePage() {
}
/>
</div>
<ScoreGauge
label="Combined · observational blend"
score={monitor.data.combined?.score}
band={monitor.data.combined?.band}
trend={monitor.data.combined}
size="md"
footnote={
<>A weighted mean of the index and the early warning for observation only. Tune the mix via the
regime config.</>
}
/>
<Suspense fallback={<SkeletonCard className="h-80" />}>
<RegimeQuadrant />
</Suspense>
<Suspense fallback={<SkeletonCard className="h-72" />}>
<ScoreHistoryChart />
</Suspense>