"""
Instrument Models — Phase 2 : saturation non-linéaire + régimes adaptatifs.

Architecture DAG (3 couches) :
  Layer 0 — Inputs :
    - input_event  : valeur auto depuis causal_event_analyses (par catégorie template)
    - input_manual : valeur utilisateur → tanh(x/scale)*coeff → pips (saturation)
  Layer 1 — Intermediate : formules agrégeant les inputs par domaine
  Layer 2 — Output : formule avec poids de régime (ex: 1.4*layer_monetary en MONETARY_DOMINANCE)

Nouveautés Phase 2 :
  - _saturate_pips() : tanh(native/scale)*coeff, slope = coeff à l'origine, sature aux extrêmes
  - detect_regime()  : identifie le régime dominant depuis les catégories d'events actifs
  - REGIME_WEIGHTS   : multiplicateurs par couche selon 6 régimes de marché
  - _apply_regime_weights() : réécrit la formule output avec les poids du régime courant
  - simulate_timeline() inclut le régime du jour

Phase macro-guidance :
  - FF_MACRO_KEYS : mapping macro_key → {currency, event name patterns}
  - build_macro_node_timeline() : reconstruit une série temporelle journalière depuis ff_calendar
  - simulate_timeline() utilise des overrides time-varying pour les noeuds avec macro_key
  - structural_pips(t) devient une courbe guidée par les fondamentaux
"""
import json
import math
from datetime import datetime, timedelta, date as date_type
from typing import Optional


# ── Macro key mapping — ff_calendar event names per fundamental variable ───────
# Each macro_key maps to a currency and a list of lowercased event name patterns.
# The currency acts as a primary filter before pattern matching.
FF_MACRO_KEYS: dict[str, dict] = {
    "fed_rate":        {"currency": "USD", "label": "Taux Fed",          "unit": "%",
                        "names": ["interest rate decision", "federal funds rate", "fed rate"]},
    "ecb_rate":        {"currency": "EUR", "label": "Taux BCE",          "unit": "%",
                        "names": ["interest rate decision", "deposit facility rate", "refinancing rate", "ecb rate"]},
    "boe_rate":        {"currency": "GBP", "label": "Taux BoE",          "unit": "%",
                        "names": ["interest rate decision", "official bank rate", "boe rate"]},
    "boj_rate":        {"currency": "JPY", "label": "Taux BoJ",          "unit": "%",
                        "names": ["interest rate decision", "boj rate", "policy rate"]},
    "us_cpi":          {"currency": "USD", "label": "CPI US MoM",        "unit": "%",
                        "names": ["cpi m/m", "core cpi m/m", "inflation rate mom"]},
    "us_cpi_yoy":      {"currency": "USD", "label": "CPI US YoY",        "unit": "%",
                        "names": ["cpi y/y", "core cpi y/y", "inflation rate yoy"]},
    "eu_cpi_yoy":      {"currency": "EUR", "label": "HICP Eurozone YoY", "unit": "%",
                        "names": ["inflation rate yoy", "hicp", "cpi y/y"]},
    "us_nfp":          {"currency": "USD", "label": "NFP US",            "unit": "K",
                        "names": ["non-farm employment change", "nfp", "non farm payrolls"]},
    "us_pmi":          {"currency": "USD", "label": "PMI US",            "unit": "pts",
                        "names": ["ism manufacturing pmi", "ism services pmi", "s&p global manufacturing"]},
    "eu_pmi":          {"currency": "EUR", "label": "PMI Eurozone",      "unit": "pts",
                        "names": ["manufacturing pmi", "services pmi", "composite pmi"]},
    "us_gdp":          {"currency": "USD", "label": "GDP US QoQ",        "unit": "%",
                        "names": ["gdp growth rate qoq", "gdp q/q", "gdp qoq"]},
    "eu_gdp":          {"currency": "EUR", "label": "GDP Eurozone",      "unit": "%",
                        "names": ["gdp growth rate qoq", "gdp growth rate yoy"]},
    "us_unemployment": {"currency": "USD", "label": "Chômage US",        "unit": "%",
                        "names": ["unemployment rate"]},
    "eu_unemployment": {"currency": "EUR", "label": "Chômage Eurozone",  "unit": "%",
                        "names": ["unemployment rate"]},
    "us_retail_sales": {"currency": "USD", "label": "Ventes détail US",  "unit": "%",
                        "names": ["retail sales m/m", "retail sales mom", "core retail sales"]},
}


def _parse_ff_num(s: Optional[str]) -> Optional[float]:
    """Parse une valeur ff_calendar (ex: '4.50%', '2.1K', '102.3') → float."""
    if not s:
        return None
    t = str(s).strip().upper()
    try:
        if t.endswith('K'):   return float(t[:-1]) * 1_000
        if t.endswith('M'):   return float(t[:-1]) * 1_000_000
        if t.endswith('B'):   return float(t[:-1]) * 1_000_000_000
        return float(t.replace('%', '').replace(',', ''))
    except Exception:
        return None


def build_macro_node_timeline(
    conn, macro_key: str, date_from: date_type, date_to: date_type
) -> dict[str, float]:
    """
    Reconstruit une série temporelle journalière {date_str: value} pour un macro_key.

    Algorithme :
    1. Requête ff_calendar sur la monnaie + patterns du macro_key (±90j de marge)
    2. Extrait les valeurs connues : actual_value pour les dates passées,
       forecast_value (ou previous si absent) pour les dates futures
    3. Interpolation linéaire entre les points connus → courbe daily lisse
    4. Avant le premier point connu : valeur du premier point (extrapolation plate)
    5. Après le dernier point connu : valeur du dernier point (extrapolation plate)
    """
    meta = FF_MACRO_KEYS.get(macro_key)
    if not meta:
        return {}

    currency = meta["currency"]
    patterns = meta["names"]
    today    = date_type.today()

    # Wide window: look back up to 2 years to capture last known rate decision
    # (rate decisions can be 6+ weeks apart; CSV data may lag by months)
    q_from = str(date_from - timedelta(days=730))
    q_to   = str(date_to   + timedelta(days=180))

    try:
        rows = conn.execute(
            """SELECT event_date, event_name, actual_value, forecast_value, previous_value
               FROM ff_calendar
               WHERE currency=? AND event_date>=? AND event_date<=?
               ORDER BY event_date ASC""",
            (currency, q_from, q_to)
        ).fetchall()
    except Exception:
        return {}

    # Filter by name pattern
    matched = [
        dict(r) for r in rows
        if any(p in r["event_name"].lower() for p in patterns)
    ]
    if not matched:
        return {}

    # Deduplicate by date (keep first match per date — most specific pattern wins)
    seen: set[str] = set()
    deduped = []
    for ev in matched:
        if ev["event_date"] not in seen:
            seen.add(ev["event_date"])
            deduped.append(ev)

    # Build known (date, value) anchor points
    known: list[tuple[date_type, float]] = []
    for ev in deduped:
        try:
            ev_date = date_type.fromisoformat(ev["event_date"])
        except ValueError:
            continue

        if ev_date <= today:
            # Past event: prefer actual, fall back to forecast then previous
            v = _parse_ff_num(ev.get("actual_value")) \
                or _parse_ff_num(ev.get("forecast_value")) \
                or _parse_ff_num(ev.get("previous_value"))
        else:
            # Future event: use forecast, fall back to previous
            v = _parse_ff_num(ev.get("forecast_value")) \
                or _parse_ff_num(ev.get("previous_value"))

        if v is not None:
            known.append((ev_date, v))

    if not known:
        return {}

    known.sort(key=lambda x: x[0])

    # Build daily timeline via linear interpolation
    result: dict[str, float] = {}
    cur = date_from
    while cur <= date_to:
        cur_str = str(cur)

        # Find surrounding anchor points
        prev_k: Optional[tuple[date_type, float]] = None
        next_k: Optional[tuple[date_type, float]] = None
        for k_date, k_val in known:
            if k_date <= cur:
                prev_k = (k_date, k_val)
            elif next_k is None:
                next_k = (k_date, k_val)
                break

        if prev_k is None and next_k is None:
            pass  # no data at all (shouldn't happen given the wide window)
        elif prev_k is None:
            # Before first known anchor: flat at first value
            result[cur_str] = next_k[1]       # type: ignore[index]
        elif next_k is None:
            # After last known anchor: flat at last value
            result[cur_str] = prev_k[1]
        else:
            # Interpolate linearly between prev and next anchor
            total_d = (next_k[0] - prev_k[0]).days
            elapsed  = (cur - prev_k[0]).days
            frac     = elapsed / total_d if total_d > 0 else 0.0
            result[cur_str] = round(prev_k[1] + frac * (next_k[1] - prev_k[1]), 4)

        cur += timedelta(days=1)

    return result


def build_node_combined_timeline(
    conn, instrument: str, node_id: str, macro_key: Optional[str],
    date_from: date_type, date_to: date_type,
) -> dict[str, float]:
    """
    Construit la timeline complète d'un nœud en fusionnant :
      1. Valeurs passées (ff_calendar actuals via macro_key)
      2. Scénarios utilisateur futurs (CT/MT/LT) avec interpolation pondérée par confidence

    Logique :
    - Pour les dates ≤ aujourd'hui : ff_calendar actuals (si disponibles)
    - Pour les dates futures : interpolation depuis la dernière valeur connue
      vers chaque scénario, pondérée par confidence
    - Si plusieurs scénarios se chevauchent : moyenne pondérée par confidence
    """
    today = date_type.today()

    # 1. Baseline depuis ff_calendar (passé uniquement en pratique)
    ff_tl = build_macro_node_timeline(conn, macro_key, date_from, date_to) if macro_key else {}

    # 2. Scénarios utilisateur
    try:
        scen_rows = conn.execute(
            """SELECT id, label, horizon, target_date, target_value, confidence, trajectory, absorption_days
               FROM node_forecast_scenarios
               WHERE instrument=? AND node_id=?
               ORDER BY target_date ASC""",
            (instrument.upper(), node_id)
        ).fetchall()
        scenarios = [dict(r) for r in scen_rows]
    except Exception:
        scenarios = []

    if not scenarios:
        return ff_tl  # Pas de scénarios → juste ff_calendar

    # 3. Valeur de départ (dernière connue depuis ff_calendar ou override statique)
    base_value: Optional[float] = None
    if ff_tl:
        # Dernière valeur connue avant ou à aujourd'hui
        for d in sorted(ff_tl.keys(), reverse=True):
            if d <= str(today):
                base_value = ff_tl[d]
                break
        if base_value is None:
            base_value = next(iter(ff_tl.values()), None)

    # Fallback sur override statique si pas de ff_calendar
    if base_value is None:
        try:
            ov = conn.execute(
                "SELECT value FROM instrument_node_overrides WHERE instrument=? AND node_id=?",
                (instrument.upper(), node_id)
            ).fetchone()
            if ov:
                base_value = float(ov["value"])
        except Exception:
            pass

    if base_value is None:
        return ff_tl   # Pas de base → on ne peut pas projeter

    # 4. Construire les waypoints (date, value, confidence) depuis les scénarios
    #    Le point de départ est toujours (today, base_value, 1.0)
    waypoints: list[tuple[date_type, float, float]] = [(today, base_value, 1.0)]
    for s in scenarios:
        try:
            td = date_type.fromisoformat(s["target_date"])
            if td > today:
                waypoints.append((td, float(s["target_value"]), float(s["confidence"])))
        except (ValueError, KeyError):
            continue
    waypoints.sort(key=lambda x: x[0])

    # 5. Construire la timeline journalière
    result: dict[str, float] = {}
    cur = date_from
    while cur <= date_to:
        cur_str = str(cur)

        if cur <= today and cur_str in ff_tl:
            # Passé → priorité ff_calendar
            result[cur_str] = ff_tl[cur_str]
        else:
            # Futur → interpolation entre waypoints
            prev_wp: Optional[tuple[date_type, float, float]] = None
            next_wp: Optional[tuple[date_type, float, float]] = None
            for wp in waypoints:
                if wp[0] <= cur:
                    prev_wp = wp
                elif next_wp is None:
                    next_wp = wp
                    break

            if prev_wp is None and next_wp is None:
                if ff_tl:
                    # Dernier connu
                    result[cur_str] = ff_tl.get(str(today)) or list(ff_tl.values())[-1]
            elif prev_wp is None:
                result[cur_str] = round(next_wp[1], 4)   # type: ignore[index]
            elif next_wp is None:
                result[cur_str] = round(prev_wp[1], 4)   # hold last value
            else:
                # Interpolation linéaire entre prev et next, pondérée par confidence
                total_d = (next_wp[0] - prev_wp[0]).days
                elapsed  = (cur - prev_wp[0]).days
                frac     = elapsed / total_d if total_d > 0 else 0.0
                # Valeur interpolée brute
                interp   = prev_wp[1] + frac * (next_wp[1] - prev_wp[1])
                # La confidence du prochain scénario pondère le drift :
                # confidence=1.0 → drift complet vers interp
                # confidence=0.5 → 50% du drift, reste à mi-chemin entre prev et interp
                conf = next_wp[2]
                blended = prev_wp[1] + (interp - prev_wp[1]) * conf
                result[cur_str] = round(blended, 4)

        cur += timedelta(days=1)

    return result


# ── Macro sync — lecture directe ff_calendar → overrides ──────────────────────

