From 5c86d6e7155f75c87abaa23fa24d9dc1f0378dc8 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Fri, 26 Jun 2026 17:42:09 +0200 Subject: [PATCH] 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 --- backend/main.py | 12 +-- backend/routers/eco.py | 36 +++---- backend/services/fmp_calendar.py | 162 ++++++++++++++++++++++++++++ frontend/src/pages/CalendarPage.tsx | 63 +++-------- 4 files changed, 199 insertions(+), 74 deletions(-) create mode 100644 backend/services/fmp_calendar.py diff --git a/backend/main.py b/backend/main.py index b02506b..2345a21 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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 diff --git a/backend/routers/eco.py b/backend/routers/eco.py index c792180..636151d 100644 --- a/backend/routers/eco.py +++ b/backend/routers/eco.py @@ -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 diff --git a/backend/services/fmp_calendar.py b/backend/services/fmp_calendar.py new file mode 100644 index 0000000..e5aa9a2 --- /dev/null +++ b/backend/services/fmp_calendar.py @@ -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() diff --git a/frontend/src/pages/CalendarPage.tsx b/frontend/src/pages/CalendarPage.tsx index 98cbde2..95ae262 100644 --- a/frontend/src/pages/CalendarPage.tsx +++ b/frontend/src/pages/CalendarPage.tsx @@ -167,27 +167,6 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () => } catch { setSyncResult('sync error') } } - const [scrapeMsg, setScrapeMsg] = useState(null) - const scrapeRef = useRef | null>(null) - - const startScrape = async () => { - setScrapeMsg('scraping…') - try { - await fetch(`${API}/api/eco/ff-scrape`, { method: 'POST' }) - scrapeRef.current = setInterval(async () => { - const s = await fetch(`${API}/api/eco/ff-scrape/status`).then(x => x.json()) - if (!s.running) { - clearInterval(scrapeRef.current!) - const r = s.last_result || {} - if (r.error) { setScrapeMsg(`scrape error: ${r.error}`); return } - setScrapeMsg(`✓ ${r.total_upserted ?? '?'} events (${r.weeks_scraped} weeks)`) - onImported() - } - }, 3000) - } catch { setScrapeMsg('scrape error') } - } - - useEffect(() => () => { if (scrapeRef.current) clearInterval(scrapeRef.current) }, []) const hasData = stats.total > 0 const res = importStatus.last_result @@ -216,28 +195,12 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () => Sync Live - {/* HTML scrape — upcoming 5 weeks with forecasts */} - - {res && !importStatus.running && ( res.error ? Import error: {res.error} : {res.inserted?.toLocaleString()} inserted )} {syncResult && {syncResult}} - {scrapeMsg && ( - - {scrapeMsg} - - )} ) } @@ -305,9 +268,9 @@ function ImpactFilter({ selected, onChange }: { selected: string[]; onChange: (v ) } -// ── Trading Economics Panel ─────────────────────────────────────────────────── +// ── FMP Panel — upcoming forecasts (free API) ───────────────────────────────── -function TEPanel({ onSynced }: { onSynced: () => void }) { +function FMPPanel({ onSynced }: { onSynced: () => void }) { const [keyStatus, setKeyStatus] = useState(null) const [keyInput, setKeyInput] = useState('') const [saving, setSaving] = useState(false) @@ -316,7 +279,7 @@ function TEPanel({ onSynced }: { onSynced: () => void }) { const pollRef = useRef | null>(null) useEffect(() => { - fetch(`${API}/api/eco/te-key`).then(r => r.json()).then(setKeyStatus).catch(() => {}) + fetch(`${API}/api/eco/fmp-key`).then(r => r.json()).then(setKeyStatus).catch(() => {}) return () => { if (pollRef.current) clearInterval(pollRef.current) } }, []) @@ -324,7 +287,7 @@ function TEPanel({ onSynced }: { onSynced: () => void }) { if (!keyInput.trim()) return setSaving(true) try { - const r = await fetch(`${API}/api/eco/te-key?key=${encodeURIComponent(keyInput.trim())}`, { method: 'POST' }).then(x => x.json()) + const r = await fetch(`${API}/api/eco/fmp-key?key=${encodeURIComponent(keyInput.trim())}`, { method: 'POST' }).then(x => x.json()) setKeyStatus({ configured: true, preview: r.preview }) setKeyInput('') } finally { setSaving(false) } @@ -334,9 +297,9 @@ function TEPanel({ onSynced }: { onSynced: () => void }) { setSyncing(true) setSyncMsg('syncing…') try { - await fetch(`${API}/api/eco/te-sync?weeks=6`, { method: 'POST' }) + await fetch(`${API}/api/eco/fmp-sync?weeks=6`, { method: 'POST' }) pollRef.current = setInterval(async () => { - const s = await fetch(`${API}/api/eco/te-sync/status`).then(x => x.json()) + const s = await fetch(`${API}/api/eco/fmp-sync/status`).then(x => x.json()) if (!s.running) { clearInterval(pollRef.current!) setSyncing(false) @@ -358,8 +321,8 @@ function TEPanel({ onSynced }: { onSynced: () => void }) {
- Trading Economics - upcoming forecasts — free API + FMP Calendar + upcoming forecasts — free API (250 req/day)
{configured ? ( @@ -373,7 +336,7 @@ function TEPanel({ onSynced }: { onSynced: () => void }) { value={keyInput} onChange={e => setKeyInput(e.target.value)} onKeyDown={handleKeyDown} - placeholder="Paste TE API key…" + placeholder="Paste FMP API key…" className="bg-slate-700 border border-slate-600 rounded px-2 py-1 text-xs text-white w-52 focus:outline-none focus:border-indigo-500" /> - Get free key ↗ + Free key ↗ (financialmodelingprep.com)
)} @@ -520,8 +483,8 @@ export default function CalendarPage() { {/* Import Panel */} { fetchStats(); fetchEvents() }} /> - {/* Trading Economics Panel — upcoming forecasts */} - { fetchStats(); fetchEvents() }} /> + {/* FMP Panel — upcoming forecasts */} + { fetchStats(); fetchEvents() }} /> {/* Period Tabs */}