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 var as var_router
|
||||||
from routers import reports as reports_router
|
from routers import reports as reports_router
|
||||||
from routers import institutional as institutional_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
|
from services.database import init_db, get_config, cleanup_stale_running_cycles
|
||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
@@ -134,6 +135,7 @@ app.include_router(impact_router.router)
|
|||||||
app.include_router(cycle_actions_router.router)
|
app.include_router(cycle_actions_router.router)
|
||||||
app.include_router(market_events_router.router)
|
app.include_router(market_events_router.router)
|
||||||
app.include_router(ai_desks_router.router)
|
app.include_router(ai_desks_router.router)
|
||||||
|
app.include_router(eco_router.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@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 clsx from 'clsx'
|
||||||
import { Calendar, Clock, Globe, AlertTriangle } from 'lucide-react'
|
import {
|
||||||
import type { EconomicEvent, AssetClass } from '../types'
|
Calendar, RefreshCw, AlertTriangle, TrendingUp, TrendingDown,
|
||||||
import { format, parseISO, isAfter, isBefore, addDays } from 'date-fns'
|
Minus, ChevronUp, ChevronDown, Download,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
const ASSET_EMOJIS: Record<string, string> = {
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
energy: '⛽', metals: '🥇', agriculture: '🌾', equities: '📈',
|
|
||||||
indices: '📊', forex: '💱', rates: '🏦',
|
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> = {
|
interface EcoStatus {
|
||||||
US: '🇺🇸', EU: '🇪🇺', CN: '🇨🇳', JP: '🇯🇵', GB: '🇬🇧',
|
total: number
|
||||||
DE: '🇩🇪', FR: '🇫🇷', Global: '🌍',
|
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 }> = {
|
interface SeriesMeta {
|
||||||
high: { color: 'text-red-400 border-red-700/40', label: 'Major', dots: 3 },
|
id: string
|
||||||
medium: { color: 'text-yellow-400 border-yellow-700/40', label: 'Moderate', dots: 2 },
|
name: string
|
||||||
low: { color: 'text-slate-400 border-slate-700/40', label: 'Minor', dots: 1 },
|
category: string
|
||||||
|
freq: string
|
||||||
|
unit: string
|
||||||
|
assets: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
function EventCard({ ev }: { ev: EconomicEvent }) {
|
interface BootstrapStatus {
|
||||||
const cfg = IMPORTANCE_CONFIG[ev.importance]
|
running: boolean
|
||||||
const isPast = ev.actual !== undefined && ev.actual !== null
|
last_result: Record<string, { status: string; inserted: number; skipped: number }> | { error: string } | null
|
||||||
const today = new Date()
|
}
|
||||||
const evDate = parseISO(ev.date)
|
|
||||||
const isToday = format(evDate, 'yyyy-MM-dd') === format(today, 'yyyy-MM-dd')
|
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||||
const isSoon = !isToday && isAfter(evDate, today) && isBefore(evDate, addDays(today, 3))
|
|
||||||
|
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 (
|
return (
|
||||||
<div className={clsx('card hover:border-slate-600/50 transition-colors', {
|
<div className="card border-blue-500/30 p-4 space-y-3">
|
||||||
'border-yellow-500/40 bg-yellow-900/5': isToday,
|
<div className="text-xs font-semibold text-blue-400 flex items-center gap-1">
|
||||||
'border-blue-500/30': isSoon && !isToday,
|
<Download className="w-3 h-3" /> Bootstrap FRED
|
||||||
'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>
|
</div>
|
||||||
{ev.asset_impact && ev.asset_impact.length > 0 && (
|
<div className="text-xs text-slate-400">
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
Télécharge les données FRED (endpoint CSV public, sans clé API) depuis la date choisie.
|
||||||
{ev.asset_impact.map(cls => (
|
Séries : NFP, Chômage, CPI, Core CPI, Core PCE, FOMC, Jobless Claims, GDP, HY Spread, T10Y2Y, T10Y3M.
|
||||||
<span key={cls} className="text-xs bg-dark-700 text-slate-400 px-1.5 py-0.5 rounded border border-slate-700/40">
|
</div>
|
||||||
{ASSET_EMOJIS[cls] ?? ''} {cls}
|
<div className="flex items-center gap-3">
|
||||||
</span>
|
<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>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Main page ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function CalendarPage() {
|
export default function CalendarPage() {
|
||||||
const { data: calendar, isLoading } = useCalendar()
|
const { data: geoNews } = useGeoNews()
|
||||||
const { data: news } = useGeoNews()
|
|
||||||
|
|
||||||
const today = new Date()
|
// ── Filters state ──
|
||||||
const upcoming = calendar?.filter(ev => isAfter(parseISO(ev.date), today)) ?? []
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
const past = calendar?.filter(ev => isBefore(parseISO(ev.date), today)) ?? []
|
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 (
|
return (
|
||||||
<div className="p-6 space-y-5">
|
<div className="p-6 space-y-4">
|
||||||
<div>
|
{/* Header */}
|
||||||
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
<div className="flex items-center justify-between">
|
||||||
<Calendar className="w-5 h-5 text-blue-400" /> Economic & Geopolitical Calendar
|
<div>
|
||||||
</h1>
|
<h1 className="text-xl font-bold text-white flex items-center gap-2">
|
||||||
<p className="text-xs text-slate-500 mt-0.5">
|
<Calendar className="w-5 h-5 text-blue-400" /> Calendrier Économique — FRED
|
||||||
Macro events, geopolitical catalysts, key dates
|
</h1>
|
||||||
</p>
|
<p className="text-xs text-slate-500 mt-0.5">
|
||||||
</div>
|
Données historiques FRED depuis 2020 · NFP, CPI, FOMC, GDP, Spreads…
|
||||||
|
</p>
|
||||||
{/* Legend */}
|
</div>
|
||||||
<div className="flex items-center gap-4 text-xs text-slate-500">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-1"><span className="text-red-400">●●●</span> Major (high volatility expected)</div>
|
{ecoStatus && (
|
||||||
<div className="flex items-center gap-1"><span className="text-yellow-400">●●</span> Moderate</div>
|
<div className="text-xs text-slate-400 bg-dark-700 border border-slate-700/40 rounded px-2 py-1">
|
||||||
<div className="flex items-center gap-1"><span className="text-slate-400">●</span> Minor</div>
|
<span className="text-white font-semibold">{ecoStatus.total.toLocaleString()}</span> events
|
||||||
</div>
|
{ecoStatus.earliest && (
|
||||||
|
<> · {ecoStatus.earliest?.slice(0, 7)} → {ecoStatus.latest?.slice(0, 7)}</>
|
||||||
<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>
|
</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 && (
|
{/* Bootstrap panel */}
|
||||||
<>
|
{showBootstrap && (
|
||||||
<div className="section-title mt-6 flex items-center gap-1 opacity-60">
|
<BootstrapPanel onDone={() => { fetchStatus(); fetchEvents(); setShowBootstrap(false) }} />
|
||||||
Past events ({past.length})
|
)}
|
||||||
|
|
||||||
|
<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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Right: geo alerts + timeline */}
|
{/* ── Right sidebar ── */}
|
||||||
<div className="col-span-1 space-y-4">
|
<div className="col-span-1 space-y-4">
|
||||||
<div className="card">
|
{/* Séries disponibles */}
|
||||||
<div className="section-title flex items-center gap-1">
|
{ecoStatus && ecoStatus.total > 0 && (
|
||||||
<AlertTriangle className="w-3 h-3 text-orange-400" /> Geopolitical alerts
|
<div className="card">
|
||||||
</div>
|
<div className="text-xs font-semibold text-slate-300 mb-2 flex items-center gap-1">
|
||||||
{highImpactNews.length > 0 ? (
|
<Calendar className="w-3 h-3 text-blue-400" /> En base
|
||||||
<div className="space-y-2">
|
</div>
|
||||||
{highImpactNews.map((n, i) => (
|
<div className="space-y-1">
|
||||||
<div key={i} className="card-sm">
|
{ecoStatus.by_series.map(s => {
|
||||||
<div className="flex items-start justify-between gap-1">
|
const meta = seriesMeta.find(m => m.id === s.series_id)
|
||||||
<div className="text-xs text-white line-clamp-2">{n.title}</div>
|
return (
|
||||||
<span className="text-xs text-orange-400 font-bold shrink-0 ml-1">
|
<div key={s.series_id} className="flex justify-between items-center">
|
||||||
{Math.round(n.impact_score * 100)}
|
<div>
|
||||||
</span>
|
<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>
|
||||||
<div className="text-xs text-slate-600 mt-1">{n.source}</div>
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Trade opportunity windows */}
|
{/* Légende z-score */}
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="section-title">Opportunity windows</div>
|
<div className="text-xs font-semibold text-slate-400 mb-2">Guide Z-Score</div>
|
||||||
<div className="space-y-2 text-xs">
|
<div className="space-y-1 text-xs">
|
||||||
{[
|
{[
|
||||||
{ window: 'Pre-FOMC (-3d)', strategy: 'Straddle on SPY', rationale: 'IV rises ahead of decision' },
|
{ label: '|z| < 0.5', desc: 'Neutre / dans la norme', cls: 'text-slate-500' },
|
||||||
{ window: 'Pre-NFP (-2d)', strategy: 'Straddle on indices', rationale: 'Directional uncertainty' },
|
{ label: '0.5–1.0σ', desc: 'Légère surprise', cls: 'text-slate-400' },
|
||||||
{ window: 'Post-OPEC', strategy: 'Bull Call Spread USO', rationale: 'Cut → oil spike likely' },
|
{ label: '1.0–1.5σ', desc: 'Surprise modérée', cls: 'text-yellow-400' },
|
||||||
{ window: 'Pre-USDA Crop', strategy: 'Long Call WEAT', rationale: 'Supply news catalyst' },
|
{ label: '≥ 1.5σ ⚡', desc: 'Forte surprise (rare)', cls: 'text-orange-400' },
|
||||||
{ window: 'US Election approaching', strategy: 'Long VIX Call', rationale: 'Vol expansion guaranteed' },
|
{ label: '≥ 2.5σ 🔥', desc: 'Choc extrême', cls: 'text-red-400' },
|
||||||
].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' },
|
|
||||||
].map((g, i) => (
|
].map((g, i) => (
|
||||||
<div key={i} className="flex justify-between items-center py-1 border-b border-slate-700/20 last:border-0">
|
<div key={i} className="flex justify-between">
|
||||||
<span className="text-slate-400">{g.event}</span>
|
<span className={clsx('font-mono', g.cls)}>{g.label}</span>
|
||||||
<span className={clsx('font-bold', g.impact.includes('+') ? 'positive' : 'negative')}>
|
<span className="text-slate-600">{g.desc}</span>
|
||||||
{g.impact}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user