Files
OpenFin/backend/services/instrument_models.py
2026-07-03 21:48:55 +02:00

1819 lines
110 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 (BCEFed)",
"node_type":"intermediate","category":"monetary","unit":"pips","display_col":1,
"formula":"ecb_rate + fed_rate",
"description":"Différentiel de taux directeurs brut BCEFed (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 BCEFed 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)
# ── 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:
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] = v0
# 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:
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