feat: score-history chart on the regime tab
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:
+12
-1
@@ -1,6 +1,6 @@
|
|||||||
"""Market-level endpoints (benchmark regime + AI/Tech regime-change monitor)."""
|
"""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 pydantic import BaseModel
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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)."""
|
None until the manual "Event Study" job has run (Admin → Jobs)."""
|
||||||
data = await event_study_service.get_event_study_report(db)
|
data = await event_study_service.get_event_study_report(db)
|
||||||
return APIEnvelope(status="success", data=data)
|
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)
|
||||||
|
|||||||
@@ -681,6 +681,30 @@ async def get_regime_monitor(db: AsyncSession) -> dict:
|
|||||||
return result
|
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)
|
# F1/F3 via grounded LLM (reuses the configured sentiment provider)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -4,12 +4,19 @@ import type {
|
|||||||
RegimeConfig,
|
RegimeConfig,
|
||||||
RegimeFundamentals,
|
RegimeFundamentals,
|
||||||
EventStudyReport,
|
EventStudyReport,
|
||||||
|
RegimeHistoryPoint,
|
||||||
} from '../lib/types';
|
} from '../lib/types';
|
||||||
|
|
||||||
export function getRegimeMonitor() {
|
export function getRegimeMonitor() {
|
||||||
return apiClient.get<RegimeMonitor>('regime/monitor').then((r) => r.data);
|
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() {
|
export function getEventStudy() {
|
||||||
return apiClient.get<EventStudyReport | null>('regime/event-study').then((r) => r.data);
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -282,6 +282,13 @@ export interface RegimeSubScore {
|
|||||||
delta_30?: number | null;
|
delta_30?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RegimeHistoryPoint {
|
||||||
|
date: string;
|
||||||
|
index: number;
|
||||||
|
early_warning: number | null;
|
||||||
|
combined: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface RegimeMonitor {
|
export interface RegimeMonitor {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
|
|||||||
@@ -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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { PageHeader } from '../components/ui/PageHeader';
|
import { PageHeader } from '../components/ui/PageHeader';
|
||||||
import { Callout } from '../components/ui/Callout';
|
import { Callout } from '../components/ui/Callout';
|
||||||
@@ -15,6 +15,9 @@ import {
|
|||||||
refreshRegimeFundamentals,
|
refreshRegimeFundamentals,
|
||||||
getEventStudy,
|
getEventStudy,
|
||||||
} from '../api/regime';
|
} 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 {
|
import type {
|
||||||
RegimeBand,
|
RegimeBand,
|
||||||
RegimeSignal,
|
RegimeSignal,
|
||||||
@@ -562,6 +565,9 @@ export default function RegimePage() {
|
|||||||
regime config.</>
|
regime config.</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Suspense fallback={<SkeletonCard className="h-72" />}>
|
||||||
|
<ScoreHistoryChart />
|
||||||
|
</Suspense>
|
||||||
{monitor.data.breakdown && <Breakdown breakdown={monitor.data.breakdown} />}
|
{monitor.data.breakdown && <Breakdown breakdown={monitor.data.breakdown} />}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user