def get_macro_sync_items(conn, instrument: str) -> list[dict]:
    """
    Pour chaque nœud avec macro_key dans le graphe, retourne :
      - actual_value  : dernière valeur publiée (passé)
      - actual_date   : date de cette publication
      - forecast_value: forecast du prochain événement (futur)
      - forecast_date / next_event_name / days_until
      - current_override : valeur déjà settée en DB (ou None)
    Utilisé par le bouton "Sync Marché" pour pré-remplir le graphe.
    """
    inst_upper = instrument.upper()
    row = conn.execute(
        "SELECT graph_json FROM instrument_models WHERE instrument=?", (inst_upper,)
    ).fetchone()
    if not row:
        return []

    graph_def = json.loads(row["graph_json"])
    today     = date_type.today()
    today_str = str(today)
    q_from    = str(today - timedelta(days=730))
    q_to      = str(today + timedelta(days=180))

    overrides = {r["node_id"]: float(r["value"]) for r in conn.execute(
        "SELECT node_id, value FROM instrument_node_overrides WHERE instrument=?", (inst_upper,)
    ).fetchall()}

    items = []
    for node in graph_def.get("nodes", []):
        mk = node.get("macro_key")
        if not mk or node.get("node_type") != "input_manual":
            continue
        mk_info = FF_MACRO_KEYS.get(mk)
        if not mk_info:
            continue

        currency = mk_info["currency"]
        patterns = mk_info["names"]

        try:
            rows = conn.execute(
                """SELECT event_date, event_name, actual_value, forecast_value, previous_value
                   FROM ff_calendar
                   WHERE currency=? AND event_date>=? AND event_date<=?
                   ORDER BY event_date ASC""",
                (currency, q_from, q_to)
            ).fetchall()
        except Exception:
            rows = []

        matched = [
            dict(r) for r in rows
            if any(p in str(r["event_name"]).lower() for p in patterns)
        ]

        # Deduplicate by date
        seen: set[str] = set()
        deduped = []
        for ev in matched:
            if ev["event_date"] not in seen:
                seen.add(ev["event_date"])
                deduped.append(ev)

        # Latest actual (most recent past event with actual_value)
        past = [e for e in deduped if e["event_date"] <= today_str]
        actual_val: Optional[float] = None
        actual_date: Optional[str] = None
        for ev in reversed(past):
            v = _parse_ff_num(ev.get("actual_value"))
            if v is not None:
                actual_val  = v
                actual_date = ev["event_date"]
                break

        # Next forecast (first future event)
        future = [e for e in deduped if e["event_date"] > today_str]
        forecast_val:  Optional[float] = None
        forecast_date: Optional[str]  = None
        next_event_name: Optional[str] = None
        days_until: Optional[int]     = None
        if future:
            nxt = future[0]
            forecast_val = (
                _parse_ff_num(nxt.get("forecast_value"))
                or _parse_ff_num(nxt.get("previous_value"))
            )
            forecast_date    = nxt["event_date"]
            next_event_name  = nxt["event_name"]
            try:
                days_until = (date_type.fromisoformat(forecast_date) - today).days
            except Exception:
                days_until = None

        # Unit-aware post-conversion
        # The FF Calendar stores NFP in absolute (e.g. 228000), node expects K (228)
        # PMI stored as absolute value (54.7), node expects deviation from 50 (4.7)
        unit = node.get("unit", "")
        def _convert(v: Optional[float]) -> Optional[float]:
            if v is None:
                return None
            if unit == "K" and abs(v) >= 1_000:
                return round(v / 1_000, 1)
            if unit == "pts" and v > 10:          # PMI > 10 → is absolute, subtract 50
                return round(v - 50.0, 1)
            return round(v, 4)

        items.append({
            "node_id":               node["id"],
            "node_label":            node["label"],
            "macro_key":             mk,
            "unit":                  unit,
            "coefficient_to_pips":   node.get("coefficient_to_pips", 1),
            "actual_value":          _convert(actual_val),
            "actual_date":           actual_date,
            "forecast_value":        _convert(forecast_val),
            "forecast_date":         forecast_date,
            "next_event_name":       next_event_name,
            "days_until_forecast":   days_until,
            "current_override":      overrides.get(node["id"]),
        })

    return items


# ── Scenario CRUD ──────────────────────────────────────────────────────────────

def get_node_scenarios(conn, instrument: str, node_id: Optional[str] = None) -> list[dict]:
    """Retourne tous les scénarios d'un instrument (ou d'un nœud spécifique)."""
    if node_id:
        rows = conn.execute(
            """SELECT * FROM node_forecast_scenarios
               WHERE instrument=? AND node_id=? ORDER BY target_date ASC""",
            (instrument.upper(), node_id)
        ).fetchall()
    else:
        rows = conn.execute(
            "SELECT * FROM node_forecast_scenarios WHERE instrument=? ORDER BY node_id, target_date ASC",
            (instrument.upper(),)
        ).fetchall()
    return [dict(r) for r in rows]


def add_node_scenario(
    conn, instrument: str, node_id: str, label: str,
    horizon: str, target_date: str, target_value: float,
    confidence: float = 0.7, trajectory: str = "linear",
    absorption_days: int = 30, notes: str = "",
) -> int:
    """Ajoute un scénario forecast sur un nœud. Retourne l'id créé."""
    cur = conn.execute(
        """INSERT INTO node_forecast_scenarios
           (instrument, node_id, label, horizon, target_date, target_value,
            confidence, trajectory, absorption_days, notes)
           VALUES (?,?,?,?,?,?,?,?,?,?)""",
        (instrument.upper(), node_id, label, horizon, target_date, float(target_value),
         float(confidence), trajectory, int(absorption_days), notes or "")
    )
    conn.commit()
    return cur.lastrowid


def delete_node_scenario(conn, scenario_id: int) -> bool:
    conn.execute("DELETE FROM node_forecast_scenarios WHERE id=?", (scenario_id,))
    conn.commit()
    return True


# ── Saturation scales (tanh) par unité native ─────────────────────────────────
# tanh(x/scale) : slope=1 à l'origine, sature asymptotiquement à ±1
# pips = coefficient_to_pips * scale * tanh(x / scale)
# → slope à x=0 : coefficient_to_pips (identique au linéaire)
# → max pips : coefficient_to_pips * scale (jamais dépassé)
_SATURATION_SCALES: dict[str, float] = {
    "bps":      200.0,   # différentiels de taux : sature autour ±300bps
    "%":          3.0,   # CPI / PIB / taux en % absolu : sature autour ±5%
    "pts%":       3.0,
    "pts":       15.0,   # PMI écart depuis 50 : sature autour ±20pts
    "score":      3.0,   # scores subjectifs -5 à +5
    "K":        200.0,   # emplois en milliers : sature autour ±400K
    "tonnes":    80.0,   # tonnes or / CB buying
    "Mds$":      40.0,
    "Mds$/sem":  15.0,
    "k lots":    80.0,   # positions CFTC
    "$/bbl":     25.0,
    "x":          5.0,   # multiples PE
    "ratio":      1.5,
    "t":         80.0,
}


def _saturate_pips(native: float, coeff: float, unit: str, scale_override: Optional[float] = None) -> float:
    """Convertit une valeur native en pips avec saturation tanh.
    Comportement identique au linéaire pour de petites valeurs,
    sature progressivement pour les valeurs extrêmes.
    """
    scale = scale_override or _SATURATION_SCALES.get(unit)
    if scale and scale > 0:
        return coeff * scale * math.tanh(native / scale)
    return coeff * native


# ── Régimes de marché et poids adaptatifs ─────────────────────────────────────
# Chaque régime amplifie certaines couches (>1) et atténue les autres (<1)
# Les clés couvrent tous les noms de couches intermédiaires possibles.

REGIME_WEIGHTS: dict[str, dict[str, float]] = {
    "MONETARY_DOMINANCE": {
        # Fed/BCE dominent tout — taux, OIS, anticipations
        "layer_monetary": 1.40, "layer_rates": 1.40,
        "layer_growth":   0.80, "layer_uk_macro": 0.80, "layer_macro": 0.80,
        "layer_risk":     0.70, "layer_risk_credit": 0.70, "layer_refuge": 0.70,
        "layer_positioning": 1.10, "layer_policy": 1.10,
        "layer_flows":    1.00, "layer_demand": 1.00,
        "layer_tech_fundamental": 0.85, "layer_supply": 1.00,
        "layer_china":    0.90, "layer_dollar": 1.20,
        "layer_global":   0.80,
    },
    "GEOPOLITICAL_RISK": {
        # Tensions géo → flight to safety, or, JPY, USD
        "layer_monetary": 0.80, "layer_rates": 0.80,
        "layer_growth":   0.90, "layer_uk_macro": 0.85, "layer_macro": 0.85,
        "layer_risk":     1.50, "layer_risk_credit": 1.50, "layer_refuge": 1.60,
        "layer_positioning": 1.10, "layer_policy": 1.30,
        "layer_flows":    1.00, "layer_demand": 1.20,
        "layer_tech_fundamental": 0.80, "layer_supply": 0.90,
        "layer_china":    0.80, "layer_dollar": 0.90,
        "layer_global":   1.30,
    },
    "CREDIT_STRESS": {
        # Banking stress / liquidity crunch → risk-off massif
        "layer_monetary": 0.90, "layer_rates": 0.90,
        "layer_growth":   1.10, "layer_uk_macro": 1.10, "layer_macro": 1.10,
        "layer_risk":     1.60, "layer_risk_credit": 1.70, "layer_refuge": 1.50,
        "layer_positioning": 0.80, "layer_policy": 0.80,
        "layer_flows":    0.70, "layer_demand": 0.80,
        "layer_tech_fundamental": 0.75, "layer_supply": 0.90,
        "layer_china":    0.85, "layer_dollar": 1.10,
        "layer_global":   1.20,
    },
    "GROWTH_SCARE": {
        # Récession / choc croissance → pivots BC, safe haven, EM sell
        "layer_monetary": 1.10, "layer_rates": 1.10,
        "layer_growth":   1.50, "layer_uk_macro": 1.50, "layer_macro": 1.50,
        "layer_risk":     1.20, "layer_risk_credit": 1.20, "layer_refuge": 1.30,
        "layer_positioning": 0.90, "layer_policy": 0.90,
        "layer_flows":    0.85, "layer_demand": 0.80,
        "layer_tech_fundamental": 0.70, "layer_supply": 0.90,
        "layer_china":    1.30, "layer_dollar": 0.90,
        "layer_global":   1.10,
    },
    "COMMODITY_SHOCK": {
        # Choc énergie/matières premières → inflation, EM, or
        "layer_monetary": 1.10, "layer_rates": 1.10,
        "layer_growth":   1.20, "layer_uk_macro": 1.10, "layer_macro": 1.20,
        "layer_risk":     1.10, "layer_risk_credit": 1.00, "layer_refuge": 1.20,
        "layer_positioning": 1.00, "layer_policy": 1.00,
        "layer_flows":    1.00, "layer_demand": 1.40,
        "layer_tech_fundamental": 0.90, "layer_supply": 1.20,
        "layer_china":    1.20, "layer_dollar": 0.95,
        "layer_global":   1.10,
    },
    "BALANCED": {
        # Régime neutre : aucune amplification
    },
}

# Mapping catégories d'events → régimes candidats
_CAT_TO_REGIME: dict[str, str] = {
    "central_bank":    "MONETARY_DOMINANCE",
    "monetary_shock":  "MONETARY_DOMINANCE",
    "geopolitical":    "GEOPOLITICAL_RISK",
    "trade_policy":    "GEOPOLITICAL_RISK",
    "credit_stress":   "CREDIT_STRESS",
    "growth_shock":    "GROWTH_SCARE",
    "commodity":       "COMMODITY_SHOCK",
    "sentiment":       "BALANCED",
    "technical":       "BALANCED",
    "positioning":     "BALANCED",
    "unclassified":    "BALANCED",
}

# ── Lifecycle & decay ──────────────────────────────────────────────────────────

def _decay(days: int, absorption: int, dtype: str) -> float:
    if days < 0: return 0.0
    if dtype == "step":   return 1.0 if days <= absorption else 0.0
    if dtype == "linear": return max(0.0, 1.0 - days / max(absorption, 1))
    lam = 3.0 / max(absorption, 1)
    return math.exp(-lam * days)


def _lifecycle(days: int, rise: int, plateau: int, absorption: int, dtype: str) -> float:
    """Montée linéaire → plateau → décroissance (exp/linear/step)."""
    if days < 0:              return 0.0
    if days < rise:           return days / max(rise, 1)
    if days < rise + plateau: return 1.0
    return _decay(days - rise - plateau, absorption, dtype)


# ── Category labels ────────────────────────────────────────────────────────────
CAT_LABELS: dict[str, str] = {
    "monetary":"Monétaire","inflation":"Inflation","macro":"Macro/Croissance",
    "political":"Politique","flows":"Flux & Réserves","positioning":"Positionnement",
    "sentiment":"Sentiment & Risque","credit":"Crédit","earnings":"Bénéfices",
    "valuation":"Valorisation","tech":"Technologie","commodity":"Commodités",
    "supply":"Offre","output":"Résultat","intermediate":"Couche intermédiaire",
    # event categories
    "central_bank":"Banques Centrales","monetary_shock":"Surprise Macro",
    "geopolitical":"Géopolitique","trade_policy":"Commerce / Tarifs",
    "growth_shock":"Choc Croissance","credit_stress":"Stress Crédit",
    "technical":"Technique","sentiment":"Sentiment",
    "positioning":"Positionnement","unclassified":"Non Classifié",
}

# ── Helper to build formula from list of node IDs ──────────────────────────────
def _sum_formula(*node_ids: str) -> str:
    return " + ".join(node_ids)


