diff --git a/backend/main.py b/backend/main.py index 7c31049..cc3251c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -85,6 +85,15 @@ def startup(): from services.geo_analyzer import GEO_PATTERNS from services.database import seed_builtin_patterns seed_builtin_patterns(GEO_PATTERNS) + # Seed causal graph templates + try: + from services.database import get_conn as _get_conn + from services.causal_graphs import init_tables as _cg_init, seed_templates as _cg_seed + _cg_conn = _get_conn() + _cg_init(_cg_conn); _cg_seed(_cg_conn); _cg_conn.close() + _log.info("[Startup] Causal graph templates seeded") + except Exception as _e: + _log.warning(f"[Startup] Causal graph seed failed: {_e}") # Auto-bootstrap désactivé — utiliser les boutons dans Cycle Actions / Timeline # Start auto-cycle scheduler if enabled from services.auto_cycle import start_scheduler diff --git a/backend/routers/causal_lab.py b/backend/routers/causal_lab.py index 4fe3159..3ff613b 100644 --- a/backend/routers/causal_lab.py +++ b/backend/routers/causal_lab.py @@ -1,13 +1,19 @@ """ -Causal Lab — Validation empirique du graphe causal EUR/USD. +Causal Lab v2 — Bibliothèque de graphes causaux + analyse empirique. -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). +Sources d'événements : market_events (toutes catégories). +Templates de graphes : causal_graph_templates (seed automatique au démarrage). +IA : GPT-4o pour recommandation de template + coefficients. -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 +Endpoints : + GET /api/causal-lab/templates — liste des templates + GET /api/causal-lab/template/{id} — détail d'un template + PUT /api/causal-lab/template/{id} — mettre à jour les coefficients + GET /api/causal-lab/market-events — événements analysables + POST /api/causal-lab/recommend — GPT-4o recommande un template + POST /api/causal-lab/analyze — analyse (prédiction + réel) + GET /api/causal-lab/analyses — historique des analyses + GET /api/causal-lab/calibration — stats de calibration par template """ import json import logging @@ -15,200 +21,109 @@ import math from datetime import datetime, timedelta from typing import Optional from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel 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}, -} +# ── Initialisation DB (appelée depuis startup) ──────────────────────────────── -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 +def _init(conn): + from services.causal_graphs import init_tables, seed_templates + init_tables(conn) + seed_templates(conn) # ── 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": []} +def _fetch_prices(event_date_str: str, instruments: list[str]) -> dict: + """ + Télécharge les prix autour de l'événement pour chaque instrument demandé. + Retourne dict { instrument: [{"t": iso, "c": float}, ...] } + """ + YFINANCE_MAP = { + "EURUSD": "EURUSD=X", + "XAUUSD": "GC=F", + "SP500": "^GSPC", + "BRENT": "BZ=F", + "US2Y": "US2YT=RR", + "US10Y": "^TNX", + "EU10Y": "GE10YT=RR", + } + + out: dict = {"mode": "none"} + for inst in instruments + ["US2Y", "US10Y", "EU10Y"]: + out[inst] = [] + try: import yfinance as yf - event_dt = datetime.strptime(event_date_str[:10], "%Y-%m-%d") - days_ago = (datetime.utcnow() - event_dt).days + 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] + # Intraday 5min (< 55 jours) pour les FX / actifs principaux + use_intraday = days_ago < 55 - # 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) - ] + for inst in instruments: + sym = YFINANCE_MAP.get(inst) + if not sym: + continue + try: + if use_intraday: + start = (event_dt - timedelta(days=1)).strftime("%Y-%m-%d") + end = (event_dt + timedelta(days=2)).strftime("%Y-%m-%d") + df = yf.download(sym, 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 = [ + {"t": idx.isoformat(), "c": round(float(row["Close"]), 5)} + for idx, row in day_df.iterrows() + if not math.isnan(float(row["Close"])) + ] + if len(rows) >= 6: + out[inst] = rows + out["mode"] = "intraday_5m" + continue - # Taux journaliers (toujours) + # Fallback journalier + start = (event_dt - timedelta(days=5)).strftime("%Y-%m-%d") + end = (event_dt + timedelta(days=5)).strftime("%Y-%m-%d") + df = yf.download(sym, 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[inst] = [ + {"t": str(idx.date()), "c": round(float(row["Close"]), 5)} + for idx, row in df.iterrows() + if float(row["Close"]) == float(row["Close"]) + ] + if out["mode"] == "none": + out["mode"] = "daily" + except Exception as e: + logger.debug(f"[causal_lab] price {sym}: {e}") + + # Taux (toujours en journalier) 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")]: + for key in ["US2Y", "US10Y", "EU10Y"]: + sym = YFINANCE_MAP.get(key) + if not sym: + continue try: - ydf = yf.download(sym, start=start, end=end, - interval="1d", progress=False, auto_adjust=True) - if ydf is None or len(ydf) == 0: + df = yf.download(sym, start=start, end=end, + interval="1d", progress=False, auto_adjust=True) + if df is None or len(df) == 0: continue - if hasattr(ydf.columns, "levels"): - ydf.columns = ydf.columns.get_level_values(0) + if hasattr(df.columns, "levels"): + df.columns = df.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 + for idx, row in df.iterrows() + if float(row["Close"]) == float(row["Close"]) ] except Exception as e: logger.debug(f"[causal_lab] yield {sym}: {e}") @@ -219,31 +134,32 @@ def _fetch_prices(event_date_str: str) -> dict: return out -# ── Métriques de drift ──────────────────────────────────────────────────────── - -def _drift_metrics(prices: dict, event_date_str: str) -> dict: - eurusd = prices.get("eurusd", []) +def _drift_metrics(prices: dict, event_date_str: str, inst: str) -> dict: + series = prices.get(inst, []) 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: + if not series: return empty if mode == "intraday_5m": - n = len(eurusd) + n = len(series) 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) + # Convertir en pips (×10000 pour FX, ×10 pour or/pétrole/SP500) + mult = 10000 if inst in ("EURUSD",) else 10 + pre_pips = round((series[mid - 1]["c"] - series[0]["c"]) * mult) + post_pips = round((series[-1]["c"] - series[mid]["c"]) * mult) else: - pre = [b for b in eurusd if b["t"] < edate] - same = [b for b in eurusd if b["t"] == edate] + pre = [b for b in series if b["t"] < edate] + same = [b for b in series if b["t"] == edate] if not pre or not same: return empty + mult = 10000 if inst in ("EURUSD",) else 10 pre_pips = None - post_pips = round((same[0]["c"] - pre[-1]["c"]) * 10000) + post_pips = round((same[0]["c"] - pre[-1]["c"]) * mult) ratio: Optional[float] = None if pre_pips is not None and post_pips and post_pips != 0: @@ -267,258 +183,482 @@ def _yield_delta(series: list, event_date: str) -> Optional[float]: return round(post[0]["c"] - pre[-1]["c"], 3) -# ── Score d'activation ──────────────────────────────────────────────────────── +def _activation_score(node_values: dict, actual_moves: dict, template_graph: dict) -> dict: + """ + Compare les sorties du graphe (outputs) aux mouvements réels. + Retourne {score, nodes: {node_id: {pred, act, status}}} + """ + output_nodes = [n for n in template_graph.get("nodes", []) if n.get("type") == "output"] + nodes_detail: dict = {} + correct = 0 + evaluated = 0 -def _activation(model: dict, us10y: Optional[float], eu10y: Optional[float], - post_pips: Optional[int]) -> dict: + for node in output_nodes: + nid = node["id"] + inst = node.get("instrument", "") + pred = node_values.get(nid) + act = actual_moves.get(inst) - 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" + if pred is None or act is None: + status = "unknown" + elif abs(pred) < 2 and abs(act) < 2: + status = "neutral" + elif (pred > 0) == (act > 0): + status = "correct" + correct += 1 + evaluated += 1 + else: + status = "wrong" + evaluated += 1 - 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 + nodes_detail[nid] = { + "pred": round(pred, 2) if pred is not None else None, + "act": round(act, 2) if act is not None else None, + "status": status, + "label": node.get("label", nid), + } - return {"score": score, "nodes": nodes, "correct": correct, "total": len(active)} + score = round(correct / evaluated, 2) if evaluated else None + return {"score": score, "nodes": nodes_detail, "correct": correct, "total": evaluated} -# ── Cache ───────────────────────────────────────────────────────────────────── +# ── Helpers GPT-4o ───────────────────────────────────────────────────────────── -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 +def _gpt4o_recommend(event: dict, templates: list) -> dict: + """Appelle GPT-4o pour recommander un template + coefficients.""" + try: + from services.database import get_config + import openai + key = get_config("openai_api_key") or "" + if not key: + return {"error": "GPT-4o non configuré (clé OpenAI manquante)"} + + client = openai.OpenAI(api_key=key) + + template_summary = [ + { + "id": t["id"], + "name": t["name"], + "category": t["category"], + "sub_type": t.get("sub_type", ""), + "instruments": t.get("instruments", []), + "description": t.get("description", ""), + } + for t in templates + ] + + system_prompt = ( + "You are a macro market analyst and causal graph specialist for the GeoOptions platform. " + "Given a market event and a library of causal graph templates, recommend the most appropriate template " + "and suggest any coefficient adjustments. Respond with a JSON object only." ) - """) - conn.commit() + + user_prompt = f"""Market event to analyze: +- Name: {event.get('name')} +- Category: {event.get('category')} +- Sub-type: {event.get('sub_type', '')} +- Description: {event.get('description', '')} +- Market impact: {event.get('market_impact', '')} +- Affected assets: {event.get('affected_assets', '')} +- Impact score: {event.get('impact_score', '')} +- Surprise (%): {event.get('surprise_pct', 'N/A')} +- Expected: {event.get('expected_value', 'N/A')} +- Actual: {event.get('actual_value', 'N/A')} + +Available templates: +{json.dumps(template_summary, ensure_ascii=False, indent=2)} + +Respond with this exact JSON structure: +{{ + "template_id": , + "template_name": "", + "confidence": <0.0-1.0>, + "rationale": "<2-3 sentences explaining why this template fits>", + "coefficient_suggestions": {{ + "": + }}, + "instrument_focus": [""], + "alternative_template_id": , + "notes": "" +}}""" + + resp = client.chat.completions.create( + model="gpt-4o", + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + response_format={"type": "json_object"}, + temperature=0.3, + max_tokens=600, + ) + raw = resp.choices[0].message.content or "{}" + return json.loads(raw) + + except Exception as e: + logger.error(f"[causal_lab] GPT-4o recommend: {e}") + return {"error": str(e)} + + +# ── Schémas Pydantic ────────────────────────────────────────────────────────── + +class UpdateCoefRequest(BaseModel): + coefficients: dict # {coef_key: float} + + +class RecommendRequest(BaseModel): + market_event_id: int + + +class AnalyzeRequest(BaseModel): + market_event_id: int + template_id: int + instrument: str = "EURUSD" + inputs: dict = {} # Inputs manuels (ex: tone_score) + coef_overrides: dict = {} # Coefficients overrides pour cet run # ── 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.""" +@router.get("/api/causal-lab/templates") +def list_templates(category: str = Query("")): + try: + from services.database import get_conn + from services.causal_graphs import get_templates + conn = get_conn() + _init(conn) + data = get_templates(conn, category) + conn.close() + return data + except Exception as e: + logger.error(f"[causal_lab] list_templates: {e}") + raise HTTPException(500, str(e)) + + +@router.get("/api/causal-lab/template/{template_id}") +def get_template_detail(template_id: int): + try: + from services.database import get_conn + from services.causal_graphs import get_template, init_tables, seed_templates + conn = get_conn() + init_tables(conn) + seed_templates(conn) + tmpl = get_template(conn, template_id) + conn.close() + if not tmpl: + raise HTTPException(404, "Template introuvable") + return tmpl + except HTTPException: + raise + except Exception as e: + logger.error(f"[causal_lab] get_template {template_id}: {e}") + raise HTTPException(500, str(e)) + + +@router.put("/api/causal-lab/template/{template_id}") +def update_template(template_id: int, body: UpdateCoefRequest): + try: + from services.database import get_conn + from services.causal_graphs import update_coefficients, init_tables, seed_templates + conn = get_conn() + init_tables(conn) + seed_templates(conn) + ok = update_coefficients(conn, template_id, body.coefficients) + conn.close() + if not ok: + raise HTTPException(404, "Template introuvable") + return {"ok": True} + except HTTPException: + raise + except Exception as e: + logger.error(f"[causal_lab] update_template {template_id}: {e}") + raise HTTPException(500, str(e)) + + +@router.get("/api/causal-lab/market-events") +def list_market_events( + limit: int = Query(200, le=500), + category: str = Query(""), + q: str = Query(""), +): + """Tous les market_events analysables (avec ou sans analyse existante).""" try: from services.database import get_conn conn = get_conn() - _ensure_cache(conn) + _init(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)) + conditions = ["1=1"] + if category: + conditions.append(f"e.category = '{category}'") + if q: + safe_q = q.replace("'", "''") + conditions.append(f"(e.name LIKE '%{safe_q}%' OR e.description LIKE '%{safe_q}%')") + + where = " AND ".join(conditions) + rows = conn.execute(f""" + SELECT e.id, e.name, e.category, e.sub_type, + e.start_date, e.end_date, e.level, + e.impact_score, e.market_impact, e.affected_assets, + e.actual_value, e.expected_value, e.surprise_pct, + a.id as analysis_id, a.template_id, a.activation_score, + a.instrument, a.analyzed_at + FROM market_events e + LEFT JOIN causal_event_analyses a ON a.market_event_id = e.id + WHERE {where} + ORDER BY e.start_date DESC + LIMIT {limit} + """).fetchall() + conn.close() + + return [dict(r) for r in rows] + + except Exception as e: + logger.error(f"[causal_lab] list_market_events: {e}") + raise HTTPException(500, str(e)) + + +@router.post("/api/causal-lab/recommend") +def recommend_template(body: RecommendRequest): + """GPT-4o recommande le template le plus adapté à l'événement.""" + try: + from services.database import get_conn + from services.causal_graphs import get_templates, init_tables, seed_templates + conn = get_conn() + init_tables(conn) + seed_templates(conn) + + ev_row = conn.execute( + "SELECT * FROM market_events WHERE id = ?", (body.market_event_id,) + ).fetchone() + if not ev_row: + conn.close() + raise HTTPException(404, "market_event introuvable") + + event = dict(ev_row) + templates = get_templates(conn) + conn.close() + + recommendation = _gpt4o_recommend(event, templates) + return {"event": event, "recommendation": recommendation} + + except HTTPException: + raise + except Exception as e: + logger.error(f"[causal_lab] recommend: {e}") + raise HTTPException(500, str(e)) + + +@router.post("/api/causal-lab/analyze") +def analyze_event(body: AnalyzeRequest): + """ + Analyse complète : évalue le template sur l'événement, + compare aux mouvements réels, stocke le résultat. + """ + try: + from services.database import get_conn + from services.causal_graphs import ( + get_template, evaluate_graph, update_calibration, + init_tables, seed_templates, ) + conn = get_conn() + init_tables(conn) + seed_templates(conn) + + ev_row = conn.execute( + "SELECT * FROM market_events WHERE id = ?", (body.market_event_id,) + ).fetchone() + if not ev_row: + conn.close() + raise HTTPException(404, "market_event introuvable") + event = dict(ev_row) + + tmpl = get_template(conn, body.template_id) + if not tmpl: + conn.close() + raise HTTPException(404, "Template introuvable") + + graph = tmpl["graph_json"] + + # Inputs de base depuis l'événement + inputs = {} + mapping = graph.get("input_mapping", {}) + for input_id, cfg in mapping.items(): + src = cfg.get("source", "") + if src == "surprise" and event.get("surprise_pct") is not None: + inputs[input_id] = float(event["surprise_pct"]) + elif src == "impact_score_scaled" and event.get("impact_score") is not None: + inputs[input_id] = float(event["impact_score"]) / 10.0 + elif src == "impact_score_pct" and event.get("impact_score") is not None: + inputs[input_id] = float(event["impact_score"]) + elif src == "actual_value" and event.get("actual_value") is not None: + inputs[input_id] = float(event["actual_value"]) + + # Inputs manuels (ex: tone_score depuis le frontend) + inputs.update(body.inputs) + + # Évaluation du graphe + node_values = evaluate_graph(graph, inputs, body.coef_overrides or {}) + + # Prix réels + instruments = list({body.instrument} | set(tmpl.get("instruments", [body.instrument]))) + prices = _fetch_prices(event["start_date"], instruments) + edate = event["start_date"][:10] + + actual_moves: dict = {} + drift_by_inst: dict = {} + for inst in instruments: + drift = _drift_metrics(prices, event["start_date"], inst) + drift_by_inst[inst] = drift + if drift.get("post_pips") is not None: + actual_moves[inst] = drift["post_pips"] + + # Activation + activation = _activation_score(node_values, actual_moves, graph) + + # Taux + yields = { + "us10y": _yield_delta(prices.get("US10Y", []), edate), + "eu10y": _yield_delta(prices.get("EU10Y", []), edate), + "us2y": _yield_delta(prices.get("US2Y", []), edate), + } + + analyzed_at = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") + + result = { + "event": event, + "template_id": body.template_id, + "template_name": tmpl["name"], + "instrument": body.instrument, + "inputs": inputs, + "node_values": node_values, + "actual_moves": actual_moves, + "yields": yields, + "activation": activation, + "drift": drift_by_inst.get(body.instrument, {}), + "prices_mode": prices.get("mode", "none"), + "analyzed_at": analyzed_at, + } + + # Persistance + conn.execute(""" + INSERT INTO causal_event_analyses + (market_event_id, template_id, instrument, inputs_json, + override_params, prediction_json, actual_json, + activation_score, drift_json, analyzed_at) + VALUES (?,?,?,?,?,?,?,?,?,?) + """, ( + body.market_event_id, + body.template_id, + body.instrument, + json.dumps(inputs), + json.dumps(body.coef_overrides), + json.dumps(node_values), + json.dumps(actual_moves), + activation.get("score"), + json.dumps(drift_by_inst.get(body.instrument, {})), + analyzed_at, + )) + conn.commit() + + # Calibration + update_calibration(conn, body.template_id, { + "activation_score": activation.get("score"), + "instrument": body.instrument, + "pred_pips": node_values.get(body.instrument.lower(), 0) or + next((v for k, v in node_values.items() + if body.instrument.lower() in k.lower()), 0), + "actual_pips": actual_moves.get(body.instrument), + "analyzed_at": analyzed_at, + }) + conn.close() + + return result + + except HTTPException: + raise + except Exception as e: + logger.error(f"[causal_lab] analyze: {e}") + raise HTTPException(500, str(e)) + + +@router.get("/api/causal-lab/analyses") +def list_analyses( + template_id: int = Query(0), + instrument: str = Query(""), + limit: int = Query(100, le=500), +): + try: + from services.database import get_conn + conn = get_conn() + _init(conn) + + conditions = ["1=1"] + if template_id: + conditions.append(f"a.template_id = {template_id}") + if instrument: + conditions.append(f"a.instrument = '{instrument}'") + where = " AND ".join(conditions) 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 + SELECT a.*, e.name as event_name, e.category, e.start_date, + t.name as template_name + FROM causal_event_analyses a + LEFT JOIN market_events e ON e.id = a.market_event_id + LEFT JOIN causal_graph_templates t ON t.id = a.template_id + WHERE {where} + ORDER BY a.analyzed_at DESC LIMIT {limit} """).fetchall() conn.close() result = [] for r in rows: - item = dict(r) - if item.get("result_json"): + d = dict(r) + for f in ("inputs_json", "override_params", "prediction_json", "actual_json", "drift_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") + d[f] = json.loads(d[f] or "{}") except Exception: - pass - item.pop("result_json", None) - item["cfg"] = SERIES_MAP.get(item["series_id"], {}) - result.append(item) - + d[f] = {} + result.append(d) return result except Exception as e: - logger.error(f"[causal_lab] list_events: {e}") + logger.error(f"[causal_lab] list_analyses: {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.""" +@router.get("/api/causal-lab/calibration") +def get_calibration(): + """Statistiques de calibration par template (avg_activation, avg_pred vs avg_actual).""" try: from services.database import get_conn conn = get_conn() - _ensure_cache(conn) + _init(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() + rows = conn.execute(""" + SELECT t.id, t.name, t.category, t.sub_type, + t.calibration_json, t.heuristic_ver, + COUNT(a.id) as n_analyses, + AVG(a.activation_score) as avg_activation + FROM causal_graph_templates t + LEFT JOIN causal_event_analyses a ON a.template_id = t.id + GROUP BY t.id + ORDER BY t.category, t.name + """).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: + result = [] + for r in rows: + d = dict(r) 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"]) - + d["calibration"] = json.loads(d.pop("calibration_json") or "{}") 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, - } + d["calibration"] = {} + if d.get("avg_activation") is not None: + d["avg_activation"] = round(d["avg_activation"], 3) + result.append(d) + return result except Exception as e: - logger.error(f"[causal_lab] summary: {e}") + logger.error(f"[causal_lab] calibration: {e}") raise HTTPException(500, str(e)) diff --git a/backend/services/causal_graphs.py b/backend/services/causal_graphs.py new file mode 100644 index 0000000..66f0ca4 --- /dev/null +++ b/backend/services/causal_graphs.py @@ -0,0 +1,687 @@ +""" +Bibliothèque de graphes causaux — templates pré-peuplés et helpers. + +Chaque template définit une chaîne causale entre un type d'événement de marché +et un ou plusieurs instruments (EURUSD, XAUUSD, SP500, BRENT...). + +Structure graph_json : + nodes[] — nœuds avec position (x,y) pour le rendu SVG + edges[] — arêtes directionnelles entre nœuds + coefficients — valeurs heuristiques + calibrées + instruments[] — instruments cibles du template + input_mapping — comment extraire les inputs depuis un market_event +""" +import ast +import json +import logging +import operator +from typing import Optional + +logger = logging.getLogger(__name__) + + +# ── Helpers de construction ──────────────────────────────────────────────────── + +def _n(id_, label, type_, x, y, formula=None, unit="", instrument=None, description=""): + n = {"id": id_, "label": label, "type": type_, "x": x, "y": y} + if formula: n["formula"] = formula + if unit: n["unit"] = unit + if instrument: n["instrument"] = instrument + if description: n["description"] = description + return n + +def _e(from_, to_, style="solid", type_="causal", label=""): + e = {"from": from_, "to": to_, "style": style, "type": type_} + if label: e["label"] = label + return e + +def _c(value, description=""): + return {"value": value, "calibrated": None, "description": description} + + +# ── Templates pré-peuplés ───────────────────────────────────────────────────── + +BUILT_IN_TEMPLATES = [ + + # ── 1. CPI US surprise ────────────────────────────────────────────────────── + { + "name": "CPI US — surprise inflationniste", + "category": "macro_us", + "sub_type": "CPI", + "instruments": ["EURUSD", "XAUUSD"], + "description": "Surprise inflationniste US → anticipations FED → taux longs → EUR/USD & Or", + "ai_rationale": ( + "Un CPI US au-dessus du consensus renforce les anticipations de hausse FED, " + "remonte le 10Y américain, élargit le spread US-EU et apprécie le dollar. " + "L'or est pénalisé par la hausse des taux réels attendus." + ), + "graph_json": { + "nodes": [ + _n("cpi_surprise", "CPI US surprise", "input", 200, 45, unit="%"), + _n("fed_fwd", "Signal FED anticipations", "intermediate", 200, 155, formula="cpi_surprise / 0.1 * {{coef_cpi_fwd}}"), + _n("us_10y", "Δ US 10Y", "intermediate", 200, 265, formula="fed_fwd * {{coef_fwd_10y}}", unit="%"), + _n("eurusd", "EUR/USD", "output", 110, 380, formula="-us_10y * {{coef_10y_eurusd}}", unit="pips", instrument="EURUSD"), + _n("xauusd", "Or (XAU/USD)", "output", 290, 380, formula="-fed_fwd * {{coef_fwd_gold}}", unit="$/oz", instrument="XAUUSD"), + ], + "edges": [ + _e("cpi_surprise", "fed_fwd", "dashed", "fwd_guidance", "anticipations hawkish"), + _e("fed_fwd", "us_10y", "dashed", "expectations"), + _e("us_10y", "eurusd", "solid", "rate_diff"), + _e("fed_fwd", "xauusd", "dashed", "real_yield"), + ], + "coefficients": { + "coef_cpi_fwd": _c(0.50, "Impact de 0.1% de surprise CPI sur signal FED"), + "coef_fwd_10y": _c(0.070, "Signal FED → Δ rendement US 10Y (%)"), + "coef_10y_eurusd": _c(200, "1% de spread → EUR/USD (pips)"), + "coef_fwd_gold": _c(15.0, "Unité signal FED → Or ($/oz)"), + }, + "instruments": ["EURUSD", "XAUUSD"], + "input_mapping": { + "cpi_surprise": {"source": "surprise", "unit": "%", "description": "actual − consensus (%)"} + }, + }, + }, + + # ── 2. NFP surprise ───────────────────────────────────────────────────────── + { + "name": "NFP — surprise emploi non-agricole", + "category": "macro_us", + "sub_type": "NFP", + "instruments": ["EURUSD", "SP500"], + "description": "Surprise créations d'emploi → signal FED → 2Y + 10Y → EUR/USD & S&P", + "ai_rationale": ( + "Un NFP supérieur aux attentes signale une économie robuste et renforce " + "les anticipations restrictives FED. Le dollar s'apprécie via le canal des taux courts. " + "L'impact sur le S&P500 est négatif net en contexte hawkish (taux > croissance)." + ), + "graph_json": { + "nodes": [ + _n("nfp_surprise", "NFP surprise", "input", 200, 45, unit="k"), + _n("fed_fwd", "Signal FED anticipations", "intermediate", 200, 155, formula="nfp_surprise / 100 * {{coef_nfp_fwd}}"), + _n("us_2y", "Δ US 2Y", "intermediate", 110, 265, formula="fed_fwd * {{coef_fwd_2y}}", unit="%"), + _n("us_10y", "Δ US 10Y", "intermediate", 290, 265, formula="fed_fwd * {{coef_fwd_10y}}", unit="%"), + _n("eurusd", "EUR/USD", "output", 110, 380, formula="-(us_2y * {{coef_2y_fx}} + us_10y * {{coef_10y_fx}})", unit="pips", instrument="EURUSD"), + _n("sp500", "S&P 500", "output", 290, 380, formula="-fed_fwd * {{coef_fwd_sp}}", unit="pts", instrument="SP500"), + ], + "edges": [ + _e("nfp_surprise", "fed_fwd", "dashed", "fwd_guidance"), + _e("fed_fwd", "us_2y", "solid", "rate_channel"), + _e("fed_fwd", "us_10y", "dashed", "expectations"), + _e("us_2y", "eurusd", "solid", "rate_diff"), + _e("us_10y", "eurusd", "dashed", "rate_diff"), + _e("fed_fwd", "sp500", "dashed", "risk_asset"), + ], + "coefficients": { + "coef_nfp_fwd": _c(0.35, "100k NFP → signal FED"), + "coef_fwd_2y": _c(0.030, "Signal FED → Δ US 2Y (%)"), + "coef_fwd_10y": _c(0.070, "Signal FED → Δ US 10Y (%)"), + "coef_2y_fx": _c(500, "1% spread 2Y → EUR/USD (pips)"), + "coef_10y_fx": _c(200, "1% spread 10Y → EUR/USD (pips)"), + "coef_fwd_sp": _c(20.0, "Unité signal FED hawkish → S&P500 (pts, négatif)"), + }, + "instruments": ["EURUSD", "SP500"], + "input_mapping": { + "nfp_surprise": {"source": "surprise", "unit": "k", "description": "actual − consensus (milliers)"} + }, + }, + }, + + # ── 3. FOMC décision de taux ───────────────────────────────────────────────── + { + "name": "FOMC — décision de taux + ton", + "category": "macro_us", + "sub_type": "FOMC", + "instruments": ["EURUSD", "XAUUSD", "SP500"], + "description": "Décision FED : surprise taux (bps) + ton du communiqué → 2 canaux → FX, Or, Actions", + "ai_rationale": ( + "La décision FOMC active deux canaux distincts : le canal taux (fait accompli → ancre 2Y) " + "et le canal ton/forward guidance (discours → anticipe trajectoire future → 10Y). " + "L'or réagit principalement au canal réel (taux nominaux − inflation attendue)." + ), + "graph_json": { + "nodes": [ + _n("rate_surprise_bps", "Surprise taux (bps)", "input", 110, 45, unit="bps", description="actual − expected en points de base"), + _n("tone_score", "Ton du communiqué", "input", 290, 45, unit="score", description="−3 très dovish … +3 très hawkish"), + _n("fed_rate_p", "Pression taux directeur", "intermediate", 110, 165, formula="rate_surprise_bps / 25 * {{coef_rate_mult}}"), + _n("fed_fwd", "Signal anticipations FED", "intermediate", 290, 165, formula="tone_score * {{coef_tone}}"), + _n("us_2y", "Δ US 2Y", "intermediate", 110, 285, formula="fed_rate_p * 0.085 + fed_fwd * 0.030", unit="%"), + _n("us_10y", "Δ US 10Y", "intermediate", 290, 285, formula="fed_rate_p * 0.035 + fed_fwd * {{coef_fwd_10y}}", unit="%"), + _n("eurusd", "EUR/USD", "output", 110, 400, formula="-(us_2y * 500 + us_10y * 200)", unit="pips", instrument="EURUSD"), + _n("xauusd", "Or (XAU/USD)", "output", 290, 400, formula="-(us_10y * {{coef_10y_gold}} + fed_fwd * {{coef_tone_gold}})", unit="$/oz", instrument="XAUUSD"), + ], + "edges": [ + _e("rate_surprise_bps", "fed_rate_p", "solid", "rate_channel"), + _e("tone_score", "fed_fwd", "dashed", "fwd_guidance"), + _e("fed_rate_p", "us_2y", "solid", "rate_anchor", "canal taux → 2Y"), + _e("fed_fwd", "us_2y", "dashed", "bleed"), + _e("fed_rate_p", "us_10y", "solid", "level_shift"), + _e("fed_fwd", "us_10y", "dashed", "expectations", "canal ton → 10Y"), + _e("us_2y", "eurusd", "solid", "rate_diff"), + _e("us_10y", "eurusd", "dashed", "rate_diff"), + _e("us_10y", "xauusd", "solid", "real_yield"), + _e("fed_fwd", "xauusd", "dashed", "expectations"), + ], + "coefficients": { + "coef_rate_mult": _c(1.0, "Multiplicateur surprise taux"), + "coef_tone": _c(1.2, "Ton → signal anticipations"), + "coef_fwd_10y": _c(0.070,"Signal ton → Δ US 10Y (%)"), + "coef_10y_gold": _c(30.0, "1% hausse US 10Y → Or ($/oz, négatif)"), + "coef_tone_gold": _c(10.0, "Signal ton hawkish → Or ($/oz, négatif)"), + }, + "instruments": ["EURUSD", "XAUUSD", "SP500"], + "input_mapping": { + "rate_surprise_bps": {"source": "surprise_bps", "unit": "bps"}, + "tone_score": {"source": "user_input", "range": [-3, 3]}, + }, + }, + }, + + # ── 4. ECB décision de taux ────────────────────────────────────────────────── + { + "name": "ECB — décision de taux + ton", + "category": "macro_eu", + "sub_type": "ECB", + "instruments": ["EURUSD"], + "description": "Décision BCE : surprise taux + ton → taux EU → spread → EUR/USD", + "ai_rationale": ( + "La BCE active le même schéma dual-canal que la FED mais dans le sens inverse pour EUR/USD. " + "Une BCE hawkish surprise élargit les spreads courts EU, réduit le spread US-EU et apprécie l'euro. " + "L'effet sur le 10Y Bund est amplifié par le canal forward guidance." + ), + "graph_json": { + "nodes": [ + _n("ecb_rate_bps", "Surprise taux ECB (bps)", "input", 110, 45, unit="bps"), + _n("ecb_tone", "Ton du communiqué BCE", "input", 290, 45, unit="score", description="−3 dovish … +3 hawkish"), + _n("ecb_rate_p", "Pression taux BCE", "intermediate", 110, 165, formula="ecb_rate_bps / 25 * {{coef_ecb_mult}}"), + _n("ecb_fwd", "Signal anticipations BCE", "intermediate", 290, 165, formula="ecb_tone * {{coef_ecb_tone}}"), + _n("eu_2y", "Δ Bund 2Y", "intermediate", 110, 285, formula="ecb_rate_p * 0.080 + ecb_fwd * 0.025", unit="%"), + _n("eu_10y", "Δ Bund 10Y", "intermediate", 290, 285, formula="ecb_rate_p * 0.030 + ecb_fwd * {{coef_ecb_fwd_10y}}", unit="%"), + _n("eurusd", "EUR/USD", "output", 200, 400, formula="eu_2y * 500 + eu_10y * 200", unit="pips", instrument="EURUSD"), + ], + "edges": [ + _e("ecb_rate_bps", "ecb_rate_p", "solid", "rate_channel"), + _e("ecb_tone", "ecb_fwd", "dashed", "fwd_guidance"), + _e("ecb_rate_p", "eu_2y", "solid", "rate_anchor"), + _e("ecb_fwd", "eu_2y", "dashed", "bleed"), + _e("ecb_rate_p", "eu_10y", "solid", "level_shift"), + _e("ecb_fwd", "eu_10y", "dashed", "expectations"), + _e("eu_2y", "eurusd", "solid", "spread_narrow"), + _e("eu_10y", "eurusd", "dashed", "spread_narrow"), + ], + "coefficients": { + "coef_ecb_mult": _c(1.0, "Multiplicateur surprise taux BCE"), + "coef_ecb_tone": _c(1.2, "Ton BCE → signal anticipations"), + "coef_ecb_fwd_10y": _c(0.060,"Signal ton BCE → Δ Bund 10Y (%)"), + }, + "instruments": ["EURUSD"], + "input_mapping": { + "ecb_rate_bps": {"source": "surprise_bps", "unit": "bps"}, + "ecb_tone": {"source": "user_input", "range": [-3, 3]}, + }, + }, + }, + + # ── 5. Escalade géopolitique Europe ────────────────────────────────────────── + { + "name": "Escalade géopolitique — Europe", + "category": "geopolitical", + "sub_type": "Geopolitical", + "instruments": ["EURUSD", "XAUUSD", "BRENT"], + "description": "Conflit / escalade en Europe → risk-off + choc énergie → EUR/USD, Or, Pétrole", + "ai_rationale": ( + "Un événement géopolitique européen active deux canaux simultanés : " + "l'aversion au risque (VIX → USD safe haven → EUR baisse) et le choc énergétique " + "(disruption approvisionnement → pétrole monte → or monte, coût importations EU → EUR faiblit)." + ), + "graph_json": { + "nodes": [ + _n("severity", "Sévérité (1−10)", "input", 200, 45, unit="score", description="1=faible, 10=crise majeure"), + _n("risk_aversion", "Aversion au risque", "intermediate", 110, 155, formula="severity * {{coef_sev_risk}}"), + _n("energy_shock", "Choc énergie EU", "intermediate", 290, 155, formula="severity * {{coef_sev_energy}}"), + _n("vix_delta", "Δ VIX", "intermediate", 110, 265, formula="risk_aversion * {{coef_risk_vix}}"), + _n("oil_delta", "Δ Pétrole (%)", "intermediate", 290, 265, formula="energy_shock * {{coef_energy_oil}}"), + _n("eurusd", "EUR/USD", "output", 80, 390, formula="-(vix_delta * {{coef_vix_fx}} + energy_shock * {{coef_energy_fx}})", unit="pips", instrument="EURUSD"), + _n("xauusd", "Or (XAU/USD)", "output", 200, 390, formula="vix_delta * {{coef_vix_gold}} + oil_delta * {{coef_oil_gold}}", unit="$/oz", instrument="XAUUSD"), + _n("brent", "Brent (USD/bbl)", "output", 320, 390, formula="oil_delta * {{coef_oil_brent}}", unit="$/bbl", instrument="BRENT"), + ], + "edges": [ + _e("severity", "risk_aversion", "solid", "transmission", "perception risque"), + _e("severity", "energy_shock", "solid", "supply_chain", "disruption énergie"), + _e("risk_aversion", "vix_delta", "solid", "fear_gauge"), + _e("energy_shock", "oil_delta", "solid", "supply_shock"), + _e("vix_delta", "eurusd", "solid", "safe_haven", "fuite vers USD"), + _e("energy_shock", "eurusd", "solid", "trade_balance", "coût imports EU"), + _e("vix_delta", "xauusd", "dashed", "safe_haven", "fuite vers or"), + _e("oil_delta", "xauusd", "dashed", "inflation_hedge"), + _e("oil_delta", "brent", "solid", "direct"), + ], + "coefficients": { + "coef_sev_risk": _c(1.5, "Sévérité → aversion au risque"), + "coef_sev_energy": _c(0.8, "Sévérité → choc énergie"), + "coef_risk_vix": _c(3.0, "Aversion risque → Δ VIX points"), + "coef_energy_oil": _c(2.0, "Choc énergie → % hausse pétrole"), + "coef_vix_fx": _c(4.0, "1 pt VIX → EUR/USD pips (négatif)"), + "coef_energy_fx": _c(8.0, "Choc énergie → EUR/USD pips (négatif)"), + "coef_vix_gold": _c(5.0, "1 pt VIX → Or $/oz (positif)"), + "coef_oil_gold": _c(3.0, "1% oil → Or $/oz"), + "coef_oil_brent": _c(5.0, "Choc énergie → Brent $/bbl"), + }, + "instruments": ["EURUSD", "XAUUSD", "BRENT"], + "input_mapping": { + "severity": {"source": "impact_score_scaled", "unit": "score", "range": [1, 10]} + }, + }, + }, + + # ── 6. Choc offre pétrole ──────────────────────────────────────────────────── + { + "name": "Choc offre pétrole — OPEC / disruption", + "category": "commodity", + "sub_type": "Oil", + "instruments": ["BRENT", "EURUSD", "XAUUSD"], + "description": "Choc d'offre pétrolière → prix → inflation EU → trade balance → EUR", + "ai_rationale": ( + "Un choc d'offre haussier sur le pétrole impacte directement le Brent. " + "L'Europe, importateur net, voit ses coûts d'importation augmenter (trade balance → EUR négatif), " + "et ses anticipations d'inflation croître (BCE contrainte entre lutte anti-inflation et croissance)." + ), + "graph_json": { + "nodes": [ + _n("oil_change_pct", "Choc prix pétrole (%)", "input", 200, 45, unit="%"), + _n("brent_move", "Δ Brent ($/bbl)", "intermediate", 110, 155, formula="oil_change_pct * {{coef_oil_brent}}"), + _n("eu_import_cost", "Coût imports EU", "intermediate", 290, 155, formula="oil_change_pct * {{coef_oil_import}}"), + _n("eu_inflation", "Anticipations inflation EU","intermediate",290, 265, formula="eu_import_cost * {{coef_import_inf}}"), + _n("eurusd", "EUR/USD", "output", 110, 380, formula="-(eu_import_cost * {{coef_import_fx}} + eu_inflation * {{coef_inf_fx}})", unit="pips", instrument="EURUSD"), + _n("xauusd", "Or (XAU/USD)", "output", 290, 380, formula="brent_move * {{coef_oil_gold}}", unit="$/oz", instrument="XAUUSD"), + ], + "edges": [ + _e("oil_change_pct", "brent_move", "solid", "direct"), + _e("oil_change_pct", "eu_import_cost","solid", "trade_balance"), + _e("eu_import_cost", "eu_inflation", "dashed", "cost_push"), + _e("eu_import_cost", "eurusd", "solid", "trade_balance", "déficit commercial EU"), + _e("eu_inflation", "eurusd", "dashed", "policy_ambiguity","BCE coincé"), + _e("brent_move", "xauusd", "dashed", "inflation_hedge"), + ], + "coefficients": { + "coef_oil_brent": _c(1.0, "% hausse oil → Δ Brent $/bbl (linéaire à calibrer)"), + "coef_oil_import": _c(0.5, "% hausse oil → pression imports EU (score)"), + "coef_import_inf": _c(0.3, "Pression imports → anticipations inflation EU"), + "coef_import_fx": _c(10.0, "Pression imports → EUR/USD pips (négatif)"), + "coef_inf_fx": _c(5.0, "Inflation EU → EUR/USD (ambiguë, négatif net)"), + "coef_oil_gold": _c(0.8, "1$/bbl Brent → Or $/oz"), + }, + "instruments": ["BRENT", "EURUSD", "XAUUSD"], + "input_mapping": { + "oil_change_pct": {"source": "impact_score_pct", "unit": "%"} + }, + }, + }, + + # ── 7. COT repositionnement institutionnel ─────────────────────────────────── + { + "name": "COT — repositionnement institutionnel EUR", + "category": "report", + "sub_type": "COT", + "instruments": ["EURUSD"], + "description": "Variation nette des positions institutionnelles EUR → signal momentum → EUR/USD", + "ai_rationale": ( + "Le rapport COT (Commitment of Traders) révèle les positions nettes des fonds spéculatifs sur EUR. " + "Un changement significatif des positions nettes agit comme signal de momentum, " + "les marchés interprétant un repositionnement institutionnel comme un signal directionnel." + ), + "graph_json": { + "nodes": [ + _n("net_longs_delta", "Δ Positions nettes EUR (k contrats)", "input", 200, 45, unit="k contracts"), + _n("momentum_signal", "Signal momentum institutionnel", "intermediate", 200, 185, formula="net_longs_delta * {{coef_cot_momentum}}"), + _n("eurusd", "EUR/USD", "output", 200, 330, formula="momentum_signal * {{coef_momentum_fx}}", unit="pips", instrument="EURUSD"), + ], + "edges": [ + _e("net_longs_delta", "momentum_signal", "solid", "positioning"), + _e("momentum_signal", "eurusd", "solid", "momentum"), + ], + "coefficients": { + "coef_cot_momentum": _c(0.5, "k contrats → signal momentum"), + "coef_momentum_fx": _c(2.0, "Signal momentum → EUR/USD pips"), + }, + "instruments": ["EURUSD"], + "input_mapping": { + "net_longs_delta": {"source": "actual_value", "unit": "k contracts"} + }, + }, + }, + + # ── 8. EIA stocks pétrole ──────────────────────────────────────────────────── + { + "name": "EIA — stocks pétroliers hebdomadaires", + "category": "report", + "sub_type": "EIA", + "instruments": ["BRENT", "EURUSD"], + "description": "Surprise stocks EIA → prix pétrole → canal inflation / risk appetite → EUR/USD", + "ai_rationale": ( + "Une surprise négative sur les stocks EIA (stocks < consensus) signale une demande forte " + "ou une offre réduite, ce qui fait monter le pétrole. Impact EUR/USD indirect via " + "le canal inflation EU (coût imports) et le risk appetite." + ), + "graph_json": { + "nodes": [ + _n("eia_surprise_mb", "Surprise EIA (Mb vs consensus)", "input", 200, 45, unit="Mb", description="négatif = stocks inférieurs aux attentes"), + _n("oil_reaction", "Réaction pétrole (%)", "intermediate", 200, 165, formula="-eia_surprise_mb * {{coef_eia_oil}}"), + _n("brent", "Δ Brent ($/bbl)", "output", 110, 290, formula="oil_reaction * {{coef_oil_dollar}}", unit="$/bbl", instrument="BRENT"), + _n("eurusd", "EUR/USD", "output", 290, 290, formula="-oil_reaction * {{coef_oil_eurusd}}", unit="pips", instrument="EURUSD"), + ], + "edges": [ + _e("eia_surprise_mb", "oil_reaction", "solid", "supply_demand", "stocks bas → oil monte"), + _e("oil_reaction", "brent", "solid", "direct"), + _e("oil_reaction", "eurusd", "dashed", "trade_balance", "coût imports EU"), + ], + "coefficients": { + "coef_eia_oil": _c(0.5, "1 Mb sous consensus → % hausse pétrole"), + "coef_oil_dollar": _c(1.0, "% hausse oil → Δ Brent $/bbl"), + "coef_oil_eurusd": _c(3.0, "% hausse oil → EUR/USD pips (négatif pour EUR)"), + }, + "instruments": ["BRENT", "EURUSD"], + "input_mapping": { + "eia_surprise_mb": {"source": "surprise", "unit": "Mb"} + }, + }, + }, + + # ── 9. Risk-off global ─────────────────────────────────────────────────────── + { + "name": "Risk-off global — fuite vers la sécurité", + "category": "sentiment", + "sub_type": "Risk", + "instruments": ["EURUSD", "XAUUSD", "SP500"], + "description": "Choc d'aversion au risque → flight-to-safety → USD & Or montent, actions baissent", + "ai_rationale": ( + "Un épisode de risk-off global (crash, crise financière, choc exogène) provoque " + "une fuite vers les actifs refuge : USD (liquidity premium), Or (store of value), " + "et une sortie des actifs risqués (actions, EM, EUR). Le VIX s'envole et amplifie la réaction." + ), + "graph_json": { + "nodes": [ + _n("risk_score", "Score risk-off (1−10)", "input", 200, 45, unit="score", description="1=légère tension, 10=crise majeure"), + _n("vix_spike", "Δ VIX", "intermediate", 110, 165, formula="risk_score * {{coef_risk_vix}}"), + _n("gold_demand", "Demande actifs refuge", "intermediate", 290, 165, formula="risk_score * {{coef_risk_refuge}}"), + _n("usd_safe", "USD safe-haven", "intermediate", 110, 285, formula="vix_spike * {{coef_vix_usd}}"), + _n("eurusd", "EUR/USD", "output", 80, 400, formula="-usd_safe * {{coef_usd_eurusd}}", unit="pips", instrument="EURUSD"), + _n("xauusd", "Or (XAU/USD)", "output", 200, 400, formula="gold_demand * {{coef_refuge_gold}}", unit="$/oz", instrument="XAUUSD"), + _n("sp500", "S&P 500", "output", 320, 400, formula="-vix_spike * {{coef_vix_sp}}", unit="pts", instrument="SP500"), + ], + "edges": [ + _e("risk_score", "vix_spike", "solid", "fear_gauge"), + _e("risk_score", "gold_demand", "solid", "safe_haven"), + _e("vix_spike", "usd_safe", "solid", "liquidity", "fuite liquidité USD"), + _e("usd_safe", "eurusd", "solid", "fx_safe_haven"), + _e("gold_demand", "xauusd", "solid", "store_value"), + _e("vix_spike", "sp500", "solid", "risk_asset", "risk premium"), + ], + "coefficients": { + "coef_risk_vix": _c(4.0, "Score risk → Δ VIX pts"), + "coef_risk_refuge": _c(2.0, "Score risk → demande refuges (score)"), + "coef_vix_usd": _c(0.8, "Δ VIX → signal USD safe-haven"), + "coef_usd_eurusd": _c(20.0, "Signal USD → EUR/USD pips (négatif)"), + "coef_refuge_gold": _c(15.0, "Demande refuge → Or $/oz"), + "coef_vix_sp": _c(12.0, "1 pt VIX → S&P 500 pts (négatif)"), + }, + "instruments": ["EURUSD", "XAUUSD", "SP500"], + "input_mapping": { + "risk_score": {"source": "impact_score_scaled", "unit": "score", "range": [1, 10]} + }, + }, + }, + + # ── 10. Tensions commerciales / tarifs ────────────────────────────────────── + { + "name": "Tarifs douaniers — tensions commerciales", + "category": "geopolitical", + "sub_type": "Trade", + "instruments": ["EURUSD", "SP500"], + "description": "Annonce tarifs → choc exportations EU + risk-off → EUR/USD & actions", + "ai_rationale": ( + "Les tarifs douaniers impactent l'EUR/USD via deux canaux : " + "le choc direct sur les exportations européennes (BCE plus dovish = EUR baisse) " + "et le canal risk-off global (incertitude → fuite USD → EUR baisse, S&P corrige)." + ), + "graph_json": { + "nodes": [ + _n("tariff_pct", "Tarifs annoncés (%)", "input", 200, 45, unit="%"), + _n("eu_export_shock","Choc exportations EU", "intermediate", 110, 165, formula="tariff_pct * {{coef_tariff_export}}"), + _n("risk_off_signal","Signal risk-off", "intermediate", 290, 165, formula="tariff_pct * {{coef_tariff_risk}}"), + _n("ecb_dovish", "Pression BCE dovish", "intermediate", 110, 285, formula="eu_export_shock * {{coef_export_ecb}}"), + _n("eurusd", "EUR/USD", "output", 110, 400, formula="-(ecb_dovish * {{coef_ecb_fx}} + risk_off_signal * {{coef_risk_fx}})", unit="pips", instrument="EURUSD"), + _n("sp500", "S&P 500", "output", 290, 400, formula="-risk_off_signal * {{coef_risk_sp}}", unit="pts", instrument="SP500"), + ], + "edges": [ + _e("tariff_pct", "eu_export_shock", "solid", "trade_impact"), + _e("tariff_pct", "risk_off_signal", "dashed", "uncertainty"), + _e("eu_export_shock", "ecb_dovish", "solid", "growth_slowdown"), + _e("ecb_dovish", "eurusd", "solid", "rate_differential"), + _e("risk_off_signal", "eurusd", "dashed", "safe_haven"), + _e("risk_off_signal", "sp500", "solid", "risk_asset"), + ], + "coefficients": { + "coef_tariff_export": _c(1.5, "% tarif → pression exports EU"), + "coef_tariff_risk": _c(1.0, "% tarif → signal risk-off"), + "coef_export_ecb": _c(0.5, "Choc exports → pression dovish BCE"), + "coef_ecb_fx": _c(15.0, "Pression BCE dovish → EUR/USD pips (négatif)"), + "coef_risk_fx": _c(10.0, "Risk-off → EUR/USD pips (négatif)"), + "coef_risk_sp": _c(25.0, "Risk-off → S&P 500 pts (négatif)"), + }, + "instruments": ["EURUSD", "SP500"], + "input_mapping": { + "tariff_pct": {"source": "impact_score_pct", "unit": "%"} + }, + }, + }, +] + + +# ── DB Helpers ───────────────────────────────────────────────────────────────── + +def init_tables(conn): + conn.execute(""" + CREATE TABLE IF NOT EXISTS causal_graph_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + category TEXT NOT NULL, + sub_type TEXT, + instruments TEXT NOT NULL DEFAULT '[]', + description TEXT DEFAULT '', + graph_json TEXT NOT NULL, + ai_rationale TEXT DEFAULT '', + calibration_json TEXT DEFAULT '{}', + created_by TEXT DEFAULT 'system', + heuristic_ver INTEGER DEFAULT 1, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS causal_event_analyses ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + market_event_id INTEGER, + template_id INTEGER, + instrument TEXT NOT NULL DEFAULT 'EURUSD', + inputs_json TEXT DEFAULT '{}', + override_params TEXT DEFAULT '{}', + prediction_json TEXT, + actual_json TEXT, + activation_score REAL, + drift_json TEXT, + ai_recommendation TEXT, + analyzed_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (template_id) REFERENCES causal_graph_templates(id) + ) + """) + conn.commit() + + +def seed_templates(conn): + """Insère les templates built-in s'ils n'existent pas encore.""" + for t in BUILT_IN_TEMPLATES: + existing = conn.execute( + "SELECT id FROM causal_graph_templates WHERE name = ?", (t["name"],) + ).fetchone() + if existing: + continue + conn.execute(""" + INSERT INTO causal_graph_templates + (name, category, sub_type, instruments, description, graph_json, ai_rationale, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, 'system') + """, ( + t["name"], + t["category"], + t.get("sub_type", ""), + json.dumps(t.get("instruments", [])), + t.get("description", ""), + json.dumps(t["graph_json"]), + t.get("ai_rationale", ""), + )) + conn.commit() + + +def get_templates(conn, category: str = "") -> list: + where = f"WHERE category = '{category}'" if category else "" + rows = conn.execute( + f"SELECT * FROM causal_graph_templates {where} ORDER BY category, name" + ).fetchall() + result = [] + for r in rows: + d = dict(r) + d["graph_json"] = json.loads(d["graph_json"] or "{}") + d["instruments"] = json.loads(d["instruments"] or "[]") + d["calibration_json"] = json.loads(d["calibration_json"] or "{}") + result.append(d) + return result + + +def get_template(conn, template_id: int) -> Optional[dict]: + row = conn.execute( + "SELECT * FROM causal_graph_templates WHERE id = ?", (template_id,) + ).fetchone() + if not row: + return None + d = dict(row) + d["graph_json"] = json.loads(d["graph_json"] or "{}") + d["instruments"] = json.loads(d["instruments"] or "[]") + d["calibration_json"] = json.loads(d["calibration_json"] or "{}") + return d + + +def update_coefficients(conn, template_id: int, coef_updates: dict): + tmpl = get_template(conn, template_id) + if not tmpl: + return False + graph = tmpl["graph_json"] + for k, v in coef_updates.items(): + if k in graph.get("coefficients", {}): + graph["coefficients"][k]["value"] = float(v) + conn.execute( + "UPDATE causal_graph_templates SET graph_json = ?, updated_at = datetime('now') WHERE id = ?", + (json.dumps(graph), template_id) + ) + conn.commit() + return True + + +def update_calibration(conn, template_id: int, analysis_result: dict): + """Met à jour les stats de calibration après une nouvelle analyse.""" + tmpl = get_template(conn, template_id) + if not tmpl: + return + calib = tmpl.get("calibration_json") or {} + n = calib.get("n_events", 0) + 1 + # Running average of activation score + prev_act = calib.get("avg_activation", 0) or 0 + new_act = analysis_result.get("activation_score") or 0 + # Running average of pred / actual pips + instrument = analysis_result.get("instrument", "EURUSD") + pred_pips = analysis_result.get("pred_pips", 0) or 0 + act_pips = analysis_result.get("actual_pips", 0) or 0 + + calib["n_events"] = n + calib["avg_activation"] = round((prev_act * (n - 1) + new_act) / n, 3) + calib["last_analyzed"] = analysis_result.get("analyzed_at", "") + + if instrument not in calib: + calib[instrument] = {"n": 0, "avg_pred": 0, "avg_actual": 0} + ci = calib[instrument] + ni = ci["n"] + 1 + ci["n"] = ni + ci["avg_pred"] = round((ci["avg_pred"] * (ni - 1) + pred_pips) / ni, 1) + ci["avg_actual"] = round((ci["avg_actual"] * (ni - 1) + act_pips) / ni, 1) + if ci["avg_pred"] != 0: + ci["coef_ratio"] = round(ci["avg_actual"] / ci["avg_pred"], 2) + + conn.execute( + "UPDATE causal_graph_templates SET calibration_json = ?, updated_at = datetime('now') WHERE id = ?", + (json.dumps(calib), template_id) + ) + conn.commit() + + +# ── Évaluateur de graphe ─────────────────────────────────────────────────────── + +def _safe_eval(expr: str, context: dict) -> float: + """Évalue une expression arithmétique simple avec des variables.""" + _ops = { + ast.Add: operator.add, + ast.Sub: operator.sub, + ast.Mult: operator.mul, + ast.Div: operator.truediv, + ast.USub: operator.neg, + } + + def _eval(node): + if isinstance(node, ast.Constant): + return float(node.value) + if isinstance(node, ast.Name): + if node.id not in context: + raise ValueError(f"Variable inconnue : {node.id}") + return float(context[node.id]) + if isinstance(node, ast.BinOp): + op = _ops.get(type(node.op)) + if op is None: + raise ValueError(f"Opérateur non supporté : {type(node.op)}") + return op(_eval(node.left), _eval(node.right)) + if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub): + return -_eval(node.operand) + raise ValueError(f"Nœud AST non supporté : {type(node)}") + + tree = ast.parse(expr.strip(), mode="eval") + return _eval(tree.body) + + +def evaluate_graph(graph_json: dict, inputs: dict, coef_overrides: Optional[dict] = None) -> dict: + """ + Évalue un graphe causal depuis les inputs, retourne dict {node_id: valeur}. + Les {{coef_xxx}} sont substitués depuis graph_json["coefficients"] (ou overrides). + """ + coefs = {k: v["value"] for k, v in graph_json.get("coefficients", {}).items()} + if coef_overrides: + coefs.update({k: float(v) for k, v in coef_overrides.items()}) + + def sub(formula: str) -> str: + for k, v in coefs.items(): + formula = formula.replace(f"{{{{{k}}}}}", str(v)) + return formula + + values = dict(inputs) + nodes = graph_json.get("nodes", []) + # Évaluation en ordre topologique (max N passes) + for _ in range(len(nodes) + 2): + changed = False + for node in nodes: + if node["id"] in values or not node.get("formula"): + continue + try: + result = _safe_eval(sub(node["formula"]), values) + values[node["id"]] = round(result, 4) + changed = True + except Exception: + pass + if not changed: + break + + return values diff --git a/frontend/src/pages/CausalLab.tsx b/frontend/src/pages/CausalLab.tsx index 3775162..c9632ce 100644 --- a/frontend/src/pages/CausalLab.tsx +++ b/frontend/src/pages/CausalLab.tsx @@ -1,1002 +1,717 @@ -import { useState, useCallback, useMemo, useEffect } from 'react' -import { FlaskConical, RefreshCw, Loader2, ChevronRight, AlertCircle, BarChart3 } from 'lucide-react' +import { useState, useEffect } from 'react' +import { + FlaskConical, Library, Zap, BarChart3, RefreshCw, ChevronRight, + Sliders, Brain, Search, Filter, CheckCircle, XCircle, Minus, AlertCircle, +} from 'lucide-react' import clsx from 'clsx' -// ── Types ────────────────────────────────────────────────────────────────────── +// ── 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 CausalNode { + id: string; label: string; type: 'input' | 'intermediate' | 'output' + x: number; y: number; formula?: string; unit?: string; instrument?: string } - -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 CausalEdge { + from: string; to: string; style: 'solid' | 'dashed'; type?: string; label?: string } - -interface ActivationNode { - pred: number; act: number | null - status: 'correct' | 'wrong' | 'neutral' | 'unknown' +interface Coefficient { + value: number; calibrated: number | null; description: string } - -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 } +interface GraphJson { + nodes: CausalNode[]; edges: CausalEdge[] + coefficients: Record + instruments: string[] + input_mapping: Record +} +interface Template { + id: number; name: string; category: string; sub_type: string + instruments: string[]; description: string; graph_json: GraphJson + ai_rationale: string; calibration_json: Record + heuristic_ver: number +} +interface MarketEvent { + id: number; name: string; category: string; sub_type: string + start_date: string; level: string; impact_score: number + market_impact: string; affected_assets: string + actual_value: number | null; expected_value: number | null; surprise_pct: number | null + analysis_id: number | null; template_id: number | null; activation_score: number | null +} +interface NodeResult { + pred: number | null; act: number | null; status: string; label: string +} +interface AnalysisResult { + event: MarketEvent; template_id: number; template_name: string; instrument: string + inputs: Record; node_values: Record + actual_moves: Record; yields: Record + activation: { + score: number | null; nodes: Record; correct: number; total: number } - 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 + prices_mode: string; analyzed_at: string +} +interface Recommendation { + template_id: number | null; template_name: string; confidence: number + rationale: string; coefficient_suggestions: Record + instrument_focus: string[]; alternative_template_id: number | null; notes: string + error?: string +} +interface CalibRow { + id: number; name: string; category: string; sub_type: string + n_analyses: number; avg_activation: number | null + calibration: Record } -interface Summary { - n: number - by_series: Record - by_node: Record - drift_dist: number[] +// ── API ─────────────────────────────────────────────────────────────────────── + +const api = async (path: string, opts?: RequestInit) => { + const r = await fetch(`http://localhost:8000${path}`, opts) + if (!r.ok) { const t = await r.text(); throw new Error(t) } + return r.json() } -// ── Helpers ──────────────────────────────────────────────────────────────────── +// ── Couleurs / labels ───────────────────────────────────────────────────────── -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' +const NODE_COLORS: Record = { + input: '#3b82f6', + intermediate: '#8b5cf6', + output: '#10b981', } - -function matchColorPips(pred: number, actual: number | null, thr = 5): string { - return matchColor(pred, actual, thr) +const STATUS_COLOR: Record = { + correct: '#10b981', wrong: '#ef4444', neutral: '#64748b', unknown: '#64748b', } - -function leakColor(leak: string): string { - return leak === 'high' ? '#f87171' : leak === 'medium' ? '#f59e0b' : leak === 'low' ? '#34d399' : '#64748b' +const CAT_LABELS: Record = { + macro_us: 'Macro US', macro_eu: 'Macro EU', geopolitical: 'Géopolitique', + commodity: 'Matières premières', report: 'Report', sentiment: 'Sentiment', } +const fmtPips = (v: number | null | undefined) => + v == null ? '—' : `${v > 0 ? '+' : ''}${v.toFixed(0)} pip` -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' -} +// ── SVG Graph renderer ──────────────────────────────────────────────────────── -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'] +function GraphSVG({ + graph, nodeValues, activationNodes, compact = false, +}: { + graph: GraphJson + nodeValues?: Record + activationNodes?: Record + compact?: boolean }) { - const { actual_us10y, actual_eu10y, actual_us2y } = yields - const { pre_pips, post_pips, leak } = drift + const nodes = graph.nodes || [] + const edges = graph.edges || [] + const xs = nodes.map(n => n.x) + const ys = nodes.map(n => n.y) + const minX = Math.min(...xs, 0) - 40 + const minY = Math.min(...ys, 0) - 30 + const maxX = Math.max(...xs, 400) + 120 + const maxY = Math.max(...ys, 400) + 60 + const W = maxX - minX; const H = maxY - minY + const nodeMap = Object.fromEntries(nodes.map(n => [n.id, n])) + const NW = compact ? 90 : 115; const NH = compact ? 28 : 38 - // 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}` + function nodeColor(n: CausalNode) { + if (activationNodes?.[n.id]) return STATUS_COLOR[activationNodes[n.id].status] ?? NODE_COLORS[n.type] + return NODE_COLORS[n.type] ?? '#64748b' + } + function edgePath(e: CausalEdge) { + const s = nodeMap[e.from]; const t = nodeMap[e.to] + if (!s || !t) return '' + const x1 = s.x; const y1 = s.y + NH / 2 + const x2 = t.x; const y2 = t.y - NH / 2 + const mx = (x1 + x2) / 2 + return `M${x1},${y1} C${x1},${mx} ${x2},${mx} ${x2},${y2}` } - - 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 + + + + + + + {edges.map((e, i) => { + const s = nodeMap[e.from]; const t = nodeMap[e.to] + if (!s || !t) return null + return ( + + ) + })} + {nodes.map(n => { + const col = nodeColor(n) + const val = nodeValues?.[n.id] + const unit = n.unit || '' + return ( + + + + {n.label.length > 18 ? n.label.slice(0, 17) + '…' : n.label} + + {val != null && ( + + {val > 0 ? '+' : ''}{val.toFixed(1)}{unit ? ` ${unit}` : ''} + + )} + + ) + })} ) } -// ── Mini graphe EUR/USD autour de l'événement ───────────────────────────────── +// ── Tab Bibliothèque ────────────────────────────────────────────────────────── -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 -
- ) +function TabLibrary() { + const [templates, setTemplates] = useState([]) + const [selected, setSelected] = useState