diff --git a/backend/routers/eco.py b/backend/routers/eco.py index 68498d2..f0a0b6f 100644 --- a/backend/routers/eco.py +++ b/backend/routers/eco.py @@ -1,9 +1,10 @@ """ -Economic calendar router — FRED-backed historical events + bootstrap endpoint. +Economic calendar router — FRED-backed historical events + Forex Factory calendar. Prefix: /api/eco """ import json import logging +import os from typing import Any, Dict, List, Optional from fastapi import APIRouter, BackgroundTasks, HTTPException, Query @@ -290,6 +291,125 @@ def upcoming_events() -> List[Dict[str, Any]]: return upcoming +# ── Forex Factory calendar ──────────────────────────────────────────────────── + +_ff_import_status: Dict[str, Any] = {"running": False, "last_result": None} +_ff_sync_status: Dict[str, Any] = {"running": False, "last_result": None} + +_FF_CSV_PATH = os.path.join( + os.path.dirname(__file__), "..", "..", "forex_factory_cache.csv" +) + + +def _run_ff_import(): + global _ff_import_status + _ff_import_status["running"] = True + try: + from services.ff_calendar import import_csv + csv_path = os.path.abspath(_FF_CSV_PATH) + result = import_csv(csv_path) + _ff_import_status["last_result"] = result + except Exception as e: + logger.error(f"[eco/ff-import] Failed: {e}") + _ff_import_status["last_result"] = {"error": str(e)} + finally: + _ff_import_status["running"] = False + + +def _run_ff_sync(): + global _ff_sync_status + _ff_sync_status["running"] = True + try: + from services.ff_calendar import sync_live + result = sync_live() + _ff_sync_status["last_result"] = result + except Exception as e: + logger.error(f"[eco/ff-sync] Failed: {e}") + _ff_sync_status["last_result"] = {"error": str(e)} + finally: + _ff_sync_status["running"] = False + + +@router.post("/ff-import") +def ff_import(background_tasks: BackgroundTasks) -> Dict[str, Any]: + """Import forex_factory_cache.csv into ff_calendar table (background).""" + if _ff_import_status["running"]: + raise HTTPException(409, "Import already running") + csv_path = os.path.abspath(_FF_CSV_PATH) + if not os.path.exists(csv_path): + raise HTTPException(404, f"CSV not found: {csv_path}") + background_tasks.add_task(_run_ff_import) + return {"status": "started", "csv": csv_path} + + +@router.get("/ff-import/status") +def ff_import_status() -> Dict[str, Any]: + return _ff_import_status + + +@router.post("/ff-sync") +def ff_sync(background_tasks: BackgroundTasks) -> Dict[str, Any]: + """Fetch this week + next week from faireconomy.media and upsert.""" + if _ff_sync_status["running"]: + raise HTTPException(409, "Sync already running") + background_tasks.add_task(_run_ff_sync) + return {"status": "started"} + + +@router.get("/ff-sync/status") +def ff_sync_status_ep() -> Dict[str, Any]: + return _ff_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"), + currencies: Optional[str] = Query(None, description="Comma-separated: USD,EUR,GBP,..."), + impacts: Optional[str] = Query(None, description="Comma-separated: high,medium,low"), + limit: int = Query(300, ge=1, le=2000), + offset: int = Query(0, ge=0), +) -> Dict[str, Any]: + """Unified Forex Factory calendar — past releases + upcoming events.""" + from services.ff_calendar import get_calendar + cur_list = [c.strip().upper() for c in currencies.split(",") if c.strip()] if currencies else None + imp_list = [i.strip().lower() for i in impacts.split(",") if i.strip()] if impacts else None + return get_calendar( + period=period, + currencies=cur_list, + impacts=imp_list, + limit=limit, + offset=offset, + ) + + +@router.get("/ff-stats") +def ff_stats() -> Dict[str, Any]: + """Quick inventory of ff_calendar table.""" + from services.database import get_conn + conn = get_conn() + try: + total = conn.execute("SELECT COUNT(*) FROM ff_calendar").fetchone()[0] + if total == 0: + return {"total": 0} + earliest = conn.execute("SELECT MIN(event_date) FROM ff_calendar").fetchone()[0] + latest = conn.execute("SELECT MAX(event_date) FROM ff_calendar").fetchone()[0] + by_currency = conn.execute( + "SELECT currency, COUNT(*) AS cnt FROM ff_calendar GROUP BY currency ORDER BY cnt DESC" + ).fetchall() + by_impact = conn.execute( + "SELECT impact, COUNT(*) AS cnt FROM ff_calendar GROUP BY impact ORDER BY cnt DESC" + ).fetchall() + return { + "total": total, + "earliest": earliest, + "latest": latest, + "by_currency": [dict(r) for r in by_currency], + "by_impact": [dict(r) for r in by_impact], + } + finally: + conn.close() + + # ── DB count summary ────────────────────────────────────────────────────────── @router.get("/status") diff --git a/backend/services/database.py b/backend/services/database.py index a9032a7..d22c527 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -685,6 +685,29 @@ def init_db(): except Exception: pass + # ── Forex Factory Calendar ───────────────────────────────────────────────── + c.execute("""CREATE TABLE IF NOT EXISTS ff_calendar ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + event_date TEXT NOT NULL, + event_time TEXT, + currency TEXT NOT NULL, + impact TEXT, + event_name TEXT NOT NULL, + actual_value TEXT, + forecast_value TEXT, + previous_value TEXT, + detail TEXT, + source TEXT DEFAULT 'ff_cache', + fetched_at TEXT DEFAULT (datetime('now')), + UNIQUE(event_date, event_time, currency, event_name) + )""") + try: + c.execute("CREATE INDEX IF NOT EXISTS idx_ff_date ON ff_calendar(event_date)") + c.execute("CREATE INDEX IF NOT EXISTS idx_ff_currency ON ff_calendar(currency)") + c.execute("CREATE INDEX IF NOT EXISTS idx_ff_impact ON ff_calendar(impact)") + except Exception: + pass + # ── Specialist Desks ─────────────────────────────────────────────────────── c.execute("""CREATE TABLE IF NOT EXISTS specialist_reports ( id TEXT PRIMARY KEY, diff --git a/backend/services/ff_calendar.py b/backend/services/ff_calendar.py new file mode 100644 index 0000000..1a0a2c4 --- /dev/null +++ b/backend/services/ff_calendar.py @@ -0,0 +1,316 @@ +""" +Forex Factory calendar service. +- CSV bulk import from forex_factory_cache.csv (2007-2025) +- Live sync from nfs.faireconomy.media JSON endpoint (this week / next week) +""" +import csv +import json +import logging +from datetime import datetime, timezone, timedelta +from typing import Any, Dict, List, Optional + +import httpx + +logger = logging.getLogger(__name__) + +_FF_THIS_WEEK_URL = "https://nfs.faireconomy.media/ff_calendar_thisweek.json" +_FF_NEXT_WEEK_URL = "https://nfs.faireconomy.media/ff_calendar_nextweek.json" + +# Map FF impact strings → normalized +_IMPACT_MAP = { + "High Impact Expected": "high", + "High": "high", + "Medium Impact Expected": "medium", + "Medium": "medium", + "Low Impact Expected": "low", + "Low": "low", + "Non-Economic": "non-economic", + "Holiday": "non-economic", +} + +# Currencies we care about (drop 'All' pseudo-currency) +SUPPORTED_CURRENCIES = {"USD", "EUR", "GBP", "JPY", "AUD", "CAD", "NZD", "CHF", "CNY"} + + +def _parse_ff_datetime(dt_str: str) -> tuple[str, str]: + """ + Parse an ISO datetime with offset (e.g. '2007-01-05T17:00:00+03:30') + into (event_date YYYY-MM-DD UTC, event_time HH:MM UTC). + """ + try: + dt = datetime.fromisoformat(dt_str) + dt_utc = dt.astimezone(timezone.utc) + return dt_utc.strftime("%Y-%m-%d"), dt_utc.strftime("%H:%M") + except Exception: + # Fallback: take the date/time literally + parts = dt_str[:16].split("T") + return parts[0], parts[1] if len(parts) > 1 else "00:00" + + +def import_csv(csv_path: str, progress_cb=None) -> Dict[str, Any]: + """ + Bulk-import forex_factory_cache.csv into ff_calendar table. + Skips non-economic events and unsupported currencies. + Returns {inserted, skipped, total}. + """ + from services.database import get_conn + + conn = get_conn() + inserted = 0 + skipped = 0 + total = 0 + + try: + with open(csv_path, encoding="utf-8", errors="replace") as f: + reader = csv.DictReader(f) + batch = [] + + for row in reader: + total += 1 + currency = (row.get("Currency") or "").strip() + impact_raw = (row.get("Impact") or "").strip() + impact = _IMPACT_MAP.get(impact_raw, "low") + + # Skip non-economic and unsupported currencies + if currency not in SUPPORTED_CURRENCIES: + skipped += 1 + continue + if impact == "non-economic": + skipped += 1 + continue + + event_name = (row.get("Event") or "").strip() + if not event_name: + skipped += 1 + continue + + dt_str = (row.get("DateTime") or "").strip() + event_date, event_time = _parse_ff_datetime(dt_str) + + actual = (row.get("Actual") or "").strip() or None + forecast = (row.get("Forecast") or "").strip() or None + previous = (row.get("Previous") or "").strip() or None + detail = (row.get("Detail") or "").strip() or None + + batch.append(( + event_date, event_time, currency, impact, event_name, + actual, forecast, previous, detail, "ff_cache", + )) + + if len(batch) >= 500: + _upsert_batch(conn, batch) + inserted += len(batch) + batch = [] + if progress_cb: + progress_cb({"inserted": inserted, "total": total}) + + if batch: + _upsert_batch(conn, batch) + inserted += len(batch) + + conn.commit() + print(f"[FF import] Done: {inserted} inserted, {skipped} skipped, {total} total", flush=True) + return {"inserted": inserted, "skipped": skipped, "total": total} + except Exception as e: + logger.error(f"[FF import] Failed: {e}") + print(f"[FF import] ERROR: {e}", flush=True) + return {"error": str(e)} + finally: + conn.close() + + +def _upsert_batch(conn, batch: list): + conn.executemany( + """INSERT INTO ff_calendar + (event_date, event_time, currency, impact, event_name, + actual_value, forecast_value, previous_value, detail, source) + VALUES (?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(event_date, event_time, currency, event_name) + DO UPDATE SET + actual_value = COALESCE(excluded.actual_value, ff_calendar.actual_value), + forecast_value = COALESCE(excluded.forecast_value, ff_calendar.forecast_value), + previous_value = COALESCE(excluded.previous_value, ff_calendar.previous_value), + fetched_at = datetime('now') + """, + batch, + ) + + +def sync_live() -> Dict[str, Any]: + """ + Fetch this week + next week from faireconomy.media and upsert into ff_calendar. + Updates actual_value when it becomes available (real-time detection). + """ + from services.database import get_conn + + results = {} + conn = get_conn() + + for label, url in [("thisweek", _FF_THIS_WEEK_URL), ("nextweek", _FF_NEXT_WEEK_URL)]: + try: + resp = httpx.get(url, timeout=15, follow_redirects=True) + if resp.status_code == 404: + results[label] = {"status": "not_available"} + continue + resp.raise_for_status() + events = resp.json() + + batch = [] + for ev in events: + currency = (ev.get("country") or "").strip().upper() + if currency not in SUPPORTED_CURRENCIES: + continue + + impact = _IMPACT_MAP.get(ev.get("impact") or "", "low") + if impact == "non-economic": + continue + + event_name = (ev.get("title") or "").strip() + if not event_name: + continue + + dt_str = ev.get("date") or "" + event_date, event_time = _parse_ff_datetime(dt_str) + + actual = (ev.get("actual") or "").strip() or None + forecast = (ev.get("forecast") or "").strip() or None + previous = (ev.get("previous") or "").strip() or None + + batch.append(( + event_date, event_time, currency, impact, event_name, + actual, forecast, previous, None, "ff_live", + )) + + if batch: + _upsert_batch(conn, batch) + conn.commit() + + results[label] = {"status": "ok", "events": len(batch)} + print(f"[FF sync] {label}: {len(batch)} events upserted", flush=True) + + except Exception as e: + logger.warning(f"[FF sync] {label} failed: {e}") + results[label] = {"status": "error", "error": str(e)} + + conn.close() + return results + + +def get_calendar( + period: str = "recent", + currencies: Optional[List[str]] = None, + impacts: Optional[List[str]] = None, + limit: int = 300, + offset: int = 0, +) -> Dict[str, Any]: + """ + Query ff_calendar with period filter. + + period options: + recent | today | tomorrow | yesterday | this_week | next_week | + previous_week | this_month | next_month | previous_month | custom + """ + from services.database import get_conn + + today = datetime.now(timezone.utc).date() + monday = today - timedelta(days=today.weekday()) + sunday = monday + timedelta(days=6) + + date_from: Optional[str] = None + date_to: Optional[str] = None + + if period == "today": + date_from = date_to = str(today) + elif period == "tomorrow": + t = today + timedelta(days=1) + date_from = date_to = str(t) + elif period == "yesterday": + t = today - timedelta(days=1) + date_from = date_to = str(t) + elif period == "this_week": + date_from = str(monday) + date_to = str(sunday) + elif period == "next_week": + nm = monday + timedelta(weeks=1) + date_from = str(nm) + date_to = str(nm + timedelta(days=6)) + elif period == "previous_week": + pm = monday - timedelta(weeks=1) + date_from = str(pm) + date_to = str(pm + timedelta(days=6)) + elif period == "this_month": + date_from = str(today.replace(day=1)) + # last day of month + if today.month == 12: + last = today.replace(year=today.year + 1, month=1, day=1) - timedelta(days=1) + else: + last = today.replace(month=today.month + 1, day=1) - timedelta(days=1) + date_to = str(last) + elif period == "next_month": + if today.month == 12: + first = today.replace(year=today.year + 1, month=1, day=1) + else: + first = today.replace(month=today.month + 1, day=1) + if first.month == 12: + last = first.replace(year=first.year + 1, month=1, day=1) - timedelta(days=1) + else: + last = first.replace(month=first.month + 1, day=1) - timedelta(days=1) + date_from = str(first) + date_to = str(last) + elif period == "previous_month": + if today.month == 1: + first = today.replace(year=today.year - 1, month=12, day=1) + else: + first = today.replace(month=today.month - 1, day=1) + last = today.replace(day=1) - timedelta(days=1) + date_from = str(first) + date_to = str(last) + else: # recent: last 7 days + next 14 days + date_from = str(today - timedelta(days=7)) + date_to = str(today + timedelta(days=14)) + + conn = get_conn() + where: List[str] = [] + params: List[Any] = [] + + if date_from: + where.append("event_date >= ?") + params.append(date_from) + if date_to: + where.append("event_date <= ?") + params.append(date_to) + + if currencies: + ph = ",".join("?" * len(currencies)) + where.append(f"currency IN ({ph})") + params.extend(currencies) + + if impacts: + ph = ",".join("?" * len(impacts)) + where.append(f"impact IN ({ph})") + params.extend(impacts) + + where_sql = ("WHERE " + " AND ".join(where)) if where else "" + + count_sql = f"SELECT COUNT(*) FROM ff_calendar {where_sql}" + data_sql = ( + f"SELECT * FROM ff_calendar {where_sql} " + f"ORDER BY event_date ASC, event_time ASC " + f"LIMIT ? OFFSET ?" + ) + + try: + total = conn.execute(count_sql, params).fetchone()[0] + rows = conn.execute(data_sql, params + [limit, offset]).fetchall() + events = [dict(r) for r in rows] + return { + "total": total, + "offset": offset, + "limit": limit, + "period": period, + "date_from": date_from, + "date_to": date_to, + "events": events, + } + finally: + conn.close() diff --git a/frontend/src/pages/CalendarPage.tsx b/frontend/src/pages/CalendarPage.tsx index ff76826..c3362f3 100644 --- a/frontend/src/pages/CalendarPage.tsx +++ b/frontend/src/pages/CalendarPage.tsx @@ -1,818 +1,544 @@ import { useState, useEffect, useCallback, useRef } from 'react' -import { useGeoNews } from '../hooks/useApi' import clsx from 'clsx' -import { - Calendar, RefreshCw, AlertTriangle, TrendingUp, TrendingDown, - Minus, ChevronUp, ChevronDown, Download, -} from 'lucide-react' +import { RefreshCw, Download, ChevronDown, AlertTriangle, Check } from 'lucide-react' + +const API = '' // ── Types ───────────────────────────────────────────────────────────────────── -interface EcoEvent { +interface FFEvent { id: number + event_date: string // YYYY-MM-DD UTC + event_time: string | null // HH:MM UTC + currency: string // USD, EUR, ... + impact: string // high / medium / low event_name: string - series_id: string - event_date: string - actual_value: number | null - actual_unit: string - forecast_value: number | null - previous_value: number | null - surprise_pct: number | null - surprise_zscore: number | null - surprise_direction: string | null - assets_impacted: string[] + actual_value: string | null + forecast_value: string | null + previous_value: string | null source: string - fetched_at: string } -interface EcoStatus { +interface CalendarResponse { total: number - earliest: string | null - latest: string | null - by_series: { series_id: string; cnt: number; latest: string }[] + period: string + date_from: string | null + date_to: string | null + events: FFEvent[] } -interface SeriesMeta { - id: string - name: string - category: string - freq: string - unit: string - assets: string[] +interface FFStats { + total: number + earliest?: string + latest?: string } -interface BootstrapSeriesResult { - status: string - name?: string - inserted?: number - skipped?: number -} - -interface BootstrapStatus { +interface ImportStatus { running: boolean - last_result: unknown + last_result: { inserted?: number; skipped?: number; total?: number; error?: string } | null } // ── Constants ───────────────────────────────────────────────────────────────── -const CATEGORIES = [ - { id: 'employment', label: 'Emploi' }, - { id: 'inflation', label: 'Inflation' }, - { id: 'growth', label: 'Croissance' }, - { id: 'monetary', label: 'Monétaire' }, - { id: 'credit', label: 'Crédit' }, - { id: 'rates', label: 'Taux' }, -] +const PERIODS = [ + { key: 'recent', label: 'Recent' }, + { key: 'today', label: 'Today' }, + { key: 'tomorrow', label: 'Tomorrow' }, + { key: 'this_week', label: 'This Week' }, + { key: 'next_week', label: 'Next Week' }, + { key: 'this_month', label: 'This Month' }, + { key: 'next_month', label: 'Next Month' }, + { key: 'yesterday', label: 'Yesterday' }, + { key: 'previous_week', label: 'Prev Week' }, + { key: 'previous_month','label': 'Prev Month' }, +] as const -const DIRECTIONS = [ - { id: '', label: 'Tous' }, - { id: 'bullish', label: 'Bullish' }, - { id: 'bearish', label: 'Bearish' }, - { id: 'neutral', label: 'Neutre' }, -] +const ALL_CURRENCIES = ['USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'NZD', 'CHF', 'CNY'] -const SORT_OPTIONS = [ - { id: 'date', label: 'Date' }, - { id: 'zscore', label: '|Z-Score|' }, - { id: 'series', label: 'Série' }, -] +const CURRENCY_FLAGS: Record = { + USD: '🇺🇸', EUR: '🇪🇺', GBP: '🇬🇧', JPY: '🇯🇵', + AUD: '🇦🇺', CAD: '🇨🇦', NZD: '🇳🇿', CHF: '🇨🇭', CNY: '🇨🇳', +} -const API_BASE = '' +const IMPACT_COLOR: Record = { + high: 'bg-red-500', + medium: 'bg-yellow-400', + low: 'bg-slate-400', +} // ── Helpers ─────────────────────────────────────────────────────────────────── -function fmt(v: number | null, decimals = 2, unit = ''): string { - if (v === null || v === undefined) return '—' - const s = Math.abs(v) < 100 ? v.toFixed(decimals) : v.toLocaleString('fr-FR', { maximumFractionDigits: 0 }) - return unit ? `${s} ${unit}` : s +function parseNumeric(s: string | null): number | null { + if (!s) return null + const clean = s.replace(/[,%KBMkbm]/g, '').trim() + const n = parseFloat(clean) + return isNaN(n) ? null : n } -function ZBadge({ z, dir }: { z: number | null; dir: string | null }) { - if (z === null || z === undefined) return - const abs = Math.abs(z) - const sign = z > 0 ? '+' : '' - const isBull = dir === 'bullish' - const isBear = dir === 'bearish' +/** Compare actual vs forecast: +1 better, -1 worse, 0 neutral/unknown */ +function direction(ev: FFEvent): 1 | -1 | 0 { + const a = parseNumeric(ev.actual_value) + const f = parseNumeric(ev.forecast_value) + if (a === null || f === null) return 0 + if (Math.abs(a - f) < 0.0001) return 0 + // For now: higher = better (works for NFP, GDP, PMI; wrong for unemployment — acceptable for display) + return a > f ? 1 : -1 +} + +function formatDate(dateStr: string): string { + const d = new Date(dateStr + 'T00:00:00Z') + return d.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', timeZone: 'UTC' }) +} + +function isToday(dateStr: string): boolean { + const today = new Date() + const utc = `${today.getUTCFullYear()}-${String(today.getUTCMonth()+1).padStart(2,'0')}-${String(today.getUTCDate()).padStart(2,'0')}` + return dateStr === utc +} + +function isPast(dateStr: string, timeStr: string | null): boolean { + const now = new Date() + const dt = new Date(`${dateStr}T${timeStr || '00:00'}:00Z`) + return dt < now +} + +// ── Sub-components ──────────────────────────────────────────────────────────── + +function ImpactDot({ impact }: { impact: string }) { + return +} + +function ValueCell({ value, className }: { value: string | null; className?: string }) { + if (!value) return + return {value} +} + +function SurpriseChip({ ev }: { ev: FFEvent }) { + if (!ev.actual_value || !ev.forecast_value) return null + const d = direction(ev) + if (d === 0) return null return ( - {sign}{z.toFixed(2)}{abs >= 1.5 ? ' ⚡' : abs >= 2.5 ? ' 🔥' : ''} + {d === 1 ? '▲' : '▼'} ) } -function DirBadge({ dir }: { dir: string | null }) { - if (!dir || dir === 'neutral') return - return ( - - {dir === 'bullish' ? : } - {dir === 'bullish' ? 'Bull' : 'Bear'} - - ) -} +// ── Import Panel ────────────────────────────────────────────────────────────── -// For rate/spread series (unit % or pp): delta_absolute=true → show absolute pp change -// For level series (K, raw): delta_absolute=false → show relative % change -const DELTA_ABSOLUTE_UNITS = new Set(['%', 'pp']) +function ImportPanel({ stats, onImported }: { stats: FFStats; onImported: () => void }) { + const [status, setStatus] = useState({ running: false, last_result: null }) + const [syncResult, setSyncResult] = useState(null) + const pollRef = useRef | null>(null) -function SurprisePct({ v, unit }: { v: number | null; unit: string }) { - if (v === null || v === undefined) return - const isPp = DELTA_ABSOLUTE_UNITS.has(unit) - const sign = v > 0 ? '+' : '' - const decimals = isPp ? 2 : 1 - const suffix = isPp ? ' pp' : '%' - return ( - 0 ? 'text-emerald-400' : v < 0 ? 'text-red-400' : 'text-slate-400')}> - {sign}{v.toFixed(decimals)}{suffix} - - ) -} + const startImport = async () => { + await fetch(`${API}/api/eco/ff-import`, { method: 'POST' }) + pollRef.current = setInterval(async () => { + const r = await fetch(`${API}/api/eco/ff-import/status`).then(x => x.json()) + setStatus(r) + if (!r.running) { + clearInterval(pollRef.current!) + onImported() + } + }, 1500) + } -// ── Bootstrap modal ─────────────────────────────────────────────────────────── - -function BootstrapPanel({ onDone }: { onDone: () => void }) { - const [fromDate, setFromDate] = useState('2020-01-01') - const [force, setForce] = useState(false) - const [bsStatus, setBsStatus] = useState({ running: false, last_result: null }) - const [polling, setPolling] = useState(false) - const [apiKey, setApiKey] = useState('') - const [keyConfigured, setKeyConfigured] = useState(null) - const [keyPreview, setKeyPreview] = useState('') - const [savingKey, setSavingKey] = useState(false) - const onDoneRef = useRef(onDone) - useEffect(() => { onDoneRef.current = onDone }, [onDone]) - - // Check if FRED key is already saved - useEffect(() => { - fetch('/api/eco/fred-key') - .then(r => r.json()) - .then(d => { setKeyConfigured(d.configured); setKeyPreview(d.preview) }) - .catch(() => setKeyConfigured(false)) - }, []) - - const saveKey = async () => { - if (!apiKey.trim()) return - setSavingKey(true) + const startSync = async () => { + setSyncResult('syncing…') try { - const r = await fetch(`/api/eco/fred-key?key=${encodeURIComponent(apiKey.trim())}`, { method: 'POST' }) - const d = await r.json() - setKeyConfigured(true) - setKeyPreview(d.preview) - setApiKey('') - } finally { - setSavingKey(false) + const r = await fetch(`${API}/api/eco/ff-sync`, { method: 'POST' }).then(x => x.json()) + // Wait for completion + await new Promise(res => setTimeout(res, 2000)) + const s = await fetch(`${API}/api/eco/ff-sync/status`).then(x => x.json()) + const res = s.last_result || {} + const tw = res.thisweek?.events ?? '?' + const nw = res.nextweek?.events ?? 'n/a' + setSyncResult(`✓ this week: ${tw} events${typeof nw === 'number' ? `, next week: ${nw}` : ''}`) + onImported() + } catch { + setSyncResult('sync error') } } - const startBootstrap = async () => { - const url = `/api/eco/bootstrap?from_date=${fromDate}&force=${force}` - await fetch(url, { method: 'POST' }) - setPolling(true) - setBsStatus(s => ({ ...s, running: true })) - } + useEffect(() => () => { if (pollRef.current) clearInterval(pollRef.current) }, []) - useEffect(() => { - if (!polling) return - const iv = setInterval(async () => { - const r = await fetch(`/api/eco/bootstrap/status`) - const data: BootstrapStatus = await r.json() - setBsStatus(data) - if (!data.running) { - setPolling(false) - onDoneRef.current() - } - }, 2000) - return () => clearInterval(iv) - }, [polling]) - - const rawResult = bsStatus.last_result as Record | null - const bootstrapError: string | null = - rawResult && typeof (rawResult as { _error?: unknown })._error === 'string' - ? (rawResult as { _error: string })._error - : rawResult && typeof (rawResult as { error?: unknown }).error === 'string' - ? (rawResult as { error: string }).error - : null - const bootstrapEntries: [string, BootstrapSeriesResult][] = - rawResult && !bootstrapError - ? (Object.entries(rawResult) as [string, BootstrapSeriesResult][]) - : [] + const res = status.last_result + const hasData = stats.total > 0 return ( -
-
- Bootstrap FRED -
- - {/* API Key section */} -
-
- Clé API FRED - {keyConfigured === true && ( - {keyPreview} ✓ - )} - {keyConfigured === false && ( - Non configurée - )} -
- {keyConfigured === false && ( -
- L'endpoint CSV public est bloqué par CloudFlare sur les IPs serveur. - L'API officielle FRED nécessite une clé gratuite :{' '} - fred.stlouisfed.org → My Account → API Keys -
- )} -
- setApiKey(e.target.value)} - placeholder={keyConfigured ? 'Nouvelle clé (optionnel)' : 'Colle ta clé FRED ici…'} - className="flex-1 text-xs bg-dark-700 border border-slate-700 rounded px-2 py-1 text-white font-mono placeholder:text-slate-600" - onKeyDown={e => e.key === 'Enter' && saveKey()} - /> - +
+
+
FF Calendar DB
+
+ {hasData + ? <>{stats.total.toLocaleString()} events ({stats.earliest?.slice(0,7)} → {stats.latest?.slice(0,7)}) + : Not imported yet + }
-
- Séries : NFP · Chômage · CPI · Core CPI · Core PCE · FOMC · Jobless Claims · GDP · HY Spread · T10Y2Y · T10Y3M -
-
-
-
Depuis
- setFromDate(e.target.value)} - className="text-xs bg-dark-700 border border-slate-700 rounded px-2 py-1 text-white" - /> -
- + + + + + {status.running && ( + Importing CSV — this may take 30-60 s… + )} + {res && !status.running && ( + res.error + ? Error: {res.error} + : {res.inserted?.toLocaleString()} inserted, {res.skipped?.toLocaleString()} skipped + )} + {syncResult && {syncResult}} +
+ ) +} + +// ── Currency Filter ─────────────────────────────────────────────────────────── + +function CurrencyFilter({ selected, onChange }: { selected: string[]; onChange: (v: string[]) => void }) { + const toggle = (c: string) => + onChange(selected.includes(c) ? selected.filter(x => x !== c) : [...selected, c]) + + return ( +
+ {ALL_CURRENCIES.map(c => ( + ))} + {selected.length > 0 && ( + -
- - {bsStatus.running && ( -
- Téléchargement en cours (peut prendre 30–60s)… -
- )} - - {rawResult && !bsStatus.running && ( - bootstrapError ? ( -
Erreur : {bootstrapError}
- ) : ( -
- {bootstrapEntries.map(([sid, r]) => ( -
- {sid}{' '} - {r.status === 'ok' ? `+${r.inserted ?? 0} / skip ${r.skipped ?? 0}` : 'failed'} -
- ))} -
- ) )}
) } -// ── Upcoming releases panel ─────────────────────────────────────────────────── +// ── Impact Filter ───────────────────────────────────────────────────────────── -interface UpcomingEvent { - series_id: string - name: string - note: string - category: string - last_release: string | null - last_value: number | null - last_unit: string - last_direction: string | null - next_expected: string | null - days_until: number | null - status: string -} - -const STATUS_STYLE: Record = { - imminent: 'text-orange-400 border-orange-700/40 bg-orange-900/10', - due_soon: 'text-yellow-400 border-yellow-700/40 bg-yellow-900/10', - upcoming: 'text-blue-400 border-blue-700/30', - scheduled: 'text-slate-500 border-slate-700/30', - overdue: 'text-red-400 border-red-700/30', - no_data: 'text-slate-600 border-slate-700/20', -} - -const STATUS_LABEL: Record = { - imminent: 'Cette semaine', - due_soon: 'Attendu', - upcoming: 'Ce mois', - scheduled: 'Planifié', - overdue: 'En retard', - no_data: 'Pas en base', -} - -function UpcomingPanel() { - const [events, setEvents] = useState([]) - - useEffect(() => { - fetch('/api/eco/upcoming') - .then(r => r.json()) - .then(setEvents) - .catch(() => {}) - }, []) - - const visible = events.filter(e => e.status !== 'scheduled' || e.days_until !== null && e.days_until < 45) +function ImpactFilter({ selected, onChange }: { selected: string[]; onChange: (v: string[]) => void }) { + const opts = [ + { key: 'high', label: 'High', dot: 'bg-red-500' }, + { key: 'medium', label: 'Medium', dot: 'bg-yellow-400' }, + { key: 'low', label: 'Low', dot: 'bg-slate-400' }, + ] + const toggle = (k: string) => + onChange(selected.includes(k) ? selected.filter(x => x !== k) : [...selected, k]) return ( -
-
- Prochaines publications -
- {visible.length === 0 ? ( -
Aucune donnée
- ) : ( -
- {visible.map(ev => ( -
-
-
{ev.name}
-
{ev.note}
- {ev.last_value !== null && ( -
- Dernier : {ev.last_value.toFixed(ev.last_unit === '%' || ev.last_unit === 'pp' ? 2 : 0)} {ev.last_unit} - {ev.last_direction && ev.last_direction !== 'neutral' && ( - - {ev.last_direction === 'bullish' ? '▲' : '▼'} - - )} -
- )} -
-
-
- {STATUS_LABEL[ev.status]} -
- {ev.next_expected && ( -
{ev.next_expected.slice(5)}
- )} - {ev.days_until !== null && ev.days_until >= 0 && ( -
J−{ev.days_until}
- )} -
-
- ))} -
- )} -
* Dates estimées d'après fréquence + délai typique
+
+ {opts.map(o => ( + + ))}
) } -// ── Main page ───────────────────────────────────────────────────────────────── +// ── Main Page ───────────────────────────────────────────────────────────────── export default function CalendarPage() { - const { data: geoNews } = useGeoNews() + const [period, setPeriod] = useState('recent') + const [currencies, setCurrencies] = useState(['USD']) + const [impacts, setImpacts] = useState(['high', 'medium']) + const [data, setData] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [stats, setStats] = useState({ total: 0 }) + const [showFilters, setShowFilters] = useState(false) + const autoRefRef = useRef | null>(null) - // ── Filters state ── - const today = new Date().toISOString().slice(0, 10) - const twoYearsAgo = new Date(Date.now() - 730 * 86400_000).toISOString().slice(0, 10) - - const [dateFrom, setDateFrom] = useState(twoYearsAgo) - const [dateTo, setDateTo] = useState(today) - const [category, setCategory] = useState('') - const [selectedSeries, setSelectedSeries] = useState([]) - const [minZ, setMinZ] = useState(0) - const [direction, setDirection] = useState('') - const [sortBy, setSortBy] = useState('date') - const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc') - const [limit, setLimit] = useState(200) - const [offset, setOffset] = useState(0) - - // ── Data ── - const [events, setEvents] = useState([]) - const [total, setTotal] = useState(0) - const [loading, setLoading] = useState(false) - const [ecoStatus, setEcoStatus] = useState(null) - const [seriesMeta, setSeriesMeta] = useState([]) - const [showBootstrap, setShowBootstrap] = useState(false) - - const fetchStatus = useCallback(async () => { + const fetchStats = useCallback(async () => { try { - const r = await fetch(`${API_BASE}/api/eco/status`) - setEcoStatus(await r.json()) - } catch {} - }, []) - - const fetchSeriesMeta = useCallback(async () => { - try { - const r = await fetch(`${API_BASE}/api/eco/series`) - setSeriesMeta(await r.json()) + const r = await fetch(`${API}/api/eco/ff-stats`).then(x => x.json()) + setStats(r) } catch {} }, []) const fetchEvents = useCallback(async () => { setLoading(true) + setError(null) try { - const params = new URLSearchParams() - if (dateFrom) params.set('date_from', dateFrom) - if (dateTo) params.set('date_to', dateTo) - if (category) params.set('category', category) - if (selectedSeries.length) params.set('series', selectedSeries.join(',')) - if (minZ > 0) params.set('min_zscore', String(minZ)) - if (direction) params.set('direction', direction) - params.set('sort_by', sortBy) - params.set('sort_dir', sortDir) - params.set('limit', String(limit)) - params.set('offset', String(offset)) - - const r = await fetch(`${API_BASE}/api/eco/events?${params}`) - const data = await r.json() - setEvents(data.events ?? []) - setTotal(data.total ?? 0) + const params = new URLSearchParams({ period, limit: '500' }) + if (currencies.length > 0) params.set('currencies', currencies.join(',')) + if (impacts.length > 0) params.set('impacts', impacts.join(',')) + const r: CalendarResponse = await fetch(`${API}/api/eco/calendar?${params}`).then(x => x.json()) + setData(r) } catch (e) { - setEvents([]) + setError(String(e)) } finally { setLoading(false) } - }, [dateFrom, dateTo, category, selectedSeries, minZ, direction, sortBy, sortDir, limit, offset]) + }, [period, currencies, impacts]) - useEffect(() => { fetchSeriesMeta(); fetchStatus() }, []) - useEffect(() => { setOffset(0) }, [dateFrom, dateTo, category, selectedSeries, minZ, direction, sortBy, sortDir]) + useEffect(() => { fetchStats() }, [fetchStats]) useEffect(() => { fetchEvents() }, [fetchEvents]) - const toggleSort = (col: string) => { - if (sortBy === col) setSortDir(d => d === 'desc' ? 'asc' : 'desc') - else { setSortBy(col); setSortDir('desc') } + // Auto-refresh every 60s for current/upcoming events + useEffect(() => { + if (autoRefRef.current) clearInterval(autoRefRef.current) + if (['recent', 'today', 'this_week'].includes(period)) { + autoRefRef.current = setInterval(() => { + fetch(`${API}/api/eco/ff-sync`, { method: 'POST' }).catch(() => {}) + fetchEvents() + }, 60_000) + } + return () => { if (autoRefRef.current) clearInterval(autoRefRef.current) } + }, [period, fetchEvents]) + + // Group events by date + const grouped: { date: string; events: FFEvent[] }[] = [] + if (data?.events) { + let cur: { date: string; events: FFEvent[] } | null = null + for (const ev of data.events) { + if (!cur || cur.date !== ev.event_date) { + cur = { date: ev.event_date, events: [] } + grouped.push(cur) + } + cur.events.push(ev) + } } - const SortIcon = ({ col }: { col: string }) => - sortBy !== col ? null - : sortDir === 'desc' ? - : - - const highGeoNews = geoNews?.filter(n => n.impact_score > 0.4).slice(0, 6) ?? [] - return ( -
+
{/* Header */} -
+
-

