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

1419 lines
95 KiB
Python

"""
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
# ── 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 différentiels : sature autour ±5%
"pts%": 3.0,
"pts": 15.0, # PMI écart depuis 50 : sature autour ±20pts
"score": 3.0, # scores subjectifs -5 à +5
"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": "Taux de change Euro/Dollar — modèle causal 3 couches avec 4 domaines d'influence",
"output_node": "eurusd",
"price_intercept": 1.10,
"pip_to_price": 0.0001,
"yf_ticker": "EURUSD=X",
"nodes": [
# ── Layer 0a : event inputs ───────────────────────────────────────────────
{"id":"in_cb", "label":"Banques Centrales", "node_type":"input_event", "category":"central_bank", "unit":"pips","display_col":0,"description":"Décisions Fed/BCE, minutes, forward guidance. Décroissance exp ~14j.","event_category":"central_bank"},
{"id":"in_macro", "label":"Surprises Macro (données)","node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"description":"CPI, NFP, PIB, PMI US/EU. Décroissance rapide ~12j.","event_category":"monetary_shock"},
{"id":"in_geo", "label":"Risque Géopolitique", "node_type":"input_event", "category":"geopolitical", "unit":"pips","display_col":0,"description":"Conflits, sanctions, tensions. Décroissance linéaire ~21j.","event_category":"geopolitical"},
{"id":"in_trade", "label":"Choc Commercial/Tarifs", "node_type":"input_event", "category":"trade_policy", "unit":"pips","display_col":0,"description":"Tarifs US-UE, accords commerciaux.","event_category":"trade_policy"},
{"id":"in_growth", "label":"Choc Croissance", "node_type":"input_event", "category":"growth_shock", "unit":"pips","display_col":0,"description":"Chocs perspectives croissance.","event_category":"growth_shock"},
{"id":"in_credit", "label":"Stress Crédit/Liquidité", "node_type":"input_event", "category":"credit_stress", "unit":"pips","display_col":0,"description":"Banking stress, spreads IG/HY.","event_category":"credit_stress"},
{"id":"in_technical", "label":"Momentum Technique", "node_type":"input_event", "category":"technical", "unit":"pips","display_col":0,"description":"Cassures niveaux clés, tendances.","event_category":"technical"},
{"id":"in_commodity", "label":"Choc Commodités", "node_type":"input_event", "category":"commodity", "unit":"pips","display_col":0,"description":"Pétrole, gaz, métaux → inflation EU.","event_category":"commodity"},
{"id":"in_sentiment", "label":"Sentiment/Positionnement","node_type":"input_event", "category":"sentiment", "unit":"pips","display_col":0,"description":"Flux institutionnels, risk-on/off.","event_category":"sentiment"},
# ── Layer 0b : manual structural inputs ──────────────────────────────────
{"id":"m_rate_diff_2y", "label":"Spread OIS 2Y USD-EUR", "node_type":"input_manual","category":"monetary", "unit":"bps", "coefficient_to_pips": 1.5, "display_col":1,"description":"Principal driver court terme. Positif = USD plus rémunérateur → pair ↓"},
{"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 attendus → USD ↓ → pair ↑. Hikes → USD ↑ → pair ↓"},
{"id":"m_ecb_path", "label":"Anticipation BCE 12m", "node_type":"input_manual","category":"monetary", "unit":"bps", "coefficient_to_pips":-0.8, "display_col":1,"description":"Cuts BCE → EUR ↓. Hikes → EUR ↑"},
{"id":"m_us_real_rate", "label":"Taux réel US 10Y (TIPS)", "node_type":"input_manual","category":"monetary", "unit":"bps", "coefficient_to_pips":-0.5, "display_col":1,"description":"Hausse → USD attractif → pair ↓"},
{"id":"m_eu_real_rate", "label":"Taux réel EU 10Y", "node_type":"input_manual","category":"monetary", "unit":"bps", "coefficient_to_pips": 0.5, "display_col":1,"description":"Hausse → EUR attractif → pair ↑"},
{"id":"m_carry", "label":"Score carry EUR/USD", "node_type":"input_manual","category":"monetary", "unit":"score", "coefficient_to_pips": 0.6, "display_col":1,"description":"Score attractivité carry (+= EUR avantageux)"},
{"id":"m_us_growth_adv", "label":"Avantage croissance US vs EU","node_type":"input_manual","category":"macro", "unit":"pts%", "coefficient_to_pips":-15.0,"display_col":1,"description":"Différentiel PIB US-EU. US surperform → USD ↑ → pair ↓"},
{"id":"m_eu_pmi", "label":"PMI composite Eurozone", "node_type":"input_manual","category":"macro", "unit":"pts", "coefficient_to_pips": 0.3, "display_col":1,"description":"Expansion EU → EUR ↑"},
{"id":"m_energy_price", "label":"Prix énergie ($/bbl delta)", "node_type":"input_manual","category":"macro", "unit":"$/bbl", "coefficient_to_pips":-0.25,"display_col":1,"description":"Énergie chère → déficit commercial EU → EUR ↓"},
{"id":"m_us_cpi", "label":"CPI US YoY", "node_type":"input_manual","category":"inflation", "unit":"%", "coefficient_to_pips":-0.8, "display_col":1,"description":"Inflation US → Fed hawkish → USD ↑ → pair ↓"},
{"id":"m_eu_cpi", "label":"HICP Eurozone YoY", "node_type":"input_manual","category":"inflation", "unit":"%", "coefficient_to_pips": 0.8, "display_col":1,"description":"Inflation EU → BCE hawkish → EUR ↑"},
{"id":"m_risk_appetite", "label":"Appétit risque mondial", "node_type":"input_manual","category":"sentiment", "unit":"score", "coefficient_to_pips": 0.5, "display_col":1,"description":"Risk-on → sorties USD → pair ↑"},
{"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 → flight to USD → pair ↓"},
{"id":"m_eu_fragmentation","label":"Risque fragmentation EU", "node_type":"input_manual","category":"political", "unit":"score", "coefficient_to_pips":-0.5, "display_col":1,"description":"Risque politique EU → EUR ↓"},
{"id":"m_us_political", "label":"Incertitude politique US", "node_type":"input_manual","category":"political", "unit":"score", "coefficient_to_pips": 0.3, "display_col":1,"description":"Incertitude US → USD ↓ → pair ↑"},
{"id":"m_cftc_eur", "label":"Positions nettes EUR (CoT)", "node_type":"input_manual","category":"positioning","unit":"k lots","coefficient_to_pips": 0.1, "display_col":1,"description":"Net long EUR CFTC. Extrême → risque retournement"},
{"id":"m_dollar_reserve", "label":"Demande réserves USD", "node_type":"input_manual","category":"flows", "unit":"score", "coefficient_to_pips":-0.4, "display_col":1,"description":"Demande réserves → USD structurellement fort → pair ↓"},
# ── Layer 1 : intermediate (4 domaines) ──────────────────────────────────
{"id":"layer_monetary", "label":"▶ Pression Monétaire & Taux", "node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
"formula":"in_cb + in_macro + m_rate_diff_2y + m_fed_path + m_ecb_path + m_us_real_rate + m_eu_real_rate + m_carry",
"description":"Domaine monétaire : taux directeurs, OIS, anticipations Fed/BCE, carry. Driver n°1 de l'EURUSD."},
{"id":"layer_growth", "label":"▶ Pression Macro & Croissance", "node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
"formula":"in_growth + in_commodity + m_us_growth_adv + m_eu_pmi + m_energy_price + m_us_cpi + m_eu_cpi",
"description":"Domaine macro : croissance relative, inflation, énergie. Impact via anticipations BC."},
{"id":"layer_risk", "label":"▶ Pression Risque & Géopolitique", "node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
"formula":"in_geo + in_credit + in_sentiment + m_risk_appetite + m_vix + m_eu_fragmentation",
"description":"Domaine risque : géopolitique, stress crédit, aversion au risque (USD safe haven)."},
{"id":"layer_positioning","label":"▶ Pression Positionnement & Flux", "node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
"formula":"in_trade + in_technical + m_cftc_eur + m_dollar_reserve + m_us_political",
"description":"Domaine positionnement : flux spéculatifs, technicalités, réserves, politique."},
# ── Layer 2 : output ──────────────────────────────────────────────────────
{"id":"eurusd","label":"EUR/USD — Impact Net","node_type":"output","category":"output","unit":"pips","display_col":3,
"formula":"layer_monetary + layer_growth + layer_risk + layer_positioning",
"description":"Pression nette cumulée = Σ(4 domaines). Positif = biais haussier EUR/USD."},
]
},
# ══════════════════════════════════════════════════════════════════════════════
"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'))
);
""")
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"],
})
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,
}
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
# ── Macro-guidance : noeuds input_manual avec macro_key → overrides time-varying ──
macro_node_timelines: dict[str, dict[str, float]] = {}
for node in graph_def.get("nodes", []):
mk = node.get("macro_key")
if mk and node.get("node_type") == "input_manual":
tl = build_macro_node_timeline(conn, mk, date_from, today)
if tl:
macro_node_timelines[node["id"]] = 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
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,
})
# ── 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 time-varying (sans events, avec macro overrides du jour)
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)
structural_pips_t = round(float(vs_s.get(output_id, 0.0)), 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, ev_by_cat, saturation=True)
vals = evaluate_graph(gj_, in_)
net = round(float(vals.get(output_id, 0.0)), 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, ev_by_cat, saturation=True)
vals = evaluate_graph(gj, inputs)
net = round(float(vals.get(output_id, 0.0)), 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