From afbfeff4681d4b5bba97177cb4f84221bf992d36 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Fri, 26 Jun 2026 16:51:55 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20FF=E2=86=92FRED=20series=20mapping=20+?= =?UTF-8?q?=20MacroSeries=20visualization=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - ff_calendar: add series_id column (migration) + FF_TO_FRED mapping dict (NFP→PAYEMS, CPI→CPIAUCSL, Jobless Claims→ICSA, GDP→GDPC1, FEDFUNDS, PCE) - import_csv + sync_live now populate series_id on each FF event - New GET /api/eco/series/{id}/history: FRED time series + linked FF events (surprises, forecast, actual) merged by date — enables context queries Frontend: - New MacroSeriesPage.tsx: sidebar with 11 FRED series grouped by category, recharts ComposedChart with area + z-score surprise reference lines (|z|≥1.5), KPI cards (latest/prev/min/max), FF events table (actual vs forecast coloring), z-score bar chart for recent surprises, range selector (1Y/2Y/5Y/10Y/All) - Route /macro-series + Sidebar entry "Macro Series" Co-Authored-By: Claude Sonnet 4.6 --- backend/routers/eco.py | 61 +++ backend/services/database.py | 7 + backend/services/ff_calendar.py | 37 +- frontend/src/App.tsx | 2 + frontend/src/components/layout/Sidebar.tsx | 1 + frontend/src/pages/MacroSeriesPage.tsx | 546 +++++++++++++++++++++ 6 files changed, 650 insertions(+), 4 deletions(-) create mode 100644 frontend/src/pages/MacroSeriesPage.tsx diff --git a/backend/routers/eco.py b/backend/routers/eco.py index ba6bc48..4b33583 100644 --- a/backend/routers/eco.py +++ b/backend/routers/eco.py @@ -407,6 +407,67 @@ def ff_calendar( ) +@router.get("/series/{series_id}/history") +def series_history( + series_id: str, + from_date: Optional[str] = Query(None, description="YYYY-MM-DD"), + to_date: Optional[str] = Query(None, description="YYYY-MM-DD"), +) -> Dict[str, Any]: + """ + Time series data for a FRED series (from economic_events table) + combined with the matching FF calendar events (surprises, forecasts). + """ + from services.database import get_conn + from services.fred_bootstrap import FRED_SERIES + + meta = FRED_SERIES.get(series_id) + if not meta: + raise HTTPException(404, f"Unknown series: {series_id}") + + conn = get_conn() + try: + # ── FRED time series ────────────────────────────────────────────── + ts_where = ["series_id = ?"] + ts_params: list = [series_id] + if from_date: + ts_where.append("event_date >= ?"); ts_params.append(from_date) + if to_date: + ts_where.append("event_date <= ?"); ts_params.append(to_date) + + ts_rows = conn.execute( + f"SELECT event_date, actual_value, surprise_zscore, surprise_direction " + f"FROM economic_events WHERE {' AND '.join(ts_where)} ORDER BY event_date ASC", + ts_params, + ).fetchall() + + # ── FF events for this series ───────────────────────────────────── + ff_where = ["series_id = ?", "currency = 'USD'"] + ff_params: list = [series_id] + if from_date: + ff_where.append("event_date >= ?"); ff_params.append(from_date) + if to_date: + ff_where.append("event_date <= ?"); ff_params.append(to_date) + + ff_rows = conn.execute( + f"SELECT event_date, event_time, event_name, actual_value, " + f"forecast_value, previous_value, impact " + f"FROM ff_calendar WHERE {' AND '.join(ff_where)} ORDER BY event_date ASC", + ff_params, + ).fetchall() + + return { + "series_id": series_id, + "name": meta["name"], + "unit": meta["unit"], + "category": meta["category"], + "freq": meta["freq"], + "timeseries": [dict(r) for r in ts_rows], + "events": [dict(r) for r in ff_rows], + } + finally: + conn.close() + + @router.get("/ff-stats") def ff_stats() -> Dict[str, Any]: """Quick inventory of ff_calendar table.""" diff --git a/backend/services/database.py b/backend/services/database.py index d22c527..666ef3c 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -696,6 +696,7 @@ def init_db(): actual_value TEXT, forecast_value TEXT, previous_value TEXT, + series_id TEXT, detail TEXT, source TEXT DEFAULT 'ff_cache', fetched_at TEXT DEFAULT (datetime('now')), @@ -705,8 +706,14 @@ def init_db(): c.execute("CREATE INDEX IF NOT EXISTS idx_ff_date ON ff_calendar(event_date)") c.execute("CREATE INDEX IF NOT EXISTS idx_ff_currency ON ff_calendar(currency)") c.execute("CREATE INDEX IF NOT EXISTS idx_ff_impact ON ff_calendar(impact)") + c.execute("CREATE INDEX IF NOT EXISTS idx_ff_series ON ff_calendar(series_id)") except Exception: pass + # Migration: add series_id column if missing (existing deployments) + try: + c.execute("ALTER TABLE ff_calendar ADD COLUMN series_id TEXT") + except Exception: + pass # Column already exists # ── Specialist Desks ─────────────────────────────────────────────────────── c.execute("""CREATE TABLE IF NOT EXISTS specialist_reports ( diff --git a/backend/services/ff_calendar.py b/backend/services/ff_calendar.py index 1a0a2c4..323e566 100644 --- a/backend/services/ff_calendar.py +++ b/backend/services/ff_calendar.py @@ -31,6 +31,32 @@ _IMPACT_MAP = { # Currencies we care about (drop 'All' pseudo-currency) SUPPORTED_CURRENCIES = {"USD", "EUR", "GBP", "JPY", "AUD", "CAD", "NZD", "CHF", "CNY"} +# ── FF event name → FRED series_id mapping ──────────────────────────────────── +# Exact FF event names → our FRED series catalog +FF_TO_FRED: dict[str, str] = { + # Employment + "Non-Farm Employment Change": "PAYEMS", + "ADP Non-Farm Employment Change": "PAYEMS", + "Unemployment Rate": "UNRATE", + "Unemployment Claims": "ICSA", + # Inflation — CPI + "CPI m/m": "CPIAUCSL", + "CPI y/y": "CPIAUCSL", + "Core CPI m/m": "CPILFESL", + "Core CPI y/y": "CPILFESL", + # Inflation — PCE + "Core PCE Price Index m/m": "PCEPILFE", + "Core PCE Price Index y/y": "PCEPILFE", + # Monetary policy + "Federal Funds Rate": "FEDFUNDS", + "FOMC Statement": "FEDFUNDS", + # Growth — GDP + "Advance GDP q/q": "GDPC1", + "Prelim GDP q/q": "GDPC1", + "Final GDP q/q": "GDPC1", + "GDP q/q": "GDPC1", +} + def _parse_ff_datetime(dt_str: str) -> tuple[str, str]: """ @@ -92,9 +118,10 @@ def import_csv(csv_path: str, progress_cb=None) -> Dict[str, Any]: previous = (row.get("Previous") or "").strip() or None detail = (row.get("Detail") or "").strip() or None + series_id = FF_TO_FRED.get(event_name) batch.append(( event_date, event_time, currency, impact, event_name, - actual, forecast, previous, detail, "ff_cache", + actual, forecast, previous, series_id, detail, "ff_cache", )) if len(batch) >= 500: @@ -123,13 +150,14 @@ def _upsert_batch(conn, batch: list): conn.executemany( """INSERT INTO ff_calendar (event_date, event_time, currency, impact, event_name, - actual_value, forecast_value, previous_value, detail, source) - VALUES (?,?,?,?,?,?,?,?,?,?) + actual_value, forecast_value, previous_value, series_id, detail, source) + VALUES (?,?,?,?,?,?,?,?,?,?,?) ON CONFLICT(event_date, event_time, currency, event_name) DO UPDATE SET actual_value = COALESCE(excluded.actual_value, ff_calendar.actual_value), forecast_value = COALESCE(excluded.forecast_value, ff_calendar.forecast_value), previous_value = COALESCE(excluded.previous_value, ff_calendar.previous_value), + series_id = COALESCE(excluded.series_id, ff_calendar.series_id), fetched_at = datetime('now') """, batch, @@ -176,9 +204,10 @@ def sync_live() -> Dict[str, Any]: forecast = (ev.get("forecast") or "").strip() or None previous = (ev.get("previous") or "").strip() or None + series_id = FF_TO_FRED.get(event_name) batch.append(( event_date, event_time, currency, impact, event_name, - actual, forecast, previous, None, "ff_live", + actual, forecast, previous, series_id, None, "ff_live", )) if batch: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ed15b8b..ba57360 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -28,6 +28,7 @@ import InstrumentDashboard from './pages/InstrumentDashboard' import CycleActions from './pages/CycleActions' import MarketEvents from './pages/MarketEvents' import AIDesks from './pages/AIDesks' +import MacroSeriesPage from './pages/MacroSeriesPage' import { Navigate } from 'react-router-dom' import { useCycleWatcher } from './hooks/useApi' @@ -55,6 +56,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index cc761ca..668ea53 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -25,6 +25,7 @@ const nav = [ { to: '/position-history', icon: GitCompare, label: 'Position History' }, { to: '/backtest', icon: History, label: 'Backtest' }, { to: '/calendar', icon: Calendar, label: 'Calendar' }, + { to: '/macro-series', icon: TrendingUp, label: 'Macro Series' }, { to: '/institutional', icon: Building2, label: 'Inst. Reports' }, { to: '/specialist-desks', icon: Users, label: 'Specialist Desks' }, { to: '/instruments', icon: CandlestickChart, label: 'Instrument Analysis' }, diff --git a/frontend/src/pages/MacroSeriesPage.tsx b/frontend/src/pages/MacroSeriesPage.tsx new file mode 100644 index 0000000..8b0a38a --- /dev/null +++ b/frontend/src/pages/MacroSeriesPage.tsx @@ -0,0 +1,546 @@ +import { useState, useEffect, useCallback } from 'react' +import clsx from 'clsx' +import { RefreshCw, TrendingUp, TrendingDown, Minus } from 'lucide-react' +import { + ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, + ResponsiveContainer, ReferenceLine, Area, +} from 'recharts' + +const API = '' + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface SeriesMeta { + id: string + name: string + category: string + freq: string + unit: string + assets: string[] +} + +interface TSPoint { + event_date: string + actual_value: number | null + surprise_zscore: number | null + surprise_direction: string | null +} + +interface FFEvent { + event_date: string + event_time: string | null + event_name: string + actual_value: string | null + forecast_value: string | null + previous_value: string | null + impact: string +} + +interface SeriesHistory { + series_id: string + name: string + unit: string + category: string + freq: string + timeseries: TSPoint[] + events: FFEvent[] +} + +// ── Constants ───────────────────────────────────────────────────────────────── + +const CATEGORY_COLORS: Record = { + employment: '#3b82f6', + inflation: '#f59e0b', + growth: '#10b981', + monetary: '#8b5cf6', + credit: '#ef4444', + rates: '#06b6d4', +} + +const CATEGORY_LABELS: Record = { + employment: 'Employment', + inflation: 'Inflation', + growth: 'Growth', + monetary: 'Monetary', + credit: 'Credit', + rates: 'Rates', +} + +const RANGE_OPTIONS = [ + { key: '1y', label: '1Y', months: 12 }, + { key: '2y', label: '2Y', months: 24 }, + { key: '5y', label: '5Y', months: 60 }, + { key: '10y', label: '10Y', months: 120 }, + { key: 'all', label: 'All', months: 999 }, +] + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function fromDateForRange(months: number): string { + if (months >= 999) return '2007-01-01' + const d = new Date() + d.setMonth(d.getMonth() - months) + return d.toISOString().slice(0, 10) +} + +function fmtDate(s: string): string { + return s.slice(0, 7) // YYYY-MM +} + +function fmtVal(v: number | null, unit: string): string { + if (v === null) return '—' + const fixed = Math.abs(v) < 10 ? 2 : 1 + return `${v.toFixed(fixed)} ${unit}` +} + +// ── Custom Tooltip ──────────────────────────────────────────────────────────── + +interface TooltipPayload { + event_date: string + value: number | null + zscore: number | null + direction: string | null + ff_actual?: string + ff_forecast?: string + ff_event?: string +} + +function ChartTooltip({ active, payload, unit }: { + active?: boolean + payload?: Array<{ payload: TooltipPayload }> + unit: string +}) { + if (!active || !payload?.length) return null + const d = payload[0].payload + return ( +
+
{d.event_date}
+
+ {d.value !== null ? `${d.value?.toFixed(2)} ${unit}` : '—'} +
+ {d.zscore !== null && ( +
+ z-score: {d.zscore?.toFixed(2)} +
+ )} + {d.ff_event && ( +
+
FF Release
+
{d.ff_event}
+ {d.ff_forecast &&
Forecast: {d.ff_forecast}
} + {d.ff_actual &&
Actual: {d.ff_actual}
} +
+ )} +
+ ) +} + +// ── Direction Icon ───────────────────────────────────────────────────────────── + +function DirIcon({ dir }: { dir: string | null }) { + if (dir === 'bullish') return + if (dir === 'bearish') return + return +} + +// ── Series Sidebar ───────────────────────────────────────────────────────────── + +function SeriesList({ + series, + selected, + onSelect, +}: { + series: SeriesMeta[] + selected: string + onSelect: (id: string) => void +}) { + const grouped = series.reduce>((acc, s) => { + if (!acc[s.category]) acc[s.category] = [] + acc[s.category].push(s) + return acc + }, {}) + + return ( +
+ {Object.entries(grouped).map(([cat, items]) => ( +
+
+ {CATEGORY_LABELS[cat] ?? cat} +
+
+ {items.map(s => ( + + ))} +
+
+ ))} +
+ ) +} + +// ── Events Table ────────────────────────────────────────────────────────────── + +function EventsTable({ events, unit }: { events: FFEvent[]; unit: string }) { + const recent = [...events].reverse().slice(0, 30) + if (!recent.length) return ( +
+ No FF events linked to this series yet.
+ Import the CSV and run the bootstrap first. +
+ ) + + return ( +
+ + + + + + + + + + + + {recent.map((ev, i) => { + const aNum = parseFloat(ev.actual_value ?? '') + const fNum = parseFloat(ev.forecast_value ?? '') + const beat = !isNaN(aNum) && !isNaN(fNum) && aNum !== fNum + const better = beat && aNum > fNum + return ( + + + + + + + + ) + })} + +
DateEventActualForecastPrevious
{ev.event_date}{ev.event_name} + {ev.actual_value ?? '—'} + + {ev.forecast_value ?? '—'} + + {ev.previous_value ?? '—'} +
+
+ ) +} + +// ── Main Page ───────────────────────────────────────────────────────────────── + +export default function MacroSeriesPage() { + const [seriesList, setSeriesList] = useState([]) + const [selectedId, setSelectedId] = useState('PAYEMS') + const [range, setRange] = useState('5y') + const [history, setHistory] = useState(null) + const [loading, setLoading] = useState(false) + + // Load series catalog + useEffect(() => { + fetch(`${API}/api/eco/series`).then(r => r.json()).then(setSeriesList).catch(() => {}) + }, []) + + const loadHistory = useCallback(async () => { + if (!selectedId) return + setLoading(true) + try { + const months = RANGE_OPTIONS.find(r => r.key === range)?.months ?? 60 + const from = fromDateForRange(months) + const r: SeriesHistory = await fetch( + `${API}/api/eco/series/${selectedId}/history?from_date=${from}` + ).then(x => x.json()) + setHistory(r) + } catch {} + finally { setLoading(false) } + }, [selectedId, range]) + + useEffect(() => { loadHistory() }, [loadHistory]) + + // Build chart data — merge FRED time series + FF events by date + const chartData = (() => { + if (!history) return [] + const ffByDate = new Map() + for (const ev of history.events) ffByDate.set(ev.event_date, ev) + + return history.timeseries.map(pt => { + const ff = ffByDate.get(pt.event_date) + return { + event_date: pt.event_date, + label: fmtDate(pt.event_date), + value: pt.actual_value, + zscore: pt.surprise_zscore, + direction: pt.surprise_direction, + ff_event: ff?.event_name ?? null, + ff_actual: ff?.actual_value ?? null, + ff_forecast:ff?.forecast_value ?? null, + // For reference lines — mark surprise events + is_surprise: pt.surprise_zscore !== null && Math.abs(pt.surprise_zscore) >= 1.5, + } + }) + })() + + const color = CATEGORY_COLORS[history?.category ?? ''] ?? '#3b82f6' + + // Stats + const values = chartData.map(d => d.value).filter((v): v is number => v !== null) + const latest = values[values.length - 1] ?? null + const prev = values[values.length - 2] ?? null + const minVal = values.length ? Math.min(...values) : null + const maxVal = values.length ? Math.max(...values) : null + const trend = latest !== null && prev !== null ? (latest > prev ? 1 : latest < prev ? -1 : 0) : 0 + + // Big surprises for reference lines + const bigSurprises = chartData.filter(d => d.is_surprise) + + return ( +
+ {/* Sidebar */} +
+
+ FRED Series +
+ { setSelectedId(id); setHistory(null) }} + /> +
+ + {/* Main content */} +
+ {/* Header */} +
+
+

