123 lines
5.3 KiB
Python
123 lines
5.3 KiB
Python
"""
|
|
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. Fallback for weeks 3..N, only if step 2 got blocked (ForexFactory now 403s the
|
|
scraper — likely Cloudflare): Trading Economics first (services.te_calendar), then
|
|
FMP (services.fmp_calendar) — whichever has a configured key and actually returns
|
|
events. NOTE: neither is actually free for this endpoint — TE's calendar needs
|
|
their $149/mo base plan, FMP's free tier excludes economic_calendar (needs their
|
|
$14.99/mo Starter). Kept wired for whichever the user ends up paying for; look for
|
|
a cheaper source before recommending either.
|
|
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
|
|
print("[calendar_sync] step 1/3 — FF live JSON (thisweek + nextweek)", flush=True)
|
|
r_live = sync_live()
|
|
result["live"] = r_live
|
|
print(f"[calendar_sync] live result: {r_live}", flush=True)
|
|
|
|
# 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:
|
|
print(f"[calendar_sync] step 2/3 — FF HTML scrape, {scrape_weeks} week(s) ahead", flush=True)
|
|
r_scrape = scrape_upcoming(weeks_ahead=scrape_weeks)
|
|
result["scrape"] = r_scrape
|
|
print(f"[calendar_sync] scrape result: {r_scrape}", flush=True)
|
|
|
|
# FF's scraper gets 403'd (Cloudflare) often enough that "weeks 3..N" can
|
|
# silently come back empty — fall back to Trading Economics (free tier), then
|
|
# FMP (needs their paid plan for this endpoint), whichever is configured.
|
|
if not r_scrape.get("total_upserted"):
|
|
print("[calendar_sync] step 3/3 — scrape got 0 events, checking TE/FMP fallback", flush=True)
|
|
|
|
from services.te_calendar import check_te_key, fetch_upcoming as te_fetch_upcoming
|
|
te_status = check_te_key()
|
|
print(f"[calendar_sync] TE key configured: {te_status['configured']}", flush=True)
|
|
if te_status["configured"]:
|
|
r_te = te_fetch_upcoming(weeks_ahead=weeks_ahead)
|
|
result["te_fallback"] = r_te
|
|
print(f"[calendar_sync] TE fallback result: {r_te}", flush=True)
|
|
if r_te.get("total_upserted"):
|
|
source = "ff+te"
|
|
else:
|
|
result["te_fallback"] = {"skipped": "no_key"}
|
|
|
|
if not result["te_fallback"].get("total_upserted"):
|
|
from services.fmp_calendar import check_fmp_key, fetch_upcoming as fmp_fetch_upcoming
|
|
fmp_status = check_fmp_key()
|
|
print(f"[calendar_sync] FMP key configured: {fmp_status['configured']}", flush=True)
|
|
if fmp_status["configured"]:
|
|
r_fmp = fmp_fetch_upcoming(weeks_ahead=weeks_ahead)
|
|
result["fmp_fallback"] = r_fmp
|
|
print(f"[calendar_sync] FMP fallback result: {r_fmp}", flush=True)
|
|
if r_fmp.get("total_upserted"):
|
|
source = "ff+fmp"
|
|
else:
|
|
result["fmp_fallback"] = {"skipped": "no_key"}
|
|
else:
|
|
print("[calendar_sync] step 3/3 — scrape got events, TE/FMP fallback not needed", flush=True)
|
|
else:
|
|
result["scrape"] = {"skipped": True}
|
|
|
|
except Exception as e:
|
|
logger.error(f"[calendar_sync] FF sync failed: {e}")
|
|
print(f"[calendar_sync] EXCEPTION: {e!r}", flush=True)
|
|
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
|