Files
OpenFin/backend/services/causal_graphs.py

730 lines
40 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.
"""
Bibliothèque de graphes causaux — templates pré-peuplés et helpers.
Chaque template définit une chaîne causale entre un type d'événement de marché
et un ou plusieurs instruments (EURUSD, XAUUSD, SP500, BRENT...).
Structure graph_json :
nodes[] — nœuds avec position (x,y) pour le rendu SVG
edges[] — arêtes directionnelles entre nœuds
coefficients — valeurs heuristiques + calibrées
instruments[] — instruments cibles du template
input_mapping — comment extraire les inputs depuis un market_event
"""
import ast
import json
import logging
import operator
from typing import Optional
logger = logging.getLogger(__name__)
# ── Helpers de construction ────────────────────────────────────────────────────
def _n(id_, label, type_, x, y, formula=None, unit="", instrument=None, description=""):
n = {"id": id_, "label": label, "type": type_, "x": x, "y": y}
if formula: n["formula"] = formula
if unit: n["unit"] = unit
if instrument: n["instrument"] = instrument
if description: n["description"] = description
return n
def _e(from_, to_, style="solid", type_="causal", strength=2, sign="neutral", label=""):
"""
strength : 1=fin, 2=normal, 3=épais
sign : "positive" (teal), "negative" (orange), "neutral" (gris)
"""
e = {"from": from_, "to": to_, "style": style, "type": type_,
"strength": strength, "sign": sign}
if label: e["label"] = label
return e
def _c(value, description=""):
return {"value": value, "calibrated": None, "description": description}
# ── Templates pré-peuplés ─────────────────────────────────────────────────────
BUILT_IN_TEMPLATES = [
# ── 1. CPI US surprise ──────────────────────────────────────────────────────
{
"name": "CPI US — surprise inflationniste",
"category": "macro_us",
"sub_type": "CPI",
"instruments": ["EURUSD", "XAUUSD"],
"description": "Surprise inflationniste US → anticipations FED → taux longs → EUR/USD & Or",
"ai_rationale": (
"Un CPI US au-dessus du consensus renforce les anticipations de hausse FED, "
"remonte le 10Y américain, élargit le spread US-EU et apprécie le dollar. "
"L'or est pénalisé par la hausse des taux réels attendus."
),
"graph_json": {
"nodes": [
_n("cpi_surprise", "CPI US surprise", "input", 200, 45, unit="%"),
_n("fed_fwd", "Signal FED anticipations", "intermediate", 200, 155, formula="cpi_surprise / 0.1 * {{coef_cpi_fwd}}"),
_n("us_10y", "Δ US 10Y", "intermediate", 200, 265, formula="fed_fwd * {{coef_fwd_10y}}", unit="%"),
_n("eurusd", "EUR/USD", "output", 110, 380, formula="-us_10y * {{coef_10y_eurusd}}", unit="pips", instrument="EURUSD"),
_n("xauusd", "Or (XAU/USD)", "output", 290, 380, formula="-fed_fwd * {{coef_fwd_gold}}", unit="$/oz", instrument="XAUUSD"),
],
"edges": [
_e("cpi_surprise", "fed_fwd", "dashed", "fwd_guidance", "anticipations hawkish"),
_e("fed_fwd", "us_10y", "dashed", "expectations"),
_e("us_10y", "eurusd", "solid", "rate_diff"),
_e("fed_fwd", "xauusd", "dashed", "real_yield"),
],
"coefficients": {
"coef_cpi_fwd": _c(0.50, "Impact de 0.1% de surprise CPI sur signal FED"),
"coef_fwd_10y": _c(0.070, "Signal FED → Δ rendement US 10Y (%)"),
"coef_10y_eurusd": _c(200, "1% de spread → EUR/USD (pips)"),
"coef_fwd_gold": _c(15.0, "Unité signal FED → Or ($/oz)"),
},
"instruments": ["EURUSD", "XAUUSD"],
"input_mapping": {
"cpi_surprise": {"source": "surprise", "unit": "%", "description": "actual consensus (%)"}
},
},
},
# ── 2. NFP surprise v2 (dual-channel, spread US-DE, OIS) ────────────────────
{
"name": "NFP — surprise emploi non-agricole",
"category": "macro_us",
"sub_type": "NFP",
"heuristic_ver": 2,
"instruments": ["EURUSD", "SP500"],
"description": (
"NFP surprise → OIS repricing → US 2Y (fort) + US 10Y (faible) → "
"spread US-DE 2Y → EUR/USD | NFP → croissance attendue → S&P500 (2 canaux)"
),
"ai_rationale": (
"Suite à la critique : (1) le nœud 'Signal FED anticipations' est remplacé par un "
"proxy OIS observable (Fed Funds Futures / OIS pricing) ; "
"(2) EUR/USD dépend du spread US-DE 2Y plutôt du seul 2Y US ; "
"(3) S&P500 reçoit deux effets opposés — croissance (positif) via canal bénéfices "
"et taux 10Y (négatif) via canal valorisation. Le lien NFP→10Y est plus faible "
"que NFP→2Y (prime de terme + croissance modèrent l'effet)."
),
"graph_json": {
"nodes": [
_n("nfp_surprise", "NFP surprise", "macro_event", 200, 50, unit="k"),
_n("ois_pricing", "Fed OIS / probabilités", "observable", 200, 165,
formula="nfp_surprise / 100 * {{coef_nfp_ois}}",
description="OIS ou Fed Funds Futures — probabilité implicite décision FED"),
_n("us_2y", "Δ US 2Y", "observable", 90, 295,
formula="ois_pricing * {{coef_ois_2y}}", unit="%"),
_n("us_10y", "Δ US 10Y", "observable", 310, 295,
formula="ois_pricing * {{coef_ois_10y}}", unit="%"),
_n("rate_diff", "Spread US-DE 2Y", "observable", 90, 405,
formula="us_2y * {{coef_diff_factor}}",
description="proxy: US 2Y seul, idéalement US2Y DE2Y"),
_n("growth_exp", "Croissance attendue", "latent", 310, 405,
formula="nfp_surprise * {{coef_nfp_growth}}"),
_n("eurusd", "EUR/USD", "market_asset", 90, 515,
formula="-rate_diff * {{coef_diff_eurusd}}", unit="pips", instrument="EURUSD"),
_n("sp500", "S&P 500", "market_asset", 310, 515,
formula="growth_exp * {{coef_growth_sp}} - us_10y * {{coef_10y_sp}}",
unit="pts", instrument="SP500"),
],
"edges": [
_e("nfp_surprise", "ois_pricing", "solid", "repricing", strength=3, sign="positive", label="repricing"),
_e("ois_pricing", "us_2y", "solid", "rate_anchor", strength=3, sign="positive", label="canal court"),
_e("ois_pricing", "us_10y", "dashed", "expectations", strength=1, sign="positive", label="canal terme"),
_e("us_2y", "rate_diff", "solid", "spread", strength=2, sign="positive"),
_e("nfp_surprise", "growth_exp", "dashed", "growth", strength=2, sign="positive", label="bénéfices"),
_e("rate_diff", "eurusd", "solid", "fx_channel", strength=3, sign="negative", label="USD s'apprécie"),
_e("growth_exp", "sp500", "solid", "growth_sp", strength=2, sign="positive", label="bénéfices +"),
_e("us_10y", "sp500", "dashed", "discount", strength=2, sign="negative", label="discount rate"),
],
"coefficients": {
"coef_nfp_ois": _c(0.35, "100k NFP → signal OIS (probabilité remonté taux)"),
"coef_ois_2y": _c(0.030, "Signal OIS → Δ US 2Y (%) — canal court fort"),
"coef_ois_10y": _c(0.012, "Signal OIS → Δ US 10Y (%) — plus faible que 2Y"),
"coef_diff_factor":_c(1.0, "US 2Y → spread US-DE (proxy sans données EU)"),
"coef_nfp_growth": _c(0.003, "100k NFP → signal croissance attendue"),
"coef_diff_eurusd":_c(500, "1% spread → EUR/USD pips (négatif pour EUR)"),
"coef_growth_sp": _c(80, "Signal croissance → S&P500 pts (positif)"),
"coef_10y_sp": _c(150, "1% hausse 10Y → S&P500 pts (négatif, valorisation)"),
},
"instruments": ["EURUSD", "SP500"],
"input_mapping": {
"nfp_surprise": {"source": "surprise", "unit": "k", "description": "actual consensus (milliers)"}
},
},
},
# ── 3. FOMC décision de taux ─────────────────────────────────────────────────
{
"name": "FOMC — décision de taux + ton",
"category": "macro_us",
"sub_type": "FOMC",
"instruments": ["EURUSD", "XAUUSD", "SP500"],
"description": "Décision FED : surprise taux (bps) + ton du communiqué → 2 canaux → FX, Or, Actions",
"ai_rationale": (
"La décision FOMC active deux canaux distincts : le canal taux (fait accompli → ancre 2Y) "
"et le canal ton/forward guidance (discours → anticipe trajectoire future → 10Y). "
"L'or réagit principalement au canal réel (taux nominaux inflation attendue)."
),
"graph_json": {
"nodes": [
_n("rate_surprise_bps", "Surprise taux (bps)", "input", 110, 45, unit="bps", description="actual expected en points de base"),
_n("tone_score", "Ton du communiqué", "input", 290, 45, unit="score", description="3 très dovish … +3 très hawkish"),
_n("fed_rate_p", "Pression taux directeur", "intermediate", 110, 165, formula="rate_surprise_bps / 25 * {{coef_rate_mult}}"),
_n("fed_fwd", "Signal anticipations FED", "intermediate", 290, 165, formula="tone_score * {{coef_tone}}"),
_n("us_2y", "Δ US 2Y", "intermediate", 110, 285, formula="fed_rate_p * 0.085 + fed_fwd * 0.030", unit="%"),
_n("us_10y", "Δ US 10Y", "intermediate", 290, 285, formula="fed_rate_p * 0.035 + fed_fwd * {{coef_fwd_10y}}", unit="%"),
_n("eurusd", "EUR/USD", "output", 110, 400, formula="-(us_2y * 500 + us_10y * 200)", unit="pips", instrument="EURUSD"),
_n("xauusd", "Or (XAU/USD)", "output", 290, 400, formula="-(us_10y * {{coef_10y_gold}} + fed_fwd * {{coef_tone_gold}})", unit="$/oz", instrument="XAUUSD"),
],
"edges": [
_e("rate_surprise_bps", "fed_rate_p", "solid", "rate_channel"),
_e("tone_score", "fed_fwd", "dashed", "fwd_guidance"),
_e("fed_rate_p", "us_2y", "solid", "rate_anchor", "canal taux → 2Y"),
_e("fed_fwd", "us_2y", "dashed", "bleed"),
_e("fed_rate_p", "us_10y", "solid", "level_shift"),
_e("fed_fwd", "us_10y", "dashed", "expectations", "canal ton → 10Y"),
_e("us_2y", "eurusd", "solid", "rate_diff"),
_e("us_10y", "eurusd", "dashed", "rate_diff"),
_e("us_10y", "xauusd", "solid", "real_yield"),
_e("fed_fwd", "xauusd", "dashed", "expectations"),
],
"coefficients": {
"coef_rate_mult": _c(1.0, "Multiplicateur surprise taux"),
"coef_tone": _c(1.2, "Ton → signal anticipations"),
"coef_fwd_10y": _c(0.070,"Signal ton → Δ US 10Y (%)"),
"coef_10y_gold": _c(30.0, "1% hausse US 10Y → Or ($/oz, négatif)"),
"coef_tone_gold": _c(10.0, "Signal ton hawkish → Or ($/oz, négatif)"),
},
"instruments": ["EURUSD", "XAUUSD", "SP500"],
"input_mapping": {
"rate_surprise_bps": {"source": "surprise_bps", "unit": "bps"},
"tone_score": {"source": "user_input", "range": [-3, 3]},
},
},
},
# ── 4. ECB décision de taux ──────────────────────────────────────────────────
{
"name": "ECB — décision de taux + ton",
"category": "macro_eu",
"sub_type": "ECB",
"instruments": ["EURUSD"],
"description": "Décision BCE : surprise taux + ton → taux EU → spread → EUR/USD",
"ai_rationale": (
"La BCE active le même schéma dual-canal que la FED mais dans le sens inverse pour EUR/USD. "
"Une BCE hawkish surprise élargit les spreads courts EU, réduit le spread US-EU et apprécie l'euro. "
"L'effet sur le 10Y Bund est amplifié par le canal forward guidance."
),
"graph_json": {
"nodes": [
_n("ecb_rate_bps", "Surprise taux ECB (bps)", "input", 110, 45, unit="bps"),
_n("ecb_tone", "Ton du communiqué BCE", "input", 290, 45, unit="score", description="3 dovish … +3 hawkish"),
_n("ecb_rate_p", "Pression taux BCE", "intermediate", 110, 165, formula="ecb_rate_bps / 25 * {{coef_ecb_mult}}"),
_n("ecb_fwd", "Signal anticipations BCE", "intermediate", 290, 165, formula="ecb_tone * {{coef_ecb_tone}}"),
_n("eu_2y", "Δ Bund 2Y", "intermediate", 110, 285, formula="ecb_rate_p * 0.080 + ecb_fwd * 0.025", unit="%"),
_n("eu_10y", "Δ Bund 10Y", "intermediate", 290, 285, formula="ecb_rate_p * 0.030 + ecb_fwd * {{coef_ecb_fwd_10y}}", unit="%"),
_n("eurusd", "EUR/USD", "output", 200, 400, formula="eu_2y * 500 + eu_10y * 200", unit="pips", instrument="EURUSD"),
],
"edges": [
_e("ecb_rate_bps", "ecb_rate_p", "solid", "rate_channel"),
_e("ecb_tone", "ecb_fwd", "dashed", "fwd_guidance"),
_e("ecb_rate_p", "eu_2y", "solid", "rate_anchor"),
_e("ecb_fwd", "eu_2y", "dashed", "bleed"),
_e("ecb_rate_p", "eu_10y", "solid", "level_shift"),
_e("ecb_fwd", "eu_10y", "dashed", "expectations"),
_e("eu_2y", "eurusd", "solid", "spread_narrow"),
_e("eu_10y", "eurusd", "dashed", "spread_narrow"),
],
"coefficients": {
"coef_ecb_mult": _c(1.0, "Multiplicateur surprise taux BCE"),
"coef_ecb_tone": _c(1.2, "Ton BCE → signal anticipations"),
"coef_ecb_fwd_10y": _c(0.060,"Signal ton BCE → Δ Bund 10Y (%)"),
},
"instruments": ["EURUSD"],
"input_mapping": {
"ecb_rate_bps": {"source": "surprise_bps", "unit": "bps"},
"ecb_tone": {"source": "user_input", "range": [-3, 3]},
},
},
},
# ── 5. Escalade géopolitique Europe ──────────────────────────────────────────
{
"name": "Escalade géopolitique — Europe",
"category": "geopolitical",
"sub_type": "Geopolitical",
"instruments": ["EURUSD", "XAUUSD", "BRENT"],
"description": "Conflit / escalade en Europe → risk-off + choc énergie → EUR/USD, Or, Pétrole",
"ai_rationale": (
"Un événement géopolitique européen active deux canaux simultanés : "
"l'aversion au risque (VIX → USD safe haven → EUR baisse) et le choc énergétique "
"(disruption approvisionnement → pétrole monte → or monte, coût importations EU → EUR faiblit)."
),
"graph_json": {
"nodes": [
_n("severity", "Sévérité (110)", "input", 200, 45, unit="score", description="1=faible, 10=crise majeure"),
_n("risk_aversion", "Aversion au risque", "intermediate", 110, 155, formula="severity * {{coef_sev_risk}}"),
_n("energy_shock", "Choc énergie EU", "intermediate", 290, 155, formula="severity * {{coef_sev_energy}}"),
_n("vix_delta", "Δ VIX", "intermediate", 110, 265, formula="risk_aversion * {{coef_risk_vix}}"),
_n("oil_delta", "Δ Pétrole (%)", "intermediate", 290, 265, formula="energy_shock * {{coef_energy_oil}}"),
_n("eurusd", "EUR/USD", "output", 80, 390, formula="-(vix_delta * {{coef_vix_fx}} + energy_shock * {{coef_energy_fx}})", unit="pips", instrument="EURUSD"),
_n("xauusd", "Or (XAU/USD)", "output", 200, 390, formula="vix_delta * {{coef_vix_gold}} + oil_delta * {{coef_oil_gold}}", unit="$/oz", instrument="XAUUSD"),
_n("brent", "Brent (USD/bbl)", "output", 320, 390, formula="oil_delta * {{coef_oil_brent}}", unit="$/bbl", instrument="BRENT"),
],
"edges": [
_e("severity", "risk_aversion", "solid", "transmission", "perception risque"),
_e("severity", "energy_shock", "solid", "supply_chain", "disruption énergie"),
_e("risk_aversion", "vix_delta", "solid", "fear_gauge"),
_e("energy_shock", "oil_delta", "solid", "supply_shock"),
_e("vix_delta", "eurusd", "solid", "safe_haven", "fuite vers USD"),
_e("energy_shock", "eurusd", "solid", "trade_balance", "coût imports EU"),
_e("vix_delta", "xauusd", "dashed", "safe_haven", "fuite vers or"),
_e("oil_delta", "xauusd", "dashed", "inflation_hedge"),
_e("oil_delta", "brent", "solid", "direct"),
],
"coefficients": {
"coef_sev_risk": _c(1.5, "Sévérité → aversion au risque"),
"coef_sev_energy": _c(0.8, "Sévérité → choc énergie"),
"coef_risk_vix": _c(3.0, "Aversion risque → Δ VIX points"),
"coef_energy_oil": _c(2.0, "Choc énergie → % hausse pétrole"),
"coef_vix_fx": _c(4.0, "1 pt VIX → EUR/USD pips (négatif)"),
"coef_energy_fx": _c(8.0, "Choc énergie → EUR/USD pips (négatif)"),
"coef_vix_gold": _c(5.0, "1 pt VIX → Or $/oz (positif)"),
"coef_oil_gold": _c(3.0, "1% oil → Or $/oz"),
"coef_oil_brent": _c(5.0, "Choc énergie → Brent $/bbl"),
},
"instruments": ["EURUSD", "XAUUSD", "BRENT"],
"input_mapping": {
"severity": {"source": "impact_score_scaled", "unit": "score", "range": [1, 10]}
},
},
},
# ── 6. Choc offre pétrole ────────────────────────────────────────────────────
{
"name": "Choc offre pétrole — OPEC / disruption",
"category": "commodity",
"sub_type": "Oil",
"instruments": ["BRENT", "EURUSD", "XAUUSD"],
"description": "Choc d'offre pétrolière → prix → inflation EU → trade balance → EUR",
"ai_rationale": (
"Un choc d'offre haussier sur le pétrole impacte directement le Brent. "
"L'Europe, importateur net, voit ses coûts d'importation augmenter (trade balance → EUR négatif), "
"et ses anticipations d'inflation croître (BCE contrainte entre lutte anti-inflation et croissance)."
),
"graph_json": {
"nodes": [
_n("oil_change_pct", "Choc prix pétrole (%)", "input", 200, 45, unit="%"),
_n("brent_move", "Δ Brent ($/bbl)", "intermediate", 110, 155, formula="oil_change_pct * {{coef_oil_brent}}"),
_n("eu_import_cost", "Coût imports EU", "intermediate", 290, 155, formula="oil_change_pct * {{coef_oil_import}}"),
_n("eu_inflation", "Anticipations inflation EU","intermediate",290, 265, formula="eu_import_cost * {{coef_import_inf}}"),
_n("eurusd", "EUR/USD", "output", 110, 380, formula="-(eu_import_cost * {{coef_import_fx}} + eu_inflation * {{coef_inf_fx}})", unit="pips", instrument="EURUSD"),
_n("xauusd", "Or (XAU/USD)", "output", 290, 380, formula="brent_move * {{coef_oil_gold}}", unit="$/oz", instrument="XAUUSD"),
],
"edges": [
_e("oil_change_pct", "brent_move", "solid", "direct"),
_e("oil_change_pct", "eu_import_cost","solid", "trade_balance"),
_e("eu_import_cost", "eu_inflation", "dashed", "cost_push"),
_e("eu_import_cost", "eurusd", "solid", "trade_balance", "déficit commercial EU"),
_e("eu_inflation", "eurusd", "dashed", "policy_ambiguity","BCE coincé"),
_e("brent_move", "xauusd", "dashed", "inflation_hedge"),
],
"coefficients": {
"coef_oil_brent": _c(1.0, "% hausse oil → Δ Brent $/bbl (linéaire à calibrer)"),
"coef_oil_import": _c(0.5, "% hausse oil → pression imports EU (score)"),
"coef_import_inf": _c(0.3, "Pression imports → anticipations inflation EU"),
"coef_import_fx": _c(10.0, "Pression imports → EUR/USD pips (négatif)"),
"coef_inf_fx": _c(5.0, "Inflation EU → EUR/USD (ambiguë, négatif net)"),
"coef_oil_gold": _c(0.8, "1$/bbl Brent → Or $/oz"),
},
"instruments": ["BRENT", "EURUSD", "XAUUSD"],
"input_mapping": {
"oil_change_pct": {"source": "impact_score_pct", "unit": "%"}
},
},
},
# ── 7. COT repositionnement institutionnel ───────────────────────────────────
{
"name": "COT — repositionnement institutionnel EUR",
"category": "report",
"sub_type": "COT",
"instruments": ["EURUSD"],
"description": "Variation nette des positions institutionnelles EUR → signal momentum → EUR/USD",
"ai_rationale": (
"Le rapport COT (Commitment of Traders) révèle les positions nettes des fonds spéculatifs sur EUR. "
"Un changement significatif des positions nettes agit comme signal de momentum, "
"les marchés interprétant un repositionnement institutionnel comme un signal directionnel."
),
"graph_json": {
"nodes": [
_n("net_longs_delta", "Δ Positions nettes EUR (k contrats)", "input", 200, 45, unit="k contracts"),
_n("momentum_signal", "Signal momentum institutionnel", "intermediate", 200, 185, formula="net_longs_delta * {{coef_cot_momentum}}"),
_n("eurusd", "EUR/USD", "output", 200, 330, formula="momentum_signal * {{coef_momentum_fx}}", unit="pips", instrument="EURUSD"),
],
"edges": [
_e("net_longs_delta", "momentum_signal", "solid", "positioning"),
_e("momentum_signal", "eurusd", "solid", "momentum"),
],
"coefficients": {
"coef_cot_momentum": _c(0.5, "k contrats → signal momentum"),
"coef_momentum_fx": _c(2.0, "Signal momentum → EUR/USD pips"),
},
"instruments": ["EURUSD"],
"input_mapping": {
"net_longs_delta": {"source": "actual_value", "unit": "k contracts"}
},
},
},
# ── 8. EIA stocks pétrole ────────────────────────────────────────────────────
{
"name": "EIA — stocks pétroliers hebdomadaires",
"category": "report",
"sub_type": "EIA",
"instruments": ["BRENT", "EURUSD"],
"description": "Surprise stocks EIA → prix pétrole → canal inflation / risk appetite → EUR/USD",
"ai_rationale": (
"Une surprise négative sur les stocks EIA (stocks < consensus) signale une demande forte "
"ou une offre réduite, ce qui fait monter le pétrole. Impact EUR/USD indirect via "
"le canal inflation EU (coût imports) et le risk appetite."
),
"graph_json": {
"nodes": [
_n("eia_surprise_mb", "Surprise EIA (Mb vs consensus)", "input", 200, 45, unit="Mb", description="négatif = stocks inférieurs aux attentes"),
_n("oil_reaction", "Réaction pétrole (%)", "intermediate", 200, 165, formula="-eia_surprise_mb * {{coef_eia_oil}}"),
_n("brent", "Δ Brent ($/bbl)", "output", 110, 290, formula="oil_reaction * {{coef_oil_dollar}}", unit="$/bbl", instrument="BRENT"),
_n("eurusd", "EUR/USD", "output", 290, 290, formula="-oil_reaction * {{coef_oil_eurusd}}", unit="pips", instrument="EURUSD"),
],
"edges": [
_e("eia_surprise_mb", "oil_reaction", "solid", "supply_demand", "stocks bas → oil monte"),
_e("oil_reaction", "brent", "solid", "direct"),
_e("oil_reaction", "eurusd", "dashed", "trade_balance", "coût imports EU"),
],
"coefficients": {
"coef_eia_oil": _c(0.5, "1 Mb sous consensus → % hausse pétrole"),
"coef_oil_dollar": _c(1.0, "% hausse oil → Δ Brent $/bbl"),
"coef_oil_eurusd": _c(3.0, "% hausse oil → EUR/USD pips (négatif pour EUR)"),
},
"instruments": ["BRENT", "EURUSD"],
"input_mapping": {
"eia_surprise_mb": {"source": "surprise", "unit": "Mb"}
},
},
},
# ── 9. Risk-off global ───────────────────────────────────────────────────────
{
"name": "Risk-off global — fuite vers la sécurité",
"category": "sentiment",
"sub_type": "Risk",
"instruments": ["EURUSD", "XAUUSD", "SP500"],
"description": "Choc d'aversion au risque → flight-to-safety → USD & Or montent, actions baissent",
"ai_rationale": (
"Un épisode de risk-off global (crash, crise financière, choc exogène) provoque "
"une fuite vers les actifs refuge : USD (liquidity premium), Or (store of value), "
"et une sortie des actifs risqués (actions, EM, EUR). Le VIX s'envole et amplifie la réaction."
),
"graph_json": {
"nodes": [
_n("risk_score", "Score risk-off (110)", "input", 200, 45, unit="score", description="1=légère tension, 10=crise majeure"),
_n("vix_spike", "Δ VIX", "intermediate", 110, 165, formula="risk_score * {{coef_risk_vix}}"),
_n("gold_demand", "Demande actifs refuge", "intermediate", 290, 165, formula="risk_score * {{coef_risk_refuge}}"),
_n("usd_safe", "USD safe-haven", "intermediate", 110, 285, formula="vix_spike * {{coef_vix_usd}}"),
_n("eurusd", "EUR/USD", "output", 80, 400, formula="-usd_safe * {{coef_usd_eurusd}}", unit="pips", instrument="EURUSD"),
_n("xauusd", "Or (XAU/USD)", "output", 200, 400, formula="gold_demand * {{coef_refuge_gold}}", unit="$/oz", instrument="XAUUSD"),
_n("sp500", "S&P 500", "output", 320, 400, formula="-vix_spike * {{coef_vix_sp}}", unit="pts", instrument="SP500"),
],
"edges": [
_e("risk_score", "vix_spike", "solid", "fear_gauge"),
_e("risk_score", "gold_demand", "solid", "safe_haven"),
_e("vix_spike", "usd_safe", "solid", "liquidity", "fuite liquidité USD"),
_e("usd_safe", "eurusd", "solid", "fx_safe_haven"),
_e("gold_demand", "xauusd", "solid", "store_value"),
_e("vix_spike", "sp500", "solid", "risk_asset", "risk premium"),
],
"coefficients": {
"coef_risk_vix": _c(4.0, "Score risk → Δ VIX pts"),
"coef_risk_refuge": _c(2.0, "Score risk → demande refuges (score)"),
"coef_vix_usd": _c(0.8, "Δ VIX → signal USD safe-haven"),
"coef_usd_eurusd": _c(20.0, "Signal USD → EUR/USD pips (négatif)"),
"coef_refuge_gold": _c(15.0, "Demande refuge → Or $/oz"),
"coef_vix_sp": _c(12.0, "1 pt VIX → S&P 500 pts (négatif)"),
},
"instruments": ["EURUSD", "XAUUSD", "SP500"],
"input_mapping": {
"risk_score": {"source": "impact_score_scaled", "unit": "score", "range": [1, 10]}
},
},
},
# ── 10. Tensions commerciales / tarifs ──────────────────────────────────────
{
"name": "Tarifs douaniers — tensions commerciales",
"category": "geopolitical",
"sub_type": "Trade",
"instruments": ["EURUSD", "SP500"],
"description": "Annonce tarifs → choc exportations EU + risk-off → EUR/USD & actions",
"ai_rationale": (
"Les tarifs douaniers impactent l'EUR/USD via deux canaux : "
"le choc direct sur les exportations européennes (BCE plus dovish = EUR baisse) "
"et le canal risk-off global (incertitude → fuite USD → EUR baisse, S&P corrige)."
),
"graph_json": {
"nodes": [
_n("tariff_pct", "Tarifs annoncés (%)", "input", 200, 45, unit="%"),
_n("eu_export_shock","Choc exportations EU", "intermediate", 110, 165, formula="tariff_pct * {{coef_tariff_export}}"),
_n("risk_off_signal","Signal risk-off", "intermediate", 290, 165, formula="tariff_pct * {{coef_tariff_risk}}"),
_n("ecb_dovish", "Pression BCE dovish", "intermediate", 110, 285, formula="eu_export_shock * {{coef_export_ecb}}"),
_n("eurusd", "EUR/USD", "output", 110, 400, formula="-(ecb_dovish * {{coef_ecb_fx}} + risk_off_signal * {{coef_risk_fx}})", unit="pips", instrument="EURUSD"),
_n("sp500", "S&P 500", "output", 290, 400, formula="-risk_off_signal * {{coef_risk_sp}}", unit="pts", instrument="SP500"),
],
"edges": [
_e("tariff_pct", "eu_export_shock", "solid", "trade_impact"),
_e("tariff_pct", "risk_off_signal", "dashed", "uncertainty"),
_e("eu_export_shock", "ecb_dovish", "solid", "growth_slowdown"),
_e("ecb_dovish", "eurusd", "solid", "rate_differential"),
_e("risk_off_signal", "eurusd", "dashed", "safe_haven"),
_e("risk_off_signal", "sp500", "solid", "risk_asset"),
],
"coefficients": {
"coef_tariff_export": _c(1.5, "% tarif → pression exports EU"),
"coef_tariff_risk": _c(1.0, "% tarif → signal risk-off"),
"coef_export_ecb": _c(0.5, "Choc exports → pression dovish BCE"),
"coef_ecb_fx": _c(15.0, "Pression BCE dovish → EUR/USD pips (négatif)"),
"coef_risk_fx": _c(10.0, "Risk-off → EUR/USD pips (négatif)"),
"coef_risk_sp": _c(25.0, "Risk-off → S&P 500 pts (négatif)"),
},
"instruments": ["EURUSD", "SP500"],
"input_mapping": {
"tariff_pct": {"source": "impact_score_pct", "unit": "%"}
},
},
},
]
# ── DB Helpers ─────────────────────────────────────────────────────────────────
def init_tables(conn):
conn.execute("""
CREATE TABLE IF NOT EXISTS causal_graph_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
category TEXT NOT NULL,
sub_type TEXT,
instruments TEXT NOT NULL DEFAULT '[]',
description TEXT DEFAULT '',
graph_json TEXT NOT NULL,
ai_rationale TEXT DEFAULT '',
calibration_json TEXT DEFAULT '{}',
created_by TEXT DEFAULT 'system',
heuristic_ver INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS causal_event_analyses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
market_event_id INTEGER,
template_id INTEGER,
instrument TEXT NOT NULL DEFAULT 'EURUSD',
inputs_json TEXT DEFAULT '{}',
override_params TEXT DEFAULT '{}',
prediction_json TEXT,
actual_json TEXT,
activation_score REAL,
drift_json TEXT,
ai_recommendation TEXT,
analyzed_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (template_id) REFERENCES causal_graph_templates(id)
)
""")
conn.commit()
def seed_templates(conn):
"""Insère les templates built-in ; met à jour si heuristic_ver a été augmentée."""
for t in BUILT_IN_TEMPLATES:
ver = t.get("heuristic_ver", 1)
existing = conn.execute(
"SELECT id, heuristic_ver FROM causal_graph_templates WHERE name = ?", (t["name"],)
).fetchone()
if existing:
if (existing["heuristic_ver"] or 1) < ver:
conn.execute("""
UPDATE causal_graph_templates
SET graph_json=?, description=?, ai_rationale=?, instruments=?,
heuristic_ver=?, updated_at=datetime('now')
WHERE name=?
""", (
json.dumps(t["graph_json"]),
t.get("description", ""),
t.get("ai_rationale", ""),
json.dumps(t.get("instruments", [])),
ver, t["name"],
))
continue
conn.execute("""
INSERT INTO causal_graph_templates
(name, category, sub_type, instruments, description, graph_json,
ai_rationale, heuristic_ver, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'system')
""", (
t["name"], t["category"], t.get("sub_type", ""),
json.dumps(t.get("instruments", [])),
t.get("description", ""),
json.dumps(t["graph_json"]),
t.get("ai_rationale", ""),
ver,
))
conn.commit()
def get_templates(conn, category: str = "") -> list:
where = f"WHERE category = '{category}'" if category else ""
rows = conn.execute(
f"SELECT * FROM causal_graph_templates {where} ORDER BY category, name"
).fetchall()
result = []
for r in rows:
d = dict(r)
d["graph_json"] = json.loads(d["graph_json"] or "{}")
d["instruments"] = json.loads(d["instruments"] or "[]")
d["calibration_json"] = json.loads(d["calibration_json"] or "{}")
result.append(d)
return result
def get_template(conn, template_id: int) -> Optional[dict]:
row = conn.execute(
"SELECT * FROM causal_graph_templates WHERE id = ?", (template_id,)
).fetchone()
if not row:
return None
d = dict(row)
d["graph_json"] = json.loads(d["graph_json"] or "{}")
d["instruments"] = json.loads(d["instruments"] or "[]")
d["calibration_json"] = json.loads(d["calibration_json"] or "{}")
return d
def update_coefficients(conn, template_id: int, coef_updates: dict):
tmpl = get_template(conn, template_id)
if not tmpl:
return False
graph = tmpl["graph_json"]
for k, v in coef_updates.items():
if k in graph.get("coefficients", {}):
graph["coefficients"][k]["value"] = float(v)
conn.execute(
"UPDATE causal_graph_templates SET graph_json = ?, updated_at = datetime('now') WHERE id = ?",
(json.dumps(graph), template_id)
)
conn.commit()
return True
def update_calibration(conn, template_id: int, analysis_result: dict):
"""Met à jour les stats de calibration après une nouvelle analyse."""
tmpl = get_template(conn, template_id)
if not tmpl:
return
calib = tmpl.get("calibration_json") or {}
n = calib.get("n_events", 0) + 1
# Running average of activation score
prev_act = calib.get("avg_activation", 0) or 0
new_act = analysis_result.get("activation_score") or 0
# Running average of pred / actual pips
instrument = analysis_result.get("instrument", "EURUSD")
pred_pips = analysis_result.get("pred_pips", 0) or 0
act_pips = analysis_result.get("actual_pips", 0) or 0
calib["n_events"] = n
calib["avg_activation"] = round((prev_act * (n - 1) + new_act) / n, 3)
calib["last_analyzed"] = analysis_result.get("analyzed_at", "")
if instrument not in calib:
calib[instrument] = {"n": 0, "avg_pred": 0, "avg_actual": 0}
ci = calib[instrument]
ni = ci["n"] + 1
ci["n"] = ni
ci["avg_pred"] = round((ci["avg_pred"] * (ni - 1) + pred_pips) / ni, 1)
ci["avg_actual"] = round((ci["avg_actual"] * (ni - 1) + act_pips) / ni, 1)
if ci["avg_pred"] != 0:
ci["coef_ratio"] = round(ci["avg_actual"] / ci["avg_pred"], 2)
conn.execute(
"UPDATE causal_graph_templates SET calibration_json = ?, updated_at = datetime('now') WHERE id = ?",
(json.dumps(calib), template_id)
)
conn.commit()
# ── Évaluateur de graphe ───────────────────────────────────────────────────────
def _safe_eval(expr: str, context: dict) -> float:
"""Évalue une expression arithmétique simple avec des variables."""
_ops = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.USub: operator.neg,
}
def _eval(node):
if isinstance(node, ast.Constant):
return float(node.value)
if isinstance(node, ast.Name):
if node.id not in context:
raise ValueError(f"Variable inconnue : {node.id}")
return float(context[node.id])
if isinstance(node, ast.BinOp):
op = _ops.get(type(node.op))
if op is None:
raise ValueError(f"Opérateur non supporté : {type(node.op)}")
return op(_eval(node.left), _eval(node.right))
if isinstance(node, ast.UnaryOp) and isinstance(node.op, ast.USub):
return -_eval(node.operand)
raise ValueError(f"Nœud AST non supporté : {type(node)}")
tree = ast.parse(expr.strip(), mode="eval")
return _eval(tree.body)
def evaluate_graph(graph_json: dict, inputs: dict, coef_overrides: Optional[dict] = None) -> dict:
"""
Évalue un graphe causal depuis les inputs, retourne dict {node_id: valeur}.
Les {{coef_xxx}} sont substitués depuis graph_json["coefficients"] (ou overrides).
"""
coefs = {k: v["value"] for k, v in graph_json.get("coefficients", {}).items()}
if coef_overrides:
coefs.update({k: float(v) for k, v in coef_overrides.items()})
def sub(formula: str) -> str:
for k, v in coefs.items():
formula = formula.replace(f"{{{{{k}}}}}", str(v))
return formula
values = dict(inputs)
nodes = graph_json.get("nodes", [])
# Évaluation en ordre topologique (max N passes)
for _ in range(len(nodes) + 2):
changed = False
for node in nodes:
if node["id"] in values or not node.get("formula"):
continue
try:
result = _safe_eval(sub(node["formula"]), values)
values[node["id"]] = round(result, 4)
changed = True
except Exception:
pass
if not changed:
break
return values