Causal lab v2

This commit is contained in:
OpenSquared
2026-06-27 23:25:59 +02:00
parent 9ac3ebb4b5
commit a7f5369d7b
4 changed files with 1866 additions and 1315 deletions

View File

@@ -85,6 +85,15 @@ def startup():
from services.geo_analyzer import GEO_PATTERNS
from services.database import seed_builtin_patterns
seed_builtin_patterns(GEO_PATTERNS)
# Seed causal graph templates
try:
from services.database import get_conn as _get_conn
from services.causal_graphs import init_tables as _cg_init, seed_templates as _cg_seed
_cg_conn = _get_conn()
_cg_init(_cg_conn); _cg_seed(_cg_conn); _cg_conn.close()
_log.info("[Startup] Causal graph templates seeded")
except Exception as _e:
_log.warning(f"[Startup] Causal graph seed failed: {_e}")
# Auto-bootstrap désactivé — utiliser les boutons dans Cycle Actions / Timeline
# Start auto-cycle scheduler if enabled
from services.auto_cycle import start_scheduler

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,687 @@
"""
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é (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 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

File diff suppressed because it is too large Load Diff