# ── Built-in instrument model definitions ──────────────────────────────────────
# Each model defines:
#   nodes  : list of node dicts
#   output_node : id of the output node
#
# Node schema:
#   id, label, node_type (input_event|input_manual|intermediate|output),
#   category, unit (for manual), coefficient_to_pips (for manual),
#   event_category (for event nodes), formula (for intermediate/output),
#   description, display_col (0=inputs-event, 1=inputs-manual, 2=intermediate, 3=output)
#
# All intermediate/output values are in pips.
# For input_manual : value_in_native_unit * coefficient_to_pips = pips injected into graph.

INSTRUMENT_MODELS: dict[str, dict] = {

# ══════════════════════════════════════════════════════════════════════════════
"EURUSD": {
  "name": "EUR/USD",
  "description": "Graphe causal pondéré 7 couches : macro fondamentaux → hawkishness → dynamiques marché → taux & demande → positionnement → flux → prix",
  "output_node": "eurusd",
  "price_intercept": 1.10,
  "pip_to_price": 0.0001,
  "yf_ticker": "EURUSD=X",
  "col_labels": ["Variables Macro", "Politique Monétaire", "Dynamiques Marché", "Taux & Demande", "Positionnement", "Order Flow", "Output"],
  "nodes": [
    # ── Chocs CT : événements court terme ─────────────────────────────────────
    # display_col:-1 → cachés dans le DAG visuel, additifs dans la formule output
    {"id":"in_cb",    "label":"Choc Banques Centrales","node_type":"input_event","category":"central_bank","unit":"pips","display_col":-1,"event_category":"central_bank","description":"Surprise Fed/BCE : décision inattendue, guidance. Choc CT additionné à l'output."},
    {"id":"in_geo",   "label":"Choc Géopolitique",     "node_type":"input_event","category":"geopolitical","unit":"pips","display_col":-1,"event_category":"geopolitical","description":"Conflits, sanctions → flight to USD. Choc CT."},
    {"id":"in_trade", "label":"Choc Commercial",       "node_type":"input_event","category":"trade_policy","unit":"pips","display_col":-1,"event_category":"trade_policy","description":"Tarifs US-UE. Choc CT."},
    {"id":"in_credit","label":"Stress Crédit",         "node_type":"input_event","category":"credit_stress","unit":"pips","display_col":-1,"event_category":"credit_stress","description":"Stress bancaire → safe haven USD. Choc CT."},

    # ── Layer 0 (col 0) : Variables macro fondamentales ───────────────────────
    # Toutes auto-synchronisées via FF Calendar (macro_key)
    # Coefficients calibrés : au neutre (all=0) output=0, range typique ±200 pips
    {"id":"fed_rate", "label":"Taux Fed (%)",         "node_type":"input_manual","category":"monetary","unit":"%","coefficient_to_pips":-60, "macro_key":"fed_rate",  "display_col":0,"description":"Taux directeur Fed en %. Hausse → USD fort → EURUSD ↓. Dominant driver."},
    {"id":"ecb_rate", "label":"Taux BCE (%)",         "node_type":"input_manual","category":"monetary","unit":"%","coefficient_to_pips":+60, "macro_key":"ecb_rate",  "display_col":0,"description":"Taux directeur BCE en %. Hausse → EUR fort → EURUSD ↑."},
    {"id":"us_cpi",   "label":"CPI US YoY (%)",       "node_type":"input_manual","category":"inflation","unit":"%","coefficient_to_pips":-20,"macro_key":"us_cpi_yoy","display_col":0,"description":"Inflation US annuelle. Hausse → Fed hawkish → USD ↑."},
    {"id":"eu_cpi",   "label":"HICP Eurozone (%)",    "node_type":"input_manual","category":"inflation","unit":"%","coefficient_to_pips":+20,"macro_key":"eu_cpi_yoy","display_col":0,"description":"Inflation EU annuelle. Hausse → BCE hawkish → EUR ↑."},
    {"id":"us_growth","label":"GDP US QoQ (%)",       "node_type":"input_manual","category":"growth","unit":"%","coefficient_to_pips":-30, "macro_key":"us_gdp",    "display_col":0,"description":"Croissance US trimestrielle. Surperformance → USD dominant."},
    {"id":"eu_growth","label":"GDP Eurozone QoQ (%)","node_type":"input_manual","category":"growth","unit":"%","coefficient_to_pips":+30, "macro_key":"eu_gdp",    "display_col":0,"description":"Croissance EU trimestrielle. Surperformance → EUR dominant."},
    {"id":"us_nfp",   "label":"NFP US (K/mois)",      "node_type":"input_manual","category":"labor","unit":"K","coefficient_to_pips":-0.08,"macro_key":"us_nfp",   "display_col":0,"description":"Emplois non-agricoles. Fort (>200K) → USD hawkish."},
    {"id":"eu_pmi",   "label":"PMI EU (écart/50)",    "node_type":"input_manual","category":"activity","unit":"pts","coefficient_to_pips":+5,"macro_key":"eu_pmi",  "display_col":0,"description":"PMI EU écart/50. Positif = expansion EU → EUR achetée."},
    {"id":"us_pmi",   "label":"PMI US (écart/50)",    "node_type":"input_manual","category":"activity","unit":"pts","coefficient_to_pips":-5,"macro_key":"us_pmi",  "display_col":0,"description":"PMI US écart/50. Positif = expansion US → USD fort."},

    # ── Layer 1 (col 1) : Politique monétaire ────────────────────────────────
    # rate_lvl : différentiel de taux directs (driver dominant)
    # ecb_hawk / fed_hawk : pression hawkish perçue (données macro → anticipations futures)
    {"id":"rate_lvl", "label":"Niveaux Taux (BCE−Fed)",
     "node_type":"intermediate","category":"monetary","unit":"pips","display_col":1,
     "formula":"ecb_rate + fed_rate",
     "description":"Différentiel de taux directeurs brut BCE−Fed (en pips). Driver dominant du pair."},
    {"id":"ecb_hawk", "label":"BCE Hawkishness",
     "node_type":"intermediate","category":"monetary","unit":"pips","display_col":1,
     "formula":"0.55*eu_cpi + 0.30*eu_growth + 0.15*eu_pmi",
     "description":"Pression hawkish BCE perçue : inflation + croissance + activité EU → anticipations taux CE."},
    {"id":"fed_hawk", "label":"Fed Hawkishness",
     "node_type":"intermediate","category":"monetary","unit":"pips","display_col":1,
     "formula":"0.55*us_cpi + 0.30*us_growth + 0.12*us_nfp + 0.03*us_pmi",
     "description":"Pression hawkish Fed perçue : inflation + croissance + emploi → anticipations taux US."},

    # ── Layer 2 (col 2) : Dynamiques de marché ───────────────────────────────
    # rate_diff : différentiel de hawkishness perçue (secondaire au rate_lvl)
    # eu_equities / us_equities : proxies croissance/risque equity
    # risk_on : appétit risque global
    {"id":"rate_diff", "label":"Différentiel Hawkishness",
     "node_type":"intermediate","category":"monetary","unit":"pips","display_col":2,
     "formula":"ecb_hawk + fed_hawk",
     "description":"Différentiel BCE−Fed de hawkishness. Positif = BCE perçue plus hawkish que Fed."},
    {"id":"eu_equities","label":"EU Equities",
     "node_type":"intermediate","category":"risk","unit":"pips","display_col":2,
     "formula":"0.60*eu_growth + 0.40*eu_pmi",
     "description":"Proxy performance equity EU. Croissance + activité → flux entrants zone euro."},
    {"id":"us_equities","label":"US Equities",
     "node_type":"intermediate","category":"risk","unit":"pips","display_col":2,
     "formula":"0.60*us_growth + 0.40*us_pmi",
     "description":"Proxy performance equity US. Expansion US → USD résilient, risk-on."},
    {"id":"risk_on",   "label":"Risk-On Global",
     "node_type":"intermediate","category":"risk","unit":"pips","display_col":2,
     "formula":"0.50*eu_equities + 0.30*us_equities + 0.20*eu_growth",
     "description":"Appétit risque global. Risk-on → carry → EUR achetée vs USD."},

    # ── Layer 3 (col 3) : Taux longs & Demande de change ─────────────────────
    {"id":"us10y",    "label":"US10Y Signal",
     "node_type":"intermediate","category":"rates","unit":"pips","display_col":3,
     "formula":"0.50*fed_hawk + 0.30*us_growth + 0.20*us_cpi",
     "description":"Signal rendement US 10Y. Hausse → USD attractif → EURUSD ↓."},
    {"id":"carry",    "label":"Carry Trade EUR",
     "node_type":"intermediate","category":"positioning","unit":"pips","display_col":3,
     "formula":"0.65*rate_diff + 0.35*risk_on",
     "description":"Attractivité carry EUR/USD. Positif = taux EU > taux US + marché calme → EUR long."},
    {"id":"eur_demand","label":"EUR Demand",
     "node_type":"intermediate","category":"flow","unit":"pips","display_col":3,
     "formula":"0.55*eu_equities + 0.30*eu_growth + 0.15*eu_pmi",
     "description":"Demande structurelle EUR : flux investissement zone euro, capitaux entrants."},
    {"id":"usd_demand","label":"USD Demand",
     "node_type":"intermediate","category":"flow","unit":"pips","display_col":3,
     "formula":"0.55*us_equities + 0.30*us_growth + 0.15*us_nfp",
     "description":"Demande structurelle USD. Forte = EURUSD ↓. (Valeurs déjà en signe négatif.)"},

    # ── Layer 4 (col 4) : Positionnement & Microstructure ────────────────────
    {"id":"cftc_usd_long","label":"CFTC USD Long",
     "node_type":"intermediate","category":"positioning","unit":"pips","display_col":4,
     "formula":"0.50*fed_hawk + 0.30*us10y + 0.20*rate_diff",
     "description":"Positionnement spéculatif long USD (proxy CFTC). Négatif = longs USD → USD déjà saturé."},
    {"id":"dealer_gamma","label":"Dealer Gamma",
     "node_type":"intermediate","category":"positioning","unit":"pips","display_col":4,
     "formula":"0.60*risk_on + 0.40*carry",
     "description":"Position gamma dealers. Positif = marché calme, options équilibrées → tends à stabiliser."},
    {"id":"liquidity", "label":"Liquidité EUR/USD",
     "node_type":"intermediate","category":"flow","unit":"pips","display_col":4,
     "formula":"0.50*eur_demand + 0.30*carry + 0.20*dealer_gamma",
     "description":"Liquidité côté EUR. Profondeur acheteurs EUR institutionnels."},

    # ── Layer 5 (col 5) : Order Flow synthétique ──────────────────────────────
    {"id":"order_flow","label":"Order Flow Net",
     "node_type":"intermediate","category":"flow","unit":"pips","display_col":5,
     "formula":"0.35*eur_demand + 0.25*liquidity + 0.20*carry + 0.12*cftc_usd_long + 0.08*dealer_gamma",
     "description":"Flux d'ordres net EUR/USD. Positif = pression acheteuse EUR. Driver direct du prix court terme."},

    # ── Output (col 6) ────────────────────────────────────────────────────────
    # Formule = contribution pondérée cross-couches (pas de double-comptage car nœuds distincts)
    # + chocs CT additifs (in_cb, in_geo, in_trade, in_credit)
    {"id":"eurusd",   "label":"EUR/USD — Biais Net",
     "node_type":"output","category":"output","unit":"pips","display_col":6,
     "formula":"0.40*rate_lvl + 0.30*rate_diff + 0.20*order_flow + 0.06*eur_demand + 0.04*usd_demand + in_cb + in_geo + in_trade + in_credit",
     "description":"Biais fondamental net EUR/USD en pips. Positif = haussier EUR. Dominé par différentiel taux + hawkishness + flux."},
  ]
},

# ══════════════════════════════════════════════════════════════════════════════
"USDJPY": {
  "name": "USD/JPY", "output_node": "usdjpy",
  "description": "Carry & safe haven — yield diff 10Y + BoJ + risk appetite",
  "price_intercept": 145.0,
  "pip_to_price": 0.01,
  "yf_ticker": "USDJPY=X",
  "nodes": [
    {"id":"in_cb",        "label":"Banques Centrales",       "node_type":"input_event","category":"central_bank", "unit":"pips","display_col":0,"event_category":"central_bank","description":"Fed/BoJ décisions."},
    {"id":"in_macro",     "label":"Surprises Macro",         "node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"event_category":"monetary_shock","description":"NFP, CPI US, Tankan, CPI Japon."},
    {"id":"in_geo",       "label":"Risque Géopolitique",     "node_type":"input_event","category":"geopolitical", "unit":"pips","display_col":0,"event_category":"geopolitical","description":"NK tensions → JPY safe haven demandé."},
    {"id":"in_trade",     "label":"Choc Commercial",         "node_type":"input_event","category":"trade_policy", "unit":"pips","display_col":0,"event_category":"trade_policy","description":"Tarifs US-Japon."},
    {"id":"in_growth",    "label":"Choc Croissance",         "node_type":"input_event","category":"growth_shock", "unit":"pips","display_col":0,"event_category":"growth_shock","description":"Récession → risk-off → JPY."},
    {"id":"in_credit",    "label":"Stress Crédit",           "node_type":"input_event","category":"credit_stress","unit":"pips","display_col":0,"event_category":"credit_stress","description":"Stress → JPY refuge."},
    {"id":"in_sentiment", "label":"Sentiment/Flux",          "node_type":"input_event","category":"sentiment",    "unit":"pips","display_col":0,"event_category":"sentiment","description":"Risk-on/off."},
    {"id":"in_technical", "label":"Momentum Technique",      "node_type":"input_event","category":"technical",    "unit":"pips","display_col":0,"event_category":"technical","description":"Niveaux clés USD/JPY."},
    {"id":"m_yield_diff", "label":"Diff. rendement 10Y US-JP","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips": 1.2,"display_col":1,"description":"Principal driver. Hausse → USD/JPY ↑"},
    {"id":"m_fed_path",   "label":"Anticipation Fed 12m",   "node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips": 0.6,"display_col":1,"description":"Fed hike → USD/JPY ↑"},
    {"id":"m_boj_stance", "label":"Biais BoJ (score)",      "node_type":"input_manual","category":"monetary","unit":"score","coefficient_to_pips":-8.0,"display_col":1,"description":"Hawkish → JPY ↑ → USD/JPY ↓"},
    {"id":"m_jgb_10y",    "label":"Rendement JGB 10Y",      "node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-0.8,"display_col":1,"description":"JGB yield ↑ → JPY attractif → paire ↓"},
    {"id":"m_us_real_rate","label":"Taux réel US 10Y",      "node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips": 0.6,"display_col":1,"description":"Taux réel US ↑ → USD ↑ vs JPY"},
    {"id":"m_risk_appetite","label":"Appétit risque",       "node_type":"input_manual","category":"sentiment","unit":"score","coefficient_to_pips":-1.2,"display_col":1,"description":"Risk-off → JPY safe haven → paire ↓"},
    {"id":"m_vix",        "label":"Niveau VIX",             "node_type":"input_manual","category":"sentiment","unit":"pts","coefficient_to_pips":-0.8,"display_col":1,"description":"VIX spike → JPY refuge → paire ↓"},
    {"id":"m_carry_momentum","label":"Momentum carry",      "node_type":"input_manual","category":"positioning","unit":"score","coefficient_to_pips": 0.4,"display_col":1,"description":"Carry actif → acheteurs USD/JPY"},
    {"id":"m_mof_risk",   "label":"Risque intervention MoF","node_type":"input_manual","category":"political","unit":"score","coefficient_to_pips":-0.8,"display_col":1,"description":"Probabilité intervention MoF. Hausse → paire ↓ préventif"},
    {"id":"m_cftc_jpy",   "label":"Net short JPY (CoT)",   "node_type":"input_manual","category":"positioning","unit":"k lots","coefficient_to_pips": 0.15,"display_col":1,"description":"Extreme short JPY → risque short squeeze → paire ↓"},
    {"id":"layer_rates",  "label":"▶ Différentiel Taux/Carry","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"in_cb + in_macro + m_yield_diff + m_fed_path + m_boj_stance + m_jgb_10y + m_us_real_rate + m_carry_momentum",
     "description":"Yield differential, Fed vs BoJ, carry trade momentum."},
    {"id":"layer_risk",   "label":"▶ Risque & Safe Haven JPY","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"in_geo + in_credit + in_sentiment + m_risk_appetite + m_vix",
     "description":"JPY comme refuge : risk-off, géopolitique, stress crédit."},
    {"id":"layer_policy", "label":"▶ Politique & Positionnement","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"in_trade + in_technical + in_growth + m_mof_risk + m_cftc_jpy",
     "description":"Intervention MoF, tarifs US-Japon, technique, CFTC."},
    {"id":"usdjpy","label":"USD/JPY — Impact Net","node_type":"output","category":"output","unit":"pips","display_col":3,
     "formula":"layer_rates + layer_risk + layer_policy","description":"Pression nette cumulée USD/JPY"},
  ]
},

# ══════════════════════════════════════════════════════════════════════════════
"XAUUSD": {
  "name": "XAU/USD (Or)", "output_node": "xauusd",
  "description": "Or/Dollar — taux réels, dollar, géopolitique, banques centrales",
  "price_intercept": 2800.0,
  "pip_to_price": 1.0,
  "yf_ticker": "GC=F",
  "nodes": [
    {"id":"in_cb",     "label":"Banques Centrales",     "node_type":"input_event","category":"central_bank", "unit":"pips","display_col":0,"event_category":"central_bank","description":"Fed (taux réels) → or."},
    {"id":"in_macro",  "label":"Surprises Macro",       "node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"event_category":"monetary_shock","description":"CPI, PCE → anticipations taux réels → or."},
    {"id":"in_geo",    "label":"Risque Géopolitique",   "node_type":"input_event","category":"geopolitical", "unit":"pips","display_col":0,"event_category":"geopolitical","description":"Conflits → refuge or maximal."},
    {"id":"in_credit", "label":"Stress Crédit/Systémique","node_type":"input_event","category":"credit_stress","unit":"pips","display_col":0,"event_category":"credit_stress","description":"Crises bancaires → or refuge absolu."},
    {"id":"in_growth", "label":"Choc Croissance",       "node_type":"input_event","category":"growth_shock", "unit":"pips","display_col":0,"event_category":"growth_shock","description":"Récession → easing → or ↑."},
    {"id":"in_trade",  "label":"Choc Commercial",       "node_type":"input_event","category":"trade_policy", "unit":"pips","display_col":0,"event_category":"trade_policy","description":"Incertitude → or refuge."},
    {"id":"in_commodity","label":"Choc Commodités",     "node_type":"input_event","category":"commodity",    "unit":"pips","display_col":0,"event_category":"commodity","description":"Inflation inputs (énergie)."},
    {"id":"in_sentiment","label":"Sentiment/Flux",      "node_type":"input_event","category":"sentiment",    "unit":"pips","display_col":0,"event_category":"sentiment","description":"Risk-off flows vers or."},
    {"id":"in_technical","label":"Momentum Technique",  "node_type":"input_event","category":"technical",   "unit":"pips","display_col":0,"event_category":"technical","description":"Cassures ATH, niveaux or."},
    {"id":"m_us_real_rate","label":"Taux réel US 10Y (TIPS)","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-2.5,"display_col":1,"description":"DRIVER PRINCIPAL. Chaque -10bps ≈ +25 pips or."},
    {"id":"m_dxy",       "label":"Indice Dollar (DXY)", "node_type":"input_manual","category":"monetary","unit":"pts","coefficient_to_pips":-3.0,"display_col":1,"description":"Or libellé USD → DXY ↑ = or ↓ mécaniquement."},
    {"id":"m_fed_path",  "label":"Anticipation Fed 12m","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-1.0,"display_col":1,"description":"Cuts attendus → taux réels ↓ → or ↑."},
    {"id":"m_breakeven", "label":"Breakeven inflation 10Y","node_type":"input_manual","category":"inflation","unit":"bps","coefficient_to_pips": 1.5,"display_col":1,"description":"Anticipations inflation → or comme couverture ↑."},
    {"id":"m_vix",       "label":"Niveau VIX",          "node_type":"input_manual","category":"sentiment","unit":"pts","coefficient_to_pips": 0.4,"display_col":1,"description":"VIX spike → or refuge."},
    {"id":"m_cb_buying", "label":"Achats CB (tonnes/mois)","node_type":"input_manual","category":"flows","unit":"t","coefficient_to_pips": 2.0,"display_col":1,"description":"Achats banques centrales. Driver structurel majeur depuis 2022."},
    {"id":"m_etf_flows", "label":"Flux ETF or (GLD, IAU)","node_type":"input_manual","category":"flows","unit":"tonnes","coefficient_to_pips": 1.5,"display_col":1,"description":"Entrées ETF → demande physique → or ↑."},
    {"id":"m_cftc_gold", "label":"Net long or (CoT)",   "node_type":"input_manual","category":"positioning","unit":"k lots","coefficient_to_pips": 0.15,"display_col":1,"description":"Extrême long → risque liquidation."},
    {"id":"m_fiscal_risk","label":"Risque fiscal US (score)","node_type":"input_manual","category":"macro","unit":"score","coefficient_to_pips": 0.5,"display_col":1,"description":"Déficit/dette US → doutes USD → or refuge."},
    {"id":"m_india_china","label":"Demande physique Inde/Chine","node_type":"input_manual","category":"flows","unit":"score","coefficient_to_pips": 0.4,"display_col":1,"description":"Joaillerie, investment. Saisonnalité."},
    {"id":"layer_rates","label":"▶ Taux Réels & Dollar","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"in_cb + in_macro + m_us_real_rate + m_dxy + m_fed_path + m_breakeven",
     "description":"Taux réels US = driver fondamental or. DXY = mécanisme de transmission."},
    {"id":"layer_demand","label":"▶ Demande & Flux",  "node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"m_cb_buying + m_etf_flows + m_cftc_gold + m_india_china + m_fiscal_risk",
     "description":"Acheteurs physiques et financiers : CB, ETF, CFTC, Asie."},
    {"id":"layer_refuge","label":"▶ Valeur Refuge",   "node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"in_geo + in_credit + in_growth + in_trade + in_sentiment + m_vix",
     "description":"Or comme couverture : géopolitique, stress systémique, récession."},
    {"id":"layer_technical","label":"▶ Technique & Momentum","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"in_technical + in_commodity",
     "description":"Signaux techniques et commodités (coûts extraction)."},
    {"id":"xauusd","label":"XAU/USD — Impact Net","node_type":"output","category":"output","unit":"pips","display_col":3,
     "formula":"layer_rates + layer_demand + layer_refuge + layer_technical","description":"Pression nette cumulée or/USD"},
  ]
},

# ══════════════════════════════════════════════════════════════════════════════
"SP500": {
  "name": "S&P 500", "output_node": "sp500",
  "description": "Indice actions US — taux, bénéfices, risque, liquidités",
  "price_intercept": 5000.0,
  "pip_to_price": 1.0,
  "yf_ticker": "^GSPC",
  "nodes": [
    {"id":"in_cb",      "label":"Banques Centrales",      "node_type":"input_event","category":"central_bank","unit":"pips","display_col":0,"event_category":"central_bank","description":"Fed pivot/hike → SP500 directement."},
    {"id":"in_macro",   "label":"Surprises Macro",        "node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"event_category":"monetary_shock","description":"NFP, CPI, PIB US."},
    {"id":"in_geo",     "label":"Risque Géopolitique",    "node_type":"input_event","category":"geopolitical","unit":"pips","display_col":0,"event_category":"geopolitical","description":"Risk-off → SP500 vendu."},
    {"id":"in_trade",   "label":"Choc Commercial",        "node_type":"input_event","category":"trade_policy","unit":"pips","display_col":0,"event_category":"trade_policy","description":"Tarifs → marges bénéficiaires SP500."},
    {"id":"in_growth",  "label":"Choc Croissance",        "node_type":"input_event","category":"growth_shock","unit":"pips","display_col":0,"event_category":"growth_shock","description":"Récession → SP500 ↓ massif."},
    {"id":"in_credit",  "label":"Stress Crédit",          "node_type":"input_event","category":"credit_stress","unit":"pips","display_col":0,"event_category":"credit_stress","description":"Banking stress → SP500 ↓ brutal."},
    {"id":"in_commodity","label":"Choc Commodités",       "node_type":"input_event","category":"commodity","unit":"pips","display_col":0,"event_category":"commodity","description":"Énergie → marges opérationnelles."},
    {"id":"in_sentiment","label":"Sentiment/Positionnement","node_type":"input_event","category":"sentiment","unit":"pips","display_col":0,"event_category":"sentiment","description":"CTA, hedge fund flows."},
    {"id":"in_technical","label":"Momentum Technique",    "node_type":"input_event","category":"technical","unit":"pips","display_col":0,"event_category":"technical","description":"MA200, cassures."},
    {"id":"m_fed_path", "label":"Anticipation Fed 12m",  "node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-0.6,"display_col":1,"description":"Cuts → taux discount ↓ → PE expansion → SP500 ↑"},
    {"id":"m_real_rate","label":"Taux réel US 10Y",       "node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-1.5,"display_col":1,"description":"Taux réel = taux d'actualisation. Hausse → PE compression → SP500 ↓"},
    {"id":"m_fin_cond", "label":"Conditions financières", "node_type":"input_manual","category":"monetary","unit":"score","coefficient_to_pips": 1.5,"display_col":1,"description":"Desserrement → SP500 ↑"},
    {"id":"m_eps_growth","label":"Croissance BPA attendue","node_type":"input_manual","category":"earnings","unit":"%","coefficient_to_pips": 8.0,"display_col":1,"description":"+1% BPA revision ≈ +8 pts SP500"},
    {"id":"m_eps_revision","label":"Ratio révisions BPA","node_type":"input_manual","category":"earnings","unit":"ratio","coefficient_to_pips": 2.0,"display_col":1,"description":"Ratio haussier/baissier. Fort driver momentum."},
    {"id":"m_pe",       "label":"Multiple PE forward",   "node_type":"input_manual","category":"valuation","unit":"x","coefficient_to_pips":12.0,"display_col":1,"description":"+1x PE ≈ +12 pts SP500"},
    {"id":"m_ig_spread","label":"Spread crédit IG (bps)","node_type":"input_manual","category":"credit","unit":"bps","coefficient_to_pips":-0.8,"display_col":1,"description":"Spread IG hausse → conditions crédit durcissent → SP500 ↓"},
    {"id":"m_buybacks", "label":"Volume rachats actions","node_type":"input_manual","category":"flows","unit":"Mds$/sem","coefficient_to_pips": 0.5,"display_col":1,"description":"Flux rachats = support technique majeur"},
    {"id":"m_vix",      "label":"Niveau VIX",            "node_type":"input_manual","category":"sentiment","unit":"pts","coefficient_to_pips":-0.8,"display_col":1,"description":"VIX spike → risk-off → SP500 ↓"},
    {"id":"m_geo_risk_prem","label":"Prime risque géopolitique","node_type":"input_manual","category":"political","unit":"score","coefficient_to_pips":-0.5,"display_col":1,"description":"Tensions géopolitiques → incertitude → SP500 ↓"},
    {"id":"m_gdp",      "label":"Croissance PIB US",     "node_type":"input_manual","category":"macro","unit":"%","coefficient_to_pips": 5.0,"display_col":1,"description":"Surprise haussière → SP500 ↑"},
    {"id":"layer_monetary","label":"▶ Politique Monétaire","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"in_cb + in_macro + m_fed_path + m_real_rate + m_fin_cond",
     "description":"Fed, taux réels, conditions financières — transmission directe sur PE."},
    {"id":"layer_earnings","label":"▶ Bénéfices & Valorisation","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"m_eps_growth + m_eps_revision + m_pe + m_gdp",
     "description":"BPA, révisions, multiple. Fondamental long terme."},
    {"id":"layer_risk_credit","label":"▶ Risque & Crédit","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"in_geo + in_credit + in_growth + in_trade + in_commodity + m_ig_spread + m_vix + m_geo_risk_prem",
     "description":"Risque systémique, géopolitique, crédit — driver de la prime de risque."},
    {"id":"layer_flows","label":"▶ Flux & Positionnement","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"in_sentiment + in_technical + m_buybacks",
     "description":"Flux institutionnels, retail, rachats, momentum."},
    {"id":"sp500","label":"S&P 500 — Impact Net","node_type":"output","category":"output","unit":"pips","display_col":3,
     "formula":"layer_monetary + layer_earnings + layer_risk_credit + layer_flows","description":"Pression nette cumulée S&P 500"},
  ]
},

# ══════════════════════════════════════════════════════════════════════════════
"TLT": {
  "name": "TLT (US Long Bonds)", "output_node": "tlt",
  "description": "ETF obligations US 20Y+ — duration, inflation, récession, supply",
  "price_intercept": 85.0,
  "pip_to_price": 0.01,
  "yf_ticker": "TLT",
  "nodes": [
    {"id":"in_cb",     "label":"Banques Centrales","node_type":"input_event","category":"central_bank","unit":"pips","display_col":0,"event_category":"central_bank","description":"FOMC décisions/minutes → impact direct TLT."},
    {"id":"in_macro",  "label":"Surprises Macro",  "node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"event_category":"monetary_shock","description":"CPI, PCE, NFP → réévaluation taux."},
    {"id":"in_geo",    "label":"Géopolitique",     "node_type":"input_event","category":"geopolitical","unit":"pips","display_col":0,"event_category":"geopolitical","description":"Crises → flight to safety Treasuries."},
    {"id":"in_growth", "label":"Choc Croissance",  "node_type":"input_event","category":"growth_shock","unit":"pips","display_col":0,"event_category":"growth_shock","description":"Récession → TLT ↑ massif."},
    {"id":"in_credit", "label":"Stress Crédit",    "node_type":"input_event","category":"credit_stress","unit":"pips","display_col":0,"event_category":"credit_stress","description":"Banking stress → Treasuries demandés."},
    {"id":"in_trade",  "label":"Choc Commercial",  "node_type":"input_event","category":"trade_policy","unit":"pips","display_col":0,"event_category":"trade_policy","description":"Tarifs → incertitude → Treasuries."},
    {"id":"in_technical","label":"Technique",      "node_type":"input_event","category":"technical","unit":"pips","display_col":0,"event_category":"technical","description":"Niveaux TLT, tendances."},
    {"id":"in_sentiment","label":"Sentiment/Flux", "node_type":"input_event","category":"sentiment","unit":"pips","display_col":0,"event_category":"sentiment","description":"Flux obligataires institutionnels."},
    {"id":"m_terminal_rate","label":"Taux terminal Fed","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-0.4,"display_col":1,"description":"Taux terminal ↑ → taux longs ↑ → TLT ↓ (duration ~18)"},
    {"id":"m_us_10y",  "label":"Rendement UST 10Y","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-0.3,"display_col":1,"description":"Chaque +1bps ≈ -$0.18 sur $100 TLT"},
    {"id":"m_us_30y",  "label":"Rendement UST 30Y","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-0.25,"display_col":1,"description":"TLT détient principalement des 20-30Y"},
    {"id":"m_breakeven","label":"Breakeven inflation 10Y","node_type":"input_manual","category":"inflation","unit":"bps","coefficient_to_pips":-0.2,"display_col":1,"description":"Inflation anticipée ↑ → taux nominaux ↑ → TLT ↓"},
    {"id":"m_recession_prob","label":"Probabilité récession 12m","node_type":"input_manual","category":"macro","unit":"%","coefficient_to_pips": 0.3,"display_col":1,"description":"Récession → flight to bonds → TLT ↑"},
    {"id":"m_qt_pace", "label":"Rythme QT Fed (Mds$/mois)","node_type":"input_manual","category":"monetary","unit":"Mds$","coefficient_to_pips":-0.1,"display_col":1,"description":"QT = pression vendeuse sur Treasuries → TLT ↓"},
    {"id":"m_deficit", "label":"Déficit fiscal US (%PIB)","node_type":"input_manual","category":"macro","unit":"%","coefficient_to_pips":-0.4,"display_col":1,"description":"Déficit → supply massive → pression vendeuse → TLT ↓"},
    {"id":"m_term_prem","label":"Prime de terme (ACM)","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-0.3,"display_col":1,"description":"Prime de terme ↑ = extra rendement exigé → TLT ↓"},
    {"id":"m_foreign_demand","label":"Demande étrangère Treasuries","node_type":"input_manual","category":"flows","unit":"Mds$","coefficient_to_pips": 0.05,"display_col":1,"description":"Achats CB étrangères (Chine, Japon) → TLT ↑"},
    {"id":"m_vix",     "label":"Niveau VIX",       "node_type":"input_manual","category":"sentiment","unit":"pts","coefficient_to_pips": 0.5,"display_col":1,"description":"VIX spike → flight to quality → TLT ↑"},
    {"id":"layer_rates","label":"▶ Taux & Politique Monétaire","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"in_cb + in_macro + m_terminal_rate + m_us_10y + m_us_30y + m_breakeven + m_qt_pace + m_term_prem",
     "description":"Taux directeurs, duration, QT, inflation anticipée."},
    {"id":"layer_supply","label":"▶ Supply & Demande Treasuries","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"m_deficit + m_foreign_demand",
     "description":"Émission nette, achats étrangers — équilibre offre/demande marché obligataire."},
    {"id":"layer_macro","label":"▶ Macro & Récession","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"in_growth + in_credit + in_trade + m_recession_prob",
     "description":"Risque récession → flight to safety → TLT ↑."},
    {"id":"layer_risk","label":"▶ Risque & Refuge","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"in_geo + in_sentiment + in_technical + m_vix",
     "description":"Géopolitique, sentiment, VIX → demande refuge Treasuries."},
    {"id":"tlt","label":"TLT — Impact Net","node_type":"output","category":"output","unit":"pips","display_col":3,
     "formula":"layer_rates + layer_supply + layer_macro + layer_risk","description":"Pression nette cumulée TLT"},
  ]
},

# ══════════════════════════════════════════════════════════════════════════════
"GBPUSD": {
  "name": "GBP/USD", "output_node": "gbpusd",
  "description": "Livre sterling/Dollar — BoE, données UK, risque politique",
  "price_intercept": 1.26,
  "pip_to_price": 0.0001,
  "yf_ticker": "GBPUSD=X",
  "nodes": [
    {"id":"in_cb",      "label":"Banques Centrales","node_type":"input_event","category":"central_bank","unit":"pips","display_col":0,"event_category":"central_bank","description":"BoE, Fed."},
    {"id":"in_macro",   "label":"Surprises Macro",  "node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"event_category":"monetary_shock","description":"CPI UK/US, NFP, GDP UK."},
    {"id":"in_geo",     "label":"Géopolitique",     "node_type":"input_event","category":"geopolitical","unit":"pips","display_col":0,"event_category":"geopolitical","description":"GBP comme devise cyclique, vendue en risk-off."},
    {"id":"in_trade",   "label":"Choc Commercial",  "node_type":"input_event","category":"trade_policy","unit":"pips","display_col":0,"event_category":"trade_policy","description":"Tarifs US → UK exposé."},
    {"id":"in_growth",  "label":"Choc Croissance",  "node_type":"input_event","category":"growth_shock","unit":"pips","display_col":0,"event_category":"growth_shock","description":"Récession UK."},
    {"id":"in_credit",  "label":"Stress Crédit",    "node_type":"input_event","category":"credit_stress","unit":"pips","display_col":0,"event_category":"credit_stress","description":"Stress Gilts, banking UK."},
    {"id":"in_technical","label":"Technique",       "node_type":"input_event","category":"technical","unit":"pips","display_col":0,"event_category":"technical","description":"Niveaux clés cable."},
    {"id":"in_sentiment","label":"Sentiment/Flux",  "node_type":"input_event","category":"sentiment","unit":"pips","display_col":0,"event_category":"sentiment","description":"Positionnement GBP."},
    {"id":"m_boe_path", "label":"Anticipation BoE 12m","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips": 0.7,"display_col":1,"description":"Hikes BoE → GBP ↑"},
    {"id":"m_fed_path", "label":"Anticipation Fed 12m","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-0.7,"display_col":1,"description":"Hikes Fed → USD ↑ → paire ↓"},
    {"id":"m_uk_cpi",   "label":"CPI UK YoY",       "node_type":"input_manual","category":"inflation","unit":"%","coefficient_to_pips": 0.7,"display_col":1,"description":"Persistance inflation → BoE hawkish → GBP ↑"},
    {"id":"m_uk_gdp",   "label":"Croissance PIB UK", "node_type":"input_manual","category":"macro","unit":"%","coefficient_to_pips": 3.0,"display_col":1,"description":"Surprise PIB UK → GBP ↑"},
    {"id":"m_uk_pmi",   "label":"PMI composite UK",  "node_type":"input_manual","category":"macro","unit":"pts","coefficient_to_pips": 0.25,"display_col":1,"description":">50 = expansion UK → GBP ↑"},
    {"id":"m_uk_pol_risk","label":"Risque politique UK","node_type":"input_manual","category":"political","unit":"score","coefficient_to_pips":-0.4,"display_col":1,"description":"Incertitude UK → GBP ↓"},
    {"id":"m_risk_appetite","label":"Appétit risque","node_type":"input_manual","category":"sentiment","unit":"score","coefficient_to_pips": 0.4,"display_col":1,"description":"Risk-on → GBP comme devise cyclique ↑"},
    {"id":"m_cftc_gbp", "label":"Positions nettes GBP","node_type":"input_manual","category":"positioning","unit":"k lots","coefficient_to_pips": 0.08,"display_col":1,"description":"Net long GBP CoT."},
    {"id":"layer_monetary","label":"▶ Différentiel Monétaire","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"in_cb + in_macro + m_boe_path + m_fed_path + m_uk_cpi",
     "description":"BoE vs Fed, inflation UK — driver principal GBP/USD."},
    {"id":"layer_uk_macro","label":"▶ Fondamentaux UK","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"in_growth + m_uk_gdp + m_uk_pmi + m_uk_pol_risk",
     "description":"Croissance, PMI, risque politique UK."},
    {"id":"layer_global","label":"▶ Risque & Flux Globaux","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"in_geo + in_credit + in_trade + in_technical + in_sentiment + m_risk_appetite + m_cftc_gbp",
     "description":"GBP exposé aux chocs globaux (devise cyclique)."},
    {"id":"gbpusd","label":"GBP/USD — Impact Net","node_type":"output","category":"output","unit":"pips","display_col":3,
     "formula":"layer_monetary + layer_uk_macro + layer_global","description":"Pression nette cumulée GBP/USD"},
  ]
},

# ══════════════════════════════════════════════════════════════════════════════
"EEM": {
  "name": "EEM (Marchés Émergents)", "output_node": "eem",
  "description": "ETF EM — dollar, Chine, commodités, risk appetite",
  "price_intercept": 42.0,
  "pip_to_price": 0.01,
  "yf_ticker": "EEM",
  "nodes": [
    {"id":"in_cb",      "label":"Banques Centrales","node_type":"input_event","category":"central_bank","unit":"pips","display_col":0,"event_category":"central_bank","description":"Fed pivot → EM bénéficient du dollar faible."},
    {"id":"in_macro",   "label":"Surprises Macro",  "node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"event_category":"monetary_shock","description":"Données Chine, US macro."},
    {"id":"in_geo",     "label":"Géopolitique",     "node_type":"input_event","category":"geopolitical","unit":"pips","display_col":0,"event_category":"geopolitical","description":"Tensions régionales EM."},
    {"id":"in_trade",   "label":"Choc Commercial",  "node_type":"input_event","category":"trade_policy","unit":"pips","display_col":0,"event_category":"trade_policy","description":"Tarifs US-Chine, sanctions."},
    {"id":"in_growth",  "label":"Choc Croissance",  "node_type":"input_event","category":"growth_shock","unit":"pips","display_col":0,"event_category":"growth_shock","description":"Récession US/Chine → EEM ↓."},
    {"id":"in_credit",  "label":"Stress Crédit EM", "node_type":"input_event","category":"credit_stress","unit":"pips","display_col":0,"event_category":"credit_stress","description":"Stress souverain EM, crise devises."},
    {"id":"in_commodity","label":"Choc Commodités", "node_type":"input_event","category":"commodity","unit":"pips","display_col":0,"event_category":"commodity","description":"Exportateurs EM profitent des commodités élevées."},
    {"id":"in_sentiment","label":"Sentiment/Flux",  "node_type":"input_event","category":"sentiment","unit":"pips","display_col":0,"event_category":"sentiment","description":"Flux ETF EM, risk-on/off."},
    {"id":"in_technical","label":"Technique",       "node_type":"input_event","category":"technical","unit":"pips","display_col":0,"event_category":"technical","description":"Niveaux EEM."},
    {"id":"m_dxy_inv",  "label":"Dollar (DXY) — impact inverse","node_type":"input_manual","category":"monetary","unit":"pts","coefficient_to_pips":-0.8,"display_col":1,"description":"USD fort → pression dettes EM → EEM ↓. DXY ↑ = EEM ↓"},
    {"id":"m_us_real_rate","label":"Taux réel US 10Y","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-0.6,"display_col":1,"description":"Taux réel US ↑ → capitaux retournent US → EEM ↓"},
    {"id":"m_fed_path", "label":"Anticipation Fed 12m","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-0.5,"display_col":1,"description":"Cuts Fed → dollar faible → EM avantageux → EEM ↑"},
    {"id":"m_china_pmi","label":"PMI manufacturier Chine","node_type":"input_manual","category":"macro","unit":"pts","coefficient_to_pips": 0.6,"display_col":1,"description":"Chine ~28% index. Expansion → EEM ↑"},
    {"id":"m_china_stimulus","label":"Stimulus Chine","node_type":"input_manual","category":"macro","unit":"score","coefficient_to_pips": 0.8,"display_col":1,"description":"PBOC/fiscal. Annonces majeures → EEM spike"},
    {"id":"m_commodity_index","label":"Indice commodités","node_type":"input_manual","category":"commodity","unit":"score","coefficient_to_pips": 0.4,"display_col":1,"description":"Exportateurs EM (Brésil, Afrique du Sud) profitent."},
    {"id":"m_em_spread","label":"Spread souverain EM (EMBI)","node_type":"input_manual","category":"credit","unit":"bps","coefficient_to_pips":-0.5,"display_col":1,"description":"Spread ↑ = stress EM → sorties → EEM ↓"},
    {"id":"m_risk_appetite","label":"Appétit risque","node_type":"input_manual","category":"sentiment","unit":"score","coefficient_to_pips": 0.6,"display_col":1,"description":"Risk-on → search for yield EM → EEM ↑"},
    {"id":"m_vix",      "label":"Niveau VIX",       "node_type":"input_manual","category":"sentiment","unit":"pts","coefficient_to_pips":-0.5,"display_col":1,"description":"VIX spike → sorties EM → EEM ↓"},
    {"id":"layer_dollar","label":"▶ Dollar & Taux US","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"in_cb + in_macro + m_dxy_inv + m_us_real_rate + m_fed_path",
     "description":"Dollar et taux réels = driver macro principal des EM."},
    {"id":"layer_china","label":"▶ Chine & Croissance EM","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"m_china_pmi + m_china_stimulus + in_growth + m_commodity_index + in_commodity",
     "description":"Moteur Chine + exportateurs commodités."},
    {"id":"layer_risk","label":"▶ Risque & Flux EM","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"in_geo + in_credit + in_trade + m_em_spread + m_risk_appetite + m_vix + in_sentiment + in_technical",
     "description":"Prime de risque EM, flux capitaux, sentiment global."},
    {"id":"eem","label":"EEM — Impact Net","node_type":"output","category":"output","unit":"pips","display_col":3,
     "formula":"layer_dollar + layer_china + layer_risk","description":"Pression nette cumulée EEM"},
  ]
},

# ══════════════════════════════════════════════════════════════════════════════
"QQQ": {
  "name": "QQQ (NASDAQ-100 Tech)", "output_node": "qqq",
  "description": "Tech US — taux réels, bénéfices big tech, IA, réglementation",
  "price_intercept": 480.0,
  "pip_to_price": 0.10,
  "yf_ticker": "QQQ",
  "nodes": [
    {"id":"in_cb",      "label":"Banques Centrales","node_type":"input_event","category":"central_bank","unit":"pips","display_col":0,"event_category":"central_bank","description":"Fed pivot → QQQ amplificateur (duration longue)."},
    {"id":"in_macro",   "label":"Surprises Macro",  "node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"event_category":"monetary_shock","description":"CPI, NFP → réévaluation Fed → QQQ."},
    {"id":"in_geo",     "label":"Géopolitique",     "node_type":"input_event","category":"geopolitical","unit":"pips","display_col":0,"event_category":"geopolitical","description":"Risk-off → vente tech en premier."},
    {"id":"in_trade",   "label":"Choc Commercial",  "node_type":"input_event","category":"trade_policy","unit":"pips","display_col":0,"event_category":"trade_policy","description":"Restrictions export chips US-Chine."},
    {"id":"in_growth",  "label":"Choc Croissance",  "node_type":"input_event","category":"growth_shock","unit":"pips","display_col":0,"event_category":"growth_shock","description":"Récession → dépenses IT coupées."},
    {"id":"in_credit",  "label":"Stress Crédit",    "node_type":"input_event","category":"credit_stress","unit":"pips","display_col":0,"event_category":"credit_stress","description":"Conditions financières → financement tech."},
    {"id":"in_commodity","label":"Choc Commodités", "node_type":"input_event","category":"commodity","unit":"pips","display_col":0,"event_category":"commodity","description":"Énergie → data centers coûts."},
    {"id":"in_sentiment","label":"Sentiment/Flux",  "node_type":"input_event","category":"sentiment","unit":"pips","display_col":0,"event_category":"sentiment","description":"CTA, hedge fund tech positions."},
    {"id":"in_technical","label":"Technique",       "node_type":"input_event","category":"technical","unit":"pips","display_col":0,"event_category":"technical","description":"QQQ niveaux, MA200, cassures."},
    {"id":"m_real_rate","label":"Taux réel US 10Y (duration)","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-1.5,"display_col":1,"description":"PRINCIPAL DRIVER : tech = duration longue. Taux réels ↑ → PE tech ↓ → QQQ ↓"},
    {"id":"m_fed_path", "label":"Anticipation Fed 12m","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-0.8,"display_col":1,"description":"Cuts → taux discount ↓ → PE tech expansion → QQQ ↑"},
    {"id":"m_m7_eps",   "label":"Révisions BPA Magnificent 7","node_type":"input_manual","category":"earnings","unit":"%","coefficient_to_pips":15.0,"display_col":1,"description":"+1% révision M7 ≈ +15 pts QQQ. AAPL, MSFT, NVDA, GOOGL, AMZN, META, TSLA"},
    {"id":"m_ai_capex", "label":"Cycle capex IA","node_type":"input_manual","category":"tech","unit":"score","coefficient_to_pips": 0.8,"display_col":1,"description":"Hyperscalers capex, NVDA GPU demand. Momentum = QQQ ↑"},
    {"id":"m_semi_cycle","label":"Cycle semi-conducteurs","node_type":"input_manual","category":"tech","unit":"score","coefficient_to_pips": 0.6,"display_col":1,"description":"Upcycle (book-to-bill, inventaires) = QQQ ↑"},
    {"id":"m_tech_pe",  "label":"Multiple PE tech (NTM)","node_type":"input_manual","category":"valuation","unit":"x","coefficient_to_pips":12.0,"display_col":1,"description":"PE forward NASDAQ-100. Expansion = QQQ ↑"},
    {"id":"m_reg_risk", "label":"Risque réglementaire tech","node_type":"input_manual","category":"political","unit":"score","coefficient_to_pips":-0.5,"display_col":1,"description":"Antitrust, EU AI Act, FTC. Hausse → QQQ ↓"},
    {"id":"m_vix",      "label":"Niveau VIX",       "node_type":"input_manual","category":"sentiment","unit":"pts","coefficient_to_pips":-0.8,"display_col":1,"description":"VIX spike → vente tech (beta élevé) → QQQ ↓ plus que SP500"},
    {"id":"m_retail_options","label":"Flux options retail","node_type":"input_manual","category":"flows","unit":"score","coefficient_to_pips": 0.4,"display_col":1,"description":"FOMO gamma squeeze. Momentum = QQQ ↑ explosif"},
    {"id":"m_cloud_growth","label":"Croissance cloud enterprise","node_type":"input_manual","category":"tech","unit":"%","coefficient_to_pips": 0.6,"display_col":1,"description":"AWS/Azure/GCP. Marges operating tech → QQQ"},
    {"id":"m_china_tech_risk","label":"Restrictions tech US-Chine","node_type":"input_manual","category":"political","unit":"score","coefficient_to_pips":-0.4,"display_col":1,"description":"Export bans, sanctions. Revenus tech ↓ → QQQ ↓"},
    {"id":"layer_rates","label":"▶ Taux & Valorisation","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"in_cb + in_macro + m_real_rate + m_fed_path + m_tech_pe",
     "description":"Taux réels = principal driver de la valorisation tech via taux d'actualisation."},
    {"id":"layer_tech_fundamental","label":"▶ Fondamentaux Tech","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"m_m7_eps + m_ai_capex + m_semi_cycle + m_cloud_growth",
     "description":"BPA Magnificent 7, cycle IA, semis, cloud. Foundation long terme."},
    {"id":"layer_risk","label":"▶ Risque & Réglementation","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"in_geo + in_credit + in_growth + in_trade + in_commodity + m_reg_risk + m_china_tech_risk + m_vix",
     "description":"Risques systémiques, réglementaires, géopolitiques tech."},
    {"id":"layer_flows","label":"▶ Flux & Momentum","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
     "formula":"in_sentiment + in_technical + m_retail_options",
     "description":"Flux spéculatifs, options retail, momentum technique."},
    {"id":"qqq","label":"QQQ — Impact Net","node_type":"output","category":"output","unit":"pips","display_col":3,
     "formula":"layer_rates + layer_tech_fundamental + layer_risk + layer_flows","description":"Pression nette cumulée QQQ"},
  ]
},

}  # end INSTRUMENT_MODELS


# ── Regime detection ───────────────────────────────────────────────────────────

def detect_regime(ev_by_cat: dict[str, float]) -> dict:
    """
    Détermine le régime de marché dominant depuis la pression event par catégorie.
    Retourne : {regime, label, scores, dominant_cat, weights}
    """
    # Score de chaque catégorie = |pips|
    cat_scores = {cat: abs(v) for cat, v in ev_by_cat.items() if v != 0}
    if not cat_scores:
        return {
            "regime": "BALANCED", "label": "Équilibré",
            "scores": {}, "dominant_cat": None,
            "weights": {},
        }

    # Cumul de score par régime candidat
    regime_scores: dict[str, float] = {}
    for cat, score in cat_scores.items():
        r = _CAT_TO_REGIME.get(cat, "BALANCED")
        regime_scores[r] = regime_scores.get(r, 0.0) + score

    dominant_regime = max(regime_scores, key=lambda r: regime_scores[r])

    # Si le score maximal ne dépasse pas 3 pips → BALANCED
    max_score = regime_scores.get(dominant_regime, 0.0)
    if max_score < 3.0 or dominant_regime == "BALANCED":
        dominant_regime = "BALANCED"

    dominant_cat = max(
        (c for c in cat_scores if _CAT_TO_REGIME.get(c) == dominant_regime),
        key=lambda c: cat_scores[c],
        default=None,
    ) if dominant_regime != "BALANCED" else None

    REGIME_LABELS = {
        "MONETARY_DOMINANCE": "Dominance Monétaire",
        "GEOPOLITICAL_RISK":  "Risque Géopolitique",
        "CREDIT_STRESS":      "Stress Crédit",
        "GROWTH_SCARE":       "Choc Croissance",
        "COMMODITY_SHOCK":    "Choc Commodités",
        "BALANCED":           "Équilibré",
    }

    weights = REGIME_WEIGHTS.get(dominant_regime, {})
    return {
        "regime":       dominant_regime,
        "label":        REGIME_LABELS.get(dominant_regime, dominant_regime),
        "scores":       {r: round(s, 1) for r, s in regime_scores.items()},
        "dominant_cat": dominant_cat,
        "weights":      weights,
    }


def _apply_regime_weights(formula: str, weights: dict[str, float]) -> str:
    """
    Injecte les multiplicateurs de régime dans une formule d'output.
    Ex: "layer_monetary + layer_risk" + {layer_monetary:1.4, layer_risk:1.5}
      → "1.40 * layer_monetary + 1.50 * layer_risk"
    Les termes sans poids restent à 1.0 (non modifiés).
    """
    if not weights:
        return formula
    terms = [t.strip() for t in formula.split('+')]
    out = []
    for term in terms:
        # Extraire l'id de couche (premier token alphanumérique_)
        layer_id = term.strip()
        w = weights.get(layer_id, 1.0)
        if abs(w - 1.0) < 0.01:
            out.append(layer_id)
        else:
            out.append(f"{w:.2f} * {layer_id}")
    return " + ".join(out)


# ── DB ─────────────────────────────────────────────────────────────────────────

def init_instrument_model_tables(conn):
    conn.executescript("""
        CREATE TABLE IF NOT EXISTS instrument_models (
            id         INTEGER PRIMARY KEY,
            instrument TEXT UNIQUE NOT NULL,
            graph_json TEXT NOT NULL,
            updated_at TEXT DEFAULT (datetime('now'))
        );
        CREATE TABLE IF NOT EXISTS instrument_node_overrides (
            id         INTEGER PRIMARY KEY,
            instrument TEXT NOT NULL,
            node_id    TEXT NOT NULL,
            value      REAL NOT NULL,
            note       TEXT,
            set_at     TEXT DEFAULT (datetime('now')),
            UNIQUE(instrument, node_id)
        );
        CREATE TABLE IF NOT EXISTS event_calibration_overrides (
            analysis_id  INTEGER PRIMARY KEY,
            calibration_json TEXT NOT NULL,
            updated_at   TEXT DEFAULT (datetime('now'))
        );
        CREATE TABLE IF NOT EXISTS node_forecast_scenarios (
            id              INTEGER PRIMARY KEY AUTOINCREMENT,
            instrument      TEXT    NOT NULL,
            node_id         TEXT    NOT NULL,
            label           TEXT    NOT NULL,
            horizon         TEXT    NOT NULL DEFAULT 'mt',
            target_date     TEXT    NOT NULL,
            target_value    REAL    NOT NULL,
            confidence      REAL    NOT NULL DEFAULT 0.7,
            trajectory      TEXT    NOT NULL DEFAULT 'linear',
            absorption_days INTEGER NOT NULL DEFAULT 30,
            notes           TEXT,
            created_at      TEXT    DEFAULT (datetime('now'))
        );
        CREATE INDEX IF NOT EXISTS idx_nfs_inst_node
            ON node_forecast_scenarios(instrument, node_id);
    """)
    conn.commit()


def seed_instrument_models(conn):
    """Seed/update models on startup. Safe to call repeatedly."""
    init_instrument_model_tables(conn)
    for inst, model in INSTRUMENT_MODELS.items():
        graph_json = json.dumps({
            "name":        model["name"],
            "description": model["description"],
            "output_node": model["output_node"],
            "nodes":       model["nodes"],
            "col_labels":  model.get("col_labels", []),
        })
        conn.execute("""
            INSERT INTO instrument_models (instrument, graph_json)
            VALUES (?,?)
            ON CONFLICT(instrument) DO UPDATE SET graph_json=excluded.graph_json, updated_at=datetime('now')
        """, (inst, graph_json))
    conn.commit()


# ── Event contribution by category (with lifecycle) ────────────────────────────

def _compute_event_by_category(conn, instrument: str, ref_date: date_type) -> dict[str, float]:
    """Sum active event contributions per template category, applying lifecycle."""
    inst_upper = instrument.upper()
    inst_lower = inst_upper.lower()
    extended_from = ref_date - timedelta(days=365)

    rows = conn.execute("""
        SELECT a.id as analysis_id,
               a.prediction_json,
               e.start_date, e.end_date, e.sub_type, e.name as title,
               t.category,
               COALESCE(o.calibration_json, t.calibration_json) as calibration_json
        FROM   causal_event_analyses a
        JOIN   market_events          e ON e.id = a.market_event_id
        JOIN   causal_graph_templates t ON t.id = a.template_id
        LEFT JOIN event_calibration_overrides o ON o.analysis_id = a.id
        WHERE  a.instrument = ?
          AND  e.start_date >= ?
          AND  e.start_date <= ?
    """, (inst_upper, str(extended_from), str(ref_date))).fetchall()

    by_cat: dict[str, float] = {}
    for row in rows:
        r = dict(row)
        try:
            predictions = json.loads(r["prediction_json"] or "{}")
            calib       = json.loads(r["calibration_json"] or "{}")
        except Exception:
            continue

        pips: Optional[float] = None
        if inst_lower in predictions:
            pips = float(predictions[inst_lower])
        else:
            for k, v in predictions.items():
                if inst_lower in k.lower():
                    try: pips = float(v); break
                    except (TypeError, ValueError): pass
        if pips is None or pips == 0:
            continue

        absorption = max(1, int(calib.get("absorption_days", 7)))
        dtype      = str(calib.get("decay_type", "exp"))
        rise       = max(0, int(calib.get("rise_days", 0)))
        plateau    = max(0, int(calib.get("plateau_days", 0)))

        # Guidance events: dynamic absorption until meeting
        ev_end = r.get("end_date")
        if ev_end and str(r.get("sub_type", "")).startswith("rate_guidance"):
            try:
                meeting  = date_type.fromisoformat(ev_end[:10])
                ev_start = date_type.fromisoformat(r["start_date"][:10])
                absorption = max(1, (meeting - ev_start).days)
                dtype = "linear"; rise = 0; plateau = 0
            except ValueError:
                pass

        try:
            ev_date = date_type.fromisoformat(r["start_date"][:10])
        except ValueError:
            continue

        days = (ref_date - ev_date).days
        df   = _lifecycle(days, rise, plateau, absorption, dtype)
        if df < 0.01:
            continue

        cat = r["category"]
        by_cat[cat] = round(by_cat.get(cat, 0.0) + pips * df, 2)

    return by_cat


# ── Graph evaluation ───────────────────────────────────────────────────────────

def _build_inputs(
    graph_def: dict, overrides: dict, ev_by_cat: dict,
    saturation: bool = True,
) -> dict:
    """
    Build inputs dict for evaluate_graph().
    Phase 2 : les nœuds input_manual utilisent _saturate_pips() (tanh)
    au lieu d'une conversion purement linéaire.
    """
    inputs: dict[str, float] = {}
    for node in graph_def["nodes"]:
        nid   = node["id"]
        ntype = node.get("node_type", "")
        ov    = overrides.get(nid)

        if ntype == "input_event":
            # Baseline (niveau structurel user) + surprise event (lifecycle) → additif
            base   = float(ov["value"]) if ov else 0.0
            events = ev_by_cat.get(node.get("event_category", ""), 0.0)
            inputs[nid] = base + events

        elif ntype == "input_manual":
            coeff  = float(node.get("coefficient_to_pips", 1.0))
            native = float(ov["value"]) if ov else 0.0
            if native == 0.0:
                inputs[nid] = 0.0
            elif saturation:
                inputs[nid] = _saturate_pips(native, coeff, node.get("unit", ""), node.get("saturation_scale"))
            else:
                inputs[nid] = coeff * native

    return inputs


def _graph_json_for_eval(graph_def: dict, regime_weights: Optional[dict] = None) -> dict:
    """
    Convertit le graph_def au format attendu par evaluate_graph().
    Phase 2 : si regime_weights est fourni, la formule du nœud output est
    réécrite avec les multiplicateurs de régime.
    """
    output_id = graph_def.get("output_node", "")
    nodes = []
    for n in graph_def["nodes"]:
        entry: dict = {"id": n["id"]}
        formula = n.get("formula")
        if formula:
            if n["id"] == output_id and regime_weights:
                formula = _apply_regime_weights(formula, regime_weights)
            entry["formula"] = formula
        nodes.append(entry)
    return {"nodes": nodes, "coefficients": {}}


# ── Public API ─────────────────────────────────────────────────────────────────

def get_active_event_details(conn, instrument: str, ref_date: date_type) -> dict:
    """
    Détail des events actifs par catégorie — utilisé pour enrichir les nœuds event du DAG.
    Retourne : {category: [{analysis_id, title, start_date, days_since,
                             pip_prediction, lifecycle_factor, remaining_pips, calibration}]}
    """
    inst_upper = instrument.upper()
    inst_lower = inst_upper.lower()
    extended_from = ref_date - timedelta(days=365)

    rows = conn.execute("""
        SELECT a.id as analysis_id,
               a.prediction_json,
               e.id as event_id, e.name as title, e.start_date, e.end_date, e.sub_type,
               t.category,
               COALESCE(o.calibration_json, t.calibration_json) as calibration_json
        FROM   causal_event_analyses a
        JOIN   market_events          e ON e.id = a.market_event_id
        JOIN   causal_graph_templates t ON t.id = a.template_id
        LEFT JOIN event_calibration_overrides o ON o.analysis_id = a.id
        WHERE  a.instrument = ?
          AND  e.start_date >= ?
          AND  e.start_date <= ?
        ORDER BY e.start_date DESC
    """, (inst_upper, str(extended_from), str(ref_date))).fetchall()

    by_cat: dict[str, list] = {}
    for row in rows:
        r = dict(row)
        try:
            preds = json.loads(r["prediction_json"] or "{}")
            calib = json.loads(r["calibration_json"] or "{}")
        except Exception:
            continue

        pips: Optional[float] = None
        if inst_lower in preds:
            pips = float(preds[inst_lower])
        else:
            for k, v in preds.items():
                if inst_lower in k.lower():
                    try: pips = float(v); break
                    except (TypeError, ValueError): pass
        if pips is None:
            continue

        absorption = max(1, int(calib.get("absorption_days", 7)))
        dtype      = str(calib.get("decay_type", "exp"))
        rise       = max(0, int(calib.get("rise_days", 0)))
        plateau    = max(0, int(calib.get("plateau_days", 0)))

        ev_end = r.get("end_date")
        if ev_end and str(r.get("sub_type", "")).startswith("rate_guidance"):
            try:
                meeting  = date_type.fromisoformat(ev_end[:10])
                ev_start = date_type.fromisoformat(r["start_date"][:10])
                absorption = max(1, (meeting - ev_start).days)
                dtype = "linear"; rise = 0; plateau = 0
            except ValueError:
                pass

        try:
            ev_date = date_type.fromisoformat(r["start_date"][:10])
        except ValueError:
            continue

        days = (ref_date - ev_date).days
        lf   = _lifecycle(days, rise, plateau, absorption, dtype)
        if lf < 0.01:
            continue

        cat = r["category"]
        by_cat.setdefault(cat, []).append({
            "analysis_id":      r["analysis_id"],
            "event_id":         r.get("event_id"),
            "title":            r.get("title", ""),
            "start_date":       r["start_date"][:10],
            "days_since":       days,
            "pip_prediction":   round(pips, 1),
            "lifecycle_factor": round(lf, 3),
            "remaining_pips":   round(pips * lf, 1),
            "calibration": {
                "absorption_days": absorption,
                "rise_days":       rise,
                "plateau_days":    plateau,
                "decay_type":      dtype,
            },
        })

    return by_cat


def get_model_state(conn, instrument: str, at_date: Optional[str] = None) -> Optional[dict]:
    """
    Full model state : DAG evaluation avec saturation (Phase 2) + poids de régime.
    Retourne aussi {regime: {regime, label, weights, scores}}.
    """
    inst_upper = instrument.upper()
    row = conn.execute(
        "SELECT graph_json FROM instrument_models WHERE instrument=?", (inst_upper,)
    ).fetchone()
    if not row:
        return None

    graph_def = json.loads(row["graph_json"])

    try:
        ref_date = date_type.fromisoformat(at_date) if at_date else datetime.utcnow().date()
    except ValueError:
        ref_date = datetime.utcnow().date()

    overrides = {r["node_id"]: dict(r) for r in conn.execute(
        "SELECT node_id, value, note, set_at FROM instrument_node_overrides WHERE instrument=?",
        (inst_upper,)
    ).fetchall()}

    # Machine structurelle pure — aucune injection automatique depuis market_events.
    # Les events du CausalLab ne touchent plus le graphe; seuls les overrides manuels
    # (Sync Marche + saisie) et les events virtuels (What-if) contribuent.
    ev_by_cat: dict[str, float] = {}

    regime_info    = detect_regime(ev_by_cat)   # toujours BALANCED hors What-if
    regime_weights = regime_info["weights"]

    from services.causal_graphs import evaluate_graph
    inputs   = _build_inputs(graph_def, overrides, ev_by_cat, saturation=True)
    gj       = _graph_json_for_eval(graph_def, regime_weights)
    all_vals = evaluate_graph(gj, inputs)

    output_id       = graph_def["output_node"]
    net_pips        = round(float(all_vals.get(output_id, 0.0)), 1)
    structural_pips = net_pips   # identique — pas d'events injectés
    event_pips      = round(net_pips - structural_pips, 1)

    meta            = INSTRUMENT_MODELS.get(inst_upper, {})
    price_intercept = meta.get("price_intercept", 0.0)
    pip_to_price    = meta.get("pip_to_price", 0.0001)
    yf_ticker       = meta.get("yf_ticker", "")
    fundamental_level = round(price_intercept + structural_pips * pip_to_price, 6)
    synthetic_price   = round(price_intercept + net_pips * pip_to_price, 6)

    nodes_out = []
    for node in graph_def["nodes"]:
        nid   = node["id"]
        ntype = node.get("node_type", "")
        val   = round(float(all_vals.get(nid, 0.0)), 1)
        ov    = overrides.get(nid)
        st    = dict(node)
        st["computed_value"]   = val
        st["pip_contribution"] = val

        if ntype == "input_event":
            cat            = node.get("event_category", "")
            event_surprise = round(ev_by_cat.get(cat, 0.0), 1)
            baseline       = float(ov["value"]) if ov else 0.0
            st["raw_value"]      = baseline
            st["baseline_value"] = baseline
            st["event_surprise"] = event_surprise
            st["source"]         = "manual" if ov else ("events" if event_surprise != 0.0 else "neutral")
            if ov:
                st["override_note"]   = ov.get("note", "")
                st["override_set_at"] = ov.get("set_at", "")

        elif ntype == "input_manual":
            coeff  = float(node.get("coefficient_to_pips", 1.0))
            native = float(ov["value"]) if ov else 0.0
            # Retourne la valeur native sans saturation (pour affichage)
            st["source"]    = "manual" if ov else "neutral"
            st["raw_value"] = native
            # pip_contribution inclut la saturation (= all_vals[nid])
            # On expose aussi la valeur linéaire pour comparaison
            st["pip_linear"]    = round(coeff * native, 1)
            st["pip_saturated"] = val
            st["saturation_pct"] = (
                round((1.0 - val / (coeff * native)) * 100, 1)
                if native != 0 and coeff != 0
                else 0.0
            )
            if ov:
                st["override_note"]   = ov.get("note", "")
                st["override_set_at"] = ov.get("set_at", "")

        elif ntype in ("intermediate", "output"):
            st["source"] = "computed"
            # Pour les intermédiaires, expose le multiplicateur de régime
            if ntype == "intermediate":
                st["regime_weight"] = regime_weights.get(nid, 1.0)

        nodes_out.append(st)

    direction = "bullish" if net_pips > 5 else "bearish" if net_pips < -5 else "neutral"

    event_details = get_active_event_details(conn, inst_upper, ref_date)

    return {
        "instrument":       inst_upper,
        "name":             graph_def["name"],
        "description":      graph_def.get("description", ""),
        "at_date":          str(ref_date),
        "net_pips":         net_pips,
        "structural_pips":  structural_pips,
        "event_pips":       event_pips,
        "price_intercept":  price_intercept,
        "pip_to_price":     pip_to_price,
        "yf_ticker":        yf_ticker,
        "fundamental_level": fundamental_level,
        "synthetic_price":  synthetic_price,
        "direction":        direction,
        "nodes":            nodes_out,
        "output_node":      output_id,
        "regime":           regime_info,
        "event_details":    event_details,
        "col_labels":       graph_def.get("col_labels", []),
    }


def simulate_timeline(
    conn, instrument: str, period: str = "1y",
    virtual_events: Optional[list] = None,
    start_date: Optional[str] = None,
) -> list[dict]:
    """
    Simulate all node values day by day over the period.
    Returns [{date, nodes: {id: value}, net_pips}].
    Uses lifecycle (rise/plateau/decay) for event contributions.
    Manual overrides are static (applied uniformly across the period).
    start_date overrides the period-based date_from when provided.
    """
    inst_upper = instrument.upper()
    row = conn.execute(
        "SELECT graph_json FROM instrument_models WHERE instrument=?", (inst_upper,)
    ).fetchone()
    if not row:
        return []

    graph_def = json.loads(row["graph_json"])
    output_id = graph_def["output_node"]

    period_days = {"5d":7,"1mo":35,"3mo":95,"6mo":190,"1y":370,"2y":740}
    lookback  = period_days.get(period, 370)
    today     = datetime.utcnow().date()
    if start_date:
        try:
            date_from = date_type.fromisoformat(start_date[:10])
        except ValueError:
            date_from = today - timedelta(days=lookback)
    else:
        date_from = today - timedelta(days=lookback)

    # Load overrides (static)
    overrides = {r["node_id"]: dict(r) for r in conn.execute(
        "SELECT node_id, value, note, set_at FROM instrument_node_overrides WHERE instrument=?",
        (inst_upper,)
    ).fetchall()}

    # Machine structurelle — seuls les events virtuels (What-if) entrent dans le calcul.
    # La timeline de base ne dépend plus de causal_event_analyses.
    events: list[dict] = []

    for ve in (virtual_events or []):
        try:
            ev_date = date_type.fromisoformat(str(ve["date"])[:10])
            events.append({
                "ev_date":    ev_date,
                "category":   ve.get("category", "unclassified"),
                "pips":       float(ve.get("pips", 0.0)),
                "rise":       int(ve.get("rise_days", 1)),
                "plateau":    int(ve.get("plateau_days", 0)),
                "absorption": int(ve.get("absorption_days", 14)),
                "dtype":      ve.get("decay_type", "exp"),
                "virtual":    True,
                "label":      ve.get("label", "Event virtuel"),
            })
        except (KeyError, ValueError):
            continue

    meta            = INSTRUMENT_MODELS.get(inst_upper, {})
    price_intercept = meta.get("price_intercept", 0.0)
    pip_to_price    = meta.get("pip_to_price", 0.0001)

    from services.causal_graphs import evaluate_graph

    # ── Timelines combinées (ff_calendar actuals + scénarios CT/MT/LT) ────────
    # Pour chaque nœud input_manual : fusion de la baseline ff_calendar et des
    # scénarios utilisateur (trajectoire blended par confidence vers chaque waypoint)
    macro_node_timelines: dict[str, dict[str, float]] = {}
    for node in graph_def.get("nodes", []):
        if node.get("node_type") != "input_manual":
            continue
        mk  = node.get("macro_key")
        nid = node["id"]
        tl  = build_node_combined_timeline(conn, inst_upper, nid, mk, date_from, today)
        if tl:
            macro_node_timelines[nid] = tl

    has_macro = bool(macro_node_timelines)

    # ── Structural baseline (static si pas de macro nodes) ─────────────────────
    gj_struct     = _graph_json_for_eval(graph_def, {})
    inputs_struct = _build_inputs(graph_def, overrides, {}, saturation=True)
    vals_struct   = evaluate_graph(gj_struct, inputs_struct)
    static_structural = round(float(vals_struct.get(output_id, 0.0)), 1)

    # Unités par nœud input_manual — pour corriger les valeurs brutes de ff_calendar
    # (NFP: raw=228000 → attendu=228 K; PMI: raw=54.7 → attendu=4.7 pts écart/50)
    macro_node_units: dict[str, str] = {
        n["id"]: n.get("unit", "")
        for n in graph_def.get("nodes", [])
        if n.get("node_type") == "input_manual"
    }

    def _unit_correct(v: float, unit: str) -> float:
        """Convertit les valeurs brutes ff_calendar en valeur attendue par le nœud."""
        if unit == "K" and abs(v) >= 1_000:
            return round(v / 1_000, 1)
        if unit == "pts" and v > 10:
            return round(v - 50.0, 1)
        return v

    # ── Auto-anchor : offset pour que la synthétique parte du prix réel à date_from ──
    # Calculé une seule fois sur le prix réel à date_from (ou le plus proche disponible).
    # Cet offset compense l'écart de calibration sans changer la dynamique du modèle.
    start_offset = 0.0
    try:
        anchor_row = conn.execute(
            """SELECT close FROM price_history_cache
               WHERE instrument=? AND date>=? ORDER BY date ASC LIMIT 1""",
            (inst_upper, str(date_from))
        ).fetchone()
        if anchor_row:
            actual_start = float(anchor_row["close"])
            if has_macro:
                # Structural avec valeurs macro au moment de l'ancrage
                anchor_overrides = dict(overrides)
                for node_id, tl in macro_node_timelines.items():
                    v = tl.get(str(date_from))
                    if v is None and tl:
                        # Valeur la plus proche avant date_from
                        v = next((tl[d] for d in sorted(tl) if d <= str(date_from)), next(iter(tl.values()), None))
                    if v is not None:
                        v = _unit_correct(v, macro_node_units.get(node_id, ""))
                        anchor_overrides[node_id] = {"value": v, "note": "anchor", "set_at": ""}
                gj_a = _graph_json_for_eval(graph_def, {})
                in_a = _build_inputs(graph_def, anchor_overrides, {}, saturation=True)
                vs_a = evaluate_graph(gj_a, in_a)
                struct_t0 = float(vs_a.get(output_id, 0.0))
            else:
                struct_t0 = static_structural
            model_start  = price_intercept + struct_t0 * pip_to_price
            start_offset = round(actual_start - model_start, 6)
    except Exception:
        start_offset = 0.0

    # Dernier override macro connu (gap-fill pour weekends/jours sans données)
    macro_last: dict[str, float] = {}
    for node_id, tl in macro_node_timelines.items():
        v0 = tl.get(str(date_from))
        if v0 is not None:
            macro_last[node_id] = _unit_correct(v0, macro_node_units.get(node_id, ""))

    # Catégories d'events gérées par les nœuds input_event du graphe
    # Les catégories sans nœud correspondant sont appliquées comme choc direct sur l'output.
    graph_event_cats: set[str] = {
        n.get("event_category", "")
        for n in graph_def.get("nodes", [])
        if n.get("node_type") == "input_event"
    } - {""}

    # Baseline linéaire pour la sensibilité temporelle des nœuds macro
    # Utilisé uniquement pour calculer le DELTA par rapport au point d'ancrage.
    # Le linéaire amplifie la sensibilité aux changements de taux/CPI/NFP qui
    # sinon disparaissent dans la zone de saturation tanh.
    base_linear_pips = float(evaluate_graph(
        _graph_json_for_eval(graph_def, {}),
        _build_inputs(graph_def, overrides, {}, saturation=False)
    ).get(output_id, 0.0)) if has_macro else 0.0

    timeline = []
    cur = date_from
    while cur <= today:
        cur_str = str(cur)

        # ── Accumule les events virtuels (What-if) actifs ce jour ─────────────
        ev_by_cat: dict[str, float] = {}
        active_events_detail: list[dict] = []
        for ev in events:
            if ev["ev_date"] > cur or ev["ev_date"] < date_from:
                continue
            days = (cur - ev["ev_date"]).days
            df   = _lifecycle(days, ev["rise"], ev["plateau"], ev["absorption"], ev["dtype"])
            if df < 0.01:
                continue
            cat          = ev["category"]
            contribution = round(ev["pips"] * df, 2)
            ev_by_cat[cat] = ev_by_cat.get(cat, 0.0) + contribution
            active_events_detail.append({
                "label":            ev["label"],
                "pips":             ev["pips"],
                "lifecycle_factor": round(df, 3),
                "contribution":     contribution,
                "date":             str(ev["ev_date"]),
                "category":         cat,
            })

        # Split ev_by_cat : catégories avec nœud dédié vs choc direct
        routed_ev    = {cat: v for cat, v in ev_by_cat.items() if cat in graph_event_cats}
        direct_shock = sum(v for cat, v in ev_by_cat.items() if cat not in graph_event_cats)

        # ── Overrides pour ce jour (statiques + time-varying macro) ───────────
        if has_macro:
            cur_overrides = dict(overrides)
            for node_id, tl in macro_node_timelines.items():
                v = tl.get(cur_str)
                if v is not None:
                    v = _unit_correct(v, macro_node_units.get(node_id, ""))
                    macro_last[node_id] = v
                else:
                    v = macro_last.get(node_id)   # gap-fill (weekend)
                if v is not None:
                    cur_overrides[node_id] = {"value": v, "note": "macro_guidance", "set_at": ""}

            # Structural pips : baseline saturé + delta linéaire (sensibilité amplifiée)
            gj_s  = _graph_json_for_eval(graph_def, {})
            in_s  = _build_inputs(graph_def, cur_overrides, {}, saturation=True)
            vs_s  = evaluate_graph(gj_s, in_s)
            sat_pips_t = float(vs_s.get(output_id, 0.0))

            # Delta linéaire = (linéaire_courant − linéaire_base) → sensibilité temporelle
            in_s_lin   = _build_inputs(graph_def, cur_overrides, {}, saturation=False)
            vs_s_lin   = evaluate_graph(gj_s, in_s_lin)
            lin_pips_t = float(vs_s_lin.get(output_id, 0.0))
            delta_lin  = lin_pips_t - base_linear_pips

            structural_pips_t = round(sat_pips_t + delta_lin, 1)

            if ev_by_cat:
                ri     = detect_regime(ev_by_cat)
                gj_    = _graph_json_for_eval(graph_def, ri["weights"])
                in_    = _build_inputs(graph_def, cur_overrides, routed_ev, saturation=True)
                vals   = evaluate_graph(gj_, in_)
                net    = round(float(vals.get(output_id, 0.0)) + delta_lin + direct_shock, 1)
                regime_label = ri["regime"]
            else:
                vals         = vs_s
                net          = structural_pips_t
                regime_label = "BALANCED"

        else:
            structural_pips_t = static_structural
            if ev_by_cat:
                ri     = detect_regime(ev_by_cat)
                gj     = _graph_json_for_eval(graph_def, ri["weights"])
                inputs = _build_inputs(graph_def, overrides, routed_ev, saturation=True)
                vals   = evaluate_graph(gj, inputs)
                net    = round(float(vals.get(output_id, 0.0)) + direct_shock, 1)
                regime_label = ri["regime"]
            else:
                vals         = vals_struct
                net          = static_structural
                regime_label = "BALANCED"

        event_pips       = round(net - structural_pips_t, 1)
        fundamental_level = round(price_intercept + structural_pips_t * pip_to_price + start_offset, 6)
        synthetic_price   = round(fundamental_level + event_pips * pip_to_price, 6)

        timeline.append({
            "date":               cur_str,
            "net_pips":           net,
            "structural_pips":    structural_pips_t,
            "event_pips":         event_pips,
            "fundamental_level":  fundamental_level,
            "synthetic_price":    synthetic_price,
            "regime":             regime_label,
            "nodes":              {k: round(float(v), 1) for k, v in vals.items()},
            "active_events":      active_events_detail,
        })
        cur += timedelta(days=1)

    return timeline


def set_node_override(conn, instrument: str, node_id: str, value: float, note: str = "") -> bool:
    conn.execute("""
        INSERT INTO instrument_node_overrides (instrument, node_id, value, note, set_at)
        VALUES (?,?,?,?,datetime('now'))
        ON CONFLICT(instrument, node_id) DO UPDATE SET value=excluded.value, note=excluded.note, set_at=datetime('now')
    """, (instrument.upper(), node_id, value, note))
    conn.commit()
    return True


def clear_node_override(conn, instrument: str, node_id: str) -> bool:
    conn.execute(
        "DELETE FROM instrument_node_overrides WHERE instrument=? AND node_id=?",
        (instrument.upper(), node_id)
    )
    conn.commit()
    return True
