diff --git a/backend/main.py b/backend/main.py index 411f21e..7c31049 100644 --- a/backend/main.py +++ b/backend/main.py @@ -15,6 +15,7 @@ from routers import reports as reports_router from routers import institutional as institutional_router from routers import eco as eco_router from routers import simulator as simulator_router +from routers import causal_lab as causal_lab_router from services.database import init_db, get_config, cleanup_stale_running_cycles import os import logging @@ -198,6 +199,7 @@ app.include_router(market_events_router.router) app.include_router(ai_desks_router.router) app.include_router(eco_router.router) app.include_router(simulator_router.router) +app.include_router(causal_lab_router.router) @app.get("/") diff --git a/backend/routers/causal_lab.py b/backend/routers/causal_lab.py new file mode 100644 index 0000000..4fe3159 --- /dev/null +++ b/backend/routers/causal_lab.py @@ -0,0 +1,524 @@ +""" +Causal Lab — Validation empirique du graphe causal EUR/USD. + +Compare les prédictions du modèle aux mouvements réels autour des publications +macro (CPI, NFP, FOMC, ECB...). Détecte également les pré-drifts (fuites d'info). + +Endpoints: + GET /api/causal-lab/events — liste des événements analysables + GET /api/causal-lab/event/{id}/analyze — analyse complète (avec cache) + GET /api/causal-lab/summary — agrégat toutes séries +""" +import json +import logging +import math +from datetime import datetime, timedelta +from typing import Optional +from fastapi import APIRouter, HTTPException, Query + +router = APIRouter() +logger = logging.getLogger(__name__) + +# ── Paramètres neutres de référence ────────────────────────────────────────── +# Tous les inputs à zéro / valeur neutre — on n'injecte que la surprise de l'event +NEUTRAL: dict = { + "fed_rate": 4.25, "ecb_rate": 3.65, + "fed_tone": 0, "ecb_tone": 0, + "cpi_us_surprise": 0, "cpi_eu_surprise": 0, + "nfp_surprise": 0, + "pmi_us": 50, "pmi_eu": 50, + "vix": 18.0, "oil": 80.0, "real_yield_us": 2.1, + "us_2y": 4.50, "us_10y": 4.30, + "eu_2y": 2.80, "eu_10y": 2.60, + "eurusd": 1.1450, +} + +# ── Mapping série FRED → nœud causal ───────────────────────────────────────── +SERIES_MAP: dict = { + # Canal forward guidance FED + "CPIAUCSL": {"name": "CPI US", "side": "US", "ch": "fed_fwd", "key": "cpi_us_surprise"}, + "CPILFESL": {"name": "Core CPI US", "side": "US", "ch": "fed_fwd", "key": "cpi_us_surprise"}, + "PAYEMS": {"name": "NFP", "side": "US", "ch": "fed_fwd", "key": "nfp_surprise"}, + "MANEMP": {"name": "Emploi Manuf.","side": "US", "ch": "fed_fwd", "key": "nfp_surprise"}, + "UNRATE": {"name": "Chômage US", "side": "US", "ch": "fed_fwd", "key": "nfp_surprise", "flip": True}, + # Canal taux directeur FED + "FEDFUNDS": {"name": "Fed Funds", "side": "US", "ch": "fed_rate", "key": "fed_rate", "level": True}, + "DFF": {"name": "Fed Funds D", "side": "US", "ch": "fed_rate", "key": "fed_rate", "level": True}, + # Canal BCE + "ECBDFR": {"name": "ECB Rate", "side": "EU", "ch": "ecb_rate", "key": "ecb_rate", "level": True}, + # CPI EU + "HICP": {"name": "CPI EU", "side": "EU", "ch": "ecb_fwd", "key": "cpi_eu_surprise"}, + "HICPEI": {"name": "Core CPI EU", "side": "EU", "ch": "ecb_fwd", "key": "cpi_eu_surprise"}, + # Canal secondaire + "DFII10": {"name": "Taux réel US", "side": "US", "ch": "secondary","key": "real_yield_us", "level": True}, + # PMI (niveau absolu) + "ISMMAN": {"name": "ISM US", "side": "US", "ch": "pmi_us", "key": "pmi_us", "level_pmi": True}, + "NAPM": {"name": "PMI US", "side": "US", "ch": "pmi_us", "key": "pmi_us", "level_pmi": True}, +} + +RELEVANT_SERIES = set(SERIES_MAP.keys()) + + +# ── Port Python exact du compute() TypeScript (EuroSimulator) ───────────────── + +def _compute(p: dict, base: dict) -> dict: + def g(k): return p.get(k, base.get(k, NEUTRAL[k])) + def gb(k): return base.get(k, NEUTRAL[k]) + + fed_rp = (g("fed_rate") - gb("fed_rate")) / 0.25 + ecb_rp = (g("ecb_rate") - gb("ecb_rate")) / 0.25 + + fed_fwd = ( + -g("fed_tone") * 1.2 + + g("cpi_us_surprise") / 0.1 * 0.50 + + g("nfp_surprise") / 100 * 0.35 + + (g("pmi_us") - 50) / 5 * 0.18 + ) + ecb_fwd = ( + -g("ecb_tone") * 1.2 + + g("cpi_eu_surprise") / 0.1 * 0.45 + + (g("pmi_eu") - 50) / 5 * 0.22 + ) + + us2d = fed_rp * 0.085 + fed_fwd * 0.030 + eu2d = ecb_rp * 0.080 + ecb_fwd * 0.025 + us10d = fed_rp * 0.035 + fed_fwd * 0.070 + (g("pmi_us") - 50) / 50 * 0.012 + eu10d = ecb_rp * 0.030 + ecb_fwd * 0.060 + (g("pmi_eu") - 50) / 50 * 0.012 + + dd2 = us2d - eu2d + dd10 = us10d - eu10d + + pmi_diff = (g("pmi_eu") - 50) - (g("pmi_us") - 50) + vix_dev = g("vix") - gb("vix") + ry_dev = g("real_yield_us") - gb("real_yield_us") + oil_dev = g("oil") - gb("oil") + + c_2y = round(-dd2 * 500) + c_10y = round(-dd10 * 200) + c_pmi = round(pmi_diff * 7) + c_vix = round(-vix_dev * 4) + c_ry = round(-ry_dev * 100) + c_oil = round(oil_dev * 0.5) + total = c_2y + c_10y + c_pmi + c_vix + c_ry + c_oil + + return { + "fed_rate_pressure": round(fed_rp, 3), + "ecb_rate_pressure": round(ecb_rp, 3), + "fed_fwd_signal": round(fed_fwd, 3), + "ecb_fwd_signal": round(ecb_fwd, 3), + "us_2y_delta": round(us2d, 3), + "eu_2y_delta": round(eu2d, 3), + "us_10y_delta": round(us10d, 3), + "eu_10y_delta": round(eu10d, 3), + "delta_diff_2y": round(dd2, 3), + "delta_diff_10y": round(dd10, 3), + "c_2y": c_2y, "c_10y": c_10y, "c_pmi": c_pmi, + "c_vix": c_vix, "c_ry": c_ry, "c_oil": c_oil, + "total_pips": total, + } + + +def _build_scenario(series_id: str, actual: float, forecast: Optional[float], previous: Optional[float]): + """Construit le scénario (p, base) isolant l'effet marginal de la surprise.""" + cfg = SERIES_MAP.get(series_id) + if not cfg: + return None, None + + p = {**NEUTRAL} + base = {**NEUTRAL} + + if cfg.get("level"): + # Événement de taux : surprise = actual vs niveau attendu + expected = forecast if forecast is not None else previous + if expected is None: + return None, None + p[cfg["key"]] = actual + base[cfg["key"]] = expected + + elif cfg.get("level_pmi"): + # PMI : niveau absolu vs consensus + expected = forecast if forecast is not None else 50.0 + p[cfg["key"]] = actual + base[cfg["key"]] = expected + + else: + # Surprise additive : actual − forecast (en unités du modèle) + if forecast is None: + return None, None + surprise = actual - forecast + if cfg.get("flip"): + surprise = -surprise + p[cfg["key"]] = surprise + + return p, base + + +# ── Récupération des prix autour d'un événement ─────────────────────────────── + +def _fetch_prices(event_date_str: str) -> dict: + out: dict = {"mode": "none", "eurusd": [], "us10y": [], "eu10y": [], "us2y": []} + try: + import yfinance as yf + event_dt = datetime.strptime(event_date_str[:10], "%Y-%m-%d") + days_ago = (datetime.utcnow() - event_dt).days + + # Intraday 5min — disponible ~55 jours + if days_ago < 55: + start = (event_dt - timedelta(days=1)).strftime("%Y-%m-%d") + end = (event_dt + timedelta(days=2)).strftime("%Y-%m-%d") + df = yf.download("EURUSD=X", start=start, end=end, + interval="5m", progress=False, auto_adjust=True) + if df is not None and len(df) > 0: + if hasattr(df.columns, "levels"): + df.columns = df.columns.get_level_values(0) + day_df = df[df.index.date == event_dt.date()] + rows = [(idx, float(row["Close"])) for idx, row in day_df.iterrows() + if not (math.isnan(row["Close"]) if isinstance(row["Close"], float) else False)] + if len(rows) >= 6: + out["mode"] = "intraday_5m" + out["eurusd"] = [{"t": idx.isoformat(), "c": round(c, 5)} for idx, c in rows] + + # Fallback journalier + if out["mode"] == "none": + start = (event_dt - timedelta(days=5)).strftime("%Y-%m-%d") + end = (event_dt + timedelta(days=5)).strftime("%Y-%m-%d") + df = yf.download("EURUSD=X", start=start, end=end, + interval="1d", progress=False, auto_adjust=True) + if df is not None and len(df) > 0: + if hasattr(df.columns, "levels"): + df.columns = df.columns.get_level_values(0) + out["mode"] = "daily" + out["eurusd"] = [ + {"t": str(idx.date()), "c": round(float(row["Close"]), 5)} + for idx, row in df.iterrows() + if not (math.isnan(float(row["Close"])) if row["Close"] == row["Close"] else True) + ] + + # Taux journaliers (toujours) + start = (event_dt - timedelta(days=4)).strftime("%Y-%m-%d") + end = (event_dt + timedelta(days=4)).strftime("%Y-%m-%d") + for sym, key in [("^TNX", "us10y"), ("GE10YT=RR", "eu10y"), ("US2YT=RR", "us2y")]: + try: + ydf = yf.download(sym, start=start, end=end, + interval="1d", progress=False, auto_adjust=True) + if ydf is None or len(ydf) == 0: + continue + if hasattr(ydf.columns, "levels"): + ydf.columns = ydf.columns.get_level_values(0) + out[key] = [ + {"t": str(idx.date()), "c": round(float(row["Close"]), 3)} + for idx, row in ydf.iterrows() + if row["Close"] == row["Close"] # not NaN + ] + except Exception as e: + logger.debug(f"[causal_lab] yield {sym}: {e}") + + except Exception as e: + logger.error(f"[causal_lab] _fetch_prices: {e}") + + return out + + +# ── Métriques de drift ──────────────────────────────────────────────────────── + +def _drift_metrics(prices: dict, event_date_str: str) -> dict: + eurusd = prices.get("eurusd", []) + mode = prices.get("mode", "none") + edate = event_date_str[:10] + + empty = {"pre_pips": None, "post_pips": None, "drift_ratio": None, "leak": "unknown"} + if not eurusd: + return empty + + if mode == "intraday_5m": + n = len(eurusd) + if n < 8: + return empty + mid = n // 2 + pre_pips = round((eurusd[mid - 1]["c"] - eurusd[0]["c"]) * 10000) + post_pips = round((eurusd[-1]["c"] - eurusd[mid]["c"]) * 10000) + else: + pre = [b for b in eurusd if b["t"] < edate] + same = [b for b in eurusd if b["t"] == edate] + if not pre or not same: + return empty + pre_pips = None + post_pips = round((same[0]["c"] - pre[-1]["c"]) * 10000) + + ratio: Optional[float] = None + if pre_pips is not None and post_pips and post_pips != 0: + ratio = round(pre_pips / post_pips, 3) + + leak = "unknown" + if ratio is not None: + ar = abs(ratio) + leak = "high" if ar > 0.5 else "medium" if ar > 0.25 else "low" + + return {"pre_pips": pre_pips, "post_pips": post_pips, "drift_ratio": ratio, "leak": leak} + + +def _yield_delta(series: list, event_date: str) -> Optional[float]: + if not series: + return None + pre = [b for b in series if b["t"] < event_date] + post = [b for b in series if b["t"] >= event_date] + if not pre or not post: + return None + return round(post[0]["c"] - pre[-1]["c"], 3) + + +# ── Score d'activation ──────────────────────────────────────────────────────── + +def _activation(model: dict, us10y: Optional[float], eu10y: Optional[float], + post_pips: Optional[int]) -> dict: + + def match(pred: float, act: Optional[float], thr: float = 0.01) -> str: + if act is None: + return "unknown" + if abs(pred) < thr: + return "neutral" + return "correct" if (pred > 0) == (act > 0) else "wrong" + + nodes = { + "us_10y": {"pred": model["us_10y_delta"], "act": us10y, "status": match(model["us_10y_delta"], us10y)}, + "eu_10y": {"pred": model["eu_10y_delta"], "act": eu10y, "status": match(model["eu_10y_delta"], eu10y)}, + "eurusd": {"pred": model["total_pips"], "act": post_pips, "status": match(model["total_pips"], post_pips, 5)}, + } + active = [v for v in nodes.values() if v["status"] not in ("unknown", "neutral")] + correct = sum(1 for v in active if v["status"] == "correct") + score = round(correct / len(active), 2) if active else None + + return {"score": score, "nodes": nodes, "correct": correct, "total": len(active)} + + +# ── Cache ───────────────────────────────────────────────────────────────────── + +def _ensure_cache(conn): + conn.execute(""" + CREATE TABLE IF NOT EXISTS causal_lab_cache ( + id INTEGER PRIMARY KEY, + event_id INTEGER UNIQUE, + result_json TEXT, + analyzed_at TEXT + ) + """) + conn.commit() + + +# ── Endpoints ───────────────────────────────────────────────────────────────── + +@router.get("/api/causal-lab/events") +def list_events(limit: int = Query(120, le=500), series: str = Query("")): + """Liste des événements macro pertinents pour le modèle EUR/USD.""" + try: + from services.database import get_conn + conn = get_conn() + _ensure_cache(conn) + + series_filter = ( + f" AND e.series_id = '{series}'" if series + else " AND e.series_id IN ({})".format( + ",".join(f"'{s}'" for s in RELEVANT_SERIES)) + ) + + rows = conn.execute(f""" + SELECT e.id, e.event_name, e.series_id, e.event_date, + e.actual_value, e.forecast_value, e.previous_value, + e.surprise_pct, e.surprise_direction, + c.analyzed_at, c.result_json + FROM economic_events e + LEFT JOIN causal_lab_cache c ON c.event_id = e.id + WHERE e.actual_value IS NOT NULL + AND e.forecast_value IS NOT NULL + {series_filter} + ORDER BY e.event_date DESC + LIMIT {limit} + """).fetchall() + conn.close() + + result = [] + for r in rows: + item = dict(r) + if item.get("result_json"): + try: + cached = json.loads(item["result_json"]) + item["activation_score"] = cached.get("activation", {}).get("score") + item["predicted_pips"] = cached.get("model", {}).get("total_pips") + item["actual_pips"] = cached.get("drift", {}).get("post_pips") + item["leak"] = cached.get("drift", {}).get("leak") + item["drift_ratio"] = cached.get("drift", {}).get("drift_ratio") + except Exception: + pass + item.pop("result_json", None) + item["cfg"] = SERIES_MAP.get(item["series_id"], {}) + result.append(item) + + return result + + except Exception as e: + logger.error(f"[causal_lab] list_events: {e}") + raise HTTPException(500, str(e)) + + +@router.get("/api/causal-lab/event/{event_id}/analyze") +def analyze_event(event_id: int, force: bool = Query(False)): + """Analyse complète d'un événement : prédiction modèle + données réelles + activation.""" + try: + from services.database import get_conn + conn = get_conn() + _ensure_cache(conn) + + # Vérifier le cache + if not force: + cached = conn.execute( + "SELECT result_json FROM causal_lab_cache WHERE event_id = ?", (event_id,) + ).fetchone() + if cached: + conn.close() + return json.loads(cached["result_json"]) + + # Charger l'événement + row = conn.execute("SELECT * FROM economic_events WHERE id = ?", (event_id,)).fetchone() + if not row: + conn.close() + raise HTTPException(404, "Événement introuvable") + ev = dict(row) + + cfg = SERIES_MAP.get(ev["series_id"]) + if not cfg: + conn.close() + raise HTTPException(400, f"Série {ev['series_id']} non mappée dans le modèle causal") + + # Construire le scénario isolé + p, base = _build_scenario( + ev["series_id"], ev["actual_value"], + ev.get("forecast_value"), ev.get("previous_value"), + ) + if p is None: + conn.close() + raise HTTPException(400, "Impossible de construire le scénario (forecast manquant)") + + model = _compute(p, base) + prices = _fetch_prices(ev["event_date"]) + drift = _drift_metrics(prices, ev["event_date"]) + edate = ev["event_date"][:10] + us10y = _yield_delta(prices["us10y"], edate) + eu10y = _yield_delta(prices["eu10y"], edate) + us2y = _yield_delta(prices["us2y"], edate) + activ = _activation(model, us10y, eu10y, drift["post_pips"]) + + surprise_abs = round( + ev["actual_value"] - (ev.get("forecast_value") or ev.get("previous_value") or 0), 4 + ) + + result = { + "event": { + "id": ev["id"], + "name": ev["event_name"], + "series_id": ev["series_id"], + "date": ev["event_date"], + "actual": ev["actual_value"], + "forecast": ev.get("forecast_value"), + "previous": ev.get("previous_value"), + "surprise": surprise_abs, + "surprise_pct": ev.get("surprise_pct"), + "direction": ev.get("surprise_direction", "neutral"), + "cfg": cfg, + }, + "model": model, + "drift": drift, + "yields": {"actual_us10y": us10y, "actual_eu10y": eu10y, "actual_us2y": us2y}, + "activation": activ, + "prices": prices, + "analyzed_at": datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ"), + } + + conn.execute( + "INSERT OR REPLACE INTO causal_lab_cache (event_id, result_json, analyzed_at) VALUES (?,?,?)", + (event_id, json.dumps(result, default=str), result["analyzed_at"]) + ) + conn.commit() + conn.close() + return result + + except HTTPException: + raise + except Exception as e: + logger.error(f"[causal_lab] analyze {event_id}: {e}") + raise HTTPException(500, str(e)) + + +@router.get("/api/causal-lab/summary") +def get_summary(): + """Agrégat de toutes les analyses — taux d'activation par nœud et par série.""" + try: + from services.database import get_conn + conn = get_conn() + _ensure_cache(conn) + rows = conn.execute( + "SELECT result_json FROM causal_lab_cache WHERE result_json IS NOT NULL" + ).fetchall() + conn.close() + + if not rows: + return {"n": 0, "by_series": {}, "by_node": {}, "drift_dist": []} + + by_series: dict = {} + node_stats: dict = {"us_10y": [0, 0], "eu_10y": [0, 0], "eurusd": [0, 0]} + drift_vals: list = [] + + for row in rows: + try: + d = json.loads(row["result_json"]) + sid = d["event"]["series_id"] + name = d["event"]["name"] + + if sid not in by_series: + by_series[sid] = { + "name": name, "n": 0, + "correct": 0, "total_nodes": 0, + "_preds": [], "_actuals": [], + } + s = by_series[sid] + s["n"] += 1 + s["correct"] += d["activation"].get("correct", 0) + s["total_nodes"] += d["activation"].get("total", 0) + s["_preds"].append(d["model"].get("total_pips", 0)) + if d["drift"].get("post_pips") is not None: + s["_actuals"].append(d["drift"]["post_pips"]) + + for nk, nv in d["activation"].get("nodes", {}).items(): + if nk in node_stats: + node_stats[nk][1] += 1 + if nv.get("status") == "correct": + node_stats[nk][0] += 1 + + if d["drift"].get("drift_ratio") is not None: + drift_vals.append(d["drift"]["drift_ratio"]) + + except Exception: + pass + + by_node = { + k: {"correct": v[0], "total": v[1], + "rate": round(v[0] / v[1], 2) if v[1] > 0 else None} + for k, v in node_stats.items() + } + + for s in by_series.values(): + s["activation_rate"] = round(s["correct"] / s["total_nodes"], 2) if s["total_nodes"] else None + s["avg_pred_pips"] = round(sum(s["_preds"]) / len(s["_preds"])) if s["_preds"] else None + s["avg_actual_pips"] = round(sum(s["_actuals"]) / len(s["_actuals"])) if s["_actuals"] else None + del s["_preds"] + del s["_actuals"] + + return { + "n": len(rows), + "by_series": by_series, + "by_node": by_node, + "drift_dist": drift_vals, + } + + except Exception as e: + logger.error(f"[causal_lab] summary: {e}") + raise HTTPException(500, str(e)) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 605fc85..08ec3c8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -30,6 +30,7 @@ import MarketEvents from './pages/MarketEvents' import AIDesks from './pages/AIDesks' import MacroSeriesPage from './pages/MacroSeriesPage' import EuroSimulator from './pages/EuroSimulator' +import CausalLab from './pages/CausalLab' import { Navigate } from 'react-router-dom' import { useCycleWatcher } from './hooks/useApi' @@ -80,6 +81,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 7ed741e..bbeb024 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -26,6 +26,7 @@ const nav = [ { to: '/backtest', icon: History, label: 'Backtest' }, { to: '/calendar', icon: Calendar, label: 'Calendar' }, { to: '/simulator', icon: Sliders, label: 'EUR/USD Simulator' }, + { to: '/causal-lab', icon: FlaskConical, label: 'Lab Causal' }, { to: '/macro-series', icon: TrendingUp, label: 'Macro Series' }, { to: '/institutional', icon: Building2, label: 'Inst. Reports' }, { to: '/specialist-desks', icon: Users, label: 'Specialist Desks' }, diff --git a/frontend/src/pages/CausalLab.tsx b/frontend/src/pages/CausalLab.tsx new file mode 100644 index 0000000..3775162 --- /dev/null +++ b/frontend/src/pages/CausalLab.tsx @@ -0,0 +1,1002 @@ +import { useState, useCallback, useMemo, useEffect } from 'react' +import { FlaskConical, RefreshCw, Loader2, ChevronRight, AlertCircle, BarChart3 } from 'lucide-react' +import clsx from 'clsx' + +// ── Types ────────────────────────────────────────────────────────────────────── + +interface EventSummary { + id: number + event_name: string + series_id: string + event_date: string + actual_value: number + forecast_value: number | null + previous_value: number | null + surprise_pct: number | null + surprise_direction: string + analyzed_at?: string + activation_score?: number | null + predicted_pips?: number | null + actual_pips?: number | null + leak?: string | null + drift_ratio?: number | null + cfg?: { name?: string; side?: string; ch?: string; flip?: boolean } +} + +interface ModelResult { + fed_rate_pressure: number; ecb_rate_pressure: number + fed_fwd_signal: number; ecb_fwd_signal: number + us_2y_delta: number; eu_2y_delta: number + us_10y_delta: number; eu_10y_delta: number + delta_diff_2y: number; delta_diff_10y: number + c_2y: number; c_10y: number; c_pmi: number + c_vix: number; c_ry: number; c_oil: number + total_pips: number +} + +interface ActivationNode { + pred: number; act: number | null + status: 'correct' | 'wrong' | 'neutral' | 'unknown' +} + +interface Analysis { + event: { + id: number; name: string; series_id: string; date: string + actual: number; forecast: number | null; previous: number | null + surprise: number; surprise_pct: number | null; direction: string + cfg: { side?: string; ch?: string; name?: string } + } + model: ModelResult + drift: { pre_pips: number | null; post_pips: number | null; drift_ratio: number | null; leak: string } + yields: { actual_us10y: number | null; actual_eu10y: number | null; actual_us2y: number | null } + activation: { score: number | null; nodes: Record; correct: number; total: number } + prices: { mode: string; eurusd: { t: string; c: number }[]; us10y: { t: string; c: number }[] } + analyzed_at: string +} + +interface Summary { + n: number + by_series: Record + by_node: Record + drift_dist: number[] +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +const sg = (v: number) => v > 0 ? '+' : '' +const f2 = (v: number) => v.toFixed(2) +const fp = (v: number) => `${sg(v)}${Math.round(v)}p` + +function matchColor(pred: number, actual: number | null, thr = 0.01): string { + if (actual === null) return '#94a3b8' + if (Math.abs(pred) < thr) return '#64748b' + return Math.sign(pred) === Math.sign(actual) ? '#34d399' : '#f87171' +} + +function matchColorPips(pred: number, actual: number | null, thr = 5): string { + return matchColor(pred, actual, thr) +} + +function leakColor(leak: string): string { + return leak === 'high' ? '#f87171' : leak === 'medium' ? '#f59e0b' : leak === 'low' ? '#34d399' : '#64748b' +} + +function scoreColor(score: number | null | undefined): string { + if (score === null || score === undefined) return 'text-slate-500' + if (score >= 0.7) return 'text-emerald-400' + if (score >= 0.4) return 'text-amber-400' + return 'text-rose-400' +} + +function formatDate(d: string) { + return d?.slice(0, 10) ?? '—' +} + +// ── Causal Chain SVG — vue analyse ──────────────────────────────────────────── + +function AnalysisCausalChain({ model, yields, drift }: { + model: ModelResult + yields: { actual_us10y: number | null; actual_eu10y: number | null; actual_us2y: number | null } + drift: Analysis['drift'] +}) { + const { actual_us10y, actual_eu10y, actual_us2y } = yields + const { pre_pips, post_pips, leak } = drift + + // Couleurs de nœuds basées sur la qualité de prédiction + const us2C = matchColor(model.us_2y_delta, actual_us2y) + const us10C = matchColor(model.us_10y_delta, actual_us10y) + const eu2C = '#64748b' // Bund 2Y rarement disponible en intraday → neutre + const eu10C = matchColor(model.eu_10y_delta, actual_eu10y) + const diff2C = matchColor(model.delta_diff_2y, null) // nécessiterait les deux yields + const diff10C = (actual_us10y !== null && actual_eu10y !== null) + ? matchColor(model.delta_diff_10y, actual_us10y - actual_eu10y) + : matchColor(model.delta_diff_10y, null) + const fxC = matchColorPips(model.total_pips, post_pips) + + const fedC = Math.abs(model.fed_fwd_signal) > 0.08 + ? model.fed_fwd_signal > 0 ? '#f87171' : '#34d399' + : '#94a3b8' + const ecbC = Math.abs(model.ecb_fwd_signal) > 0.08 + ? model.ecb_fwd_signal > 0 ? '#34d399' : '#f87171' + : '#94a3b8' + + const lkC = leakColor(leak) + const aw = (v: number) => Math.max(1, Math.min(3.5, Math.abs(v) * 1.2 + 1)) + + const W = 400; const H = 510 + const fedX = 100; const ecbX = 300 + const us2X = 52; const us10X = 155 + const eu2X = 348; const eu10X = 245 + const diff2X = 140; const diff10X = 260 + const cX = 200 + + const yCB = 56 + const yYld = 160 + const yDiff = 265 + const yFX = 370 + const yDrift= 440 + + const COLORS = ['#f87171', '#34d399', '#94a3b8', '#f59e0b'] + const mk = (id: string, c: string) => ( + + + + ) + + // Sub-label: "pred X | act Y" + const sub = (pred: number, act: number | null, unit: string) => { + const ps = `${sg(pred)}${Math.abs(pred) < 0.01 ? '0' : f2(pred)}${unit}` + const as = act !== null ? `${sg(act)}${f2(act)}${unit}` : '?' + return `pred ${ps} | act ${as}` + } + + const Node = ({ x, y, label, subLabel, col, w = 88 }: { + x: number; y: number; label: string; subLabel?: string; col: string; w?: number + }) => ( + + + {label} + {subLabel && ( + {subLabel} + )} + + ) + + const Arr = ({ x1, y1, x2, y2, col, w, dashed = false }: { + x1: number; y1: number; x2: number; y2: number; col: string; w: number; dashed?: boolean + }) => ( + + ) + + return ( + + {COLORS.map(c => mk(`a${c.replace('#', '')}`, c))} + + {/* FED & BCE */} + + + + {/* FED → US 2Y (solide) & US 10Y (tirets) */} + + + + {/* BCE → EU 2Y (solide) & EU 10Y (tirets) */} + + + + {/* Nœuds taux */} + + + + + + {/* Taux → différentiels */} + + + + + + {/* Nœuds différentiels */} + + + + {/* Différentiels → EUR/USD */} + + + + {/* Nœud EUR/USD */} + + EUR/USD + + pred {sg(model.total_pips)}{model.total_pips}p + + + act {post_pips !== null ? fp(post_pips) : '—'} + + + {/* Annotation pré-drift */} + {pre_pips !== null && ( + + + + Pré-drift : {fp(pre_pips)} | Fuite : {drift.leak} + {drift.drift_ratio !== null ? ` (ratio ${drift.drift_ratio.toFixed(2)})` : ''} + + + )} + {pre_pips === null && ( + + Pré-drift indisponible (données journalières) + + )} + + {/* Légende */} + + taux (2Y) + + ton (10Y) + + correct + + incorrect + + ) +} + +// ── Mini graphe EUR/USD autour de l'événement ───────────────────────────────── + +function PriceChart({ prices, mode }: { prices: { eurusd: {t: string; c: number}[] }; mode: string }) { + const data = prices.eurusd + if (!data || data.length < 2) return ( +
+ Données de prix indisponibles +
+ ) + + const vals = data.map(d => d.c) + const min = Math.min(...vals) + const max = Math.max(...vals) + const range = max - min || 0.001 + const W = 240; const H = 80 + const pts = data.map((d, i) => { + const x = (i / (data.length - 1)) * W + const y = H - ((d.c - min) / range) * (H - 8) - 4 + return `${x},${y}` + }).join(' ') + + const mid = Math.floor(data.length / 2) + const midX = (mid / (data.length - 1)) * W + + return ( +
+
+ EUR/USD — {mode === 'intraday_5m' ? '5min (jour J)' : 'journalier ±5j'} +
+ + + + publication + +
+ {min.toFixed(4)} + {max.toFixed(4)} +
+
+ ) +} + +// ── DriftBar — pré-drift vs post-release ────────────────────────────────────── + +function DriftBar({ pre, post, modelSign }: { pre: number | null; post: number | null; modelSign: number }) { + const maxAbs = Math.max(Math.abs(pre ?? 0), Math.abs(post ?? 0), 1) + + const Bar = ({ pips, label }: { pips: number | null; label: string }) => { + if (pips === null) return ( +
+ {label} +
+ +
+ ) + const pct = (Math.abs(pips) / maxAbs) * 100 + const isPos = pips > 0 + const aligned = modelSign !== 0 && Math.sign(pips) === Math.sign(modelSign) + const barCol = label.includes('Pré') ? (aligned ? 'bg-amber-500' : 'bg-slate-600') : (isPos ? 'bg-emerald-500' : 'bg-rose-500') + return ( +
+ {label} +
+
+
+ + {pips > 0 ? '+' : ''}{pips}p + +
+ ) + } + + return ( +
+ + +
+ ) +} + +// ── Score d'activation ───────────────────────────────────────────────────────── + +function ActivationScore({ activation }: { activation: Analysis['activation'] }) { + const { score, nodes, correct, total } = activation + const pct = score !== null ? Math.round(score * 100) : null + + const statusIcon = (s: string) => + s === 'correct' ? '✓' : s === 'wrong' ? '✗' : s === 'neutral' ? '~' : '?' + const statusCol = (s: string) => + s === 'correct' ? 'text-emerald-400' : s === 'wrong' ? 'text-rose-400' : 'text-slate-500' + + const nodeLabels: Record = { + us_10y: 'US 10Y (ton)', eu_10y: 'Bund 10Y (ton)', eurusd: 'EUR/USD' + } + + return ( +
+ {/* Score global */} +
+
+ {pct !== null ? `${pct}%` : '—'} +
+
+ {correct}/{total} nœuds corrects +
+
+ + {/* Détail par nœud */} +
+ {Object.entries(nodes).map(([k, n]) => ( +
+ + {statusIcon(n.status)} + + {nodeLabels[k] ?? k} + + {n.pred > 0 ? '+' : ''}{typeof n.pred === 'number' + ? (Math.abs(n.pred) > 10 ? Math.round(n.pred)+'p' : n.pred.toFixed(2)) + : '?'} + + {n.act !== null && ( + + → {n.act > 0 ? '+' : ''}{typeof n.act === 'number' + ? (Math.abs(n.act) > 10 ? Math.round(n.act)+'p' : n.act.toFixed(2)) + : '?'} + + )} +
+ ))} +
+
+ ) +} + +// ── Liste d'événements (colonne gauche) ──────────────────────────────────────── + +const SERIES_OPTS = [ + { value: '', label: 'Toutes séries' }, + { value: 'CPIAUCSL', label: 'CPI US' }, + { value: 'CPILFESL', label: 'Core CPI US' }, + { value: 'PAYEMS', label: 'NFP' }, + { value: 'FEDFUNDS', label: 'Fed Funds' }, + { value: 'DFF', label: 'Fed Funds D' }, + { value: 'ECBDFR', label: 'ECB Rate' }, + { value: 'DFII10', label: 'Taux réel US' }, +] + +function EventList({ events, selectedId, onSelect, loading }: { + events: EventSummary[]; selectedId: number | null + onSelect: (e: EventSummary) => void; loading: boolean +}) { + const [filter, setFilter] = useState('') + + const filtered = useMemo(() => + filter ? events.filter(e => e.series_id === filter) : events, + [events, filter] + ) + + const LeakDot = ({ leak }: { leak?: string | null }) => { + if (!leak || leak === 'unknown') return null + return ( +
+ ) + } + + return ( +
+
+ +
{filtered.length} événements
+
+ +
+ {loading && ( +
+ + Chargement… +
+ )} + {!loading && filtered.map(ev => { + const isSelected = ev.id === selectedId + const surprise = (ev.actual_value ?? 0) - (ev.forecast_value ?? ev.previous_value ?? 0) + const isPos = surprise > 0 + const side = ev.cfg?.side ?? 'US' + // Direction effet EUR : US hawkish = rouge (baisse EUR), EU hawkish = vert + const surpriseCol = side === 'US' + ? (isPos ? 'text-rose-400' : 'text-emerald-400') + : (isPos ? 'text-emerald-400' : 'text-rose-400') + + return ( + + ) + })} + {!loading && filtered.length === 0 && ( +
Aucun événement trouvé
+ )} +
+
+ ) +} + +// ── Panneau stats (colonne droite) ───────────────────────────────────────────── + +function StatsPanel({ analysis, onAnalyze, analyzing }: { + analysis: Analysis | null; onAnalyze: () => void; analyzing: boolean +}) { + if (!analysis && !analyzing) return ( +
+ +
Sélectionner un événement puis lancer l'analyse
+ +
+ ) + + if (analyzing) return ( +
+ +
Récupération des données de marché…
+
EUR/USD + taux (yfinance)
+
+ ) + + if (!analysis) return null + const { model, drift, yields, activation, prices } = analysis + + return ( +
+ + {/* Score d'activation */} +
+
Score d'activation
+ +
+ + {/* Pips prédit vs observé */} +
+
EUR/USD — pips
+
+ {[ + { label: 'Prédit', val: model.total_pips, unit: 'p' }, + { label: 'Observé', val: drift.post_pips, unit: 'p' }, + ].map(it => ( +
+
{it.label}
+
5 ? 'text-emerald-400' : it.val < -5 ? 'text-rose-400' : 'text-slate-400')}> + {it.val !== null ? `${it.val > 0 ? '+' : ''}${it.val}p` : '—'} +
+
+ ))} +
+
+ + {/* Drift pré/post */} +
+
+
Drift pré/post
+ {drift.leak !== 'unknown' && ( +
+
+ + Fuite {drift.leak} + +
+ )} +
+ + {drift.drift_ratio !== null && ( +
+ Ratio pré/post : {drift.drift_ratio.toFixed(2)} + {' '}(seuil fuite : 0.25 / 0.50) +
+ )} + {drift.pre_pips === null && ( +
+ Détection de fuite nécessite données intraday (<55j) +
+ )} +
+ + {/* Taux observés */} +
+
Taux observés (Δ jour J)
+ {[ + { label: 'US 10Y', pred: model.us_10y_delta, act: yields.actual_us10y }, + { label: 'US 2Y', pred: model.us_2y_delta, act: yields.actual_us2y }, + { label: 'Bund 10Y', pred: model.eu_10y_delta, act: yields.actual_eu10y }, + ].map(r => { + const col = matchColor(r.pred, r.act) + return ( +
+
+ {r.label} + + pred {r.pred >= 0 ? '+' : ''}{r.pred.toFixed(2)}% + + + act {r.act !== null ? `${r.act >= 0 ? '+' : ''}${r.act.toFixed(2)}%` : '—'} + +
+ ) + })} +
+ + {/* Mini graphe prix */} +
+ +
+ + {/* Re-analyser */} + + +
+ Analysé le {analysis.analyzed_at?.slice(0, 10) ?? '—'} +
+
+ ) +} + +// ── Vue instrument — agrégat ─────────────────────────────────────────────────── + +function InstrumentView({ summary, loading }: { summary: Summary | null; loading: boolean }) { + if (loading) return ( +
+ + Chargement du bilan… +
+ ) + if (!summary || summary.n === 0) return ( +
+ Aucune analyse disponible — analysez d'abord des événements dans la vue "Par événement". +
+ ) + + const { by_series, by_node, drift_dist, n } = summary + + const nodeLabels: Record = { + us_10y: 'US 10Y — canal ton', + eu_10y: 'Bund 10Y — canal ton', + eurusd: 'EUR/USD — résultat final', + } + + const leakHigh = drift_dist.filter(d => Math.abs(d) > 0.5).length + const leakMedium = drift_dist.filter(d => Math.abs(d) > 0.25 && Math.abs(d) <= 0.5).length + const leakLow = drift_dist.filter(d => d !== null && Math.abs(d) <= 0.25).length + + return ( +
+ + {/* Header stats */} +
+ {[ + { label: 'Événements analysés', val: n, col: 'text-white' }, + { label: 'Activation US 10Y', val: by_node.us_10y?.rate !== null ? `${Math.round((by_node.us_10y?.rate ?? 0)*100)}%` : '—', col: scoreColor(by_node.us_10y?.rate) }, + { label: 'Activation EUR/USD', val: by_node.eurusd?.rate !== null ? `${Math.round((by_node.eurusd?.rate ?? 0)*100)}%` : '—', col: scoreColor(by_node.eurusd?.rate) }, + { label: 'Fuites potentielles', val: leakHigh, col: leakHigh > 0 ? 'text-rose-400' : 'text-slate-400' }, + ].map(s => ( +
+
{s.label}
+
{s.val}
+
+ ))} +
+ +
+ + {/* Activation par nœud */} +
+
+ Taux d'activation par nœud +
+ {Object.entries(by_node).map(([k, v]) => { + const pct = v.rate !== null ? Math.round(v.rate * 100) : null + return ( +
+
+ {nodeLabels[k] ?? k} + + {pct !== null ? `${pct}%` : '—'} + ({v.correct}/{v.total}) + +
+
+
= 0.7 ? 'bg-emerald-500' : (v.rate ?? 0) >= 0.4 ? 'bg-amber-500' : 'bg-rose-500' + )} style={{ width: `${pct ?? 0}%` }} /> +
+
+ ) + })} +
+ + {/* Distribution drift ratio */} +
+
+ Distribution des pré-drifts +
+ {drift_dist.length === 0 ? ( +
+ Aucun drift mesuré — nécessite données intraday (<55j) +
+ ) : ( + <> +
+ {[ + { label: 'Fuite faible', n: leakLow, col: 'text-emerald-400', bg: 'bg-emerald-500' }, + { label: 'Fuite modérée', n: leakMedium, col: 'text-amber-400', bg: 'bg-amber-500' }, + { label: 'Fuite forte', n: leakHigh, col: 'text-rose-400', bg: 'bg-rose-500' }, + ].map(b => ( +
+
{b.label}
+
{b.n}
+
+ ))} +
+
+ Fuite = pré-drift dans la même direction que la surprise, avant publication. + Ratio >0.50 = fort signal de fuite. +
+ + )} +
+
+ + {/* Table par série */} +
+
+ Résultats par série +
+ + + + {['Série', 'N', 'Activation', 'Pips prédit moy.', 'Pips observé moy.', 'Calibration'].map(h => ( + + ))} + + + + {Object.entries(by_series).map(([sid, s]) => { + const calib = (s.avg_pred_pips !== null && s.avg_actual_pips !== null && s.avg_pred_pips !== 0) + ? Math.round((s.avg_actual_pips / s.avg_pred_pips) * 100) + : null + return ( + + + + + + + + + ) + })} + +
{h}
+
{s.name}
+
{sid}
+
{s.n} + + {s.activation_rate !== null ? `${Math.round(s.activation_rate * 100)}%` : '—'} + + + {s.avg_pred_pips !== null ? `${s.avg_pred_pips > 0 ? '+' : ''}${s.avg_pred_pips}p` : '—'} + + 0 ? 'text-emerald-400' : 'text-rose-400' + : 'text-slate-600'}> + {s.avg_actual_pips !== null ? `${s.avg_actual_pips > 0 ? '+' : ''}${s.avg_actual_pips}p` : '—'} + + + + {calib !== null ? `×${(calib / 100).toFixed(1)}` : '—'} + +
+
+ +
+ Calibration = observé / prédit — ×1.0 = modèle parfait · >×1.5 = modèle sous-estime · <×0.5 = modèle sur-estime +
+
+ ) +} + +// ── Page principale ──────────────────────────────────────────────────────────── + +export default function CausalLab() { + const [view, setView] = useState<'event' | 'instrument'>('event') + const [events, setEvents] = useState([]) + const [loadingEvts, setLoadEvts]= useState(true) + const [selected, setSelected] = useState(null) + const [analysis, setAnalysis] = useState(null) + const [analyzing, setAnalyzing] = useState(false) + const [summary, setSummary] = useState(null) + const [loadingSummary, setLoadSummary] = useState(false) + + useEffect(() => { + setLoadEvts(true) + fetch('/api/causal-lab/events') + .then(r => r.json()) + .then(data => { setEvents(data); setLoadEvts(false) }) + .catch(() => setLoadEvts(false)) + }, []) + + useEffect(() => { + if (view === 'instrument' && !summary) { + setLoadSummary(true) + fetch('/api/causal-lab/summary') + .then(r => r.json()) + .then(data => { setSummary(data); setLoadSummary(false) }) + .catch(() => setLoadSummary(false)) + } + }, [view, summary]) + + const handleSelect = useCallback((ev: EventSummary) => { + setSelected(ev) + // Si déjà analysé, charger depuis le cache + if (ev.analyzed_at) { + setAnalyzing(true) + fetch(`/api/causal-lab/event/${ev.id}/analyze`) + .then(r => r.json()) + .then(data => { setAnalysis(data); setAnalyzing(false) }) + .catch(() => setAnalyzing(false)) + } else { + setAnalysis(null) + } + }, []) + + const handleAnalyze = useCallback((force = false) => { + if (!selected) return + setAnalyzing(true) + setAnalysis(null) + fetch(`/api/causal-lab/event/${selected.id}/analyze${force ? '?force=true' : ''}`) + .then(r => r.json()) + .then(data => { + setAnalysis(data) + setAnalyzing(false) + // Mettre à jour l'événement dans la liste + setEvents(prev => prev.map(e => + e.id === selected.id + ? { ...e, analyzed_at: data.analyzed_at, + activation_score: data.activation?.score, + predicted_pips: data.model?.total_pips, + actual_pips: data.drift?.post_pips, + leak: data.drift?.leak, + drift_ratio: data.drift?.drift_ratio } + : e + )) + // Invalider le résumé pour forcer la mise à jour + setSummary(null) + }) + .catch(() => setAnalyzing(false)) + }, [selected]) + + return ( +
+ + {/* Header */} +
+
+ +
+

