From 64ff777da61afb037494040095a9056a78b53d55 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Fri, 26 Jun 2026 14:42:44 +0200 Subject: [PATCH] feat: FRED bootstrap + Calendar page complete rebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backend/services/fred_bootstrap.py: fetch 11 FRED series (PAYEMS, UNRATE, CPI, PCE, FEDFUNDS, ICSA, GDP, HY spread, T10Y2Y, T10Y3M) from public CSV endpoint — no API key needed; computes rolling z-scores and upserts into economic_events table - backend/routers/eco.py: new /api/eco router with bootstrap (POST + status GET), events list with full filtering (date range, category, series, min z-score, direction, sort/pagination), series catalog, and db status endpoints - backend/main.py: register eco router - frontend/src/pages/CalendarPage.tsx: complete rewrite — real data table from /api/eco/events, Bootstrap FRED button with live polling, filter bar (date range, category, series chips, |z| threshold, direction), sort by date/z-score/series, pagination, z-score badges with color coding, sidebar with series inventory + geo alerts + z-score guide Co-Authored-By: Claude Sonnet 4.6 --- backend/main.py | 2 + backend/routers/eco.py | 198 ++++++++ backend/services/fred_bootstrap.py | 281 +++++++++++ frontend/src/pages/CalendarPage.tsx | 743 ++++++++++++++++++++++------ 4 files changed, 1060 insertions(+), 164 deletions(-) create mode 100644 backend/routers/eco.py create mode 100644 backend/services/fred_bootstrap.py diff --git a/backend/main.py b/backend/main.py index 02b8785..94ceb72 100644 --- a/backend/main.py +++ b/backend/main.py @@ -13,6 +13,7 @@ from routers import logs as logs_router from routers import var as var_router from routers import reports as reports_router from routers import institutional as institutional_router +from routers import eco as eco_router from services.database import init_db, get_config, cleanup_stale_running_cycles import os import logging @@ -134,6 +135,7 @@ app.include_router(impact_router.router) app.include_router(cycle_actions_router.router) app.include_router(market_events_router.router) app.include_router(ai_desks_router.router) +app.include_router(eco_router.router) @app.get("/") diff --git a/backend/routers/eco.py b/backend/routers/eco.py new file mode 100644 index 0000000..7e0201b --- /dev/null +++ b/backend/routers/eco.py @@ -0,0 +1,198 @@ +""" +Economic calendar router — FRED-backed historical events + bootstrap endpoint. +Prefix: /api/eco +""" +import json +import logging +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, BackgroundTasks, HTTPException, Query + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/eco", tags=["Economic Calendar"]) + + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def _parse_row(row: Dict) -> Dict: + for field in ("assets_impacted",): + val = row.get(field) + if isinstance(val, str): + try: + row[field] = json.loads(val) if val else [] + except Exception: + row[field] = [] + elif val is None: + row[field] = [] + return row + + +# ── Bootstrap ───────────────────────────────────────────────────────────────── + +_bootstrap_status: Dict[str, Any] = {"running": False, "last_result": None} + + +def _run_bootstrap(from_date: str, series_ids: Optional[List[str]], force: bool): + global _bootstrap_status + _bootstrap_status["running"] = True + try: + from services.fred_bootstrap import bootstrap_fred + result = bootstrap_fred(from_date=from_date, series_ids=series_ids, force=force) + _bootstrap_status["last_result"] = result + total_inserted = sum(v.get("inserted", 0) for v in result.values() if isinstance(v, dict)) + logger.info(f"[eco/bootstrap] Done — {total_inserted} rows inserted") + except Exception as e: + logger.error(f"[eco/bootstrap] Failed: {e}") + _bootstrap_status["last_result"] = {"error": str(e)} + finally: + _bootstrap_status["running"] = False + + +@router.post("/bootstrap") +def bootstrap( + background_tasks: BackgroundTasks, + from_date: str = Query("2020-01-01", description="Start date for historical data"), + series: Optional[str] = Query(None, description="Comma-separated series IDs (blank = all)"), + force: bool = Query(False, description="Overwrite existing rows"), +) -> Dict[str, Any]: + """ + Trigger FRED data bootstrap (runs in background). + Fetches public CSV endpoint — no API key required. + """ + if _bootstrap_status["running"]: + raise HTTPException(409, "Bootstrap already running") + series_ids = [s.strip() for s in series.split(",") if s.strip()] if series else None + background_tasks.add_task(_run_bootstrap, from_date, series_ids, force) + return {"status": "started", "from_date": from_date, "series": series_ids or "all"} + + +@router.get("/bootstrap/status") +def bootstrap_status() -> Dict[str, Any]: + return _bootstrap_status + + +# ── Series catalog ──────────────────────────────────────────────────────────── + +@router.get("/series") +def list_series() -> List[Dict[str, Any]]: + """Return available FRED series metadata.""" + from services.fred_bootstrap import FRED_SERIES, CATEGORIES + return [ + { + "id": sid, + "name": meta["name"], + "category": meta["category"], + "freq": meta["freq"], + "unit": meta["unit"], + "assets": meta["assets"], + } + for sid, meta in FRED_SERIES.items() + ] + + +# ── Events list ─────────────────────────────────────────────────────────────── + +_SORT_COLS = { + "date": "ee.event_date", + "zscore": "ABS(COALESCE(ee.surprise_zscore, 0))", + "series": "ee.series_id", + "name": "ee.event_name", +} + + +@router.get("/events") +def list_eco_events( + date_from: Optional[str] = Query(None), + date_to: Optional[str] = Query(None), + series: Optional[str] = Query(None, description="Comma-separated series IDs"), + category: Optional[str] = Query(None, description="employment|inflation|growth|monetary|credit|rates"), + min_zscore: float = Query(0.0, ge=0.0, description="Minimum |z-score| filter"), + direction: Optional[str] = Query(None, description="bullish|bearish|neutral"), + sort_by: str = Query("date", description="date|zscore|series|name"), + sort_dir: str = Query("desc", description="asc|desc"), + limit: int = Query(200, ge=1, le=2000), + offset: int = Query(0, ge=0), +) -> Dict[str, Any]: + from services.database import get_conn + from services.fred_bootstrap import FRED_SERIES + + conn = get_conn() + where_parts: List[str] = [] + params: List[Any] = [] + + if date_from: + where_parts.append("ee.event_date >= ?") + params.append(date_from) + if date_to: + where_parts.append("ee.event_date <= ?") + params.append(date_to) + + if series: + ids = [s.strip() for s in series.split(",") if s.strip()] + if ids: + placeholders = ",".join("?" * len(ids)) + where_parts.append(f"ee.series_id IN ({placeholders})") + params.extend(ids) + + if category: + # Map category → series IDs + matching = [sid for sid, m in FRED_SERIES.items() if m["category"] == category] + if matching: + placeholders = ",".join("?" * len(matching)) + where_parts.append(f"ee.series_id IN ({placeholders})") + params.extend(matching) + else: + # No matching series — return empty + conn.close() + return {"total": 0, "offset": offset, "limit": limit, "events": []} + + if min_zscore > 0: + where_parts.append("ABS(COALESCE(ee.surprise_zscore, 0)) >= ?") + params.append(min_zscore) + if direction: + where_parts.append("ee.surprise_direction = ?") + params.append(direction) + + where_sql = ("WHERE " + " AND ".join(where_parts)) if where_parts else "" + sort_col = _SORT_COLS.get(sort_by, "ee.event_date") + order_sql = f"ORDER BY {sort_col} {'DESC' if sort_dir == 'desc' else 'ASC'}" + + count_sql = f"SELECT COUNT(*) FROM economic_events ee {where_sql}" + data_sql = f"SELECT ee.* FROM economic_events ee {where_sql} {order_sql} LIMIT ? OFFSET ?" + + try: + total = conn.execute(count_sql, params).fetchone()[0] + rows = conn.execute(data_sql, params + [limit, offset]).fetchall() + finally: + conn.close() + + events = [_parse_row(dict(r)) for r in rows] + return {"total": total, "offset": offset, "limit": limit, "events": events} + + +# ── DB count summary ────────────────────────────────────────────────────────── + +@router.get("/status") +def eco_status() -> Dict[str, Any]: + from services.database import get_conn + conn = get_conn() + try: + total = conn.execute("SELECT COUNT(*) FROM economic_events").fetchone()[0] + latest = conn.execute( + "SELECT MAX(event_date) FROM economic_events" + ).fetchone()[0] + earliest = conn.execute( + "SELECT MIN(event_date) FROM economic_events" + ).fetchone()[0] + by_series = conn.execute( + "SELECT series_id, COUNT(*) as cnt, MAX(event_date) as latest " + "FROM economic_events GROUP BY series_id ORDER BY series_id" + ).fetchall() + return { + "total": total, + "earliest": earliest, + "latest": latest, + "by_series": [dict(r) for r in by_series], + } + finally: + conn.close() diff --git a/backend/services/fred_bootstrap.py b/backend/services/fred_bootstrap.py new file mode 100644 index 0000000..7a01139 --- /dev/null +++ b/backend/services/fred_bootstrap.py @@ -0,0 +1,281 @@ +""" +FRED historical bootstrap — fetches monthly/quarterly/weekly release data +from FRED's public CSV endpoint (no API key required) and stores it in +the economic_events table with rolling z-score surprises. +""" +import logging +from datetime import datetime, date +from typing import Any, Dict, List, Optional, Tuple + +import httpx +import pandas as pd + +logger = logging.getLogger(__name__) + +# ── Series catalog ──────────────────────────────────────────────────────────── + +FRED_SERIES: Dict[str, Dict[str, Any]] = { + "PAYEMS": { + "name": "Non-Farm Payrolls", + "unit": "K", + "freq": "monthly", + "category": "employment", + "higher_is_bullish": True, + "assets": ["SPY", "QQQ", "EURUSD=X", "TLT"], + "zscore_window": 12, + }, + "UNRATE": { + "name": "Unemployment Rate", + "unit": "%", + "freq": "monthly", + "category": "employment", + "higher_is_bullish": False, + "assets": ["SPY", "QQQ", "EURUSD=X"], + "zscore_window": 12, + }, + "CPIAUCSL": { + "name": "CPI (All Items YoY)", + "unit": "%", + "freq": "monthly", + "category": "inflation", + "higher_is_bullish": False, + "assets": ["TLT", "GLD", "EURUSD=X", "SPY"], + "zscore_window": 12, + }, + "CPILFESL": { + "name": "Core CPI", + "unit": "%", + "freq": "monthly", + "category": "inflation", + "higher_is_bullish": False, + "assets": ["TLT", "GLD", "EURUSD=X"], + "zscore_window": 12, + }, + "PCEPILFE": { + "name": "Core PCE", + "unit": "%", + "freq": "monthly", + "category": "inflation", + "higher_is_bullish": False, + "assets": ["TLT", "GLD", "SPY"], + "zscore_window": 12, + }, + "FEDFUNDS": { + "name": "Fed Funds Rate (FOMC)", + "unit": "%", + "freq": "monthly", + "category": "monetary", + "higher_is_bullish": False, + "assets": ["TLT", "SPY", "EURUSD=X", "GLD"], + "zscore_window": 12, + }, + "ICSA": { + "name": "Initial Jobless Claims", + "unit": "K", + "freq": "weekly", + "category": "employment", + "higher_is_bullish": False, + "assets": ["SPY", "QQQ"], + "zscore_window": 52, + }, + "A191RL1Q225SBEA": { + "name": "GDP Growth (Quarterly)", + "unit": "%", + "freq": "quarterly", + "category": "growth", + "higher_is_bullish": True, + "assets": ["SPY", "QQQ", "EURUSD=X", "TLT"], + "zscore_window": 8, + }, + "BAMLH0A0HYM2": { + "name": "HY Credit Spread (OAS)", + "unit": "pp", + "freq": "weekly", + "category": "credit", + "higher_is_bullish": False, + "assets": ["HYG", "LQD", "SPY"], + "zscore_window": 52, + }, + "T10Y2Y": { + "name": "Yield Spread 10Y-2Y", + "unit": "pp", + "freq": "weekly", + "category": "rates", + "higher_is_bullish": True, + "assets": ["TLT", "IEF", "SPY", "HYG"], + "zscore_window": 52, + }, + "T10Y3M": { + "name": "Yield Spread 10Y-3M", + "unit": "pp", + "freq": "weekly", + "category": "rates", + "higher_is_bullish": True, + "assets": ["TLT", "IEF", "SPY"], + "zscore_window": 52, + }, +} + +CATEGORIES = sorted({v["category"] for v in FRED_SERIES.values()}) + + +# ── FRED fetch ──────────────────────────────────────────────────────────────── + +_FRED_CSV_URL = "https://fred.stlouisfed.org/graph/fredgraph.csv?id={series_id}" + + +def _fetch_fred_csv(series_id: str, from_date: str = "2019-01-01") -> Optional[pd.DataFrame]: + """ + Download FRED series as CSV. Returns DataFrame with DATE index and VALUE column. + Uses public endpoint — no API key required. + """ + url = _FRED_CSV_URL.format(series_id=series_id) + try: + resp = httpx.get(url, timeout=30, follow_redirects=True) + resp.raise_for_status() + from io import StringIO + df = pd.read_csv(StringIO(resp.text), parse_dates=["DATE"], index_col="DATE") + df.columns = ["value"] + df["value"] = pd.to_numeric(df["value"], errors="coerce") + df = df.dropna() + df = df[df.index >= pd.Timestamp(from_date)] + df = df.sort_index() + return df + except Exception as e: + logger.warning(f"[FRED] Failed to fetch {series_id}: {e}") + return None + + +# ── Z-score computation ─────────────────────────────────────────────────────── + +def _compute_zscore_series(values: pd.Series, window: int) -> Tuple[pd.Series, pd.Series, pd.Series]: + """ + Returns (z_scores, rolling_mean, rolling_std) for a value series. + Z-score = (value - rolling_mean_prev) / rolling_std_prev + Uses previous window to avoid look-ahead. + """ + roll_mean = values.shift(1).rolling(window, min_periods=max(4, window // 3)).mean() + roll_std = values.shift(1).rolling(window, min_periods=max(4, window // 3)).std() + z = (values - roll_mean) / roll_std.replace(0, float("nan")) + return z.round(2), roll_mean.round(4), roll_std.round(4) + + +def _direction(zscore: float, higher_is_bullish: bool) -> str: + if abs(zscore) < 0.5: + return "neutral" + positive = zscore > 0 + return "bullish" if positive == higher_is_bullish else "bearish" + + +# ── Downsample for noisy daily/weekly series ────────────────────────────────── + +def _resample_weekly(df: pd.DataFrame) -> pd.DataFrame: + """Resample to weekly (last value of each week, Friday).""" + return df.resample("W-FRI").last().dropna() + + +# ── Main bootstrap ──────────────────────────────────────────────────────────── + +def bootstrap_fred( + from_date: str = "2020-01-01", + series_ids: Optional[List[str]] = None, + force: bool = False, +) -> Dict[str, Any]: + """ + Fetch and store FRED data from from_date to today. + Returns summary dict with counts per series. + """ + from services.database import get_conn + import json + + target_series = series_ids or list(FRED_SERIES.keys()) + results: Dict[str, Any] = {} + + conn = get_conn() + + for sid in target_series: + meta = FRED_SERIES.get(sid) + if not meta: + logger.warning(f"[FRED bootstrap] Unknown series: {sid}") + continue + + logger.info(f"[FRED bootstrap] Fetching {sid} ({meta['name']}) from {from_date}") + + # Fetch one extra year before from_date for z-score warm-up + fetch_from = str(date(int(from_date[:4]) - 1, 1, 1)) + df = _fetch_fred_csv(sid, from_date=fetch_from) + if df is None or df.empty: + results[sid] = {"status": "fetch_failed", "count": 0} + continue + + # For weekly-sampled continuous series, resample + if meta["freq"] == "weekly" and len(df) > 200: + df = _resample_weekly(df) + + window = meta["zscore_window"] + z_series, mean_series, _ = _compute_zscore_series(df["value"], window) + + inserted = 0 + skipped = 0 + + for dt, row in df.iterrows(): + ev_date = dt.strftime("%Y-%m-%d") + # Skip rows before the real from_date (warm-up period) + if ev_date < from_date: + continue + + val = float(row["value"]) + z = z_series.get(dt) + prev_val = df["value"].shift(1).get(dt) + + if z is None or pd.isna(z): + z = 0.0 + z = float(z) + + prev = float(prev_val) if prev_val is not None and not pd.isna(prev_val) else None + surprise_pct = round((val - prev) / abs(prev) * 100, 2) if prev and prev != 0 else None + direction = _direction(z, meta["higher_is_bullish"]) + + try: + conn.execute( + """INSERT INTO economic_events + (event_name, series_id, event_date, actual_value, actual_unit, + forecast_value, previous_value, surprise_pct, surprise_zscore, + surprise_direction, assets_impacted, source) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + ON CONFLICT(series_id, event_date) DO """ + ( + "UPDATE SET actual_value=excluded.actual_value, " + "previous_value=excluded.previous_value, " + "surprise_pct=excluded.surprise_pct, " + "surprise_zscore=excluded.surprise_zscore, " + "surprise_direction=excluded.surprise_direction, " + "fetched_at=datetime('now')" + if force else "NOTHING" + ), + ( + meta["name"], sid, ev_date, val, meta["unit"], + None, # no consensus forecast available + prev, surprise_pct, z, direction, + json.dumps(meta["assets"]), + "FRED/bootstrap", + ), + ) + if conn.execute("SELECT changes()").fetchone()[0]: + inserted += 1 + else: + skipped += 1 + except Exception as e: + logger.debug(f"[FRED bootstrap] {sid} {ev_date}: {e}") + + conn.commit() + results[sid] = { + "status": "ok", + "name": meta["name"], + "inserted": inserted, + "skipped": skipped, + "total_fetched": len(df), + } + logger.info(f"[FRED bootstrap] {sid}: {inserted} inserted, {skipped} skipped") + + conn.close() + return results diff --git a/frontend/src/pages/CalendarPage.tsx b/frontend/src/pages/CalendarPage.tsx index 9832942..16ce0d2 100644 --- a/frontend/src/pages/CalendarPage.tsx +++ b/frontend/src/pages/CalendarPage.tsx @@ -1,208 +1,623 @@ -import { useCalendar, useGeoNews } from '../hooks/useApi' +import { useState, useEffect, useCallback, useRef } from 'react' +import { useGeoNews } from '../hooks/useApi' import clsx from 'clsx' -import { Calendar, Clock, Globe, AlertTriangle } from 'lucide-react' -import type { EconomicEvent, AssetClass } from '../types' -import { format, parseISO, isAfter, isBefore, addDays } from 'date-fns' +import { + Calendar, RefreshCw, AlertTriangle, TrendingUp, TrendingDown, + Minus, ChevronUp, ChevronDown, Download, +} from 'lucide-react' -const ASSET_EMOJIS: Record = { - energy: '⛽', metals: '🥇', agriculture: '🌾', equities: '📈', - indices: '📊', forex: '💱', rates: '🏦', +// ── Types ───────────────────────────────────────────────────────────────────── + +interface EcoEvent { + id: number + 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[] + source: string + fetched_at: string } -const COUNTRY_FLAGS: Record = { - US: '🇺🇸', EU: '🇪🇺', CN: '🇨🇳', JP: '🇯🇵', GB: '🇬🇧', - DE: '🇩🇪', FR: '🇫🇷', Global: '🌍', +interface EcoStatus { + total: number + earliest: string | null + latest: string | null + by_series: { series_id: string; cnt: number; latest: string }[] } -const IMPORTANCE_CONFIG: Record = { - high: { color: 'text-red-400 border-red-700/40', label: 'Major', dots: 3 }, - medium: { color: 'text-yellow-400 border-yellow-700/40', label: 'Moderate', dots: 2 }, - low: { color: 'text-slate-400 border-slate-700/40', label: 'Minor', dots: 1 }, +interface SeriesMeta { + id: string + name: string + category: string + freq: string + unit: string + assets: string[] } -function EventCard({ ev }: { ev: EconomicEvent }) { - const cfg = IMPORTANCE_CONFIG[ev.importance] - const isPast = ev.actual !== undefined && ev.actual !== null - const today = new Date() - const evDate = parseISO(ev.date) - const isToday = format(evDate, 'yyyy-MM-dd') === format(today, 'yyyy-MM-dd') - const isSoon = !isToday && isAfter(evDate, today) && isBefore(evDate, addDays(today, 3)) +interface BootstrapStatus { + running: boolean + last_result: Record | { 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 DIRECTIONS = [ + { id: '', label: 'Tous' }, + { id: 'bullish', label: 'Bullish' }, + { id: 'bearish', label: 'Bearish' }, + { id: 'neutral', label: 'Neutre' }, +] + +const SORT_OPTIONS = [ + { id: 'date', label: 'Date' }, + { id: 'zscore', label: '|Z-Score|' }, + { id: 'series', label: 'Série' }, +] + +const API_BASE = 'http://localhost:8000' + +// ── 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 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' + return ( + + {sign}{z.toFixed(2)}{abs >= 1.5 ? ' ⚡' : abs >= 2.5 ? ' 🔥' : ''} + + ) +} + +function DirBadge({ dir }: { dir: string | null }) { + if (!dir || dir === 'neutral') return + return ( + + {dir === 'bullish' ? : } + {dir === 'bullish' ? 'Bull' : 'Bear'} + + ) +} + +function SurprisePct({ v }: { v: number | null }) { + if (v === null || v === undefined) return + const sign = v > 0 ? '+' : '' + return ( + 0 ? 'text-emerald-400' : v < 0 ? 'text-red-400' : 'text-slate-400')}> + {sign}{v.toFixed(1)}% + + ) +} + +// ── 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 onDoneRef = useRef(onDone) + useEffect(() => { onDoneRef.current = onDone }, [onDone]) + + const startBootstrap = async () => { + const url = `${API_BASE}/api/eco/bootstrap?from_date=${fromDate}&force=${force}` + await fetch(url, { method: 'POST' }) + setPolling(true) + setBsStatus(s => ({ ...s, running: true })) + } + + useEffect(() => { + if (!polling) return + const iv = setInterval(async () => { + const r = await fetch(`${API_BASE}/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 result = bsStatus.last_result + const hasError = result && 'error' in result return ( -
-
-
-
- {COUNTRY_FLAGS[ev.country] ?? '🌍'} - - {'●'.repeat(cfg.dots)} - - {isToday && TODAY} - {isSoon && !isToday && SOON} -
-
{ev.title}
-
- {format(evDate, "EEEE, MMM d yyyy")} · {ev.country} -
-
-
-
{cfg.label}
- {ev.previous &&
Prev: {ev.previous}
} - {ev.forecast &&
Fcst: {ev.forecast}
} - {ev.actual && ( -
Actual: {ev.actual}
- )} - {ev.surprise_zscore !== undefined && Math.abs(ev.surprise_zscore) >= 0.8 && ( -
- z={ev.surprise_zscore > 0 ? '+' : ''}{ev.surprise_zscore?.toFixed(1)} { - Math.abs(ev.surprise_zscore) >= 1.5 ? '⚡' : '' - } -
- )} -
+
+
+ Bootstrap FRED
- {ev.asset_impact && ev.asset_impact.length > 0 && ( -
- {ev.asset_impact.map(cls => ( - - {ASSET_EMOJIS[cls] ?? ''} {cls} - - ))} +
+ Télécharge les données FRED (endpoint CSV public, sans clé API) depuis la date choisie. + 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" + />
+ + +
+ + {bsStatus.running && ( +
+ Téléchargement en cours (peut prendre 30–60s)… +
+ )} + + {result && !bsStatus.running && ( + hasError ? ( +
Erreur : {'error' in result ? result.error : ''}
+ ) : ( +
+ {Object.entries(result as Record).map(([sid, r]) => ( +
+ {sid}{' '} + {r.status === 'ok' ? `+${r.inserted} / skip ${r.skipped}` : 'failed'} +
+ ))} +
+ ) )}
) } +// ── Main page ───────────────────────────────────────────────────────────────── + export default function CalendarPage() { - const { data: calendar, isLoading } = useCalendar() - const { data: news } = useGeoNews() + const { data: geoNews } = useGeoNews() - const today = new Date() - const upcoming = calendar?.filter(ev => isAfter(parseISO(ev.date), today)) ?? [] - const past = calendar?.filter(ev => isBefore(parseISO(ev.date), today)) ?? [] + // ── Filters state ── + const today = new Date().toISOString().slice(0, 10) + const twoYearsAgo = new Date(Date.now() - 730 * 86400_000).toISOString().slice(0, 10) - const highImpactNews = news?.filter(n => n.impact_score > 0.4).slice(0, 5) ?? [] + 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 () => { + 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()) + } catch {} + }, []) + + const fetchEvents = useCallback(async () => { + setLoading(true) + 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) + } catch (e) { + setEvents([]) + } finally { + setLoading(false) + } + }, [dateFrom, dateTo, category, selectedSeries, minZ, direction, sortBy, sortDir, limit, offset]) + + useEffect(() => { fetchSeriesMeta(); fetchStatus() }, []) + useEffect(() => { setOffset(0) }, [dateFrom, dateTo, category, selectedSeries, minZ, direction, sortBy, sortDir]) + useEffect(() => { fetchEvents() }, [fetchEvents]) + + const toggleSort = (col: string) => { + if (sortBy === col) setSortDir(d => d === 'desc' ? 'asc' : 'desc') + else { setSortBy(col); setSortDir('desc') } + } + + const SortIcon = ({ col }: { col: string }) => + sortBy !== col ? null + : sortDir === 'desc' ? + : + + const highGeoNews = geoNews?.filter(n => n.impact_score > 0.4).slice(0, 6) ?? [] return ( -
-
-