+ {history?.name ?? selectedId} +

+
+ {history?.category && ( + + {CATEGORY_LABELS[history.category]} + + )} + {history?.freq} · FRED/{selectedId} +
+
+ +
+ {/* Range selector */} +
+ {RANGE_OPTIONS.map(r => ( + + ))} +
+ +
+
+ + {/* KPI cards */} + {history && values.length > 0 && ( +
+ {[ + { + label: 'Latest', + value: fmtVal(latest, history.unit), + sub: history.timeseries[history.timeseries.length - 1]?.event_date, + accent: trend === 1 ? 'text-emerald-400' : trend === -1 ? 'text-red-400' : 'text-slate-200', + icon: trend === 1 ? : trend === -1 ? : , + }, + { label: 'Previous', value: fmtVal(prev, history.unit), sub: history.timeseries[history.timeseries.length - 2]?.event_date, accent: 'text-slate-300', icon: null }, + { label: 'Min (period)', value: fmtVal(minVal, history.unit), sub: null, accent: 'text-sky-400', icon: null }, + { label: 'Max (period)', value: fmtVal(maxVal, history.unit), sub: null, accent: 'text-amber-400', icon: null }, + ].map(card => ( +
+
{card.label}
+
+ {card.icon}{card.value} +
+ {card.sub &&
{card.sub}
} +
+ ))} +
+ )} + + {/* Chart */} +
+
+ + Time Series + {history && ({chartData.length} data points)} + + {bigSurprises.length > 0 && ( + + {bigSurprises.length} big surprises (|z| ≥ 1.5) + + )} +
+ + {loading && ( +
+ Loading… +
+ )} + + {!loading && chartData.length === 0 && ( +
+ No data — run FRED bootstrap first in the Calendar page. +
+ )} + + {!loading && chartData.length > 0 && ( + + + + + + + + + + + `${v}${history?.unit === '%' || history?.unit === 'pp' ? '' : ''}`} + /> + } /> + + {/* Zero line if series crosses zero */} + {minVal !== null && maxVal !== null && minVal < 0 && maxVal > 0 && ( + + )} + + {/* Big surprise markers */} + {bigSurprises.map(s => ( + + ))} + + + + + )} + + {/* Legend for reference lines */} + {bigSurprises.length > 0 && ( +
+ + + Bullish surprise (|z|≥1.5) + + + + Bearish surprise + +
+ )} +
+ + {/* Events table */} +
+
+ FF Calendar Events + {history && ({history.events.length} linked)} +
+ {history && } +
+ + {/* Z-score context */} + {history && chartData.some(d => d.zscore !== null) && ( +
+
Recent Surprises (FRED z-score)
+
+ {chartData + .filter(d => d.zscore !== null && d.zscore !== 0) + .slice(-15) + .reverse() + .map((d, i) => ( +
+ {d.event_date} + +
+
+
+ + {d.zscore!.toFixed(2)}σ + + + {d.value?.toFixed(2)} {history.unit} + +
+ ))} +
+
+ )} +
+
+ ) +}