""" 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()