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:
@@ -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
198
backend/routers/eco.py
Normal 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()
|
||||
281
backend/services/fred_bootstrap.py
Normal file
281
backend/services/fred_bootstrap.py
Normal 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
|
||||
@@ -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 30–60s)…
|
||||
</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.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) => (
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user