""" Financial Modeling Prep calendar service — upcoming events with consensus forecasts. Free API key: https://financialmodelingprep.com/developer/docs (250 req/day free) Endpoint: GET https://financialmodelingprep.com/api/v3/economic_calendar """ import logging from datetime import datetime, timezone, timedelta, date from typing import Any, Dict import httpx logger = logging.getLogger(__name__) _FMP_BASE = "https://financialmodelingprep.com/api/v3" # FMP country codes → our currency codes _FMP_COUNTRY_TO_CCY: Dict[str, str] = { "US": "USD", "EU": "EUR", "GB": "GBP", "JP": "JPY", "AU": "AUD", "CA": "CAD", "NZ": "NZD", "CH": "CHF", "CN": "CNY", } # FMP impact strings → our normalized labels _FMP_IMPACT: Dict[str, str] = { "High": "high", "Medium": "medium", "Low": "low", } def _get_fmp_key() -> str: from services.database import get_config return (get_config("fmp_api_key") or "").strip() def check_fmp_key() -> Dict[str, Any]: key = _get_fmp_key() return { "configured": bool(key), "preview": (key[:4] + "…" + key[-4:]) if len(key) > 8 else ("***" if key else ""), } def _parse_fmp_dt(dt_str: str) -> tuple[str, str]: """Parse FMP datetime '2026-07-04 12:30:00' (UTC) → (date, time).""" if not dt_str: return "", "00:00" try: # FMP returns UTC strings like "2026-07-04 12:30:00" or ISO format dt_str = dt_str.replace("T", " ").split(".")[0] dt = datetime.strptime(dt_str, "%Y-%m-%d %H:%M:%S") return dt.strftime("%Y-%m-%d"), dt.strftime("%H:%M") except Exception: return dt_str[:10] if len(dt_str) >= 10 else "", "00:00" def fetch_upcoming(weeks_ahead: int = 6) -> Dict[str, Any]: """ Fetch upcoming calendar events from FMP API and upsert into ff_calendar. One request covers all countries and the full date range. """ from services.database import get_conn from services.ff_calendar import _upsert_batch, FF_TO_FRED key = _get_fmp_key() if not key: return { "error": ( "FMP API key not configured. " "Register free at financialmodelingprep.com/developer/docs" ) } today = date.today() date_to = today + timedelta(weeks=weeks_ahead) url = f"{_FMP_BASE}/economic_calendar" params = { "from": str(today), "to": str(date_to), "apikey": key, } print(f"[FMP calendar] fetching {today} -> {date_to}", flush=True) try: resp = httpx.get(url, params=params, timeout=30, follow_redirects=True) print(f"[FMP calendar] HTTP {resp.status_code}", flush=True) if resp.status_code == 401: return {"error": "Invalid FMP API key"} if resp.status_code == 403: return {"error": "Economic Calendar not available on FMP free plan — requires Starter ($14.99/month) at financialmodelingprep.com/developer/docs"} resp.raise_for_status() events = resp.json() # FMP returns {"Error Message": "..."} on bad key if isinstance(events, dict) and "Error Message" in events: return {"error": events["Error Message"]} if not isinstance(events, list): return {"error": f"Unexpected FMP response: {str(events)[:200]}"} except Exception as e: return {"error": f"FMP request failed: {e}"} batch = [] for ev in events: country_code = (ev.get("country") or "").upper().strip() ccy = _FMP_COUNTRY_TO_CCY.get(country_code) if not ccy: continue event_name = (ev.get("event") or "").strip() if not event_name: continue impact_raw = (ev.get("impact") or "").strip() impact = _FMP_IMPACT.get(impact_raw, "low") dt_str = (ev.get("date") or "").strip() ev_date, ev_time = _parse_fmp_dt(dt_str) if not ev_date: continue # FMP uses "actual", "previous", "estimate" (= consensus forecast) actual = _fmt(ev.get("actual")) forecast = _fmt(ev.get("estimate")) # FMP calls it "estimate" previous = _fmt(ev.get("previous")) series_id = FF_TO_FRED.get(event_name) batch.append(( ev_date, ev_time, ccy, impact, event_name, actual or None, forecast or None, previous or None, series_id, None, "fmp_api", )) conn = get_conn() if batch: _upsert_batch(conn, batch) conn.commit() conn.close() print(f"[FMP calendar] {len(batch)} events upserted", flush=True) return { "total_upserted": len(batch), "date_from": str(today), "date_to": str(date_to), } def _fmt(v: Any) -> str: if v is None: return "" try: f = float(v) if f != f: # NaN return "" return str(int(f)) if f == int(f) else str(round(f, 4)) except (TypeError, ValueError): return str(v).strip()