feat: FF→FRED series mapping + MacroSeries visualization page
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user