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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
162
backend/services/fmp_calendar.py
Normal file
162
backend/services/fmp_calendar.py
Normal 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()
|
||||
@@ -167,27 +167,6 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () =>
|
||||
} catch { setSyncResult('sync error') }
|
||||
}
|
||||
|
||||
const [scrapeMsg, setScrapeMsg] = useState<string | null>(null)
|
||||
const scrapeRef = useRef<ReturnType<typeof setInterval> | 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
|
||||
</button>
|
||||
|
||||
{/* HTML scrape — upcoming 5 weeks with forecasts */}
|
||||
<button
|
||||
onClick={startScrape}
|
||||
disabled={scrapeMsg === 'scraping…'}
|
||||
className="px-3 py-1.5 rounded bg-indigo-700 hover:bg-indigo-600 disabled:opacity-50 flex items-center gap-1.5 text-white"
|
||||
title="Scrape forexfactory.com for upcoming 5 weeks with consensus forecasts"
|
||||
>
|
||||
<RefreshCw size={13} className={clsx(scrapeMsg === 'scraping…' && 'animate-spin')} />
|
||||
Scrape Upcoming (5w)
|
||||
</button>
|
||||
|
||||
{res && !importStatus.running && (
|
||||
res.error
|
||||
? <span className="text-red-400 text-xs">Import error: {res.error}</span>
|
||||
: <span className="text-emerald-400 text-xs flex items-center gap-1"><Check size={12}/> {res.inserted?.toLocaleString()} inserted</span>
|
||||
)}
|
||||
{syncResult && <span className="text-sky-400 text-xs">{syncResult}</span>}
|
||||
{scrapeMsg && (
|
||||
<span className={clsx('text-xs', scrapeMsg.startsWith('✓') ? 'text-emerald-400' : scrapeMsg.includes('error') ? 'text-red-400' : 'text-yellow-400')}>
|
||||
{scrapeMsg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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<TEKeyStatus | null>(null)
|
||||
const [keyInput, setKeyInput] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
@@ -316,7 +279,7 @@ function TEPanel({ onSynced }: { onSynced: () => void }) {
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | 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 }) {
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Key size={14} className="text-indigo-400" />
|
||||
<span className="text-slate-300 font-medium">Trading Economics</span>
|
||||
<span className="text-xs text-slate-500">upcoming forecasts — free API</span>
|
||||
<span className="text-slate-300 font-medium">FMP Calendar</span>
|
||||
<span className="text-xs text-slate-500">upcoming forecasts — free API (250 req/day)</span>
|
||||
</div>
|
||||
|
||||
{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"
|
||||
/>
|
||||
<button
|
||||
@@ -384,11 +347,11 @@ function TEPanel({ onSynced }: { onSynced: () => void }) {
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<a
|
||||
href="https://tradingeconomics.com/api/login"
|
||||
href="https://financialmodelingprep.com/developer/docs"
|
||||
target="_blank" rel="noreferrer"
|
||||
className="text-xs text-indigo-400 hover:text-indigo-300 underline"
|
||||
>
|
||||
Get free key ↗
|
||||
Free key ↗ (financialmodelingprep.com)
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
@@ -520,8 +483,8 @@ export default function CalendarPage() {
|
||||
{/* Import Panel */}
|
||||
<ImportPanel stats={stats} onImported={() => { fetchStats(); fetchEvents() }} />
|
||||
|
||||
{/* Trading Economics Panel — upcoming forecasts */}
|
||||
<TEPanel onSynced={() => { fetchStats(); fetchEvents() }} />
|
||||
{/* FMP Panel — upcoming forecasts */}
|
||||
<FMPPanel onSynced={() => { fetchStats(); fetchEvents() }} />
|
||||
|
||||
{/* Period Tabs */}
|
||||
<div className="flex flex-wrap gap-1 mb-3">
|
||||
|
||||
Reference in New Issue
Block a user