feat: FRED bootstrap + Calendar page complete rebuild

- 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 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-26 14:42:44 +02:00
parent 325d52b3b9
commit 64ff777da6
4 changed files with 1060 additions and 164 deletions

View File

@@ -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("/")

198
backend/routers/eco.py Normal file
View File

@@ -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()

View File

@@ -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

View File

@@ -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<string, string> = {
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<string, string> = {
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<string, { color: string; label: string; dots: number }> = {
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<string, { status: string; inserted: number; skipped: 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 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 <span className="text-slate-600"></span>
const abs = Math.abs(z)
const sign = z > 0 ? '+' : ''
const isBull = dir === 'bullish'
const isBear = dir === 'bearish'
return (
<span className={clsx(
'inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded text-xs font-bold border',
isBull ? 'text-emerald-400 border-emerald-700/40 bg-emerald-900/10'
: isBear ? 'text-red-400 border-red-700/40 bg-red-900/10'
: 'text-slate-400 border-slate-700/30',
)}>
{sign}{z.toFixed(2)}{abs >= 1.5 ? ' ⚡' : abs >= 2.5 ? ' 🔥' : ''}
</span>
)
}
function DirBadge({ dir }: { dir: string | null }) {
if (!dir || dir === 'neutral') return <span className="text-slate-600 text-xs"></span>
return (
<span className={clsx(
'inline-flex items-center gap-0.5 text-xs font-semibold',
dir === 'bullish' ? 'text-emerald-400' : 'text-red-400',
)}>
{dir === 'bullish' ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
{dir === 'bullish' ? 'Bull' : 'Bear'}
</span>
)
}
function SurprisePct({ v }: { v: number | null }) {
if (v === null || v === undefined) return <span className="text-slate-600"></span>
const sign = v > 0 ? '+' : ''
return (
<span className={clsx('text-xs font-mono', v > 0 ? 'text-emerald-400' : v < 0 ? 'text-red-400' : 'text-slate-400')}>
{sign}{v.toFixed(1)}%
</span>
)
}
// ── Bootstrap modal ───────────────────────────────────────────────────────────
function BootstrapPanel({ onDone }: { onDone: () => void }) {
const [fromDate, setFromDate] = useState('2020-01-01')
const [force, setForce] = useState(false)
const [bsStatus, setBsStatus] = useState<BootstrapStatus>({ 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 (
<div className={clsx('card hover:border-slate-600/50 transition-colors', {
'border-yellow-500/40 bg-yellow-900/5': isToday,
'border-blue-500/30': isSoon && !isToday,
'opacity-60': isPast,
})}>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-base">{COUNTRY_FLAGS[ev.country] ?? '🌍'}</span>
<span className={clsx('text-xs font-bold uppercase tracking-wider', cfg.color.split(' ')[0])}>
{'●'.repeat(cfg.dots)}
</span>
{isToday && <span className="badge badge-yellow text-xs">TODAY</span>}
{isSoon && !isToday && <span className="badge badge-blue text-xs">SOON</span>}
</div>
<div className="text-sm text-white font-semibold">{ev.title}</div>
<div className="text-xs text-slate-500 mt-0.5">
{format(evDate, "EEEE, MMM d yyyy")} · {ev.country}
</div>
</div>
<div className="text-right shrink-0 space-y-0.5">
<div className={clsx('text-xs font-semibold', cfg.color.split(' ')[0])}>{cfg.label}</div>
{ev.previous && <div className="text-xs text-slate-600">Prev: {ev.previous}</div>}
{ev.forecast && <div className="text-xs text-slate-500">Fcst: {ev.forecast}</div>}
{ev.actual && (
<div className="text-xs text-emerald-400 font-bold">Actual: {ev.actual}</div>
)}
{ev.surprise_zscore !== undefined && Math.abs(ev.surprise_zscore) >= 0.8 && (
<div className={clsx(
'text-xs font-bold px-1.5 py-0.5 rounded border',
ev.surprise_direction === 'bullish'
? 'text-emerald-400 border-emerald-700/40 bg-emerald-900/20'
: ev.surprise_direction === 'bearish'
? 'text-red-400 border-red-700/40 bg-red-900/20'
: 'text-slate-400 border-slate-700/40'
)}>
z={ev.surprise_zscore > 0 ? '+' : ''}{ev.surprise_zscore?.toFixed(1)} {
Math.abs(ev.surprise_zscore) >= 1.5 ? '⚡' : ''
}
</div>
)}
</div>
<div className="card border-blue-500/30 p-4 space-y-3">
<div className="text-xs font-semibold text-blue-400 flex items-center gap-1">
<Download className="w-3 h-3" /> Bootstrap FRED
</div>
{ev.asset_impact && ev.asset_impact.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{ev.asset_impact.map(cls => (
<span key={cls} className="text-xs bg-dark-700 text-slate-400 px-1.5 py-0.5 rounded border border-slate-700/40">
{ASSET_EMOJIS[cls] ?? ''} {cls}
</span>
))}
<div className="text-xs text-slate-400">
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.
</div>
<div className="flex items-center gap-3">
<div>
<div className="text-xs text-slate-500 mb-0.5">Depuis</div>
<input
type="date" value={fromDate} onChange={e => setFromDate(e.target.value)}
className="text-xs bg-dark-700 border border-slate-700 rounded px-2 py-1 text-white"
/>
</div>
<label className="flex items-center gap-1.5 text-xs text-slate-400 cursor-pointer mt-4">
<input type="checkbox" checked={force} onChange={e => setForce(e.target.checked)}
className="w-3 h-3 accent-blue-500" />
Écraser existants
</label>
<button
onClick={startBootstrap}
disabled={bsStatus.running}
className={clsx(
'mt-4 px-3 py-1.5 rounded text-xs font-semibold transition-colors',
bsStatus.running
? 'bg-blue-900/40 text-blue-400 cursor-wait'
: 'bg-blue-600 hover:bg-blue-500 text-white',
)}
>
{bsStatus.running ? '⏳ En cours...' : 'Lancer'}
</button>
</div>
{bsStatus.running && (
<div className="text-xs text-blue-300 animate-pulse">
Téléchargement en cours (peut prendre 3060s)
</div>
)}
{result && !bsStatus.running && (
hasError ? (
<div className="text-xs text-red-400">Erreur : {'error' in result ? result.error : ''}</div>
) : (
<div className="grid grid-cols-3 gap-2 mt-2">
{Object.entries(result as Record<string, { status: string; inserted: number; skipped: number; name: string }>).map(([sid, r]) => (
<div key={sid} className={clsx(
'text-xs px-2 py-1 rounded border',
r.status === 'ok' ? 'border-emerald-700/30 bg-emerald-900/5 text-emerald-300'
: 'border-red-700/30 text-red-400',
)}>
<span className="font-mono text-slate-400">{sid}</span>{' '}
{r.status === 'ok' ? `+${r.inserted} / skip ${r.skipped}` : 'failed'}
</div>
))}
</div>
)
)}
</div>
)
}
// ── 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<string[]>([])
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<EcoEvent[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [ecoStatus, setEcoStatus] = useState<EcoStatus | null>(null)
const [seriesMeta, setSeriesMeta] = useState<SeriesMeta[]>([])
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' ? <ChevronDown className="w-3 h-3 inline ml-0.5" />
: <ChevronUp className="w-3 h-3 inline ml-0.5" />
const highGeoNews = geoNews?.filter(n => n.impact_score > 0.4).slice(0, 6) ?? []
return (
<div className="p-6 space-y-5">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<Calendar className="w-5 h-5 text-blue-400" /> Economic & Geopolitical Calendar
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Macro events, geopolitical catalysts, key dates
</p>
</div>
{/* Legend */}
<div className="flex items-center gap-4 text-xs text-slate-500">
<div className="flex items-center gap-1"><span className="text-red-400"></span> Major (high volatility expected)</div>
<div className="flex items-center gap-1"><span className="text-yellow-400"></span> Moderate</div>
<div className="flex items-center gap-1"><span className="text-slate-400"></span> Minor</div>
</div>
<div className="grid grid-cols-3 gap-5">
{/* Economic calendar */}
<div className="col-span-2 space-y-3">
<div className="section-title flex items-center gap-1">
<Clock className="w-3 h-3" /> Upcoming events ({upcoming.length})
</div>
{isLoading ? (
[1,2,3].map(i => <div key={i} className="card animate-pulse h-20 bg-dark-700"></div>)
) : upcoming.length > 0 ? (
upcoming.map((ev, i) => <EventCard key={i} ev={ev} />)
) : (
<div className="card text-center py-8 text-slate-500 text-sm">
Start the backend to load the calendar
<div className="p-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-bold text-white flex items-center gap-2">
<Calendar className="w-5 h-5 text-blue-400" /> Calendrier Économique FRED
</h1>
<p className="text-xs text-slate-500 mt-0.5">
Données historiques FRED depuis 2020 · NFP, CPI, FOMC, GDP, Spreads
</p>
</div>
<div className="flex items-center gap-2">
{ecoStatus && (
<div className="text-xs text-slate-400 bg-dark-700 border border-slate-700/40 rounded px-2 py-1">
<span className="text-white font-semibold">{ecoStatus.total.toLocaleString()}</span> events
{ecoStatus.earliest && (
<> · {ecoStatus.earliest?.slice(0, 7)} {ecoStatus.latest?.slice(0, 7)}</>
)}
</div>
)}
<button
onClick={() => { setShowBootstrap(s => !s) }}
className={clsx(
'flex items-center gap-1 text-xs px-3 py-1.5 rounded font-semibold border transition-colors',
showBootstrap
? 'bg-blue-900/30 border-blue-500/40 text-blue-300'
: 'bg-dark-700 border-slate-700 text-slate-300 hover:border-blue-500/40',
)}
>
<Download className="w-3 h-3" /> Bootstrap FRED
</button>
<button
onClick={fetchEvents}
className="flex items-center gap-1 text-xs px-2 py-1.5 rounded bg-dark-700 border border-slate-700 text-slate-400 hover:text-white"
>
<RefreshCw className={clsx('w-3 h-3', loading && 'animate-spin')} />
</button>
</div>
</div>
{past.length > 0 && (
<>
<div className="section-title mt-6 flex items-center gap-1 opacity-60">
Past events ({past.length})
{/* Bootstrap panel */}
{showBootstrap && (
<BootstrapPanel onDone={() => { fetchStatus(); fetchEvents(); setShowBootstrap(false) }} />
)}
<div className="grid grid-cols-4 gap-4">
{/* ── Filters + Table ── */}
<div className="col-span-3 space-y-3">
{/* Filter bar */}
<div className="card p-3 space-y-2">
{/* Row 1: dates + direction + z-score */}
<div className="flex flex-wrap items-end gap-3">
<div>
<div className="text-xs text-slate-500 mb-0.5">Du</div>
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className="text-xs bg-dark-700 border border-slate-700 rounded px-2 py-1 text-white w-32" />
</div>
{past.map((ev, i) => <EventCard key={i} ev={ev} />)}
</>
<div>
<div className="text-xs text-slate-500 mb-0.5">Au</div>
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
className="text-xs bg-dark-700 border border-slate-700 rounded px-2 py-1 text-white w-32" />
</div>
<div>
<div className="text-xs text-slate-500 mb-0.5">|Z| min</div>
<select value={minZ} onChange={e => setMinZ(Number(e.target.value))}
className="text-xs bg-dark-700 border border-slate-700 rounded px-2 py-1 text-white">
{[0, 0.5, 1, 1.5, 2, 2.5].map(v => (
<option key={v} value={v}>{v === 0 ? 'Tous' : `${v}σ`}</option>
))}
</select>
</div>
<div>
<div className="text-xs text-slate-500 mb-0.5">Direction</div>
<div className="flex gap-1">
{DIRECTIONS.map(d => (
<button key={d.id}
onClick={() => setDirection(d.id)}
className={clsx(
'text-xs px-2 py-0.5 rounded border transition-colors',
direction === d.id
? d.id === 'bullish' ? 'bg-emerald-900/30 border-emerald-500/40 text-emerald-300'
: d.id === 'bearish' ? 'bg-red-900/30 border-red-500/40 text-red-300'
: 'bg-blue-900/30 border-blue-500/40 text-blue-300'
: 'bg-dark-700 border-slate-700 text-slate-400 hover:text-white',
)}
>{d.label}</button>
))}
</div>
</div>
<div>
<div className="text-xs text-slate-500 mb-0.5">Limite</div>
<select value={limit} onChange={e => setLimit(Number(e.target.value))}
className="text-xs bg-dark-700 border border-slate-700 rounded px-2 py-1 text-white">
{[50, 100, 200, 500].map(v => <option key={v} value={v}>{v}</option>)}
</select>
</div>
</div>
{/* Row 2: category chips */}
<div className="flex flex-wrap gap-1.5">
<button onClick={() => setCategory('')}
className={clsx('text-xs px-2 py-0.5 rounded border transition-colors',
!category ? 'bg-slate-700 border-slate-500 text-white' : 'bg-dark-700 border-slate-700 text-slate-400 hover:text-white')}>
Toutes catégories
</button>
{CATEGORIES.map(c => (
<button key={c.id} onClick={() => setCategory(category === c.id ? '' : c.id)}
className={clsx('text-xs px-2 py-0.5 rounded border transition-colors',
category === c.id ? 'bg-blue-900/30 border-blue-500/40 text-blue-300'
: 'bg-dark-700 border-slate-700 text-slate-400 hover:text-white')}>
{c.label}
</button>
))}
</div>
{/* Row 3: series chips */}
<div className="flex flex-wrap gap-1">
{seriesMeta.map(s => (
<button key={s.id}
onClick={() => setSelectedSeries(prev =>
prev.includes(s.id) ? prev.filter(x => x !== s.id) : [...prev, s.id]
)}
className={clsx('text-xs px-2 py-0.5 rounded border transition-colors font-mono',
selectedSeries.includes(s.id)
? 'bg-violet-900/30 border-violet-500/40 text-violet-300'
: 'bg-dark-700 border-slate-700/50 text-slate-500 hover:text-white hover:border-slate-600',
)}
title={s.name}
>
{s.id}
</button>
))}
{selectedSeries.length > 0 && (
<button onClick={() => setSelectedSeries([])}
className="text-xs px-2 py-0.5 rounded border border-slate-700 text-slate-500 hover:text-red-400">
Reset
</button>
)}
</div>
</div>
{/* Results summary */}
<div className="flex items-center justify-between text-xs text-slate-500">
<span>
{total.toLocaleString()} événements
{total > limit && <> · page {Math.floor(offset / limit) + 1}/{Math.ceil(total / limit)}</>}
</span>
<div className="flex gap-2">
{offset > 0 && (
<button onClick={() => setOffset(Math.max(0, offset - limit))}
className="text-blue-400 hover:text-blue-300"> Précédent</button>
)}
{offset + limit < total && (
<button onClick={() => setOffset(offset + limit)}
className="text-blue-400 hover:text-blue-300">Suivant </button>
)}
</div>
</div>
{/* Table */}
{ecoStatus?.total === 0 ? (
<div className="card text-center py-12 space-y-3">
<div className="text-slate-400 text-sm">Aucune donnée économique en base</div>
<div className="text-slate-500 text-xs">Lance le Bootstrap FRED pour importer les données depuis 2020</div>
<button onClick={() => setShowBootstrap(true)}
className="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs rounded font-semibold">
Ouvrir Bootstrap
</button>
</div>
) : loading ? (
<div className="space-y-1">
{Array.from({ length: 8 }).map((_, i) => (
<div key={i} className="h-8 bg-dark-700 animate-pulse rounded" />
))}
</div>
) : events.length === 0 ? (
<div className="card text-center py-8 text-slate-500 text-sm">
Aucun événement pour ces filtres
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-slate-500 border-b border-slate-700/50">
<th className="text-left py-1.5 pr-3 cursor-pointer hover:text-white" onClick={() => toggleSort('date')}>
Date <SortIcon col="date" />
</th>
<th className="text-left py-1.5 pr-3 cursor-pointer hover:text-white" onClick={() => toggleSort('name')}>
Série <SortIcon col="name" />
</th>
<th className="text-right py-1.5 pr-3">Actuel</th>
<th className="text-right py-1.5 pr-3">Précédent</th>
<th className="text-right py-1.5 pr-3">Δ%</th>
<th className="text-right py-1.5 pr-3 cursor-pointer hover:text-white" onClick={() => toggleSort('zscore')}>
Z-Score <SortIcon col="zscore" />
</th>
<th className="text-center py-1.5 pr-3">Signal</th>
<th className="text-left py-1.5">Assets</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700/20">
{events.map(ev => {
const z = ev.surprise_zscore
const isStrong = z !== null && Math.abs(z) >= 1.5
return (
<tr key={ev.id} className={clsx(
'hover:bg-dark-600/30 transition-colors',
isStrong && ev.surprise_direction === 'bullish' && 'bg-emerald-900/5',
isStrong && ev.surprise_direction === 'bearish' && 'bg-red-900/5',
)}>
<td className="py-1.5 pr-3 font-mono text-slate-400 whitespace-nowrap">
{ev.event_date}
</td>
<td className="py-1.5 pr-3">
<div className="text-white font-medium">{ev.event_name}</div>
<div className="text-slate-600 font-mono text-xs">{ev.series_id}</div>
</td>
<td className="py-1.5 pr-3 text-right font-mono text-white">
{fmt(ev.actual_value)} <span className="text-slate-600">{ev.actual_unit}</span>
</td>
<td className="py-1.5 pr-3 text-right font-mono text-slate-500">
{fmt(ev.previous_value)}
</td>
<td className="py-1.5 pr-3 text-right">
<SurprisePct v={ev.surprise_pct} />
</td>
<td className="py-1.5 pr-3 text-right">
<ZBadge z={ev.surprise_zscore} dir={ev.surprise_direction} />
</td>
<td className="py-1.5 pr-3 text-center">
<DirBadge dir={ev.surprise_direction} />
</td>
<td className="py-1.5">
<div className="flex flex-wrap gap-0.5">
{ev.assets_impacted.slice(0, 3).map(a => (
<span key={a} className="text-xs px-1 rounded bg-dark-600 text-slate-500 border border-slate-700/30">
{a}
</span>
))}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
{/* Right: geo alerts + timeline */}
{/* ── Right sidebar ── */}
<div className="col-span-1 space-y-4">
<div className="card">
<div className="section-title flex items-center gap-1">
<AlertTriangle className="w-3 h-3 text-orange-400" /> Geopolitical alerts
</div>
{highImpactNews.length > 0 ? (
<div className="space-y-2">
{highImpactNews.map((n, i) => (
<div key={i} className="card-sm">
<div className="flex items-start justify-between gap-1">
<div className="text-xs text-white line-clamp-2">{n.title}</div>
<span className="text-xs text-orange-400 font-bold shrink-0 ml-1">
{Math.round(n.impact_score * 100)}
</span>
{/* Séries disponibles */}
{ecoStatus && ecoStatus.total > 0 && (
<div className="card">
<div className="text-xs font-semibold text-slate-300 mb-2 flex items-center gap-1">
<Calendar className="w-3 h-3 text-blue-400" /> En base
</div>
<div className="space-y-1">
{ecoStatus.by_series.map(s => {
const meta = seriesMeta.find(m => m.id === s.series_id)
return (
<div key={s.series_id} className="flex justify-between items-center">
<div>
<div className="text-xs font-mono text-slate-400">{s.series_id}</div>
<div className="text-xs text-slate-600">{meta?.name}</div>
</div>
<div className="text-right">
<div className="text-xs text-white">{s.cnt}</div>
<div className="text-xs text-slate-600">{s.latest?.slice(0, 7)}</div>
</div>
</div>
)
})}
</div>
</div>
)}
{/* Geo alerts */}
<div className="card">
<div className="text-xs font-semibold text-slate-300 mb-2 flex items-center gap-1">
<AlertTriangle className="w-3 h-3 text-orange-400" /> Alertes géopolitiques
</div>
{highGeoNews.length > 0 ? (
<div className="space-y-2">
{highGeoNews.map((n, i) => (
<div key={i} className="border-b border-slate-700/20 pb-1.5 last:border-0 last:pb-0">
<div className="text-xs text-white line-clamp-2">{n.title}</div>
<div className="flex items-center justify-between mt-0.5">
<span className="text-xs text-slate-600">{n.source}</span>
<span className="text-xs text-orange-400 font-bold">{Math.round(n.impact_score * 100)}</span>
</div>
<div className="text-xs text-slate-600 mt-1">{n.source}</div>
</div>
))}
</div>
) : (
<div className="text-xs text-slate-600 text-center py-4">Load geopolitical news</div>
<div className="text-xs text-slate-600 py-4 text-center">Aucune alerte active</div>
)}
</div>
{/* Trade opportunity windows */}
{/* Légende z-score */}
<div className="card">
<div className="section-title">Opportunity windows</div>
<div className="space-y-2 text-xs">
<div className="text-xs font-semibold text-slate-400 mb-2">Guide Z-Score</div>
<div className="space-y-1 text-xs">
{[
{ 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) => (
<div key={i} className="card-sm">
<div className="font-semibold text-blue-400">{op.window}</div>
<div className="text-white mt-0.5">{op.strategy}</div>
<div className="text-slate-500">{op.rationale}</div>
</div>
))}
</div>
</div>
{/* Geo-event impact guide */}
<div className="card">
<div className="section-title flex items-center gap-1">
<Globe className="w-3 h-3" /> Impact guide
</div>
<div className="space-y-1.5 text-xs">
{[
{ 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.51.0σ', desc: 'Légère surprise', cls: 'text-slate-400' },
{ label: '1.01.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) => (
<div key={i} className="flex justify-between items-center py-1 border-b border-slate-700/20 last:border-0">
<span className="text-slate-400">{g.event}</span>
<span className={clsx('font-bold', g.impact.includes('+') ? 'positive' : 'negative')}>
{g.impact}
</span>
<div key={i} className="flex justify-between">
<span className={clsx('font-mono', g.cls)}>{g.label}</span>
<span className="text-slate-600">{g.desc}</span>
</div>
))}
</div>