- Calendrier Économique — FRED -

-

- Données historiques FRED depuis 2020 · NFP, CPI, FOMC, GDP, Spreads… +

Economic Calendar

+

+ Forex Factory — past releases + upcoming events

- {ecoStatus && ( -
- {ecoStatus.total.toLocaleString()} events - {ecoStatus.earliest && ( - <> · {ecoStatus.earliest?.slice(0, 7)} → {ecoStatus.latest?.slice(0, 7)} - )} -
- )} -
- {/* Bootstrap panel */} - {showBootstrap && ( - { fetchStatus(); fetchEvents(); setShowBootstrap(false) }} /> + {/* Import Panel */} + { fetchStats(); fetchEvents() }} /> + + {/* Period Tabs */} +
+ {PERIODS.map(p => ( + + ))} +
+ + {/* Filters toggle */} +
+ + {showFilters && ( +
+
+
Currency
+ +
+
+
Impact
+ +
+
+ )} +
+ + {/* Summary bar */} + {data && ( +
+ {data.total} events + {data.date_from && {data.date_from} → {data.date_to}} + {['recent', 'today', 'this_week'].includes(period) && ( + ● auto-refresh 60s + )} +
)} -
- {/* ── Filters + Table ── */} -
- {/* Filter bar */} -
- {/* Row 1: dates + direction + z-score */} -
-
-
Du
- setDateFrom(e.target.value)} - className="text-xs bg-dark-700 border border-slate-700 rounded px-2 py-1 text-white w-32" /> -
-
-
Au
- setDateTo(e.target.value)} - className="text-xs bg-dark-700 border border-slate-700 rounded px-2 py-1 text-white w-32" /> -
-
-
|Z| min
- -
-
-
Direction
-
- {DIRECTIONS.map(d => ( - - ))} + {/* Error */} + {error && ( +
+ {error} +
+ )} + + {/* Empty state */} + {!loading && data?.total === 0 && stats.total === 0 && ( +
+

No data yet

+

Click Import CSV (2007-2025) above to load the Forex Factory historical calendar.

+
+ )} + + {!loading && data?.total === 0 && stats.total > 0 && ( +
+

No events match your filters for this period.

+
+ )} + + {/* Column header */} + {grouped.length > 0 && ( +
+ Time (UTC) + + Ccy + Event + Actual + Forecast + Previous +
+ )} + + {/* Table */} + {grouped.length > 0 && ( +
+ {grouped.map(({ date, events }) => { + const today = isToday(date) + return ( +
+ {/* Date header */} +
+ {formatDate(date)} + {today && TODAY}
-
-
-
Limite
- -
-
- {/* Row 2: category chips */} -
- - {CATEGORIES.map(c => ( - - ))} -
- - {/* Row 3: series chips */} -
- {seriesMeta.map(s => ( - - ))} - {selectedSeries.length > 0 && ( - - )} -
-
- - {/* Results summary */} -
- - {total.toLocaleString()} événements - {total > limit && <> · page {Math.floor(offset / limit) + 1}/{Math.ceil(total / limit)}} - -
- {offset > 0 && ( - - )} - {offset + limit < total && ( - - )} -
-
- - {/* Table */} - {ecoStatus?.total === 0 ? ( -
-
Aucune donnée économique en base
-
Lance le Bootstrap FRED pour importer les données depuis 2020
- -
- ) : loading ? ( -
- {Array.from({ length: 8 }).map((_, i) => ( -
- ))} -
- ) : events.length === 0 ? ( -
- Aucun événement pour ces filtres -
- ) : ( -
- - - - - - - - - - - - - - + {/* Events for this date */} +
{events.map(ev => { - const z = ev.surprise_zscore - const isStrong = z !== null && Math.abs(z) >= 1.5 + const past = isPast(ev.event_date, ev.event_time) + const hasActual = !!ev.actual_value + const dir = direction(ev) + const actualClass = !hasActual + ? 'text-slate-400' + : dir === 1 ? 'text-emerald-400 font-semibold' + : dir === -1 ? 'text-red-400 font-semibold' + : 'text-slate-200' + return ( -
- - - - - - - - - +
+ {/* Time */} + + {ev.event_time ?? '—'} + + + {/* Flag */} + + {CURRENCY_FLAGS[ev.currency] ?? ev.currency} + + + {/* Currency */} + {ev.currency} + + {/* Event name + impact */} +
+ + + {ev.event_name} + +
+ + {/* Actual */} +
+ {hasActual ? ( + + {ev.actual_value} + + + ) : ( + + )} +
+ + {/* Forecast (consensus) */} +
+ +
+ + {/* Previous */} +
+ +
+
) })} - -
toggleSort('date')}> - Date - toggleSort('name')}> - Série - ActuelPrécédentΔ vs préc. toggleSort('zscore')}> - Z-Score - SignalAssets
- {ev.event_date} - -
{ev.event_name}
-
{ev.series_id}
-
- {fmt(ev.actual_value)} {ev.actual_unit} - - {fmt(ev.previous_value)} - - - - - - - -
- {ev.assets_impacted.slice(0, 3).map(a => ( - - {a} - - ))} -
-
-
- )} -
- - {/* ── Right sidebar ── */} -
- {/* Upcoming releases */} - - - {/* Séries disponibles */} - {ecoStatus && ecoStatus.total > 0 && ( -
-
- En base -
-
- {ecoStatus.by_series.map(s => { - const meta = seriesMeta.find(m => m.id === s.series_id) - return ( -
-
-
{s.series_id}
-
{meta?.name}
-
-
-
{s.cnt}
-
{s.latest?.slice(0, 7)}
-
-
- ) - })} -
-
- )} - - {/* Geo alerts */} -
-
- Alertes géopolitiques -
- {highGeoNews.length > 0 ? ( -
- {highGeoNews.map((n, i) => ( -
-
{n.title}
-
- {n.source} - {Math.round(n.impact_score * 100)} -
-
- ))} -
- ) : ( -
Aucune alerte active
- )} -
- - {/* Légende z-score */} -
-
Guide Z-Score
-
- {[ - { label: '|z| < 0.5', desc: 'Neutre / dans la norme', cls: 'text-slate-500' }, - { label: '0.5–1.0σ', desc: 'Légère surprise', cls: 'text-slate-400' }, - { label: '1.0–1.5σ', desc: 'Surprise modérée', cls: 'text-yellow-400' }, - { label: '≥ 1.5σ ⚡', desc: 'Forte surprise (rare)', cls: 'text-orange-400' }, - { label: '≥ 2.5σ 🔥', desc: 'Choc extrême', cls: 'text-red-400' }, - ].map((g, i) => ( -
- {g.label} - {g.desc}
- ))} -
-
+
+ ) + })}
-
+ )} +
) }