FXStreet calendar.fxstreet.com/eventdate/ returns ~300-400 events over 6 weeks including consensus forecasts, no authentication required. FF HTML scraper is blocked by Cloudflare even on residential IPs. FMP free plan returns 403 on /economic_calendar (requires Starter plan). - Add backend/services/fxstreet_calendar.py: single GET request returning all major currencies; maps Volatility 0/1/2 → low/medium/high - Add POST /api/eco/fxs-sync + GET /api/eco/fxs-sync/status endpoints - Add FXStreet to daily background sync in main.py (runs every 24h) - Add "Sync Upcoming (FXStreet)" button in ImportPanel (no key needed) - Fix FMP 403 error message to say endpoint requires Starter plan - Keep FMP panel for users who upgrade to FMP Starter ($14.99/month) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
148 lines
4.3 KiB
Python
148 lines
4.3 KiB
Python
"""
|
|
FXStreet economic calendar — upcoming events with consensus forecasts.
|
|
No API key required. Endpoint: calendar.fxstreet.com/eventdate/
|
|
Covers ~300-400 events over 6 weeks for all major currencies.
|
|
"""
|
|
import logging
|
|
from datetime import datetime, timedelta, date
|
|
from typing import Any, Dict
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_FXS_URL = "https://calendar.fxstreet.com/eventdate/"
|
|
|
|
_SUPPORTED_CCY = {"USD", "EUR", "GBP", "JPY", "AUD", "CAD", "NZD", "CHF", "CNY"}
|
|
|
|
_VOLATILITY = {0: "low", 1: "medium", 2: "high"}
|
|
|
|
_HEADERS = {
|
|
"Accept": "application/json",
|
|
"User-Agent": (
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
"Chrome/125.0.0.0 Safari/537.36"
|
|
),
|
|
"Origin": "https://www.fxstreet.com",
|
|
"Referer": "https://www.fxstreet.com/economic-calendar",
|
|
}
|
|
|
|
|
|
def _parse_dt(dt_str: str) -> tuple[str, str]:
|
|
"""'2026-07-04T12:30:00Z' → ('2026-07-04', '12:30')"""
|
|
if not dt_str:
|
|
return "", "00:00"
|
|
try:
|
|
dt_str = dt_str.replace("Z", "+00:00")
|
|
dt = datetime.fromisoformat(dt_str)
|
|
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 _fmt(v: Any) -> str | None:
|
|
if v is None:
|
|
return None
|
|
try:
|
|
f = float(v)
|
|
if f != f:
|
|
return None
|
|
return str(int(f)) if f == int(f) else str(round(f, 4))
|
|
except (TypeError, ValueError):
|
|
s = str(v).strip()
|
|
return s or None
|
|
|
|
|
|
def fetch_upcoming(weeks_ahead: int = 6) -> Dict[str, Any]:
|
|
"""
|
|
Fetch upcoming economic events from FXStreet (no API key needed).
|
|
One request returns all currencies for the full date range.
|
|
"""
|
|
from services.database import get_conn
|
|
from services.ff_calendar import _upsert_batch, FF_TO_FRED
|
|
|
|
today = date.today()
|
|
date_to = today + timedelta(weeks=weeks_ahead)
|
|
|
|
params = {
|
|
"f": "json",
|
|
"v": "2",
|
|
"from": str(today),
|
|
"to": str(date_to),
|
|
"timezone": "UTC",
|
|
"culture": "en-GB",
|
|
}
|
|
|
|
print(f"[FXS calendar] fetching {today} → {date_to}", flush=True)
|
|
|
|
try:
|
|
resp = httpx.get(
|
|
_FXS_URL, params=params, headers=_HEADERS,
|
|
timeout=30, follow_redirects=True,
|
|
)
|
|
print(f"[FXS calendar] HTTP {resp.status_code}", flush=True)
|
|
|
|
if resp.status_code == 403:
|
|
return {"error": "FXStreet blocked this server IP (403). Try again later."}
|
|
if resp.status_code == 429:
|
|
return {"error": "FXStreet rate limit hit (429). Wait a few minutes."}
|
|
|
|
resp.raise_for_status()
|
|
events = resp.json()
|
|
|
|
if not isinstance(events, list):
|
|
return {"error": f"Unexpected FXStreet response: {str(events)[:200]}"}
|
|
|
|
except Exception as e:
|
|
return {"error": f"FXStreet request failed: {e}"}
|
|
|
|
batch = []
|
|
skipped = 0
|
|
for ev in events:
|
|
event_obj = ev.get("Event") or {}
|
|
|
|
ccy = (event_obj.get("CurrencyId") or "").strip()
|
|
if ccy not in _SUPPORTED_CCY:
|
|
skipped += 1
|
|
continue
|
|
|
|
event_name = (event_obj.get("Name") or "").strip()
|
|
if not event_name:
|
|
continue
|
|
|
|
dt_str = (ev.get("DateUtc") or "").strip()
|
|
ev_date, ev_time = _parse_dt(dt_str)
|
|
if not ev_date:
|
|
continue
|
|
|
|
volatility = ev.get("Volatility")
|
|
impact = _VOLATILITY.get(volatility, "low") if volatility is not None else "low"
|
|
|
|
actual = _fmt(ev.get("Actual"))
|
|
forecast = _fmt(ev.get("Consensus"))
|
|
# Use Revised previous if available, otherwise Previous
|
|
previous = _fmt(ev.get("Revised")) or _fmt(ev.get("Previous"))
|
|
|
|
series_id = FF_TO_FRED.get(event_name)
|
|
|
|
batch.append((
|
|
ev_date, ev_time, ccy, impact, event_name,
|
|
actual, forecast, previous,
|
|
series_id, None, "fxstreet",
|
|
))
|
|
|
|
conn = get_conn()
|
|
if batch:
|
|
_upsert_batch(conn, batch)
|
|
conn.commit()
|
|
conn.close()
|
|
|
|
print(f"[FXS calendar] {len(batch)} upserted, {skipped} skipped (non-major CCY)", flush=True)
|
|
return {
|
|
"total_upserted": len(batch),
|
|
"skipped": skipped,
|
|
"date_from": str(today),
|
|
"date_to": str(date_to),
|
|
}
|