feat: score-history chart on the regime tab
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 41s
Deploy / deploy (push) Successful in 25s

Plots the index, early-warning, and combined scores over time beneath the live
gauges, with a 1M/3M/6M/All range toggle and band reference lines — so the trend
and any divergence between the scores is visible, not just today's snapshot.

- Backend: GET /regime/history + get_regime_history (the three scores per
  snapshot date from regime_snapshots).
- Frontend: recharts line chart, lazy-loaded so recharts ships in its own
  regime-tab chunk instead of nearly doubling the main bundle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 15:48:42 +02:00
parent 60def1155b
commit 66444af65c
6 changed files with 192 additions and 2 deletions
+12 -1
View File
@@ -1,6 +1,6 @@
"""Market-level endpoints (benchmark regime + AI/Tech regime-change monitor)."""
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
@@ -129,3 +129,14 @@ async def regime_event_study(
None until the manual "Event Study" job has run (Admin → Jobs)."""
data = await event_study_service.get_event_study_report(db)
return APIEnvelope(status="success", data=data)
@router.get("/regime/history", response_model=APIEnvelope)
async def regime_history(
days: int = Query(default=400, ge=7, le=2000),
_user: User = Depends(require_access),
db: AsyncSession = Depends(get_db),
) -> APIEnvelope:
"""Daily history of the index / early-warning / combined scores (for the chart)."""
data = await regime_monitor_service.get_regime_history(db, days=days)
return APIEnvelope(status="success", data=data)
+24
View File
@@ -681,6 +681,30 @@ async def get_regime_monitor(db: AsyncSession) -> dict:
return result
async def get_regime_history(db: AsyncSession, days: int = 400) -> list[dict]:
"""Daily history of the index, early-warning, and combined scores for the
score-over-time chart. One point per snapshot date, ascending."""
cutoff = date.today() - timedelta(days=days)
res = await db.execute(
select(RegimeSnapshot)
.where(RegimeSnapshot.date >= cutoff)
.order_by(RegimeSnapshot.date.asc())
)
out: list[dict] = []
for row in res.scalars().all():
try:
data = json.loads(row.breakdown_json)
except (TypeError, ValueError):
data = {}
out.append({
"date": row.date.isoformat(),
"index": row.total_score,
"early_warning": (data.get("early_warning") or {}).get("score"),
"combined": (data.get("combined") or {}).get("score"),
})
return out
# ---------------------------------------------------------------------------
# F1/F3 via grounded LLM (reuses the configured sentiment provider)
# ---------------------------------------------------------------------------
+7
View File
@@ -4,12 +4,19 @@ import type {
RegimeConfig,
RegimeFundamentals,
EventStudyReport,
RegimeHistoryPoint,
} from '../lib/types';
export function getRegimeMonitor() {
return apiClient.get<RegimeMonitor>('regime/monitor').then((r) => r.data);
}
export function getRegimeHistory(days = 400) {
return apiClient
.get<RegimeHistoryPoint[]>('regime/history', { params: { days } })
.then((r) => r.data);
}
export function getEventStudy() {
return apiClient.get<EventStudyReport | null>('regime/event-study').then((r) => r.data);
}
@@ -0,0 +1,135 @@
import { useState, useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from 'recharts';
import { getRegimeHistory } from '../../api/regime';
import { Callout } from '../ui/Callout';
import { SkeletonCard } from '../ui/Skeleton';
import { formatDate } from '../../lib/format';
// Lazy-loaded (see RegimePage) so recharts only ships in the regime-tab chunk.
const HISTORY_RANGES = [
{ key: '1M', days: 30 },
{ key: '3M', days: 90 },
{ key: '6M', days: 182 },
{ key: 'All', days: 100000 },
] as const;
type HistoryRange = (typeof HISTORY_RANGES)[number]['key'];
const HISTORY_SERIES = [
{ key: 'index', label: 'Index', color: '#60a5fa' },
{ key: 'early_warning', label: 'Early warning', color: '#fb923c' },
{ key: 'combined', label: 'Combined', color: '#a78bfa' },
] as const;
export default function ScoreHistoryChart() {
const [range, setRange] = useState<HistoryRange>('3M');
const history = useQuery({ queryKey: ['regime', 'history'], queryFn: () => getRegimeHistory(400) });
const filtered = useMemo(() => {
const data = history.data ?? [];
const days = HISTORY_RANGES.find((r) => r.key === range)!.days;
if (range === 'All') return data;
const cutoff = new Date();
cutoff.setDate(cutoff.getDate() - days);
return data.filter((p) => new Date(p.date) >= cutoff);
}, [history.data, range]);
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">Score history</div>
<div className="flex gap-1">
{HISTORY_RANGES.map((r) => (
<button
key={r.key}
type="button"
onClick={() => setRange(r.key)}
className={`rounded px-2 py-1 text-[11px] font-medium tabular-nums transition-colors ${
range === r.key ? 'bg-white/10 text-blue-300' : 'text-gray-500 hover:text-gray-300'
}`}
>
{r.key}
</button>
))}
</div>
</div>
{history.isLoading ? (
<SkeletonCard className="mt-3 h-56" />
) : filtered.length < 2 ? (
<Callout variant="empty">Not enough history yet it accumulates as the daily job runs.</Callout>
) : (
<>
<div className="mt-3 h-60">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={filtered} margin={{ top: 6, right: 8, left: -18, bottom: 0 }}>
<CartesianGrid stroke="rgba(255,255,255,0.05)" vertical={false} />
<XAxis
dataKey="date"
tick={{ fill: '#6b7280', fontSize: 10 }}
tickFormatter={(d) => formatDate(String(d))}
minTickGap={28}
tickLine={false}
axisLine={{ stroke: 'rgba(255,255,255,0.08)' }}
/>
<YAxis
domain={[0, 100]}
ticks={[0, 30, 60, 80, 100]}
tick={{ fill: '#6b7280', fontSize: 10 }}
width={28}
tickLine={false}
axisLine={false}
/>
<ReferenceLine y={30} stroke="rgba(255,255,255,0.06)" />
<ReferenceLine y={60} stroke="rgba(255,255,255,0.06)" />
<ReferenceLine y={80} stroke="rgba(255,255,255,0.06)" />
<Tooltip
contentStyle={{
background: 'rgba(17,24,39,0.95)',
border: '1px solid rgba(255,255,255,0.1)',
borderRadius: 8,
fontSize: 12,
}}
labelStyle={{ color: '#9ca3af' }}
labelFormatter={(l) => formatDate(String(l))}
formatter={(value) => (value == null ? '—' : Math.round(Number(value)))}
/>
{HISTORY_SERIES.map((s) => (
<Line
key={s.key}
type="monotone"
dataKey={s.key}
name={s.label}
stroke={s.color}
dot={false}
strokeWidth={1.5}
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
<div className="mt-2 flex flex-wrap gap-4">
{HISTORY_SERIES.map((s) => (
<span key={s.key} className="flex items-center gap-1.5 text-[11px] text-gray-400">
<span className="inline-block h-2 w-3 rounded-sm" style={{ background: s.color }} />
{s.label}
</span>
))}
</div>
</>
)}
</div>
);
}
+7
View File
@@ -282,6 +282,13 @@ export interface RegimeSubScore {
delta_30?: number | null;
}
export interface RegimeHistoryPoint {
date: string;
index: number;
early_warning: number | null;
combined: number | null;
}
export interface RegimeMonitor {
available: boolean;
reason?: string;
+7 -1
View File
@@ -1,4 +1,4 @@
import { useState, type ReactNode } from 'react';
import { useState, lazy, Suspense, type ReactNode } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { PageHeader } from '../components/ui/PageHeader';
import { Callout } from '../components/ui/Callout';
@@ -15,6 +15,9 @@ import {
refreshRegimeFundamentals,
getEventStudy,
} from '../api/regime';
// Lazy so recharts (heavy) ships in its own chunk, loaded only on this tab.
const ScoreHistoryChart = lazy(() => import('../components/regime/ScoreHistoryChart'));
import type {
RegimeBand,
RegimeSignal,
@@ -562,6 +565,9 @@ export default function RegimePage() {
regime config.</>
}
/>
<Suspense fallback={<SkeletonCard className="h-72" />}>
<ScoreHistoryChart />
</Suspense>
{monitor.data.breakdown && <Breakdown breakdown={monitor.data.breakdown} />}
</>
)}