diff --git a/app/routers/market.py b/app/routers/market.py index bcac902..a66d9af 100644 --- a/app/routers/market.py +++ b/app/routers/market.py @@ -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) diff --git a/app/services/regime_monitor_service.py b/app/services/regime_monitor_service.py index f8df038..7975420 100644 --- a/app/services/regime_monitor_service.py +++ b/app/services/regime_monitor_service.py @@ -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) # --------------------------------------------------------------------------- diff --git a/frontend/src/api/regime.ts b/frontend/src/api/regime.ts index 50044de..6a5fba8 100644 --- a/frontend/src/api/regime.ts +++ b/frontend/src/api/regime.ts @@ -4,12 +4,19 @@ import type { RegimeConfig, RegimeFundamentals, EventStudyReport, + RegimeHistoryPoint, } from '../lib/types'; export function getRegimeMonitor() { return apiClient.get('regime/monitor').then((r) => r.data); } +export function getRegimeHistory(days = 400) { + return apiClient + .get('regime/history', { params: { days } }) + .then((r) => r.data); +} + export function getEventStudy() { return apiClient.get('regime/event-study').then((r) => r.data); } diff --git a/frontend/src/components/regime/ScoreHistoryChart.tsx b/frontend/src/components/regime/ScoreHistoryChart.tsx new file mode 100644 index 0000000..13a7530 --- /dev/null +++ b/frontend/src/components/regime/ScoreHistoryChart.tsx @@ -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('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 ( +
+
+
Score history
+
+ {HISTORY_RANGES.map((r) => ( + + ))} +
+
+ + {history.isLoading ? ( + + ) : filtered.length < 2 ? ( + Not enough history yet — it accumulates as the daily job runs. + ) : ( + <> +
+ + + + formatDate(String(d))} + minTickGap={28} + tickLine={false} + axisLine={{ stroke: 'rgba(255,255,255,0.08)' }} + /> + + + + + formatDate(String(l))} + formatter={(value) => (value == null ? '—' : Math.round(Number(value)))} + /> + {HISTORY_SERIES.map((s) => ( + + ))} + + +
+
+ {HISTORY_SERIES.map((s) => ( + + + {s.label} + + ))} +
+ + )} +
+ ); +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 216df0a..5cb5ffb 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -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; diff --git a/frontend/src/pages/RegimePage.tsx b/frontend/src/pages/RegimePage.tsx index d2e4b84..b196d4b 100644 --- a/frontend/src/pages/RegimePage.tsx +++ b/frontend/src/pages/RegimePage.tsx @@ -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. } /> + }> + + {monitor.data.breakdown && } )}