165 lines
5.5 KiB
Python
165 lines
5.5 KiB
Python
"""
|
|
Trading Economics calendar service — upcoming events with consensus forecasts.
|
|
NOTE: confirmed (2026-07) that the calendar endpoint is NOT actually free despite the
|
|
signup page — their base paid plan is $149/month. Kept wired in case a key is ever
|
|
available, but don't recommend this as a free option (same dead end as FMP, whose
|
|
free tier also excludes the economic_calendar endpoint — needs their $14.99/mo Starter).
|
|
Signup: https://tradingeconomics.com/api/login
|
|
Endpoint: GET https://api.tradingeconomics.com/calendar/country/united%20states?c=KEY
|
|
"""
|
|
import logging
|
|
from datetime import datetime, timezone, timedelta, date
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_TE_BASE = "https://api.tradingeconomics.com"
|
|
|
|
# TE country names → our currency codes
|
|
_TE_COUNTRY_TO_CCY: Dict[str, str] = {
|
|
"united states": "USD",
|
|
"euro area": "EUR",
|
|
"united kingdom":"GBP",
|
|
"japan": "JPY",
|
|
"australia": "AUD",
|
|
"canada": "CAD",
|
|
"new zealand": "NZD",
|
|
"switzerland": "CHF",
|
|
"china": "CNY",
|
|
}
|
|
|
|
# TE importance (1=low, 2=medium, 3=high) → our impact labels
|
|
_TE_IMPORTANCE = {1: "low", 2: "medium", 3: "high"}
|
|
|
|
_COUNTRIES = list(_TE_COUNTRY_TO_CCY.keys())
|
|
|
|
|
|
def _get_te_key() -> str:
|
|
from services.database import get_config
|
|
return (get_config("te_api_key") or "").strip()
|
|
|
|
|
|
def check_te_key() -> Dict[str, Any]:
|
|
key = _get_te_key()
|
|
return {
|
|
"configured": bool(key),
|
|
"preview": (key[:4] + "…" + key[-4:]) if len(key) > 8 else ("***" if key else ""),
|
|
}
|
|
|
|
|
|
def _parse_te_datetime(dt_str: str) -> tuple[str, str]:
|
|
"""Parse TE datetime '2026-07-04T12:30:00' (UTC) → (date, time)."""
|
|
if not dt_str:
|
|
return "", "00:00"
|
|
try:
|
|
# TE returns UTC datetimes
|
|
dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
|
|
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 Trading Economics API and upsert into ff_calendar.
|
|
Covers today → today + weeks_ahead weeks.
|
|
"""
|
|
from services.database import get_conn
|
|
from services.ff_calendar import _upsert_batch, FF_TO_FRED, SUPPORTED_CURRENCIES
|
|
|
|
key = _get_te_key()
|
|
if not key:
|
|
return {"error": "Trading Economics API key not configured. Register free at tradingeconomics.com/api/login"}
|
|
|
|
today = date.today()
|
|
date_to = today + timedelta(weeks=weeks_ahead)
|
|
|
|
params = {
|
|
"c": key,
|
|
"d1": str(today),
|
|
"d2": str(date_to),
|
|
"importance": "1,2,3", # all impact levels
|
|
}
|
|
|
|
total_inserted = 0
|
|
conn = get_conn()
|
|
|
|
for country in _COUNTRIES:
|
|
ccy = _TE_COUNTRY_TO_CCY[country]
|
|
try:
|
|
url = f"{_TE_BASE}/calendar/country/{country.replace(' ', '%20')}"
|
|
resp = httpx.get(url, params=params, timeout=20, follow_redirects=True)
|
|
print(f"[TE calendar] {country}: HTTP {resp.status_code}", flush=True)
|
|
|
|
if resp.status_code == 401:
|
|
conn.close()
|
|
return {"error": "Invalid Trading Economics API key"}
|
|
if resp.status_code == 403:
|
|
conn.close()
|
|
return {"error": "TE API key quota exceeded or invalid plan"}
|
|
|
|
resp.raise_for_status()
|
|
events = resp.json()
|
|
if not isinstance(events, list):
|
|
continue
|
|
|
|
batch = []
|
|
for ev in events:
|
|
event_name = (ev.get("Event") or ev.get("event") or "").strip()
|
|
if not event_name:
|
|
continue
|
|
|
|
importance = ev.get("Importance") or ev.get("importance") or 1
|
|
impact = _TE_IMPORTANCE.get(int(importance), "low")
|
|
|
|
dt_str = ev.get("Date") or ev.get("date") or ""
|
|
ev_date, ev_time = _parse_te_datetime(dt_str)
|
|
if not ev_date:
|
|
continue
|
|
|
|
actual = _fmt_val(ev.get("Actual") or ev.get("actual"))
|
|
forecast = _fmt_val(ev.get("Forecast") or ev.get("forecast"))
|
|
previous = _fmt_val(ev.get("Previous") or 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, "te_api",
|
|
))
|
|
|
|
if batch:
|
|
_upsert_batch(conn, batch)
|
|
conn.commit()
|
|
total_inserted += len(batch)
|
|
print(f"[TE calendar] {country}: {len(batch)} events upserted", flush=True)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"[TE calendar] {country}: {e}")
|
|
print(f"[TE calendar] {country} error: {e}", flush=True)
|
|
|
|
conn.close()
|
|
return {
|
|
"total_upserted": total_inserted,
|
|
"date_from": str(today),
|
|
"date_to": str(date_to),
|
|
"countries": len(_COUNTRIES),
|
|
}
|
|
|
|
|
|
def _fmt_val(v: Any) -> str:
|
|
"""Convert TE numeric value to string, return empty if None/NaN."""
|
|
if v is None:
|
|
return ""
|
|
try:
|
|
f = float(v)
|
|
if f != f: # NaN check
|
|
return ""
|
|
# Format cleanly: no trailing .0 for integers
|
|
return str(int(f)) if f == int(f) else str(round(f, 4))
|
|
except (TypeError, ValueError):
|
|
return str(v).strip()
|