""" Unified calendar sync. 1. FF live JSON (nfs.faireconomy.media) → this week + next week (actual values, precise) 2. FF HTML scraper → weeks 3..N ahead (upcoming forecasts) 3. FMP API fallback → weeks 3..N ahead, only if step 2 got blocked (ForexFactory now 403s the scraper — likely Cloudflare) AND an FMP key is configured (services.fmp_calendar) Called by the background loop in main.py and manually via POST /api/eco/calendar-sync. """ import logging from datetime import datetime, timezone from typing import Any, Dict logger = logging.getLogger(__name__) _sync_status: Dict[str, Any] = { "running": False, "last_run": None, "last_result": None, "source": None, "next_run": None, } def calendar_sync(weeks_ahead: int = 8) -> Dict[str, Any]: """ Sync upcoming economic events from ForexFactory only: - FF live JSON covers this week + next week (with actuals as they publish) - FF HTML scraper covers weeks 3..weeks_ahead for forecasts """ global _sync_status _sync_status["running"] = True _sync_status["last_run"] = datetime.now(timezone.utc).isoformat() result: Dict[str, Any] = {} source = "ff" try: from services.ff_calendar import sync_live, scrape_upcoming # Step 1 — live JSON (thisweek + nextweek) — picks up actuals in real time r_live = sync_live() result["live"] = r_live # Step 2 — HTML scraper for weeks 3..N ahead (forecasts only, no actuals yet) scrape_weeks = max(0, weeks_ahead - 2) if scrape_weeks > 0: r_scrape = scrape_upcoming(weeks_ahead=scrape_weeks) result["scrape"] = r_scrape # FF's scraper gets 403'd (Cloudflare) often enough that "weeks 3..N" can # silently come back empty — fall back to FMP for that same range when it does. if not r_scrape.get("total_upserted"): from services.fmp_calendar import check_fmp_key, fetch_upcoming if check_fmp_key()["configured"]: r_fmp = fetch_upcoming(weeks_ahead=weeks_ahead) result["fmp_fallback"] = r_fmp if r_fmp.get("total_upserted"): source = "ff+fmp" else: result["fmp_fallback"] = {"skipped": "no_key"} else: result["scrape"] = {"skipped": True} except Exception as e: logger.error(f"[calendar_sync] FF sync failed: {e}") result = {"error": str(e)} source = "error" # Log forecast/actual changes to macro_series_log try: from services.database import get_conn from services.macro_series_log import scan_and_log_all _conn = get_conn() log_result = scan_and_log_all(_conn, source=source) _conn.close() result["_log"] = log_result except Exception as e: logger.warning(f"[calendar_sync] macro_series_log scan failed: {e}") _sync_status["running"] = False _sync_status["last_result"] = result _sync_status["source"] = source return result def get_sync_status() -> Dict[str, Any]: return dict(_sync_status) def set_next_run(ts: str): _sync_status["next_run"] = ts