feat: regime quadrant plot in place of the combined gauge
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:
@@ -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 & 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 & 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'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user