""" 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), }