163 lines
4.9 KiB
Python
163 lines
4.9 KiB
Python
"""
|
|
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()
|