- Economic & Geopolitical Calendar -

-

- Macro events, geopolitical catalysts, key dates -

-
- - {/* Legend */} -
-
●●● Major (high volatility expected)
-
●● Moderate
-
Minor
-
- -
- {/* Economic calendar */} -
-
- Upcoming events ({upcoming.length}) -
- {isLoading ? ( - [1,2,3].map(i =>
) - ) : upcoming.length > 0 ? ( - upcoming.map((ev, i) => ) - ) : ( -
- Start the backend to load the calendar +
+ {/* Header */} +
+
+

+ Calendrier Économique — FRED +

+

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

+
+
+ {ecoStatus && ( +
+ {ecoStatus.total.toLocaleString()} events + {ecoStatus.earliest && ( + <> · {ecoStatus.earliest?.slice(0, 7)} → {ecoStatus.latest?.slice(0, 7)} + )}
)} + + +
+
- {past.length > 0 && ( - <> -
- Past events ({past.length}) + {/* Bootstrap panel */} + {showBootstrap && ( + { fetchStatus(); fetchEvents(); setShowBootstrap(false) }} /> + )} + +
+ {/* ── 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" />
- {past.map((ev, i) => )} - +
+
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 => ( + + ))} +
+
+
+
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.map(ev => { + const z = ev.surprise_zscore + const isStrong = z !== null && Math.abs(z) >= 1.5 + return ( + + + + + + + + + + + ) + })} + +
toggleSort('date')}> + Date + toggleSort('name')}> + Série + ActuelPrécédentΔ% 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: geo alerts + timeline */} + {/* ── Right sidebar ── */}
-
-
- Geopolitical alerts -
- {highImpactNews.length > 0 ? ( -
- {highImpactNews.map((n, i) => ( -
-
-
{n.title}
- - {Math.round(n.impact_score * 100)} - + {/* 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)}
-
{n.source}
))}
) : ( -
Load geopolitical news
+
Aucune alerte active
)}
- {/* Trade opportunity windows */} + {/* Légende z-score */}
-
Opportunity windows
-
+
Guide Z-Score
+
{[ - { window: 'Pre-FOMC (-3d)', strategy: 'Straddle on SPY', rationale: 'IV rises ahead of decision' }, - { window: 'Pre-NFP (-2d)', strategy: 'Straddle on indices', rationale: 'Directional uncertainty' }, - { window: 'Post-OPEC', strategy: 'Bull Call Spread USO', rationale: 'Cut → oil spike likely' }, - { window: 'Pre-USDA Crop', strategy: 'Long Call WEAT', rationale: 'Supply news catalyst' }, - { window: 'US Election approaching', strategy: 'Long VIX Call', rationale: 'Vol expansion guaranteed' }, - ].map((op, i) => ( -
-
{op.window}
-
{op.strategy}
-
{op.rationale}
-
- ))} -
-
- - {/* Geo-event impact guide */} -
-
- Impact guide -
-
- {[ - { event: 'Middle East conflict', impact: 'Oil +10-20%', cls: 'energy' }, - { event: 'Russia sanctions', impact: 'Gas +15-40%', cls: 'energy' }, - { event: 'US-China tariffs', impact: 'Soy -8%', cls: 'agriculture' }, - { event: 'Health crisis', impact: 'Gold +7-12%', cls: 'metals' }, - { event: 'Fed hikes', impact: 'USD +2-4%', cls: 'forex' }, - { event: 'Ukraine war', impact: 'Wheat +15-50%', cls: 'agriculture' }, + { 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.event} - - {g.impact} - +
+ {g.label} + {g.desc}
))}