diff --git a/backend/main.py b/backend/main.py index cc3251c..4d99d86 100644 --- a/backend/main.py +++ b/backend/main.py @@ -106,41 +106,28 @@ def startup(): from services.institutional_scheduler import start_institutional_scheduler start_institutional_scheduler() - # Daily calendars sync — FF live (this week) + TE upcoming (if key configured) + # Calendar sync loop — FXStreet primary, FF fallback; interval from calendar_refresh_h config (default 6h) import threading, time as _time - def _daily_calendar_sync(): - _time.sleep(60) # let server fully boot first + def _calendar_sync_loop(): + _time.sleep(60) # let server fully boot while True: try: - # FF live JSON — this week actuals - from services.ff_calendar import sync_live - r = sync_live() - print(f"[Daily] FF live sync: {r}", flush=True) + from datetime import datetime as _dt, timezone as _tz, timedelta as _td + interval_h = float(get_config("calendar_refresh_h") or 6) + from services.calendar_sync import calendar_sync, set_next_run + set_next_run((_dt.now(_tz.utc) + _td(hours=interval_h)).isoformat()) + r = calendar_sync() + src = r.get("source") or ("ff_fallback" if r.get("_fallback") else "fxstreet") + print(f"[Calendar] Sync done — {src}, upserted={r.get('total_upserted', '?')}", flush=True) except Exception as e: - print(f"[Daily] FF live sync error: {e}", flush=True) - + print(f"[Calendar] Sync loop error: {e}", flush=True) try: - # FXStreet — upcoming forecasts (no API key needed) - from services.fxstreet_calendar import fetch_upcoming as fxs_fetch - r = fxs_fetch(weeks_ahead=6) - print(f"[Daily] FXStreet sync: {r.get('total_upserted', 0)} upserted", flush=True) - except Exception as e: - print(f"[Daily] FXStreet sync error: {e}", flush=True) + interval_h = float(get_config("calendar_refresh_h") or 6) + except Exception: + interval_h = 6 + _time.sleep(int(interval_h * 3600)) - try: - # FMP — upcoming forecasts (only if key is set + Starter plan) - from services.database import get_config - fmp_key = get_config("fmp_api_key") or "" - if fmp_key: - from services.fmp_calendar import fetch_upcoming - r = fetch_upcoming(weeks_ahead=6) - print(f"[Daily] FMP sync: {r.get('total_upserted', 0)} upserted", flush=True) - except Exception as e: - print(f"[Daily] FMP sync error: {e}", flush=True) - - _time.sleep(86400) # repeat every 24h - - threading.Thread(target=_daily_calendar_sync, daemon=True).start() + threading.Thread(target=_calendar_sync_loop, daemon=True).start() # Auto-import Forex Factory CSV if file is present (force, then delete CSV) import threading diff --git a/backend/routers/config.py b/backend/routers/config.py index 0791fab..9086de6 100644 --- a/backend/routers/config.py +++ b/backend/routers/config.py @@ -12,6 +12,7 @@ class ApiKeysRequest(BaseModel): newsapi_key: Optional[str] = None eia_api_key: Optional[str] = None fred_api_key: Optional[str] = None + calendar_refresh_h: Optional[str] = None class SourcesRequest(BaseModel): @@ -61,6 +62,9 @@ def update_api_keys(req: ApiKeysRequest): if req.fred_api_key is not None: set_config("fred_api_key", req.fred_api_key) updated.append("fred") + if req.calendar_refresh_h is not None: + set_config("calendar_refresh_h", req.calendar_refresh_h) + updated.append("calendar_refresh_h") return {"status": "ok", "updated": updated} diff --git a/backend/routers/eco.py b/backend/routers/eco.py index a1ce7ae..95db791 100644 --- a/backend/routers/eco.py +++ b/backend/routers/eco.py @@ -570,6 +570,36 @@ def fxs_sync_status_ep() -> Dict[str, Any]: return _fxs_sync_status +# ── Unified calendar sync ───────────────────────────────────────────────────── + +def _run_calendar_sync(weeks_ahead: int): + from services.calendar_sync import calendar_sync + try: + calendar_sync(weeks_ahead=weeks_ahead) + except Exception as e: + logger.error(f"[eco/calendar-sync] Failed: {e}") + + +@router.post("/calendar-sync") +def calendar_sync_ep( + background_tasks: BackgroundTasks, + weeks: int = Query(8, ge=1, le=12), +) -> Dict[str, Any]: + """Trigger unified calendar sync (FXStreet primary → FF fallback) in background.""" + from services.calendar_sync import get_sync_status + if get_sync_status()["running"]: + raise HTTPException(409, "Calendar sync already running") + background_tasks.add_task(_run_calendar_sync, weeks) + return {"status": "started", "weeks_ahead": weeks} + + +@router.get("/calendar-sync/status") +def calendar_sync_status_ep() -> Dict[str, Any]: + """Get unified calendar sync status (last_run, source, last_result, next_run).""" + from services.calendar_sync import get_sync_status + return get_sync_status() + + @router.get("/calendar") def ff_calendar( period: str = Query("recent", description="recent|today|tomorrow|yesterday|this_week|next_week|previous_week|this_month|next_month|previous_month|custom"), diff --git a/backend/services/calendar_sync.py b/backend/services/calendar_sync.py new file mode 100644 index 0000000..ba02d51 --- /dev/null +++ b/backend/services/calendar_sync.py @@ -0,0 +1,61 @@ +""" +Unified calendar sync — FXStreet (primary) with FF live fallback. +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: + 1. Try FXStreet (no API key, ~300-400 events over weeks_ahead weeks) + 2. Fall back to FF live JSON if FXStreet returns 403 or fails + """ + global _sync_status + _sync_status["running"] = True + _sync_status["last_run"] = datetime.now(timezone.utc).isoformat() + + result: Dict[str, Any] = {} + source = "unknown" + + try: + from services.fxstreet_calendar import fetch_upcoming as fxs_fetch + r = fxs_fetch(weeks_ahead=weeks_ahead) + if r.get("error"): + logger.warning(f"[calendar_sync] FXStreet error: {r['error']} — falling back to FF live") + from services.ff_calendar import sync_live + r_ff = sync_live() + result = {**r_ff, "_fallback": True, "_fxs_error": r["error"]} + source = "ff_fallback" + else: + result = r + source = "fxstreet" + except Exception as e: + logger.error(f"[calendar_sync] Exception: {e}") + result = {"error": str(e)} + source = "error" + + _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 diff --git a/frontend/src/pages/CalendarPage.tsx b/frontend/src/pages/CalendarPage.tsx index c7b5991..8b9c378 100644 --- a/frontend/src/pages/CalendarPage.tsx +++ b/frontend/src/pages/CalendarPage.tsx @@ -131,16 +131,50 @@ function SurpriseChip({ ev }: { ev: FFEvent }) { // ── Import Panel ────────────────────────────────────────────────────────────── +interface CalSyncStatus { + running: boolean + last_run: string | null + last_result: Record | null + source: string | null + next_run: string | null +} + +function fmtAgo(iso: string | null): string | null { + if (!iso) return null + const ms = Date.now() - new Date(iso).getTime() + const m = Math.floor(ms / 60000) + if (m < 1) return 'just now' + if (m < 60) return `${m}m ago` + const h = Math.floor(m / 60) + return `${h}h ${m % 60}m ago` +} + +function fmtNext(iso: string | null): string | null { + if (!iso) return null + const ms = new Date(iso).getTime() - Date.now() + if (ms <= 0) return 'imminent' + const m = Math.floor(ms / 60000) + if (m < 60) return `in ${m}m` + const h = Math.floor(m / 60) + return `in ${h}h ${m % 60}m` +} + function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () => void }) { - const [syncResult, setSyncResult] = useState(null) - const [fxsMsg, setFxsMsg] = useState(null) - const [fxsSyncing, setFxsSyncing] = useState(false) const [importStatus, setImportStatus] = useState({ running: false, last_result: null }) - const pollRef = useRef | null>(null) - const fxsPollRef = useRef | null>(null) + const pollRef = useRef | null>(null) + + const [calStatus, setCalStatus] = useState({ running: false, last_run: null, last_result: null, source: null, next_run: null }) + const [syncMsg, setSyncMsg] = useState(null) + const calPollRef = useRef | null>(null) + + const [teHtmlMsg, setTeHtmlMsg] = useState(null) + const [teHtmlBusy, setTeHtmlBusy] = useState(false) + const teHtmlPollRef = useRef | null>(null) + const fileInputId = useId() useEffect(() => { - const poll = async () => { + // Check startup auto-import status + const pollImport = async () => { const r: ImportStatus = await fetch(`${API}/api/eco/ff-import/status`).then(x => x.json()).catch(() => ({ running: false, last_result: null })) setImportStatus(r) if (r.running) { @@ -151,50 +185,44 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () => }, 2000) } } - poll() + pollImport() + + // Load calendar sync status + fetch(`${API}/api/eco/calendar-sync/status`).then(x => x.json()).then(setCalStatus).catch(() => {}) + return () => { - if (pollRef.current) clearInterval(pollRef.current) - if (fxsPollRef.current) clearInterval(fxsPollRef.current) + if (pollRef.current) clearInterval(pollRef.current) + if (calPollRef.current) clearInterval(calPollRef.current) + if (teHtmlPollRef.current) clearInterval(teHtmlPollRef.current) } }, [onImported]) - const startSync = async () => { - setSyncResult('syncing…') + const startCalendarSync = async () => { + setSyncMsg('syncing…') + setCalStatus(s => ({ ...s, running: true })) try { - await fetch(`${API}/api/eco/ff-sync`, { method: 'POST' }) - await new Promise(res => setTimeout(res, 2500)) - const s = await fetch(`${API}/api/eco/ff-sync/status`).then(x => x.json()) - const res = s.last_result || {} - setSyncResult(`✓ live: ${res.thisweek?.events ?? '?'} events`) - onImported() - } catch { setSyncResult('sync error') } - } - - const startFxsSync = async () => { - setFxsSyncing(true) - setFxsMsg('syncing FXStreet…') - try { - await fetch(`${API}/api/eco/fxs-sync?weeks=6`, { method: 'POST' }) - fxsPollRef.current = setInterval(async () => { - const s = await fetch(`${API}/api/eco/fxs-sync/status`).then(x => x.json()) + await fetch(`${API}/api/eco/calendar-sync?weeks=8`, { method: 'POST' }) + calPollRef.current = setInterval(async () => { + const s: CalSyncStatus = await fetch(`${API}/api/eco/calendar-sync/status`).then(x => x.json()) + setCalStatus(s) if (!s.running) { - clearInterval(fxsPollRef.current!) - setFxsSyncing(false) + clearInterval(calPollRef.current!) const r = s.last_result || {} - if (r.error) { setFxsMsg(`Error: ${r.error}`); return } - setFxsMsg(`✓ FXStreet: ${r.total_upserted} events (${r.date_from} → ${r.date_to})`) + if (r.error) { + setSyncMsg(`Erreur: ${r.error}`) + } else if (s.source === 'ff_fallback') { + const tw = r.thisweek?.events ?? '?' + const nw = r.nextweek?.events ?? '?' + setSyncMsg(`✓ FF fallback: ${tw}+${nw} events`) + } else { + setSyncMsg(`✓ FXStreet: ${r.total_upserted} events`) + } onImported() } }, 2000) - } catch { setFxsSyncing(false); setFxsMsg('FXStreet error') } + } catch { setSyncMsg('sync error'); setCalStatus(s => ({ ...s, running: false })) } } - // TE HTML upload + import - const [teHtmlMsg, setTeHtmlMsg] = useState(null) - const [teHtmlBusy, setTeHtmlBusy] = useState(false) - const teHtmlPollRef = useRef | null>(null) - const fileInputId = useId() - const uploadAndImportTeHtml = async (file: File) => { setTeHtmlBusy(true) setTeHtmlMsg('uploading…') @@ -223,6 +251,7 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () => const hasData = stats.total > 0 const res = importStatus.last_result + const srcLabel = calStatus.source === 'ff_fallback' ? 'FF fallback' : calStatus.source === 'fxstreet' ? 'FXStreet' : null return (
@@ -230,36 +259,32 @@ function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () =>
FF Calendar DB
{importStatus.running - ? Importing CSV… (auto-import on startup) + ? Importing CSV… : hasData ? <>{stats.total.toLocaleString()} events ({stats.earliest?.slice(0,7)} → {stats.latest?.slice(0,7)}) - : Auto-import will run on next server start + : No data yet }
+ {calStatus.last_run && ( +
+ Last: {fmtAgo(calStatus.last_run)}{srcLabel ? ` via ${srcLabel}` : ''} + {calStatus.next_run && · Next: {fmtNext(calStatus.next_run)}} +
+ )}
- {/* FF live sync — this week actuals */} + {/* Unified sync button */} - - {/* FXStreet — upcoming 6 weeks + forecasts, no API key */} - - {/* TE HTML import — paste calendar_data.txt from tradingeconomics.com */} + {/* TE HTML import */}