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