- backend/services/fred_bootstrap.py: fetch 11 FRED series (PAYEMS, UNRATE, CPI, PCE, FEDFUNDS, ICSA, GDP, HY spread, T10Y2Y, T10Y3M) from public CSV endpoint — no API key needed; computes rolling z-scores and upserts into economic_events table - backend/routers/eco.py: new /api/eco router with bootstrap (POST + status GET), events list with full filtering (date range, category, series, min z-score, direction, sort/pagination), series catalog, and db status endpoints - backend/main.py: register eco router - frontend/src/pages/CalendarPage.tsx: complete rewrite — real data table from /api/eco/events, Bootstrap FRED button with live polling, filter bar (date range, category, series chips, |z| threshold, direction), sort by date/z-score/series, pagination, z-score badges with color coding, sidebar with series inventory + geo alerts + z-score guide Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
199 lines
7.7 KiB
Python
199 lines
7.7 KiB
Python
"""
|
|
Economic calendar router — FRED-backed historical events + bootstrap endpoint.
|
|
Prefix: /api/eco
|
|
"""
|
|
import json
|
|
import logging
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/eco", tags=["Economic Calendar"])
|
|
|
|
|
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
def _parse_row(row: Dict) -> Dict:
|
|
for field in ("assets_impacted",):
|
|
val = row.get(field)
|
|
if isinstance(val, str):
|
|
try:
|
|
row[field] = json.loads(val) if val else []
|
|
except Exception:
|
|
row[field] = []
|
|
elif val is None:
|
|
row[field] = []
|
|
return row
|
|
|
|
|
|
# ── Bootstrap ─────────────────────────────────────────────────────────────────
|
|
|
|
_bootstrap_status: Dict[str, Any] = {"running": False, "last_result": None}
|
|
|
|
|
|
def _run_bootstrap(from_date: str, series_ids: Optional[List[str]], force: bool):
|
|
global _bootstrap_status
|
|
_bootstrap_status["running"] = True
|
|
try:
|
|
from services.fred_bootstrap import bootstrap_fred
|
|
result = bootstrap_fred(from_date=from_date, series_ids=series_ids, force=force)
|
|
_bootstrap_status["last_result"] = result
|
|
total_inserted = sum(v.get("inserted", 0) for v in result.values() if isinstance(v, dict))
|
|
logger.info(f"[eco/bootstrap] Done — {total_inserted} rows inserted")
|
|
except Exception as e:
|
|
logger.error(f"[eco/bootstrap] Failed: {e}")
|
|
_bootstrap_status["last_result"] = {"error": str(e)}
|
|
finally:
|
|
_bootstrap_status["running"] = False
|
|
|
|
|
|
@router.post("/bootstrap")
|
|
def bootstrap(
|
|
background_tasks: BackgroundTasks,
|
|
from_date: str = Query("2020-01-01", description="Start date for historical data"),
|
|
series: Optional[str] = Query(None, description="Comma-separated series IDs (blank = all)"),
|
|
force: bool = Query(False, description="Overwrite existing rows"),
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Trigger FRED data bootstrap (runs in background).
|
|
Fetches public CSV endpoint — no API key required.
|
|
"""
|
|
if _bootstrap_status["running"]:
|
|
raise HTTPException(409, "Bootstrap already running")
|
|
series_ids = [s.strip() for s in series.split(",") if s.strip()] if series else None
|
|
background_tasks.add_task(_run_bootstrap, from_date, series_ids, force)
|
|
return {"status": "started", "from_date": from_date, "series": series_ids or "all"}
|
|
|
|
|
|
@router.get("/bootstrap/status")
|
|
def bootstrap_status() -> Dict[str, Any]:
|
|
return _bootstrap_status
|
|
|
|
|
|
# ── Series catalog ────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/series")
|
|
def list_series() -> List[Dict[str, Any]]:
|
|
"""Return available FRED series metadata."""
|
|
from services.fred_bootstrap import FRED_SERIES, CATEGORIES
|
|
return [
|
|
{
|
|
"id": sid,
|
|
"name": meta["name"],
|
|
"category": meta["category"],
|
|
"freq": meta["freq"],
|
|
"unit": meta["unit"],
|
|
"assets": meta["assets"],
|
|
}
|
|
for sid, meta in FRED_SERIES.items()
|
|
]
|
|
|
|
|
|
# ── Events list ───────────────────────────────────────────────────────────────
|
|
|
|
_SORT_COLS = {
|
|
"date": "ee.event_date",
|
|
"zscore": "ABS(COALESCE(ee.surprise_zscore, 0))",
|
|
"series": "ee.series_id",
|
|
"name": "ee.event_name",
|
|
}
|
|
|
|
|
|
@router.get("/events")
|
|
def list_eco_events(
|
|
date_from: Optional[str] = Query(None),
|
|
date_to: Optional[str] = Query(None),
|
|
series: Optional[str] = Query(None, description="Comma-separated series IDs"),
|
|
category: Optional[str] = Query(None, description="employment|inflation|growth|monetary|credit|rates"),
|
|
min_zscore: float = Query(0.0, ge=0.0, description="Minimum |z-score| filter"),
|
|
direction: Optional[str] = Query(None, description="bullish|bearish|neutral"),
|
|
sort_by: str = Query("date", description="date|zscore|series|name"),
|
|
sort_dir: str = Query("desc", description="asc|desc"),
|
|
limit: int = Query(200, ge=1, le=2000),
|
|
offset: int = Query(0, ge=0),
|
|
) -> Dict[str, Any]:
|
|
from services.database import get_conn
|
|
from services.fred_bootstrap import FRED_SERIES
|
|
|
|
conn = get_conn()
|
|
where_parts: List[str] = []
|
|
params: List[Any] = []
|
|
|
|
if date_from:
|
|
where_parts.append("ee.event_date >= ?")
|
|
params.append(date_from)
|
|
if date_to:
|
|
where_parts.append("ee.event_date <= ?")
|
|
params.append(date_to)
|
|
|
|
if series:
|
|
ids = [s.strip() for s in series.split(",") if s.strip()]
|
|
if ids:
|
|
placeholders = ",".join("?" * len(ids))
|
|
where_parts.append(f"ee.series_id IN ({placeholders})")
|
|
params.extend(ids)
|
|
|
|
if category:
|
|
# Map category → series IDs
|
|
matching = [sid for sid, m in FRED_SERIES.items() if m["category"] == category]
|
|
if matching:
|
|
placeholders = ",".join("?" * len(matching))
|
|
where_parts.append(f"ee.series_id IN ({placeholders})")
|
|
params.extend(matching)
|
|
else:
|
|
# No matching series — return empty
|
|
conn.close()
|
|
return {"total": 0, "offset": offset, "limit": limit, "events": []}
|
|
|
|
if min_zscore > 0:
|
|
where_parts.append("ABS(COALESCE(ee.surprise_zscore, 0)) >= ?")
|
|
params.append(min_zscore)
|
|
if direction:
|
|
where_parts.append("ee.surprise_direction = ?")
|
|
params.append(direction)
|
|
|
|
where_sql = ("WHERE " + " AND ".join(where_parts)) if where_parts else ""
|
|
sort_col = _SORT_COLS.get(sort_by, "ee.event_date")
|
|
order_sql = f"ORDER BY {sort_col} {'DESC' if sort_dir == 'desc' else 'ASC'}"
|
|
|
|
count_sql = f"SELECT COUNT(*) FROM economic_events ee {where_sql}"
|
|
data_sql = f"SELECT ee.* FROM economic_events ee {where_sql} {order_sql} LIMIT ? OFFSET ?"
|
|
|
|
try:
|
|
total = conn.execute(count_sql, params).fetchone()[0]
|
|
rows = conn.execute(data_sql, params + [limit, offset]).fetchall()
|
|
finally:
|
|
conn.close()
|
|
|
|
events = [_parse_row(dict(r)) for r in rows]
|
|
return {"total": total, "offset": offset, "limit": limit, "events": events}
|
|
|
|
|
|
# ── DB count summary ──────────────────────────────────────────────────────────
|
|
|
|
@router.get("/status")
|
|
def eco_status() -> Dict[str, Any]:
|
|
from services.database import get_conn
|
|
conn = get_conn()
|
|
try:
|
|
total = conn.execute("SELECT COUNT(*) FROM economic_events").fetchone()[0]
|
|
latest = conn.execute(
|
|
"SELECT MAX(event_date) FROM economic_events"
|
|
).fetchone()[0]
|
|
earliest = conn.execute(
|
|
"SELECT MIN(event_date) FROM economic_events"
|
|
).fetchone()[0]
|
|
by_series = conn.execute(
|
|
"SELECT series_id, COUNT(*) as cnt, MAX(event_date) as latest "
|
|
"FROM economic_events GROUP BY series_id ORDER BY series_id"
|
|
).fetchall()
|
|
return {
|
|
"total": total,
|
|
"earliest": earliest,
|
|
"latest": latest,
|
|
"by_series": [dict(r) for r in by_series],
|
|
}
|
|
finally:
|
|
conn.close()
|