feat: instrument model

This commit is contained in:
OpenSquared
2026-07-03 16:03:59 +02:00
parent 2d8cccec07
commit 2bb8109eb4
3 changed files with 525 additions and 159 deletions

View File

@@ -194,6 +194,170 @@ def build_macro_node_timeline(
return result
def build_node_combined_timeline(
conn, instrument: str, node_id: str, macro_key: Optional[str],
date_from: date_type, date_to: date_type,
) -> dict[str, float]:
"""
Construit la timeline complète d'un nœud en fusionnant :
1. Valeurs passées (ff_calendar actuals via macro_key)
2. Scénarios utilisateur futurs (CT/MT/LT) avec interpolation pondérée par confidence
Logique :
- Pour les dates ≤ aujourd'hui : ff_calendar actuals (si disponibles)
- Pour les dates futures : interpolation depuis la dernière valeur connue
vers chaque scénario, pondérée par confidence
- Si plusieurs scénarios se chevauchent : moyenne pondérée par confidence
"""
today = date_type.today()
# 1. Baseline depuis ff_calendar (passé uniquement en pratique)
ff_tl = build_macro_node_timeline(conn, macro_key, date_from, date_to) if macro_key else {}
# 2. Scénarios utilisateur
try:
scen_rows = conn.execute(
"""SELECT id, label, horizon, target_date, target_value, confidence, trajectory, absorption_days
FROM node_forecast_scenarios
WHERE instrument=? AND node_id=?
ORDER BY target_date ASC""",
(instrument.upper(), node_id)
).fetchall()
scenarios = [dict(r) for r in scen_rows]
except Exception:
scenarios = []
if not scenarios:
return ff_tl # Pas de scénarios → juste ff_calendar
# 3. Valeur de départ (dernière connue depuis ff_calendar ou override statique)
base_value: Optional[float] = None
if ff_tl:
# Dernière valeur connue avant ou à aujourd'hui
for d in sorted(ff_tl.keys(), reverse=True):
if d <= str(today):
base_value = ff_tl[d]
break
if base_value is None:
base_value = next(iter(ff_tl.values()), None)
# Fallback sur override statique si pas de ff_calendar
if base_value is None:
try:
ov = conn.execute(
"SELECT value FROM instrument_node_overrides WHERE instrument=? AND node_id=?",
(instrument.upper(), node_id)
).fetchone()
if ov:
base_value = float(ov["value"])
except Exception:
pass
if base_value is None:
return ff_tl # Pas de base → on ne peut pas projeter
# 4. Construire les waypoints (date, value, confidence) depuis les scénarios
# Le point de départ est toujours (today, base_value, 1.0)
waypoints: list[tuple[date_type, float, float]] = [(today, base_value, 1.0)]
for s in scenarios:
try:
td = date_type.fromisoformat(s["target_date"])
if td > today:
waypoints.append((td, float(s["target_value"]), float(s["confidence"])))
except (ValueError, KeyError):
continue
waypoints.sort(key=lambda x: x[0])
# 5. Construire la timeline journalière
result: dict[str, float] = {}
cur = date_from
while cur <= date_to:
cur_str = str(cur)
if cur <= today and cur_str in ff_tl:
# Passé → priorité ff_calendar
result[cur_str] = ff_tl[cur_str]
else:
# Futur → interpolation entre waypoints
prev_wp: Optional[tuple[date_type, float, float]] = None
next_wp: Optional[tuple[date_type, float, float]] = None
for wp in waypoints:
if wp[0] <= cur:
prev_wp = wp
elif next_wp is None:
next_wp = wp
break
if prev_wp is None and next_wp is None:
if ff_tl:
# Dernier connu
result[cur_str] = ff_tl.get(str(today)) or list(ff_tl.values())[-1]
elif prev_wp is None:
result[cur_str] = round(next_wp[1], 4) # type: ignore[index]
elif next_wp is None:
result[cur_str] = round(prev_wp[1], 4) # hold last value
else:
# Interpolation linéaire entre prev et next, pondérée par confidence
total_d = (next_wp[0] - prev_wp[0]).days
elapsed = (cur - prev_wp[0]).days
frac = elapsed / total_d if total_d > 0 else 0.0
# Valeur interpolée brute
interp = prev_wp[1] + frac * (next_wp[1] - prev_wp[1])
# La confidence du prochain scénario pondère le drift :
# confidence=1.0 → drift complet vers interp
# confidence=0.5 → 50% du drift, reste à mi-chemin entre prev et interp
conf = next_wp[2]
blended = prev_wp[1] + (interp - prev_wp[1]) * conf
result[cur_str] = round(blended, 4)
cur += timedelta(days=1)
return result
# ── Scenario CRUD ──────────────────────────────────────────────────────────────
def get_node_scenarios(conn, instrument: str, node_id: Optional[str] = None) -> list[dict]:
"""Retourne tous les scénarios d'un instrument (ou d'un nœud spécifique)."""
if node_id:
rows = conn.execute(
"""SELECT * FROM node_forecast_scenarios
WHERE instrument=? AND node_id=? ORDER BY target_date ASC""",
(instrument.upper(), node_id)
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM node_forecast_scenarios WHERE instrument=? ORDER BY node_id, target_date ASC",
(instrument.upper(),)
).fetchall()
return [dict(r) for r in rows]
def add_node_scenario(
conn, instrument: str, node_id: str, label: str,
horizon: str, target_date: str, target_value: float,
confidence: float = 0.7, trajectory: str = "linear",
absorption_days: int = 30, notes: str = "",
) -> int:
"""Ajoute un scénario forecast sur un nœud. Retourne l'id créé."""
cur = conn.execute(
"""INSERT INTO node_forecast_scenarios
(instrument, node_id, label, horizon, target_date, target_value,
confidence, trajectory, absorption_days, notes)
VALUES (?,?,?,?,?,?,?,?,?,?)""",
(instrument.upper(), node_id, label, horizon, target_date, float(target_value),
float(confidence), trajectory, int(absorption_days), notes or "")
)
conn.commit()
return cur.lastrowid
def delete_node_scenario(conn, scenario_id: int) -> bool:
conn.execute("DELETE FROM node_forecast_scenarios WHERE id=?", (scenario_id,))
conn.commit()
return True
# ── Saturation scales (tanh) par unité native ─────────────────────────────────
# tanh(x/scale) : slope=1 à l'origine, sature asymptotiquement à ±1
# pips = coefficient_to_pips * scale * tanh(x / scale)
@@ -201,10 +365,11 @@ def build_macro_node_timeline(
# → 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%
"%": 3.0, # CPI / PIB / taux en % absolu : sature autour ±5%
"pts%": 3.0,
"pts": 15.0, # PMI écart depuis 50 : sature autour ±20pts
"score": 3.0, # scores subjectifs -5 à +5
"K": 200.0, # emplois en milliers : sature autour ±400K
"tonnes": 80.0, # tonnes or / CB buying
"Mds$": 40.0,
"Mds$/sem": 15.0,
@@ -364,57 +529,49 @@ 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",
"description": "EUR/USD — Graphe causal macro-natif : noeuds = variables macro réelles auto-synchronisées depuis FF Calendar",
"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 0a : chocs événementiels (surprises court terme) ───────────────
{"id":"in_cb", "label":"Choc Banques Centrales", "node_type":"input_event","category":"central_bank", "unit":"pips","display_col":0,"event_category":"central_bank", "description":"Surprises Fed/BCE : décisions inattendues, guidance hawkish/dovish, minutes."},
{"id":"in_geo", "label":"Choc Géopolitique", "node_type":"input_event","category":"geopolitical", "unit":"pips","display_col":0,"event_category":"geopolitical", "description":"Conflits, sanctions, tensions → flight to USD."},
{"id":"in_trade", "label":"Choc Commercial/Tarifs", "node_type":"input_event","category":"trade_policy", "unit":"pips","display_col":0,"event_category":"trade_policy", "description":"Tarifs US-UE, représailles → USD/EUR volatilité."},
{"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 bancaire, spreads → USD safe haven."},
# ── Layer 0b : politique monétaire (taux directeurs absolus) ─────────────
# macro_key = id → auto-sync depuis ff_calendar sans mapping manuel
{"id":"fed_rate","label":"Taux Fed (%)", "node_type":"input_manual","category":"monetary","unit":"%","coefficient_to_pips":-30, "macro_key":"fed_rate", "display_col":1,"description":"Taux directeur Fed en %. Hausse → USD fort → pair ↓. Auto-sync depuis FF Calendar."},
{"id":"ecb_rate","label":"Taux BCE (%)", "node_type":"input_manual","category":"monetary","unit":"%","coefficient_to_pips":+30, "macro_key":"ecb_rate", "display_col":1,"description":"Taux directeur BCE en %. Hausse → EUR fort → pair ↑. Auto-sync depuis FF Calendar."},
# ── Layer 0c : inflation ─────────────────────────────────────────────────
{"id":"us_cpi", "label":"CPI US YoY (%)", "node_type":"input_manual","category":"inflation","unit":"%","coefficient_to_pips":-10, "macro_key":"us_cpi_yoy","display_col":1,"description":"Inflation US YoY. Hausse → anticipations Fed hawkish → USD ↑ → pair ↓."},
{"id":"eu_cpi", "label":"HICP Eurozone (%)", "node_type":"input_manual","category":"inflation","unit":"%","coefficient_to_pips":+10, "macro_key":"eu_cpi_yoy","display_col":1,"description":"Inflation EU YoY. Hausse → BCE hawkish → EUR ↑ → pair ↑."},
# ── Layer 0d : croissance ─────────────────────────────────────────────────
{"id":"us_gdp", "label":"GDP US QoQ (%)", "node_type":"input_manual","category":"growth","unit":"%","coefficient_to_pips":-15, "macro_key":"us_gdp", "display_col":1,"description":"Croissance US trimestrielle. Surperformance → USD ↑ → pair ↓."},
{"id":"eu_gdp", "label":"GDP Eurozone QoQ (%)","node_type":"input_manual","category":"growth","unit":"%","coefficient_to_pips":+15, "macro_key":"eu_gdp", "display_col":1,"description":"Croissance EU trimestrielle. Surperformance → EUR ↑ → pair ↑."},
# ── Layer 0e : emploi & activité ─────────────────────────────────────────
{"id":"us_nfp", "label":"NFP US (K/mois)", "node_type":"input_manual","category":"labor","unit":"K","coefficient_to_pips":-0.05, "macro_key":"us_nfp", "display_col":1,"description":"Emplois non-agricoles US en K. 150K= neutre. Plus → USD ↑ → pair ↓."},
{"id":"eu_pmi", "label":"PMI EU (écart/50)", "node_type":"input_manual","category":"activity","unit":"pts","coefficient_to_pips":+2.5,"macro_key":"eu_pmi", "display_col":1,"description":"PMI Composite Eurozone MOINS 50 (+4 = PMI=54, expansion → EUR ↑)."},
{"id":"us_pmi", "label":"PMI US (écart/50)", "node_type":"input_manual","category":"activity","unit":"pts","coefficient_to_pips":-2.5,"macro_key":"us_pmi", "display_col":1,"description":"PMI ISM US MOINS 50 (+3 = PMI=53, expansion → USD ↑ → pair ↓)."},
# ── Layer 0f : sentiment & risque (pas de macro_key — saisi ou events) ───
{"id":"vix", "label":"VIX (niveau)", "node_type":"input_manual","category":"risk","unit":"pts","coefficient_to_pips":-1.5, "display_col":1,"description":"Volatilité equity US. Spike → safe haven USD → pair ↓."},
{"id":"us_equity","label":"S&P 500 momentum", "node_type":"input_manual","category":"risk","unit":"score","coefficient_to_pips":+0.3, "display_col":1,"description":"Risk-on US. Hausse → appétit risque → EUR ↑. Score -5/+5."},
{"id":"eu_fragm","label":"Fragmentation EU", "node_type":"input_manual","category":"political","unit":"score","coefficient_to_pips":-0.5, "display_col":1,"description":"Risque fragmentation zone euro, stress BTP/Bund. Score 0-5 → EUR ↓."},
# ── Layer 1 : domaines synthèse ───────────────────────────────────────────
{"id":"layer_monetary","label":"▶ Différentiel Monétaire","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
"formula":"in_cb + fed_rate + ecb_rate + us_cpi + eu_cpi",
"description":"Taux directeurs + inflation → différentiel de politique monétaire Fed/BCE."},
{"id":"layer_growth", "label":"▶ Différentiel Croissance","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
"formula":"us_gdp + eu_gdp + us_nfp + eu_pmi + us_pmi",
"description":"Croissance relative US vs EU. Positif = EU surperform → EUR ↑."},
{"id":"layer_risk", "label":"▶ Sentiment & Risque", "node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
"formula":"in_geo + in_trade + in_credit + vix + us_equity + eu_fragm",
"description":"Risque géopolitique, sentiment, appétit risque → impact EUR/USD."},
# ── 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."},
{"id":"eurusd","label":"EUR/USD — Biais Net","node_type":"output","category":"output","unit":"pips","display_col":3,
"formula":"layer_monetary + layer_growth + layer_risk",
"description":"Biais net EUR/USD. Positif = haussier EUR. Divisé en 3 piliers : monétaire, croissance, risque."},
]
},
@@ -816,6 +973,22 @@ def init_instrument_model_tables(conn):
calibration_json TEXT NOT NULL,
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS node_forecast_scenarios (
id INTEGER PRIMARY KEY AUTOINCREMENT,
instrument TEXT NOT NULL,
node_id TEXT NOT NULL,
label TEXT NOT NULL,
horizon TEXT NOT NULL DEFAULT 'mt',
target_date TEXT NOT NULL,
target_value REAL NOT NULL,
confidence REAL NOT NULL DEFAULT 0.7,
trajectory TEXT NOT NULL DEFAULT 'linear',
absorption_days INTEGER NOT NULL DEFAULT 30,
notes TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_nfs_inst_node
ON node_forecast_scenarios(instrument, node_id);
""")
conn.commit()
@@ -1251,14 +1424,18 @@ def simulate_timeline(
from services.causal_graphs import evaluate_graph
# ── Macro-guidance : noeuds input_manual avec macro_key → overrides time-varying ──
# ── Timelines combinées (ff_calendar actuals + scénarios CT/MT/LT) ────────
# Pour chaque nœud input_manual : fusion de la baseline ff_calendar et des
# scénarios utilisateur (trajectoire blended par confidence vers chaque waypoint)
macro_node_timelines: dict[str, dict[str, float]] = {}
for node in graph_def.get("nodes", []):
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
if node.get("node_type") != "input_manual":
continue
mk = node.get("macro_key")
nid = node["id"]
tl = build_node_combined_timeline(conn, inst_upper, nid, mk, date_from, today)
if tl:
macro_node_timelines[nid] = tl
has_macro = bool(macro_node_timelines)