feat: replace Trading Economics with FMP for upcoming calendar forecasts

TE costs $199/month and its Economic Calendar is not in the free tier.
FMP (Financial Modeling Prep) offers a free API key (250 req/day) with
full economic calendar coverage including consensus estimates (forecasts).

- Add backend/services/fmp_calendar.py: fetches upcoming events from
  GET /api/v3/economic_calendar (one request, all countries, date range)
- Replace /api/eco/te-key + te-sync endpoints with fmp-key + fmp-sync
- Update daily background sync in main.py to use fmp_calendar
- Replace TEPanel with FMPPanel in CalendarPage.tsx (link to FMP docs)
- Remove broken Cloudflare-blocked FF HTML scrape button from ImportPanel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-26 17:42:09 +02:00
parent ec6fcc1e3d
commit 5c86d6e715
4 changed files with 199 additions and 74 deletions

View File

@@ -109,15 +109,15 @@ def startup():
print(f"[Daily] FF live sync error: {e}", flush=True)
try:
# Trading Economics — upcoming forecasts (only if key is set)
# FMP — upcoming forecasts (only if key is set)
from services.database import get_config
te_key = get_config("te_api_key") or ""
if te_key:
from services.te_calendar import fetch_upcoming
fmp_key = get_config("fmp_api_key") or ""
if fmp_key:
from services.fmp_calendar import fetch_upcoming
r = fetch_upcoming(weeks_ahead=6)
print(f"[Daily] TE sync: {r.get('total_upserted', 0)} upserted", flush=True)
print(f"[Daily] FMP sync: {r.get('total_upserted', 0)} upserted", flush=True)
except Exception as e:
print(f"[Daily] TE sync error: {e}", flush=True)
print(f"[Daily] FMP sync error: {e}", flush=True)
_time.sleep(86400) # repeat every 24h

View File

@@ -420,52 +420,52 @@ def ff_scrape_status_ep() -> Dict[str, Any]:
return _ff_scrape_status
# ── Trading Economics calendar (upcoming weeks with forecasts) ─────────────────
# ── FMP calendar (upcoming weeks with consensus forecasts) ────────────────────
@router.get("/te-key")
def get_te_key() -> Dict[str, Any]:
from services.te_calendar import check_te_key
return check_te_key()
@router.get("/fmp-key")
def get_fmp_key() -> Dict[str, Any]:
from services.fmp_calendar import check_fmp_key
return check_fmp_key()
@router.post("/te-key")
def save_te_key(key: str = Query(..., description="Trading Economics API key")) -> Dict[str, Any]:
@router.post("/fmp-key")
def save_fmp_key(key: str = Query(..., description="FMP API key")) -> Dict[str, Any]:
from services.database import set_config
key = key.strip()
if not key:
raise HTTPException(400, "Empty key")
set_config("te_api_key", key)
set_config("fmp_api_key", key)
return {"status": "saved", "preview": key[:4] + "" + key[-4:]}
def _run_te_sync(weeks_ahead: int):
def _run_fmp_sync(weeks_ahead: int):
global _te_sync_status
_te_sync_status["running"] = True
try:
from services.te_calendar import fetch_upcoming
from services.fmp_calendar import fetch_upcoming
result = fetch_upcoming(weeks_ahead=weeks_ahead)
_te_sync_status["last_result"] = result
except Exception as e:
logger.error(f"[eco/te-sync] Failed: {e}")
logger.error(f"[eco/fmp-sync] Failed: {e}")
_te_sync_status["last_result"] = {"error": str(e)}
finally:
_te_sync_status["running"] = False
@router.post("/te-sync")
def te_sync(
@router.post("/fmp-sync")
def fmp_sync(
background_tasks: BackgroundTasks,
weeks: int = Query(6, ge=1, le=12),
) -> Dict[str, Any]:
"""Fetch upcoming economic events + forecasts from Trading Economics API."""
"""Fetch upcoming economic events + forecasts from FMP API (free, 250 req/day)."""
if _te_sync_status["running"]:
raise HTTPException(409, "TE sync already running")
background_tasks.add_task(_run_te_sync, weeks)
raise HTTPException(409, "FMP sync already running")
background_tasks.add_task(_run_fmp_sync, weeks)
return {"status": "started", "weeks_ahead": weeks}
@router.get("/te-sync/status")
def te_sync_status_ep() -> Dict[str, Any]:
@router.get("/fmp-sync/status")
def fmp_sync_status_ep() -> Dict[str, Any]:
return _te_sync_status

View File

@@ -0,0 +1,162 @@
"""
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": "FMP API key quota exceeded"}
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()