""" 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))