Lab Causal

+

+ Validation empirique du graphe EUR/USD · prédiction vs réalité · détection pré-drift +

+
+
+
+ {(['event', 'instrument'] as const).map(v => ( + + ))} +
+
+ + {/* Vue par événement */} + {view === 'event' && ( +
+ + {/* Colonne gauche — liste événements */} +
+
+ Événements macro +
+
+ +
+
+ + {/* Colonne centre — graphe causal */} +
+ {!selected && !analysis && ( +
+ +
Sélectionner un événement à gauche
+
+ Le graphe causal montrera les prédictions du modèle vs les mouvements réels + observés sur les taux et EUR/USD. +
+
+ )} + + {selected && !analysis && !analyzing && ( +
+
+ {selected.event_name} + {' '}— {formatDate(selected.event_date)} +
+
+ Actual: {selected.actual_value} + {' / '}Forecast: {selected.forecast_value ?? '—'} +
+ +
+ Récupère EUR/USD + taux obligataires via yfinance (~5-10s) +
+
+ )} + + {analyzing && ( +
+ +
Analyse en cours…
+
Récupération des prix de marché
+
+ )} + + {analysis && !analyzing && ( + <> +
+ Chaîne causale — {analysis.event.name} · {formatDate(analysis.event.date)} +
+ + {/* Event info strip */} +
+ Surprise + 0 ? 'text-rose-400' : analysis.event.surprise < 0 ? 'text-emerald-400' : 'text-slate-400')}> + {analysis.event.surprise > 0 ? '+' : ''}{analysis.event.surprise.toFixed(3)} + + | + Canal + {analysis.event.cfg?.ch ?? '—'} + | + Prédiction + 5 ? 'text-emerald-400' : analysis.model.total_pips < -5 ? 'text-rose-400' : 'text-slate-400')}> + {analysis.model.total_pips > 0 ? '+' : ''}{analysis.model.total_pips}p + +
+ + + + )} +
+ + {/* Colonne droite — stats */} +
+
+ Métriques +
+ handleAnalyze(analysis !== null)} + analyzing={analyzing} + /> +
+
+ )} + + {/* Vue par instrument */} + {view === 'instrument' && ( +
+ +
+ )} +
+ ) +}