diff --git a/frontend/src/components/regime/RegimeQuadrant.tsx b/frontend/src/components/regime/RegimeQuadrant.tsx new file mode 100644 index 0000000..36b18a8 --- /dev/null +++ b/frontend/src/components/regime/RegimeQuadrant.tsx @@ -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 ( +
+
{p.date}
+
+ Regime {Math.round(p.x)} · Early warning{' '} + {Math.round(p.y)} +
+
+ ); +} + +export default function RegimeQuadrant() { + const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(400) }); + + const points = useMemo(() => { + 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 ( +
+
+
+ Regime quadrant — last {TRAIL} sessions +
+ {latest && ( +
+ now: regime {Math.round(latest.x)} · warning{' '} + {Math.round(latest.y)} +
+ )} +
+ + {history.isLoading ? ( + + ) : !points.length ? ( + + Not enough history yet — the early-warning fills in as the daily job runs. + + ) : ( + <> +
+ + + {/* Quadrant shading (drawn first, behind everything) */} + + + + + + + + + + + } /> + {/* Trail (chronological path) */} + + {/* Today */} + {latest && ( + ( + + )} + /> + )} + + +
+ +
+ ① Hot & brittle — narrow melt-up, shakeout risk + ② Transition — break may be starting + ③ Healthy & broad — calm uptrend + ④ Real downturn — regime breaking, broad +
+

+ 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. +

+ + )} +
+ ); +} diff --git a/frontend/src/pages/RegimePage.tsx b/frontend/src/pages/RegimePage.tsx index b196d4b..1618515 100644 --- a/frontend/src/pages/RegimePage.tsx +++ b/frontend/src/pages/RegimePage.tsx @@ -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() { } /> - A weighted mean of the index and the early warning — for observation only. Tune the mix via the - regime config. - } - /> + }> + + }>