1837 lines
111 KiB
Python
1837 lines
111 KiB
Python
"""
|
||
Instrument Models — Phase 2 : saturation non-linéaire + régimes adaptatifs.
|
||
|
||
Architecture DAG (3 couches) :
|
||
Layer 0 — Inputs :
|
||
- input_event : valeur auto depuis causal_event_analyses (par catégorie template)
|
||
- input_manual : valeur utilisateur → tanh(x/scale)*coeff → pips (saturation)
|
||
Layer 1 — Intermediate : formules agrégeant les inputs par domaine
|
||
Layer 2 — Output : formule avec poids de régime (ex: 1.4*layer_monetary en MONETARY_DOMINANCE)
|
||
|
||
Nouveautés Phase 2 :
|
||
- _saturate_pips() : tanh(native/scale)*coeff, slope = coeff à l'origine, sature aux extrêmes
|
||
- detect_regime() : identifie le régime dominant depuis les catégories d'events actifs
|
||
- REGIME_WEIGHTS : multiplicateurs par couche selon 6 régimes de marché
|
||
- _apply_regime_weights() : réécrit la formule output avec les poids du régime courant
|
||
- simulate_timeline() inclut le régime du jour
|
||
|
||
Phase macro-guidance :
|
||
- FF_MACRO_KEYS : mapping macro_key → {currency, event name patterns}
|
||
- build_macro_node_timeline() : reconstruit une série temporelle journalière depuis ff_calendar
|
||
- simulate_timeline() utilise des overrides time-varying pour les noeuds avec macro_key
|
||
- structural_pips(t) devient une courbe guidée par les fondamentaux
|
||
"""
|
||
import json
|
||
import math
|
||
from datetime import datetime, timedelta, date as date_type
|
||
from typing import Optional
|
||
|
||
|
||
# ── Macro key mapping — ff_calendar event names per fundamental variable ───────
|
||
# Each macro_key maps to a currency and a list of lowercased event name patterns.
|
||
# The currency acts as a primary filter before pattern matching.
|
||
FF_MACRO_KEYS: dict[str, dict] = {
|
||
"fed_rate": {"currency": "USD", "label": "Taux Fed", "unit": "%",
|
||
"names": ["interest rate decision", "federal funds rate", "fed rate"]},
|
||
"ecb_rate": {"currency": "EUR", "label": "Taux BCE", "unit": "%",
|
||
"names": ["interest rate decision", "deposit facility rate", "refinancing rate", "ecb rate"]},
|
||
"boe_rate": {"currency": "GBP", "label": "Taux BoE", "unit": "%",
|
||
"names": ["interest rate decision", "official bank rate", "boe rate"]},
|
||
"boj_rate": {"currency": "JPY", "label": "Taux BoJ", "unit": "%",
|
||
"names": ["interest rate decision", "boj rate", "policy rate"]},
|
||
"us_cpi": {"currency": "USD", "label": "CPI US MoM", "unit": "%",
|
||
"names": ["cpi m/m", "core cpi m/m", "inflation rate mom"]},
|
||
"us_cpi_yoy": {"currency": "USD", "label": "CPI US YoY", "unit": "%",
|
||
"names": ["cpi y/y", "core cpi y/y", "inflation rate yoy"]},
|
||
"eu_cpi_yoy": {"currency": "EUR", "label": "HICP Eurozone YoY", "unit": "%",
|
||
"names": ["inflation rate yoy", "hicp", "cpi y/y"]},
|
||
"us_nfp": {"currency": "USD", "label": "NFP US", "unit": "K",
|
||
"names": ["non-farm employment change", "nfp", "non farm payrolls"]},
|
||
"us_pmi": {"currency": "USD", "label": "PMI US", "unit": "pts",
|
||
"names": ["ism manufacturing pmi", "ism services pmi", "s&p global manufacturing"]},
|
||
"eu_pmi": {"currency": "EUR", "label": "PMI Eurozone", "unit": "pts",
|
||
"names": ["manufacturing pmi", "services pmi", "composite pmi"]},
|
||
"us_gdp": {"currency": "USD", "label": "GDP US QoQ", "unit": "%",
|
||
"names": ["gdp growth rate qoq", "gdp q/q", "gdp qoq"]},
|
||
"eu_gdp": {"currency": "EUR", "label": "GDP Eurozone", "unit": "%",
|
||
"names": ["gdp growth rate qoq", "gdp growth rate yoy"]},
|
||
"us_unemployment": {"currency": "USD", "label": "Chômage US", "unit": "%",
|
||
"names": ["unemployment rate"]},
|
||
"eu_unemployment": {"currency": "EUR", "label": "Chômage Eurozone", "unit": "%",
|
||
"names": ["unemployment rate"]},
|
||
"us_retail_sales": {"currency": "USD", "label": "Ventes détail US", "unit": "%",
|
||
"names": ["retail sales m/m", "retail sales mom", "core retail sales"]},
|
||
}
|
||
|
||
|
||
def _parse_ff_num(s: Optional[str]) -> Optional[float]:
|
||
"""Parse une valeur ff_calendar (ex: '4.50%', '2.1K', '102.3') → float."""
|
||
if not s:
|
||
return None
|
||
t = str(s).strip().upper()
|
||
try:
|
||
if t.endswith('K'): return float(t[:-1]) * 1_000
|
||
if t.endswith('M'): return float(t[:-1]) * 1_000_000
|
||
if t.endswith('B'): return float(t[:-1]) * 1_000_000_000
|
||
return float(t.replace('%', '').replace(',', ''))
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def build_macro_node_timeline(
|
||
conn, macro_key: str, date_from: date_type, date_to: date_type
|
||
) -> dict[str, float]:
|
||
"""
|
||
Reconstruit une série temporelle journalière {date_str: value} pour un macro_key.
|
||
|
||
Algorithme :
|
||
1. Requête ff_calendar sur la monnaie + patterns du macro_key (±90j de marge)
|
||
2. Extrait les valeurs connues : actual_value pour les dates passées,
|
||
forecast_value (ou previous si absent) pour les dates futures
|
||
3. Interpolation linéaire entre les points connus → courbe daily lisse
|
||
4. Avant le premier point connu : valeur du premier point (extrapolation plate)
|
||
5. Après le dernier point connu : valeur du dernier point (extrapolation plate)
|
||
"""
|
||
meta = FF_MACRO_KEYS.get(macro_key)
|
||
if not meta:
|
||
return {}
|
||
|
||
currency = meta["currency"]
|
||
patterns = meta["names"]
|
||
today = date_type.today()
|
||
|
||
# Wide window: look back up to 2 years to capture last known rate decision
|
||
# (rate decisions can be 6+ weeks apart; CSV data may lag by months)
|
||
q_from = str(date_from - timedelta(days=730))
|
||
q_to = str(date_to + timedelta(days=180))
|
||
|
||
try:
|
||
rows = conn.execute(
|
||
"""SELECT event_date, event_name, actual_value, forecast_value, previous_value
|
||
FROM ff_calendar
|
||
WHERE currency=? AND event_date>=? AND event_date<=?
|
||
ORDER BY event_date ASC""",
|
||
(currency, q_from, q_to)
|
||
).fetchall()
|
||
except Exception:
|
||
return {}
|
||
|
||
# Filter by name pattern
|
||
matched = [
|
||
dict(r) for r in rows
|
||
if any(p in r["event_name"].lower() for p in patterns)
|
||
]
|
||
if not matched:
|
||
return {}
|
||
|
||
# Deduplicate by date (keep first match per date — most specific pattern wins)
|
||
seen: set[str] = set()
|
||
deduped = []
|
||
for ev in matched:
|
||
if ev["event_date"] not in seen:
|
||
seen.add(ev["event_date"])
|
||
deduped.append(ev)
|
||
|
||
# Build known (date, value) anchor points
|
||
known: list[tuple[date_type, float]] = []
|
||
for ev in deduped:
|
||
try:
|
||
ev_date = date_type.fromisoformat(ev["event_date"])
|
||
except ValueError:
|
||
continue
|
||
|
||
if ev_date <= today:
|
||
# Past event: prefer actual, fall back to forecast then previous
|
||
v = _parse_ff_num(ev.get("actual_value")) \
|
||
or _parse_ff_num(ev.get("forecast_value")) \
|
||
or _parse_ff_num(ev.get("previous_value"))
|
||
else:
|
||
# Future event: use forecast, fall back to previous
|
||
v = _parse_ff_num(ev.get("forecast_value")) \
|
||
or _parse_ff_num(ev.get("previous_value"))
|
||
|
||
if v is not None:
|
||
known.append((ev_date, v))
|
||
|
||
if not known:
|
||
return {}
|
||
|
||
known.sort(key=lambda x: x[0])
|
||
|
||
# Build daily timeline via linear interpolation
|
||
result: dict[str, float] = {}
|
||
cur = date_from
|
||
while cur <= date_to:
|
||
cur_str = str(cur)
|
||
|
||
# Find surrounding anchor points
|
||
prev_k: Optional[tuple[date_type, float]] = None
|
||
next_k: Optional[tuple[date_type, float]] = None
|
||
for k_date, k_val in known:
|
||
if k_date <= cur:
|
||
prev_k = (k_date, k_val)
|
||
elif next_k is None:
|
||
next_k = (k_date, k_val)
|
||
break
|
||
|
||
if prev_k is None and next_k is None:
|
||
pass # no data at all (shouldn't happen given the wide window)
|
||
elif prev_k is None:
|
||
# Before first known anchor: flat at first value
|
||
result[cur_str] = next_k[1] # type: ignore[index]
|
||
elif next_k is None:
|
||
# After last known anchor: flat at last value
|
||
result[cur_str] = prev_k[1]
|
||
else:
|
||
# Interpolate linearly between prev and next anchor
|
||
total_d = (next_k[0] - prev_k[0]).days
|
||
elapsed = (cur - prev_k[0]).days
|
||
frac = elapsed / total_d if total_d > 0 else 0.0
|
||
result[cur_str] = round(prev_k[1] + frac * (next_k[1] - prev_k[1]), 4)
|
||
|
||
cur += timedelta(days=1)
|
||
|
||
return result
|
||
|
||
|
||
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
|
||
|
||
|
||
# ── Macro sync — lecture directe ff_calendar → overrides ──────────────────────
|
||
|
||
def get_macro_sync_items(conn, instrument: str) -> list[dict]:
|
||
"""
|
||
Pour chaque nœud avec macro_key dans le graphe, retourne :
|
||
- actual_value : dernière valeur publiée (passé)
|
||
- actual_date : date de cette publication
|
||
- forecast_value: forecast du prochain événement (futur)
|
||
- forecast_date / next_event_name / days_until
|
||
- current_override : valeur déjà settée en DB (ou None)
|
||
Utilisé par le bouton "Sync Marché" pour pré-remplir le graphe.
|
||
"""
|
||
inst_upper = instrument.upper()
|
||
row = conn.execute(
|
||
"SELECT graph_json FROM instrument_models WHERE instrument=?", (inst_upper,)
|
||
).fetchone()
|
||
if not row:
|
||
return []
|
||
|
||
graph_def = json.loads(row["graph_json"])
|
||
today = date_type.today()
|
||
today_str = str(today)
|
||
q_from = str(today - timedelta(days=730))
|
||
q_to = str(today + timedelta(days=180))
|
||
|
||
overrides = {r["node_id"]: float(r["value"]) for r in conn.execute(
|
||
"SELECT node_id, value FROM instrument_node_overrides WHERE instrument=?", (inst_upper,)
|
||
).fetchall()}
|
||
|
||
items = []
|
||
for node in graph_def.get("nodes", []):
|
||
mk = node.get("macro_key")
|
||
if not mk or node.get("node_type") != "input_manual":
|
||
continue
|
||
mk_info = FF_MACRO_KEYS.get(mk)
|
||
if not mk_info:
|
||
continue
|
||
|
||
currency = mk_info["currency"]
|
||
patterns = mk_info["names"]
|
||
|
||
try:
|
||
rows = conn.execute(
|
||
"""SELECT event_date, event_name, actual_value, forecast_value, previous_value
|
||
FROM ff_calendar
|
||
WHERE currency=? AND event_date>=? AND event_date<=?
|
||
ORDER BY event_date ASC""",
|
||
(currency, q_from, q_to)
|
||
).fetchall()
|
||
except Exception:
|
||
rows = []
|
||
|
||
matched = [
|
||
dict(r) for r in rows
|
||
if any(p in str(r["event_name"]).lower() for p in patterns)
|
||
]
|
||
|
||
# Deduplicate by date
|
||
seen: set[str] = set()
|
||
deduped = []
|
||
for ev in matched:
|
||
if ev["event_date"] not in seen:
|
||
seen.add(ev["event_date"])
|
||
deduped.append(ev)
|
||
|
||
# Latest actual (most recent past event with actual_value)
|
||
past = [e for e in deduped if e["event_date"] <= today_str]
|
||
actual_val: Optional[float] = None
|
||
actual_date: Optional[str] = None
|
||
for ev in reversed(past):
|
||
v = _parse_ff_num(ev.get("actual_value"))
|
||
if v is not None:
|
||
actual_val = v
|
||
actual_date = ev["event_date"]
|
||
break
|
||
|
||
# Next forecast (first future event)
|
||
future = [e for e in deduped if e["event_date"] > today_str]
|
||
forecast_val: Optional[float] = None
|
||
forecast_date: Optional[str] = None
|
||
next_event_name: Optional[str] = None
|
||
days_until: Optional[int] = None
|
||
if future:
|
||
nxt = future[0]
|
||
forecast_val = (
|
||
_parse_ff_num(nxt.get("forecast_value"))
|
||
or _parse_ff_num(nxt.get("previous_value"))
|
||
)
|
||
forecast_date = nxt["event_date"]
|
||
next_event_name = nxt["event_name"]
|
||
try:
|
||
days_until = (date_type.fromisoformat(forecast_date) - today).days
|
||
except Exception:
|
||
days_until = None
|
||
|
||
# Unit-aware post-conversion
|
||
# The FF Calendar stores NFP in absolute (e.g. 228000), node expects K (228)
|
||
# PMI stored as absolute value (54.7), node expects deviation from 50 (4.7)
|
||
unit = node.get("unit", "")
|
||
def _convert(v: Optional[float]) -> Optional[float]:
|
||
if v is None:
|
||
return None
|
||
if unit == "K" and abs(v) >= 1_000:
|
||
return round(v / 1_000, 1)
|
||
if unit == "pts" and v > 10: # PMI > 10 → is absolute, subtract 50
|
||
return round(v - 50.0, 1)
|
||
return round(v, 4)
|
||
|
||
items.append({
|
||
"node_id": node["id"],
|
||
"node_label": node["label"],
|
||
"macro_key": mk,
|
||
"unit": unit,
|
||
"coefficient_to_pips": node.get("coefficient_to_pips", 1),
|
||
"actual_value": _convert(actual_val),
|
||
"actual_date": actual_date,
|
||
"forecast_value": _convert(forecast_val),
|
||
"forecast_date": forecast_date,
|
||
"next_event_name": next_event_name,
|
||
"days_until_forecast": days_until,
|
||
"current_override": overrides.get(node["id"]),
|
||
})
|
||
|
||
return items
|
||
|
||
|
||
# ── 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)
|
||
# → slope à x=0 : coefficient_to_pips (identique au linéaire)
|
||
# → max pips : coefficient_to_pips * scale (jamais dépassé)
|
||
_SATURATION_SCALES: dict[str, float] = {
|
||
"bps": 200.0, # différentiels de taux : sature autour ±300bps
|
||
"%": 3.0, # CPI / PIB / 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,
|
||
"k lots": 80.0, # positions CFTC
|
||
"$/bbl": 25.0,
|
||
"x": 5.0, # multiples PE
|
||
"ratio": 1.5,
|
||
"t": 80.0,
|
||
}
|
||
|
||
|
||
def _saturate_pips(native: float, coeff: float, unit: str, scale_override: Optional[float] = None) -> float:
|
||
"""Convertit une valeur native en pips avec saturation tanh.
|
||
Comportement identique au linéaire pour de petites valeurs,
|
||
sature progressivement pour les valeurs extrêmes.
|
||
"""
|
||
scale = scale_override or _SATURATION_SCALES.get(unit)
|
||
if scale and scale > 0:
|
||
return coeff * scale * math.tanh(native / scale)
|
||
return coeff * native
|
||
|
||
|
||
# ── Régimes de marché et poids adaptatifs ─────────────────────────────────────
|
||
# Chaque régime amplifie certaines couches (>1) et atténue les autres (<1)
|
||
# Les clés couvrent tous les noms de couches intermédiaires possibles.
|
||
|
||
REGIME_WEIGHTS: dict[str, dict[str, float]] = {
|
||
"MONETARY_DOMINANCE": {
|
||
# Fed/BCE dominent tout — taux, OIS, anticipations
|
||
"layer_monetary": 1.40, "layer_rates": 1.40,
|
||
"layer_growth": 0.80, "layer_uk_macro": 0.80, "layer_macro": 0.80,
|
||
"layer_risk": 0.70, "layer_risk_credit": 0.70, "layer_refuge": 0.70,
|
||
"layer_positioning": 1.10, "layer_policy": 1.10,
|
||
"layer_flows": 1.00, "layer_demand": 1.00,
|
||
"layer_tech_fundamental": 0.85, "layer_supply": 1.00,
|
||
"layer_china": 0.90, "layer_dollar": 1.20,
|
||
"layer_global": 0.80,
|
||
},
|
||
"GEOPOLITICAL_RISK": {
|
||
# Tensions géo → flight to safety, or, JPY, USD
|
||
"layer_monetary": 0.80, "layer_rates": 0.80,
|
||
"layer_growth": 0.90, "layer_uk_macro": 0.85, "layer_macro": 0.85,
|
||
"layer_risk": 1.50, "layer_risk_credit": 1.50, "layer_refuge": 1.60,
|
||
"layer_positioning": 1.10, "layer_policy": 1.30,
|
||
"layer_flows": 1.00, "layer_demand": 1.20,
|
||
"layer_tech_fundamental": 0.80, "layer_supply": 0.90,
|
||
"layer_china": 0.80, "layer_dollar": 0.90,
|
||
"layer_global": 1.30,
|
||
},
|
||
"CREDIT_STRESS": {
|
||
# Banking stress / liquidity crunch → risk-off massif
|
||
"layer_monetary": 0.90, "layer_rates": 0.90,
|
||
"layer_growth": 1.10, "layer_uk_macro": 1.10, "layer_macro": 1.10,
|
||
"layer_risk": 1.60, "layer_risk_credit": 1.70, "layer_refuge": 1.50,
|
||
"layer_positioning": 0.80, "layer_policy": 0.80,
|
||
"layer_flows": 0.70, "layer_demand": 0.80,
|
||
"layer_tech_fundamental": 0.75, "layer_supply": 0.90,
|
||
"layer_china": 0.85, "layer_dollar": 1.10,
|
||
"layer_global": 1.20,
|
||
},
|
||
"GROWTH_SCARE": {
|
||
# Récession / choc croissance → pivots BC, safe haven, EM sell
|
||
"layer_monetary": 1.10, "layer_rates": 1.10,
|
||
"layer_growth": 1.50, "layer_uk_macro": 1.50, "layer_macro": 1.50,
|
||
"layer_risk": 1.20, "layer_risk_credit": 1.20, "layer_refuge": 1.30,
|
||
"layer_positioning": 0.90, "layer_policy": 0.90,
|
||
"layer_flows": 0.85, "layer_demand": 0.80,
|
||
"layer_tech_fundamental": 0.70, "layer_supply": 0.90,
|
||
"layer_china": 1.30, "layer_dollar": 0.90,
|
||
"layer_global": 1.10,
|
||
},
|
||
"COMMODITY_SHOCK": {
|
||
# Choc énergie/matières premières → inflation, EM, or
|
||
"layer_monetary": 1.10, "layer_rates": 1.10,
|
||
"layer_growth": 1.20, "layer_uk_macro": 1.10, "layer_macro": 1.20,
|
||
"layer_risk": 1.10, "layer_risk_credit": 1.00, "layer_refuge": 1.20,
|
||
"layer_positioning": 1.00, "layer_policy": 1.00,
|
||
"layer_flows": 1.00, "layer_demand": 1.40,
|
||
"layer_tech_fundamental": 0.90, "layer_supply": 1.20,
|
||
"layer_china": 1.20, "layer_dollar": 0.95,
|
||
"layer_global": 1.10,
|
||
},
|
||
"BALANCED": {
|
||
# Régime neutre : aucune amplification
|
||
},
|
||
}
|
||
|
||
# Mapping catégories d'events → régimes candidats
|
||
_CAT_TO_REGIME: dict[str, str] = {
|
||
"central_bank": "MONETARY_DOMINANCE",
|
||
"monetary_shock": "MONETARY_DOMINANCE",
|
||
"geopolitical": "GEOPOLITICAL_RISK",
|
||
"trade_policy": "GEOPOLITICAL_RISK",
|
||
"credit_stress": "CREDIT_STRESS",
|
||
"growth_shock": "GROWTH_SCARE",
|
||
"commodity": "COMMODITY_SHOCK",
|
||
"sentiment": "BALANCED",
|
||
"technical": "BALANCED",
|
||
"positioning": "BALANCED",
|
||
"unclassified": "BALANCED",
|
||
}
|
||
|
||
# ── Lifecycle & decay ──────────────────────────────────────────────────────────
|
||
|
||
def _decay(days: int, absorption: int, dtype: str) -> float:
|
||
if days < 0: return 0.0
|
||
if dtype == "step": return 1.0 if days <= absorption else 0.0
|
||
if dtype == "linear": return max(0.0, 1.0 - days / max(absorption, 1))
|
||
lam = 3.0 / max(absorption, 1)
|
||
return math.exp(-lam * days)
|
||
|
||
|
||
def _lifecycle(days: int, rise: int, plateau: int, absorption: int, dtype: str) -> float:
|
||
"""Montée linéaire → plateau → décroissance (exp/linear/step)."""
|
||
if days < 0: return 0.0
|
||
if days < rise: return days / max(rise, 1)
|
||
if days < rise + plateau: return 1.0
|
||
return _decay(days - rise - plateau, absorption, dtype)
|
||
|
||
|
||
# ── Category labels ────────────────────────────────────────────────────────────
|
||
CAT_LABELS: dict[str, str] = {
|
||
"monetary":"Monétaire","inflation":"Inflation","macro":"Macro/Croissance",
|
||
"political":"Politique","flows":"Flux & Réserves","positioning":"Positionnement",
|
||
"sentiment":"Sentiment & Risque","credit":"Crédit","earnings":"Bénéfices",
|
||
"valuation":"Valorisation","tech":"Technologie","commodity":"Commodités",
|
||
"supply":"Offre","output":"Résultat","intermediate":"Couche intermédiaire",
|
||
# event categories
|
||
"central_bank":"Banques Centrales","monetary_shock":"Surprise Macro",
|
||
"geopolitical":"Géopolitique","trade_policy":"Commerce / Tarifs",
|
||
"growth_shock":"Choc Croissance","credit_stress":"Stress Crédit",
|
||
"technical":"Technique","sentiment":"Sentiment",
|
||
"positioning":"Positionnement","unclassified":"Non Classifié",
|
||
}
|
||
|
||
# ── Helper to build formula from list of node IDs ──────────────────────────────
|
||
def _sum_formula(*node_ids: str) -> str:
|
||
return " + ".join(node_ids)
|
||
|
||
|
||
# ── Built-in instrument model definitions ──────────────────────────────────────
|
||
# Each model defines:
|
||
# nodes : list of node dicts
|
||
# output_node : id of the output node
|
||
#
|
||
# Node schema:
|
||
# id, label, node_type (input_event|input_manual|intermediate|output),
|
||
# category, unit (for manual), coefficient_to_pips (for manual),
|
||
# event_category (for event nodes), formula (for intermediate/output),
|
||
# description, display_col (0=inputs-event, 1=inputs-manual, 2=intermediate, 3=output)
|
||
#
|
||
# All intermediate/output values are in pips.
|
||
# For input_manual : value_in_native_unit * coefficient_to_pips = pips injected into graph.
|
||
|
||
INSTRUMENT_MODELS: dict[str, dict] = {
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
"EURUSD": {
|
||
"name": "EUR/USD",
|
||
"description": "Graphe causal pondéré 7 couches : macro fondamentaux → hawkishness → dynamiques marché → taux & demande → positionnement → flux → prix",
|
||
"output_node": "eurusd",
|
||
"price_intercept": 1.10,
|
||
"pip_to_price": 0.0001,
|
||
"yf_ticker": "EURUSD=X",
|
||
"col_labels": ["Variables Macro", "Politique Monétaire", "Dynamiques Marché", "Taux & Demande", "Positionnement", "Order Flow", "Output"],
|
||
"nodes": [
|
||
# ── Chocs CT : événements court terme ─────────────────────────────────────
|
||
# display_col:-1 → cachés dans le DAG visuel, additifs dans la formule output
|
||
{"id":"in_cb", "label":"Choc Banques Centrales","node_type":"input_event","category":"central_bank","unit":"pips","display_col":-1,"event_category":"central_bank","description":"Surprise Fed/BCE : décision inattendue, guidance. Choc CT additionné à l'output."},
|
||
{"id":"in_geo", "label":"Choc Géopolitique", "node_type":"input_event","category":"geopolitical","unit":"pips","display_col":-1,"event_category":"geopolitical","description":"Conflits, sanctions → flight to USD. Choc CT."},
|
||
{"id":"in_trade", "label":"Choc Commercial", "node_type":"input_event","category":"trade_policy","unit":"pips","display_col":-1,"event_category":"trade_policy","description":"Tarifs US-UE. Choc CT."},
|
||
{"id":"in_credit","label":"Stress Crédit", "node_type":"input_event","category":"credit_stress","unit":"pips","display_col":-1,"event_category":"credit_stress","description":"Stress bancaire → safe haven USD. Choc CT."},
|
||
|
||
# ── Layer 0 (col 0) : Variables macro fondamentales ───────────────────────
|
||
# Toutes auto-synchronisées via FF Calendar (macro_key)
|
||
# Coefficients calibrés : au neutre (all=0) output=0, range typique ±200 pips
|
||
{"id":"fed_rate", "label":"Taux Fed (%)", "node_type":"input_manual","category":"monetary","unit":"%","coefficient_to_pips":-60, "macro_key":"fed_rate", "display_col":0,"description":"Taux directeur Fed en %. Hausse → USD fort → EURUSD ↓. Dominant driver."},
|
||
{"id":"ecb_rate", "label":"Taux BCE (%)", "node_type":"input_manual","category":"monetary","unit":"%","coefficient_to_pips":+60, "macro_key":"ecb_rate", "display_col":0,"description":"Taux directeur BCE en %. Hausse → EUR fort → EURUSD ↑."},
|
||
{"id":"us_cpi", "label":"CPI US YoY (%)", "node_type":"input_manual","category":"inflation","unit":"%","coefficient_to_pips":-20,"macro_key":"us_cpi_yoy","display_col":0,"description":"Inflation US annuelle. Hausse → Fed hawkish → USD ↑."},
|
||
{"id":"eu_cpi", "label":"HICP Eurozone (%)", "node_type":"input_manual","category":"inflation","unit":"%","coefficient_to_pips":+20,"macro_key":"eu_cpi_yoy","display_col":0,"description":"Inflation EU annuelle. Hausse → BCE hawkish → EUR ↑."},
|
||
{"id":"us_growth","label":"GDP US QoQ (%)", "node_type":"input_manual","category":"growth","unit":"%","coefficient_to_pips":-30, "macro_key":"us_gdp", "display_col":0,"description":"Croissance US trimestrielle. Surperformance → USD dominant."},
|
||
{"id":"eu_growth","label":"GDP Eurozone QoQ (%)","node_type":"input_manual","category":"growth","unit":"%","coefficient_to_pips":+30, "macro_key":"eu_gdp", "display_col":0,"description":"Croissance EU trimestrielle. Surperformance → EUR dominant."},
|
||
{"id":"us_nfp", "label":"NFP US (K/mois)", "node_type":"input_manual","category":"labor","unit":"K","coefficient_to_pips":-0.08,"macro_key":"us_nfp", "display_col":0,"description":"Emplois non-agricoles. Fort (>200K) → USD hawkish."},
|
||
{"id":"eu_pmi", "label":"PMI EU (écart/50)", "node_type":"input_manual","category":"activity","unit":"pts","coefficient_to_pips":+5,"macro_key":"eu_pmi", "display_col":0,"description":"PMI EU écart/50. Positif = expansion EU → EUR achetée."},
|
||
{"id":"us_pmi", "label":"PMI US (écart/50)", "node_type":"input_manual","category":"activity","unit":"pts","coefficient_to_pips":-5,"macro_key":"us_pmi", "display_col":0,"description":"PMI US écart/50. Positif = expansion US → USD fort."},
|
||
|
||
# ── Layer 1 (col 1) : Politique monétaire ────────────────────────────────
|
||
# rate_lvl : différentiel de taux directs (driver dominant)
|
||
# ecb_hawk / fed_hawk : pression hawkish perçue (données macro → anticipations futures)
|
||
{"id":"rate_lvl", "label":"Niveaux Taux (BCE−Fed)",
|
||
"node_type":"intermediate","category":"monetary","unit":"pips","display_col":1,
|
||
"formula":"ecb_rate + fed_rate",
|
||
"description":"Différentiel de taux directeurs brut BCE−Fed (en pips). Driver dominant du pair."},
|
||
{"id":"ecb_hawk", "label":"BCE Hawkishness",
|
||
"node_type":"intermediate","category":"monetary","unit":"pips","display_col":1,
|
||
"formula":"0.55*eu_cpi + 0.30*eu_growth + 0.15*eu_pmi",
|
||
"description":"Pression hawkish BCE perçue : inflation + croissance + activité EU → anticipations taux CE."},
|
||
{"id":"fed_hawk", "label":"Fed Hawkishness",
|
||
"node_type":"intermediate","category":"monetary","unit":"pips","display_col":1,
|
||
"formula":"0.55*us_cpi + 0.30*us_growth + 0.12*us_nfp + 0.03*us_pmi",
|
||
"description":"Pression hawkish Fed perçue : inflation + croissance + emploi → anticipations taux US."},
|
||
|
||
# ── Layer 2 (col 2) : Dynamiques de marché ───────────────────────────────
|
||
# rate_diff : différentiel de hawkishness perçue (secondaire au rate_lvl)
|
||
# eu_equities / us_equities : proxies croissance/risque equity
|
||
# risk_on : appétit risque global
|
||
{"id":"rate_diff", "label":"Différentiel Hawkishness",
|
||
"node_type":"intermediate","category":"monetary","unit":"pips","display_col":2,
|
||
"formula":"ecb_hawk + fed_hawk",
|
||
"description":"Différentiel BCE−Fed de hawkishness. Positif = BCE perçue plus hawkish que Fed."},
|
||
{"id":"eu_equities","label":"EU Equities",
|
||
"node_type":"intermediate","category":"risk","unit":"pips","display_col":2,
|
||
"formula":"0.60*eu_growth + 0.40*eu_pmi",
|
||
"description":"Proxy performance equity EU. Croissance + activité → flux entrants zone euro."},
|
||
{"id":"us_equities","label":"US Equities",
|
||
"node_type":"intermediate","category":"risk","unit":"pips","display_col":2,
|
||
"formula":"0.60*us_growth + 0.40*us_pmi",
|
||
"description":"Proxy performance equity US. Expansion US → USD résilient, risk-on."},
|
||
{"id":"risk_on", "label":"Risk-On Global",
|
||
"node_type":"intermediate","category":"risk","unit":"pips","display_col":2,
|
||
"formula":"0.50*eu_equities + 0.30*us_equities + 0.20*eu_growth",
|
||
"description":"Appétit risque global. Risk-on → carry → EUR achetée vs USD."},
|
||
|
||
# ── Layer 3 (col 3) : Taux longs & Demande de change ─────────────────────
|
||
{"id":"us10y", "label":"US10Y Signal",
|
||
"node_type":"intermediate","category":"rates","unit":"pips","display_col":3,
|
||
"formula":"0.50*fed_hawk + 0.30*us_growth + 0.20*us_cpi",
|
||
"description":"Signal rendement US 10Y. Hausse → USD attractif → EURUSD ↓."},
|
||
{"id":"carry", "label":"Carry Trade EUR",
|
||
"node_type":"intermediate","category":"positioning","unit":"pips","display_col":3,
|
||
"formula":"0.65*rate_diff + 0.35*risk_on",
|
||
"description":"Attractivité carry EUR/USD. Positif = taux EU > taux US + marché calme → EUR long."},
|
||
{"id":"eur_demand","label":"EUR Demand",
|
||
"node_type":"intermediate","category":"flow","unit":"pips","display_col":3,
|
||
"formula":"0.55*eu_equities + 0.30*eu_growth + 0.15*eu_pmi",
|
||
"description":"Demande structurelle EUR : flux investissement zone euro, capitaux entrants."},
|
||
{"id":"usd_demand","label":"USD Demand",
|
||
"node_type":"intermediate","category":"flow","unit":"pips","display_col":3,
|
||
"formula":"0.55*us_equities + 0.30*us_growth + 0.15*us_nfp",
|
||
"description":"Demande structurelle USD. Forte = EURUSD ↓. (Valeurs déjà en signe négatif.)"},
|
||
|
||
# ── Layer 4 (col 4) : Positionnement & Microstructure ────────────────────
|
||
{"id":"cftc_usd_long","label":"CFTC USD Long",
|
||
"node_type":"intermediate","category":"positioning","unit":"pips","display_col":4,
|
||
"formula":"0.50*fed_hawk + 0.30*us10y + 0.20*rate_diff",
|
||
"description":"Positionnement spéculatif long USD (proxy CFTC). Négatif = longs USD → USD déjà saturé."},
|
||
{"id":"dealer_gamma","label":"Dealer Gamma",
|
||
"node_type":"intermediate","category":"positioning","unit":"pips","display_col":4,
|
||
"formula":"0.60*risk_on + 0.40*carry",
|
||
"description":"Position gamma dealers. Positif = marché calme, options équilibrées → tends à stabiliser."},
|
||
{"id":"liquidity", "label":"Liquidité EUR/USD",
|
||
"node_type":"intermediate","category":"flow","unit":"pips","display_col":4,
|
||
"formula":"0.50*eur_demand + 0.30*carry + 0.20*dealer_gamma",
|
||
"description":"Liquidité côté EUR. Profondeur acheteurs EUR institutionnels."},
|
||
|
||
# ── Layer 5 (col 5) : Order Flow synthétique ──────────────────────────────
|
||
{"id":"order_flow","label":"Order Flow Net",
|
||
"node_type":"intermediate","category":"flow","unit":"pips","display_col":5,
|
||
"formula":"0.35*eur_demand + 0.25*liquidity + 0.20*carry + 0.12*cftc_usd_long + 0.08*dealer_gamma",
|
||
"description":"Flux d'ordres net EUR/USD. Positif = pression acheteuse EUR. Driver direct du prix court terme."},
|
||
|
||
# ── Output (col 6) ────────────────────────────────────────────────────────
|
||
# Formule = contribution pondérée cross-couches (pas de double-comptage car nœuds distincts)
|
||
# + chocs CT additifs (in_cb, in_geo, in_trade, in_credit)
|
||
{"id":"eurusd", "label":"EUR/USD — Biais Net",
|
||
"node_type":"output","category":"output","unit":"pips","display_col":6,
|
||
"formula":"0.40*rate_lvl + 0.30*rate_diff + 0.20*order_flow + 0.06*eur_demand + 0.04*usd_demand + in_cb + in_geo + in_trade + in_credit",
|
||
"description":"Biais fondamental net EUR/USD en pips. Positif = haussier EUR. Dominé par différentiel taux + hawkishness + flux."},
|
||
]
|
||
},
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
"USDJPY": {
|
||
"name": "USD/JPY", "output_node": "usdjpy",
|
||
"description": "Carry & safe haven — yield diff 10Y + BoJ + risk appetite",
|
||
"price_intercept": 145.0,
|
||
"pip_to_price": 0.01,
|
||
"yf_ticker": "USDJPY=X",
|
||
"nodes": [
|
||
{"id":"in_cb", "label":"Banques Centrales", "node_type":"input_event","category":"central_bank", "unit":"pips","display_col":0,"event_category":"central_bank","description":"Fed/BoJ décisions."},
|
||
{"id":"in_macro", "label":"Surprises Macro", "node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"event_category":"monetary_shock","description":"NFP, CPI US, Tankan, CPI Japon."},
|
||
{"id":"in_geo", "label":"Risque Géopolitique", "node_type":"input_event","category":"geopolitical", "unit":"pips","display_col":0,"event_category":"geopolitical","description":"NK tensions → JPY safe haven demandé."},
|
||
{"id":"in_trade", "label":"Choc Commercial", "node_type":"input_event","category":"trade_policy", "unit":"pips","display_col":0,"event_category":"trade_policy","description":"Tarifs US-Japon."},
|
||
{"id":"in_growth", "label":"Choc Croissance", "node_type":"input_event","category":"growth_shock", "unit":"pips","display_col":0,"event_category":"growth_shock","description":"Récession → risk-off → JPY."},
|
||
{"id":"in_credit", "label":"Stress Crédit", "node_type":"input_event","category":"credit_stress","unit":"pips","display_col":0,"event_category":"credit_stress","description":"Stress → JPY refuge."},
|
||
{"id":"in_sentiment", "label":"Sentiment/Flux", "node_type":"input_event","category":"sentiment", "unit":"pips","display_col":0,"event_category":"sentiment","description":"Risk-on/off."},
|
||
{"id":"in_technical", "label":"Momentum Technique", "node_type":"input_event","category":"technical", "unit":"pips","display_col":0,"event_category":"technical","description":"Niveaux clés USD/JPY."},
|
||
{"id":"m_yield_diff", "label":"Diff. rendement 10Y US-JP","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips": 1.2,"display_col":1,"description":"Principal driver. Hausse → USD/JPY ↑"},
|
||
{"id":"m_fed_path", "label":"Anticipation Fed 12m", "node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips": 0.6,"display_col":1,"description":"Fed hike → USD/JPY ↑"},
|
||
{"id":"m_boj_stance", "label":"Biais BoJ (score)", "node_type":"input_manual","category":"monetary","unit":"score","coefficient_to_pips":-8.0,"display_col":1,"description":"Hawkish → JPY ↑ → USD/JPY ↓"},
|
||
{"id":"m_jgb_10y", "label":"Rendement JGB 10Y", "node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-0.8,"display_col":1,"description":"JGB yield ↑ → JPY attractif → paire ↓"},
|
||
{"id":"m_us_real_rate","label":"Taux réel US 10Y", "node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips": 0.6,"display_col":1,"description":"Taux réel US ↑ → USD ↑ vs JPY"},
|
||
{"id":"m_risk_appetite","label":"Appétit risque", "node_type":"input_manual","category":"sentiment","unit":"score","coefficient_to_pips":-1.2,"display_col":1,"description":"Risk-off → JPY safe haven → paire ↓"},
|
||
{"id":"m_vix", "label":"Niveau VIX", "node_type":"input_manual","category":"sentiment","unit":"pts","coefficient_to_pips":-0.8,"display_col":1,"description":"VIX spike → JPY refuge → paire ↓"},
|
||
{"id":"m_carry_momentum","label":"Momentum carry", "node_type":"input_manual","category":"positioning","unit":"score","coefficient_to_pips": 0.4,"display_col":1,"description":"Carry actif → acheteurs USD/JPY"},
|
||
{"id":"m_mof_risk", "label":"Risque intervention MoF","node_type":"input_manual","category":"political","unit":"score","coefficient_to_pips":-0.8,"display_col":1,"description":"Probabilité intervention MoF. Hausse → paire ↓ préventif"},
|
||
{"id":"m_cftc_jpy", "label":"Net short JPY (CoT)", "node_type":"input_manual","category":"positioning","unit":"k lots","coefficient_to_pips": 0.15,"display_col":1,"description":"Extreme short JPY → risque short squeeze → paire ↓"},
|
||
{"id":"layer_rates", "label":"▶ Différentiel Taux/Carry","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"in_cb + in_macro + m_yield_diff + m_fed_path + m_boj_stance + m_jgb_10y + m_us_real_rate + m_carry_momentum",
|
||
"description":"Yield differential, Fed vs BoJ, carry trade momentum."},
|
||
{"id":"layer_risk", "label":"▶ Risque & Safe Haven JPY","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"in_geo + in_credit + in_sentiment + m_risk_appetite + m_vix",
|
||
"description":"JPY comme refuge : risk-off, géopolitique, stress crédit."},
|
||
{"id":"layer_policy", "label":"▶ Politique & Positionnement","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"in_trade + in_technical + in_growth + m_mof_risk + m_cftc_jpy",
|
||
"description":"Intervention MoF, tarifs US-Japon, technique, CFTC."},
|
||
{"id":"usdjpy","label":"USD/JPY — Impact Net","node_type":"output","category":"output","unit":"pips","display_col":3,
|
||
"formula":"layer_rates + layer_risk + layer_policy","description":"Pression nette cumulée USD/JPY"},
|
||
]
|
||
},
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
"XAUUSD": {
|
||
"name": "XAU/USD (Or)", "output_node": "xauusd",
|
||
"description": "Or/Dollar — taux réels, dollar, géopolitique, banques centrales",
|
||
"price_intercept": 2800.0,
|
||
"pip_to_price": 1.0,
|
||
"yf_ticker": "GC=F",
|
||
"nodes": [
|
||
{"id":"in_cb", "label":"Banques Centrales", "node_type":"input_event","category":"central_bank", "unit":"pips","display_col":0,"event_category":"central_bank","description":"Fed (taux réels) → or."},
|
||
{"id":"in_macro", "label":"Surprises Macro", "node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"event_category":"monetary_shock","description":"CPI, PCE → anticipations taux réels → or."},
|
||
{"id":"in_geo", "label":"Risque Géopolitique", "node_type":"input_event","category":"geopolitical", "unit":"pips","display_col":0,"event_category":"geopolitical","description":"Conflits → refuge or maximal."},
|
||
{"id":"in_credit", "label":"Stress Crédit/Systémique","node_type":"input_event","category":"credit_stress","unit":"pips","display_col":0,"event_category":"credit_stress","description":"Crises bancaires → or refuge absolu."},
|
||
{"id":"in_growth", "label":"Choc Croissance", "node_type":"input_event","category":"growth_shock", "unit":"pips","display_col":0,"event_category":"growth_shock","description":"Récession → easing → or ↑."},
|
||
{"id":"in_trade", "label":"Choc Commercial", "node_type":"input_event","category":"trade_policy", "unit":"pips","display_col":0,"event_category":"trade_policy","description":"Incertitude → or refuge."},
|
||
{"id":"in_commodity","label":"Choc Commodités", "node_type":"input_event","category":"commodity", "unit":"pips","display_col":0,"event_category":"commodity","description":"Inflation inputs (énergie)."},
|
||
{"id":"in_sentiment","label":"Sentiment/Flux", "node_type":"input_event","category":"sentiment", "unit":"pips","display_col":0,"event_category":"sentiment","description":"Risk-off flows vers or."},
|
||
{"id":"in_technical","label":"Momentum Technique", "node_type":"input_event","category":"technical", "unit":"pips","display_col":0,"event_category":"technical","description":"Cassures ATH, niveaux or."},
|
||
{"id":"m_us_real_rate","label":"Taux réel US 10Y (TIPS)","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-2.5,"display_col":1,"description":"DRIVER PRINCIPAL. Chaque -10bps ≈ +25 pips or."},
|
||
{"id":"m_dxy", "label":"Indice Dollar (DXY)", "node_type":"input_manual","category":"monetary","unit":"pts","coefficient_to_pips":-3.0,"display_col":1,"description":"Or libellé USD → DXY ↑ = or ↓ mécaniquement."},
|
||
{"id":"m_fed_path", "label":"Anticipation Fed 12m","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-1.0,"display_col":1,"description":"Cuts attendus → taux réels ↓ → or ↑."},
|
||
{"id":"m_breakeven", "label":"Breakeven inflation 10Y","node_type":"input_manual","category":"inflation","unit":"bps","coefficient_to_pips": 1.5,"display_col":1,"description":"Anticipations inflation → or comme couverture ↑."},
|
||
{"id":"m_vix", "label":"Niveau VIX", "node_type":"input_manual","category":"sentiment","unit":"pts","coefficient_to_pips": 0.4,"display_col":1,"description":"VIX spike → or refuge."},
|
||
{"id":"m_cb_buying", "label":"Achats CB (tonnes/mois)","node_type":"input_manual","category":"flows","unit":"t","coefficient_to_pips": 2.0,"display_col":1,"description":"Achats banques centrales. Driver structurel majeur depuis 2022."},
|
||
{"id":"m_etf_flows", "label":"Flux ETF or (GLD, IAU)","node_type":"input_manual","category":"flows","unit":"tonnes","coefficient_to_pips": 1.5,"display_col":1,"description":"Entrées ETF → demande physique → or ↑."},
|
||
{"id":"m_cftc_gold", "label":"Net long or (CoT)", "node_type":"input_manual","category":"positioning","unit":"k lots","coefficient_to_pips": 0.15,"display_col":1,"description":"Extrême long → risque liquidation."},
|
||
{"id":"m_fiscal_risk","label":"Risque fiscal US (score)","node_type":"input_manual","category":"macro","unit":"score","coefficient_to_pips": 0.5,"display_col":1,"description":"Déficit/dette US → doutes USD → or refuge."},
|
||
{"id":"m_india_china","label":"Demande physique Inde/Chine","node_type":"input_manual","category":"flows","unit":"score","coefficient_to_pips": 0.4,"display_col":1,"description":"Joaillerie, investment. Saisonnalité."},
|
||
{"id":"layer_rates","label":"▶ Taux Réels & Dollar","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"in_cb + in_macro + m_us_real_rate + m_dxy + m_fed_path + m_breakeven",
|
||
"description":"Taux réels US = driver fondamental or. DXY = mécanisme de transmission."},
|
||
{"id":"layer_demand","label":"▶ Demande & Flux", "node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"m_cb_buying + m_etf_flows + m_cftc_gold + m_india_china + m_fiscal_risk",
|
||
"description":"Acheteurs physiques et financiers : CB, ETF, CFTC, Asie."},
|
||
{"id":"layer_refuge","label":"▶ Valeur Refuge", "node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"in_geo + in_credit + in_growth + in_trade + in_sentiment + m_vix",
|
||
"description":"Or comme couverture : géopolitique, stress systémique, récession."},
|
||
{"id":"layer_technical","label":"▶ Technique & Momentum","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"in_technical + in_commodity",
|
||
"description":"Signaux techniques et commodités (coûts extraction)."},
|
||
{"id":"xauusd","label":"XAU/USD — Impact Net","node_type":"output","category":"output","unit":"pips","display_col":3,
|
||
"formula":"layer_rates + layer_demand + layer_refuge + layer_technical","description":"Pression nette cumulée or/USD"},
|
||
]
|
||
},
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
"SP500": {
|
||
"name": "S&P 500", "output_node": "sp500",
|
||
"description": "Indice actions US — taux, bénéfices, risque, liquidités",
|
||
"price_intercept": 5000.0,
|
||
"pip_to_price": 1.0,
|
||
"yf_ticker": "^GSPC",
|
||
"nodes": [
|
||
{"id":"in_cb", "label":"Banques Centrales", "node_type":"input_event","category":"central_bank","unit":"pips","display_col":0,"event_category":"central_bank","description":"Fed pivot/hike → SP500 directement."},
|
||
{"id":"in_macro", "label":"Surprises Macro", "node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"event_category":"monetary_shock","description":"NFP, CPI, PIB US."},
|
||
{"id":"in_geo", "label":"Risque Géopolitique", "node_type":"input_event","category":"geopolitical","unit":"pips","display_col":0,"event_category":"geopolitical","description":"Risk-off → SP500 vendu."},
|
||
{"id":"in_trade", "label":"Choc Commercial", "node_type":"input_event","category":"trade_policy","unit":"pips","display_col":0,"event_category":"trade_policy","description":"Tarifs → marges bénéficiaires SP500."},
|
||
{"id":"in_growth", "label":"Choc Croissance", "node_type":"input_event","category":"growth_shock","unit":"pips","display_col":0,"event_category":"growth_shock","description":"Récession → SP500 ↓ massif."},
|
||
{"id":"in_credit", "label":"Stress Crédit", "node_type":"input_event","category":"credit_stress","unit":"pips","display_col":0,"event_category":"credit_stress","description":"Banking stress → SP500 ↓ brutal."},
|
||
{"id":"in_commodity","label":"Choc Commodités", "node_type":"input_event","category":"commodity","unit":"pips","display_col":0,"event_category":"commodity","description":"Énergie → marges opérationnelles."},
|
||
{"id":"in_sentiment","label":"Sentiment/Positionnement","node_type":"input_event","category":"sentiment","unit":"pips","display_col":0,"event_category":"sentiment","description":"CTA, hedge fund flows."},
|
||
{"id":"in_technical","label":"Momentum Technique", "node_type":"input_event","category":"technical","unit":"pips","display_col":0,"event_category":"technical","description":"MA200, cassures."},
|
||
{"id":"m_fed_path", "label":"Anticipation Fed 12m", "node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-0.6,"display_col":1,"description":"Cuts → taux discount ↓ → PE expansion → SP500 ↑"},
|
||
{"id":"m_real_rate","label":"Taux réel US 10Y", "node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-1.5,"display_col":1,"description":"Taux réel = taux d'actualisation. Hausse → PE compression → SP500 ↓"},
|
||
{"id":"m_fin_cond", "label":"Conditions financières", "node_type":"input_manual","category":"monetary","unit":"score","coefficient_to_pips": 1.5,"display_col":1,"description":"Desserrement → SP500 ↑"},
|
||
{"id":"m_eps_growth","label":"Croissance BPA attendue","node_type":"input_manual","category":"earnings","unit":"%","coefficient_to_pips": 8.0,"display_col":1,"description":"+1% BPA revision ≈ +8 pts SP500"},
|
||
{"id":"m_eps_revision","label":"Ratio révisions BPA","node_type":"input_manual","category":"earnings","unit":"ratio","coefficient_to_pips": 2.0,"display_col":1,"description":"Ratio haussier/baissier. Fort driver momentum."},
|
||
{"id":"m_pe", "label":"Multiple PE forward", "node_type":"input_manual","category":"valuation","unit":"x","coefficient_to_pips":12.0,"display_col":1,"description":"+1x PE ≈ +12 pts SP500"},
|
||
{"id":"m_ig_spread","label":"Spread crédit IG (bps)","node_type":"input_manual","category":"credit","unit":"bps","coefficient_to_pips":-0.8,"display_col":1,"description":"Spread IG hausse → conditions crédit durcissent → SP500 ↓"},
|
||
{"id":"m_buybacks", "label":"Volume rachats actions","node_type":"input_manual","category":"flows","unit":"Mds$/sem","coefficient_to_pips": 0.5,"display_col":1,"description":"Flux rachats = support technique majeur"},
|
||
{"id":"m_vix", "label":"Niveau VIX", "node_type":"input_manual","category":"sentiment","unit":"pts","coefficient_to_pips":-0.8,"display_col":1,"description":"VIX spike → risk-off → SP500 ↓"},
|
||
{"id":"m_geo_risk_prem","label":"Prime risque géopolitique","node_type":"input_manual","category":"political","unit":"score","coefficient_to_pips":-0.5,"display_col":1,"description":"Tensions géopolitiques → incertitude → SP500 ↓"},
|
||
{"id":"m_gdp", "label":"Croissance PIB US", "node_type":"input_manual","category":"macro","unit":"%","coefficient_to_pips": 5.0,"display_col":1,"description":"Surprise haussière → SP500 ↑"},
|
||
{"id":"layer_monetary","label":"▶ Politique Monétaire","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"in_cb + in_macro + m_fed_path + m_real_rate + m_fin_cond",
|
||
"description":"Fed, taux réels, conditions financières — transmission directe sur PE."},
|
||
{"id":"layer_earnings","label":"▶ Bénéfices & Valorisation","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"m_eps_growth + m_eps_revision + m_pe + m_gdp",
|
||
"description":"BPA, révisions, multiple. Fondamental long terme."},
|
||
{"id":"layer_risk_credit","label":"▶ Risque & Crédit","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"in_geo + in_credit + in_growth + in_trade + in_commodity + m_ig_spread + m_vix + m_geo_risk_prem",
|
||
"description":"Risque systémique, géopolitique, crédit — driver de la prime de risque."},
|
||
{"id":"layer_flows","label":"▶ Flux & Positionnement","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"in_sentiment + in_technical + m_buybacks",
|
||
"description":"Flux institutionnels, retail, rachats, momentum."},
|
||
{"id":"sp500","label":"S&P 500 — Impact Net","node_type":"output","category":"output","unit":"pips","display_col":3,
|
||
"formula":"layer_monetary + layer_earnings + layer_risk_credit + layer_flows","description":"Pression nette cumulée S&P 500"},
|
||
]
|
||
},
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
"TLT": {
|
||
"name": "TLT (US Long Bonds)", "output_node": "tlt",
|
||
"description": "ETF obligations US 20Y+ — duration, inflation, récession, supply",
|
||
"price_intercept": 85.0,
|
||
"pip_to_price": 0.01,
|
||
"yf_ticker": "TLT",
|
||
"nodes": [
|
||
{"id":"in_cb", "label":"Banques Centrales","node_type":"input_event","category":"central_bank","unit":"pips","display_col":0,"event_category":"central_bank","description":"FOMC décisions/minutes → impact direct TLT."},
|
||
{"id":"in_macro", "label":"Surprises Macro", "node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"event_category":"monetary_shock","description":"CPI, PCE, NFP → réévaluation taux."},
|
||
{"id":"in_geo", "label":"Géopolitique", "node_type":"input_event","category":"geopolitical","unit":"pips","display_col":0,"event_category":"geopolitical","description":"Crises → flight to safety Treasuries."},
|
||
{"id":"in_growth", "label":"Choc Croissance", "node_type":"input_event","category":"growth_shock","unit":"pips","display_col":0,"event_category":"growth_shock","description":"Récession → TLT ↑ massif."},
|
||
{"id":"in_credit", "label":"Stress Crédit", "node_type":"input_event","category":"credit_stress","unit":"pips","display_col":0,"event_category":"credit_stress","description":"Banking stress → Treasuries demandés."},
|
||
{"id":"in_trade", "label":"Choc Commercial", "node_type":"input_event","category":"trade_policy","unit":"pips","display_col":0,"event_category":"trade_policy","description":"Tarifs → incertitude → Treasuries."},
|
||
{"id":"in_technical","label":"Technique", "node_type":"input_event","category":"technical","unit":"pips","display_col":0,"event_category":"technical","description":"Niveaux TLT, tendances."},
|
||
{"id":"in_sentiment","label":"Sentiment/Flux", "node_type":"input_event","category":"sentiment","unit":"pips","display_col":0,"event_category":"sentiment","description":"Flux obligataires institutionnels."},
|
||
{"id":"m_terminal_rate","label":"Taux terminal Fed","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-0.4,"display_col":1,"description":"Taux terminal ↑ → taux longs ↑ → TLT ↓ (duration ~18)"},
|
||
{"id":"m_us_10y", "label":"Rendement UST 10Y","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-0.3,"display_col":1,"description":"Chaque +1bps ≈ -$0.18 sur $100 TLT"},
|
||
{"id":"m_us_30y", "label":"Rendement UST 30Y","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-0.25,"display_col":1,"description":"TLT détient principalement des 20-30Y"},
|
||
{"id":"m_breakeven","label":"Breakeven inflation 10Y","node_type":"input_manual","category":"inflation","unit":"bps","coefficient_to_pips":-0.2,"display_col":1,"description":"Inflation anticipée ↑ → taux nominaux ↑ → TLT ↓"},
|
||
{"id":"m_recession_prob","label":"Probabilité récession 12m","node_type":"input_manual","category":"macro","unit":"%","coefficient_to_pips": 0.3,"display_col":1,"description":"Récession → flight to bonds → TLT ↑"},
|
||
{"id":"m_qt_pace", "label":"Rythme QT Fed (Mds$/mois)","node_type":"input_manual","category":"monetary","unit":"Mds$","coefficient_to_pips":-0.1,"display_col":1,"description":"QT = pression vendeuse sur Treasuries → TLT ↓"},
|
||
{"id":"m_deficit", "label":"Déficit fiscal US (%PIB)","node_type":"input_manual","category":"macro","unit":"%","coefficient_to_pips":-0.4,"display_col":1,"description":"Déficit → supply massive → pression vendeuse → TLT ↓"},
|
||
{"id":"m_term_prem","label":"Prime de terme (ACM)","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-0.3,"display_col":1,"description":"Prime de terme ↑ = extra rendement exigé → TLT ↓"},
|
||
{"id":"m_foreign_demand","label":"Demande étrangère Treasuries","node_type":"input_manual","category":"flows","unit":"Mds$","coefficient_to_pips": 0.05,"display_col":1,"description":"Achats CB étrangères (Chine, Japon) → TLT ↑"},
|
||
{"id":"m_vix", "label":"Niveau VIX", "node_type":"input_manual","category":"sentiment","unit":"pts","coefficient_to_pips": 0.5,"display_col":1,"description":"VIX spike → flight to quality → TLT ↑"},
|
||
{"id":"layer_rates","label":"▶ Taux & Politique Monétaire","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"in_cb + in_macro + m_terminal_rate + m_us_10y + m_us_30y + m_breakeven + m_qt_pace + m_term_prem",
|
||
"description":"Taux directeurs, duration, QT, inflation anticipée."},
|
||
{"id":"layer_supply","label":"▶ Supply & Demande Treasuries","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"m_deficit + m_foreign_demand",
|
||
"description":"Émission nette, achats étrangers — équilibre offre/demande marché obligataire."},
|
||
{"id":"layer_macro","label":"▶ Macro & Récession","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"in_growth + in_credit + in_trade + m_recession_prob",
|
||
"description":"Risque récession → flight to safety → TLT ↑."},
|
||
{"id":"layer_risk","label":"▶ Risque & Refuge","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"in_geo + in_sentiment + in_technical + m_vix",
|
||
"description":"Géopolitique, sentiment, VIX → demande refuge Treasuries."},
|
||
{"id":"tlt","label":"TLT — Impact Net","node_type":"output","category":"output","unit":"pips","display_col":3,
|
||
"formula":"layer_rates + layer_supply + layer_macro + layer_risk","description":"Pression nette cumulée TLT"},
|
||
]
|
||
},
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
"GBPUSD": {
|
||
"name": "GBP/USD", "output_node": "gbpusd",
|
||
"description": "Livre sterling/Dollar — BoE, données UK, risque politique",
|
||
"price_intercept": 1.26,
|
||
"pip_to_price": 0.0001,
|
||
"yf_ticker": "GBPUSD=X",
|
||
"nodes": [
|
||
{"id":"in_cb", "label":"Banques Centrales","node_type":"input_event","category":"central_bank","unit":"pips","display_col":0,"event_category":"central_bank","description":"BoE, Fed."},
|
||
{"id":"in_macro", "label":"Surprises Macro", "node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"event_category":"monetary_shock","description":"CPI UK/US, NFP, GDP UK."},
|
||
{"id":"in_geo", "label":"Géopolitique", "node_type":"input_event","category":"geopolitical","unit":"pips","display_col":0,"event_category":"geopolitical","description":"GBP comme devise cyclique, vendue en risk-off."},
|
||
{"id":"in_trade", "label":"Choc Commercial", "node_type":"input_event","category":"trade_policy","unit":"pips","display_col":0,"event_category":"trade_policy","description":"Tarifs US → UK exposé."},
|
||
{"id":"in_growth", "label":"Choc Croissance", "node_type":"input_event","category":"growth_shock","unit":"pips","display_col":0,"event_category":"growth_shock","description":"Récession UK."},
|
||
{"id":"in_credit", "label":"Stress Crédit", "node_type":"input_event","category":"credit_stress","unit":"pips","display_col":0,"event_category":"credit_stress","description":"Stress Gilts, banking UK."},
|
||
{"id":"in_technical","label":"Technique", "node_type":"input_event","category":"technical","unit":"pips","display_col":0,"event_category":"technical","description":"Niveaux clés cable."},
|
||
{"id":"in_sentiment","label":"Sentiment/Flux", "node_type":"input_event","category":"sentiment","unit":"pips","display_col":0,"event_category":"sentiment","description":"Positionnement GBP."},
|
||
{"id":"m_boe_path", "label":"Anticipation BoE 12m","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips": 0.7,"display_col":1,"description":"Hikes BoE → GBP ↑"},
|
||
{"id":"m_fed_path", "label":"Anticipation Fed 12m","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-0.7,"display_col":1,"description":"Hikes Fed → USD ↑ → paire ↓"},
|
||
{"id":"m_uk_cpi", "label":"CPI UK YoY", "node_type":"input_manual","category":"inflation","unit":"%","coefficient_to_pips": 0.7,"display_col":1,"description":"Persistance inflation → BoE hawkish → GBP ↑"},
|
||
{"id":"m_uk_gdp", "label":"Croissance PIB UK", "node_type":"input_manual","category":"macro","unit":"%","coefficient_to_pips": 3.0,"display_col":1,"description":"Surprise PIB UK → GBP ↑"},
|
||
{"id":"m_uk_pmi", "label":"PMI composite UK", "node_type":"input_manual","category":"macro","unit":"pts","coefficient_to_pips": 0.25,"display_col":1,"description":">50 = expansion UK → GBP ↑"},
|
||
{"id":"m_uk_pol_risk","label":"Risque politique UK","node_type":"input_manual","category":"political","unit":"score","coefficient_to_pips":-0.4,"display_col":1,"description":"Incertitude UK → GBP ↓"},
|
||
{"id":"m_risk_appetite","label":"Appétit risque","node_type":"input_manual","category":"sentiment","unit":"score","coefficient_to_pips": 0.4,"display_col":1,"description":"Risk-on → GBP comme devise cyclique ↑"},
|
||
{"id":"m_cftc_gbp", "label":"Positions nettes GBP","node_type":"input_manual","category":"positioning","unit":"k lots","coefficient_to_pips": 0.08,"display_col":1,"description":"Net long GBP CoT."},
|
||
{"id":"layer_monetary","label":"▶ Différentiel Monétaire","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"in_cb + in_macro + m_boe_path + m_fed_path + m_uk_cpi",
|
||
"description":"BoE vs Fed, inflation UK — driver principal GBP/USD."},
|
||
{"id":"layer_uk_macro","label":"▶ Fondamentaux UK","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"in_growth + m_uk_gdp + m_uk_pmi + m_uk_pol_risk",
|
||
"description":"Croissance, PMI, risque politique UK."},
|
||
{"id":"layer_global","label":"▶ Risque & Flux Globaux","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"in_geo + in_credit + in_trade + in_technical + in_sentiment + m_risk_appetite + m_cftc_gbp",
|
||
"description":"GBP exposé aux chocs globaux (devise cyclique)."},
|
||
{"id":"gbpusd","label":"GBP/USD — Impact Net","node_type":"output","category":"output","unit":"pips","display_col":3,
|
||
"formula":"layer_monetary + layer_uk_macro + layer_global","description":"Pression nette cumulée GBP/USD"},
|
||
]
|
||
},
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
"EEM": {
|
||
"name": "EEM (Marchés Émergents)", "output_node": "eem",
|
||
"description": "ETF EM — dollar, Chine, commodités, risk appetite",
|
||
"price_intercept": 42.0,
|
||
"pip_to_price": 0.01,
|
||
"yf_ticker": "EEM",
|
||
"nodes": [
|
||
{"id":"in_cb", "label":"Banques Centrales","node_type":"input_event","category":"central_bank","unit":"pips","display_col":0,"event_category":"central_bank","description":"Fed pivot → EM bénéficient du dollar faible."},
|
||
{"id":"in_macro", "label":"Surprises Macro", "node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"event_category":"monetary_shock","description":"Données Chine, US macro."},
|
||
{"id":"in_geo", "label":"Géopolitique", "node_type":"input_event","category":"geopolitical","unit":"pips","display_col":0,"event_category":"geopolitical","description":"Tensions régionales EM."},
|
||
{"id":"in_trade", "label":"Choc Commercial", "node_type":"input_event","category":"trade_policy","unit":"pips","display_col":0,"event_category":"trade_policy","description":"Tarifs US-Chine, sanctions."},
|
||
{"id":"in_growth", "label":"Choc Croissance", "node_type":"input_event","category":"growth_shock","unit":"pips","display_col":0,"event_category":"growth_shock","description":"Récession US/Chine → EEM ↓."},
|
||
{"id":"in_credit", "label":"Stress Crédit EM", "node_type":"input_event","category":"credit_stress","unit":"pips","display_col":0,"event_category":"credit_stress","description":"Stress souverain EM, crise devises."},
|
||
{"id":"in_commodity","label":"Choc Commodités", "node_type":"input_event","category":"commodity","unit":"pips","display_col":0,"event_category":"commodity","description":"Exportateurs EM profitent des commodités élevées."},
|
||
{"id":"in_sentiment","label":"Sentiment/Flux", "node_type":"input_event","category":"sentiment","unit":"pips","display_col":0,"event_category":"sentiment","description":"Flux ETF EM, risk-on/off."},
|
||
{"id":"in_technical","label":"Technique", "node_type":"input_event","category":"technical","unit":"pips","display_col":0,"event_category":"technical","description":"Niveaux EEM."},
|
||
{"id":"m_dxy_inv", "label":"Dollar (DXY) — impact inverse","node_type":"input_manual","category":"monetary","unit":"pts","coefficient_to_pips":-0.8,"display_col":1,"description":"USD fort → pression dettes EM → EEM ↓. DXY ↑ = EEM ↓"},
|
||
{"id":"m_us_real_rate","label":"Taux réel US 10Y","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-0.6,"display_col":1,"description":"Taux réel US ↑ → capitaux retournent US → EEM ↓"},
|
||
{"id":"m_fed_path", "label":"Anticipation Fed 12m","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-0.5,"display_col":1,"description":"Cuts Fed → dollar faible → EM avantageux → EEM ↑"},
|
||
{"id":"m_china_pmi","label":"PMI manufacturier Chine","node_type":"input_manual","category":"macro","unit":"pts","coefficient_to_pips": 0.6,"display_col":1,"description":"Chine ~28% index. Expansion → EEM ↑"},
|
||
{"id":"m_china_stimulus","label":"Stimulus Chine","node_type":"input_manual","category":"macro","unit":"score","coefficient_to_pips": 0.8,"display_col":1,"description":"PBOC/fiscal. Annonces majeures → EEM spike"},
|
||
{"id":"m_commodity_index","label":"Indice commodités","node_type":"input_manual","category":"commodity","unit":"score","coefficient_to_pips": 0.4,"display_col":1,"description":"Exportateurs EM (Brésil, Afrique du Sud) profitent."},
|
||
{"id":"m_em_spread","label":"Spread souverain EM (EMBI)","node_type":"input_manual","category":"credit","unit":"bps","coefficient_to_pips":-0.5,"display_col":1,"description":"Spread ↑ = stress EM → sorties → EEM ↓"},
|
||
{"id":"m_risk_appetite","label":"Appétit risque","node_type":"input_manual","category":"sentiment","unit":"score","coefficient_to_pips": 0.6,"display_col":1,"description":"Risk-on → search for yield EM → EEM ↑"},
|
||
{"id":"m_vix", "label":"Niveau VIX", "node_type":"input_manual","category":"sentiment","unit":"pts","coefficient_to_pips":-0.5,"display_col":1,"description":"VIX spike → sorties EM → EEM ↓"},
|
||
{"id":"layer_dollar","label":"▶ Dollar & Taux US","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"in_cb + in_macro + m_dxy_inv + m_us_real_rate + m_fed_path",
|
||
"description":"Dollar et taux réels = driver macro principal des EM."},
|
||
{"id":"layer_china","label":"▶ Chine & Croissance EM","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"m_china_pmi + m_china_stimulus + in_growth + m_commodity_index + in_commodity",
|
||
"description":"Moteur Chine + exportateurs commodités."},
|
||
{"id":"layer_risk","label":"▶ Risque & Flux EM","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"in_geo + in_credit + in_trade + m_em_spread + m_risk_appetite + m_vix + in_sentiment + in_technical",
|
||
"description":"Prime de risque EM, flux capitaux, sentiment global."},
|
||
{"id":"eem","label":"EEM — Impact Net","node_type":"output","category":"output","unit":"pips","display_col":3,
|
||
"formula":"layer_dollar + layer_china + layer_risk","description":"Pression nette cumulée EEM"},
|
||
]
|
||
},
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
"QQQ": {
|
||
"name": "QQQ (NASDAQ-100 Tech)", "output_node": "qqq",
|
||
"description": "Tech US — taux réels, bénéfices big tech, IA, réglementation",
|
||
"price_intercept": 480.0,
|
||
"pip_to_price": 0.10,
|
||
"yf_ticker": "QQQ",
|
||
"nodes": [
|
||
{"id":"in_cb", "label":"Banques Centrales","node_type":"input_event","category":"central_bank","unit":"pips","display_col":0,"event_category":"central_bank","description":"Fed pivot → QQQ amplificateur (duration longue)."},
|
||
{"id":"in_macro", "label":"Surprises Macro", "node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"event_category":"monetary_shock","description":"CPI, NFP → réévaluation Fed → QQQ."},
|
||
{"id":"in_geo", "label":"Géopolitique", "node_type":"input_event","category":"geopolitical","unit":"pips","display_col":0,"event_category":"geopolitical","description":"Risk-off → vente tech en premier."},
|
||
{"id":"in_trade", "label":"Choc Commercial", "node_type":"input_event","category":"trade_policy","unit":"pips","display_col":0,"event_category":"trade_policy","description":"Restrictions export chips US-Chine."},
|
||
{"id":"in_growth", "label":"Choc Croissance", "node_type":"input_event","category":"growth_shock","unit":"pips","display_col":0,"event_category":"growth_shock","description":"Récession → dépenses IT coupées."},
|
||
{"id":"in_credit", "label":"Stress Crédit", "node_type":"input_event","category":"credit_stress","unit":"pips","display_col":0,"event_category":"credit_stress","description":"Conditions financières → financement tech."},
|
||
{"id":"in_commodity","label":"Choc Commodités", "node_type":"input_event","category":"commodity","unit":"pips","display_col":0,"event_category":"commodity","description":"Énergie → data centers coûts."},
|
||
{"id":"in_sentiment","label":"Sentiment/Flux", "node_type":"input_event","category":"sentiment","unit":"pips","display_col":0,"event_category":"sentiment","description":"CTA, hedge fund tech positions."},
|
||
{"id":"in_technical","label":"Technique", "node_type":"input_event","category":"technical","unit":"pips","display_col":0,"event_category":"technical","description":"QQQ niveaux, MA200, cassures."},
|
||
{"id":"m_real_rate","label":"Taux réel US 10Y (duration)","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-1.5,"display_col":1,"description":"PRINCIPAL DRIVER : tech = duration longue. Taux réels ↑ → PE tech ↓ → QQQ ↓"},
|
||
{"id":"m_fed_path", "label":"Anticipation Fed 12m","node_type":"input_manual","category":"monetary","unit":"bps","coefficient_to_pips":-0.8,"display_col":1,"description":"Cuts → taux discount ↓ → PE tech expansion → QQQ ↑"},
|
||
{"id":"m_m7_eps", "label":"Révisions BPA Magnificent 7","node_type":"input_manual","category":"earnings","unit":"%","coefficient_to_pips":15.0,"display_col":1,"description":"+1% révision M7 ≈ +15 pts QQQ. AAPL, MSFT, NVDA, GOOGL, AMZN, META, TSLA"},
|
||
{"id":"m_ai_capex", "label":"Cycle capex IA","node_type":"input_manual","category":"tech","unit":"score","coefficient_to_pips": 0.8,"display_col":1,"description":"Hyperscalers capex, NVDA GPU demand. Momentum = QQQ ↑"},
|
||
{"id":"m_semi_cycle","label":"Cycle semi-conducteurs","node_type":"input_manual","category":"tech","unit":"score","coefficient_to_pips": 0.6,"display_col":1,"description":"Upcycle (book-to-bill, inventaires) = QQQ ↑"},
|
||
{"id":"m_tech_pe", "label":"Multiple PE tech (NTM)","node_type":"input_manual","category":"valuation","unit":"x","coefficient_to_pips":12.0,"display_col":1,"description":"PE forward NASDAQ-100. Expansion = QQQ ↑"},
|
||
{"id":"m_reg_risk", "label":"Risque réglementaire tech","node_type":"input_manual","category":"political","unit":"score","coefficient_to_pips":-0.5,"display_col":1,"description":"Antitrust, EU AI Act, FTC. Hausse → QQQ ↓"},
|
||
{"id":"m_vix", "label":"Niveau VIX", "node_type":"input_manual","category":"sentiment","unit":"pts","coefficient_to_pips":-0.8,"display_col":1,"description":"VIX spike → vente tech (beta élevé) → QQQ ↓ plus que SP500"},
|
||
{"id":"m_retail_options","label":"Flux options retail","node_type":"input_manual","category":"flows","unit":"score","coefficient_to_pips": 0.4,"display_col":1,"description":"FOMO gamma squeeze. Momentum = QQQ ↑ explosif"},
|
||
{"id":"m_cloud_growth","label":"Croissance cloud enterprise","node_type":"input_manual","category":"tech","unit":"%","coefficient_to_pips": 0.6,"display_col":1,"description":"AWS/Azure/GCP. Marges operating tech → QQQ"},
|
||
{"id":"m_china_tech_risk","label":"Restrictions tech US-Chine","node_type":"input_manual","category":"political","unit":"score","coefficient_to_pips":-0.4,"display_col":1,"description":"Export bans, sanctions. Revenus tech ↓ → QQQ ↓"},
|
||
{"id":"layer_rates","label":"▶ Taux & Valorisation","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"in_cb + in_macro + m_real_rate + m_fed_path + m_tech_pe",
|
||
"description":"Taux réels = principal driver de la valorisation tech via taux d'actualisation."},
|
||
{"id":"layer_tech_fundamental","label":"▶ Fondamentaux Tech","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"m_m7_eps + m_ai_capex + m_semi_cycle + m_cloud_growth",
|
||
"description":"BPA Magnificent 7, cycle IA, semis, cloud. Foundation long terme."},
|
||
{"id":"layer_risk","label":"▶ Risque & Réglementation","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"in_geo + in_credit + in_growth + in_trade + in_commodity + m_reg_risk + m_china_tech_risk + m_vix",
|
||
"description":"Risques systémiques, réglementaires, géopolitiques tech."},
|
||
{"id":"layer_flows","label":"▶ Flux & Momentum","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2,
|
||
"formula":"in_sentiment + in_technical + m_retail_options",
|
||
"description":"Flux spéculatifs, options retail, momentum technique."},
|
||
{"id":"qqq","label":"QQQ — Impact Net","node_type":"output","category":"output","unit":"pips","display_col":3,
|
||
"formula":"layer_rates + layer_tech_fundamental + layer_risk + layer_flows","description":"Pression nette cumulée QQQ"},
|
||
]
|
||
},
|
||
|
||
} # end INSTRUMENT_MODELS
|
||
|
||
|
||
# ── Regime detection ───────────────────────────────────────────────────────────
|
||
|
||
def detect_regime(ev_by_cat: dict[str, float]) -> dict:
|
||
"""
|
||
Détermine le régime de marché dominant depuis la pression event par catégorie.
|
||
Retourne : {regime, label, scores, dominant_cat, weights}
|
||
"""
|
||
# Score de chaque catégorie = |pips|
|
||
cat_scores = {cat: abs(v) for cat, v in ev_by_cat.items() if v != 0}
|
||
if not cat_scores:
|
||
return {
|
||
"regime": "BALANCED", "label": "Équilibré",
|
||
"scores": {}, "dominant_cat": None,
|
||
"weights": {},
|
||
}
|
||
|
||
# Cumul de score par régime candidat
|
||
regime_scores: dict[str, float] = {}
|
||
for cat, score in cat_scores.items():
|
||
r = _CAT_TO_REGIME.get(cat, "BALANCED")
|
||
regime_scores[r] = regime_scores.get(r, 0.0) + score
|
||
|
||
dominant_regime = max(regime_scores, key=lambda r: regime_scores[r])
|
||
|
||
# Si le score maximal ne dépasse pas 3 pips → BALANCED
|
||
max_score = regime_scores.get(dominant_regime, 0.0)
|
||
if max_score < 3.0 or dominant_regime == "BALANCED":
|
||
dominant_regime = "BALANCED"
|
||
|
||
dominant_cat = max(
|
||
(c for c in cat_scores if _CAT_TO_REGIME.get(c) == dominant_regime),
|
||
key=lambda c: cat_scores[c],
|
||
default=None,
|
||
) if dominant_regime != "BALANCED" else None
|
||
|
||
REGIME_LABELS = {
|
||
"MONETARY_DOMINANCE": "Dominance Monétaire",
|
||
"GEOPOLITICAL_RISK": "Risque Géopolitique",
|
||
"CREDIT_STRESS": "Stress Crédit",
|
||
"GROWTH_SCARE": "Choc Croissance",
|
||
"COMMODITY_SHOCK": "Choc Commodités",
|
||
"BALANCED": "Équilibré",
|
||
}
|
||
|
||
weights = REGIME_WEIGHTS.get(dominant_regime, {})
|
||
return {
|
||
"regime": dominant_regime,
|
||
"label": REGIME_LABELS.get(dominant_regime, dominant_regime),
|
||
"scores": {r: round(s, 1) for r, s in regime_scores.items()},
|
||
"dominant_cat": dominant_cat,
|
||
"weights": weights,
|
||
}
|
||
|
||
|
||
def _apply_regime_weights(formula: str, weights: dict[str, float]) -> str:
|
||
"""
|
||
Injecte les multiplicateurs de régime dans une formule d'output.
|
||
Ex: "layer_monetary + layer_risk" + {layer_monetary:1.4, layer_risk:1.5}
|
||
→ "1.40 * layer_monetary + 1.50 * layer_risk"
|
||
Les termes sans poids restent à 1.0 (non modifiés).
|
||
"""
|
||
if not weights:
|
||
return formula
|
||
terms = [t.strip() for t in formula.split('+')]
|
||
out = []
|
||
for term in terms:
|
||
# Extraire l'id de couche (premier token alphanumérique_)
|
||
layer_id = term.strip()
|
||
w = weights.get(layer_id, 1.0)
|
||
if abs(w - 1.0) < 0.01:
|
||
out.append(layer_id)
|
||
else:
|
||
out.append(f"{w:.2f} * {layer_id}")
|
||
return " + ".join(out)
|
||
|
||
|
||
# ── DB ─────────────────────────────────────────────────────────────────────────
|
||
|
||
def init_instrument_model_tables(conn):
|
||
conn.executescript("""
|
||
CREATE TABLE IF NOT EXISTS instrument_models (
|
||
id INTEGER PRIMARY KEY,
|
||
instrument TEXT UNIQUE NOT NULL,
|
||
graph_json TEXT NOT NULL,
|
||
updated_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
CREATE TABLE IF NOT EXISTS instrument_node_overrides (
|
||
id INTEGER PRIMARY KEY,
|
||
instrument TEXT NOT NULL,
|
||
node_id TEXT NOT NULL,
|
||
value REAL NOT NULL,
|
||
note TEXT,
|
||
set_at TEXT DEFAULT (datetime('now')),
|
||
UNIQUE(instrument, node_id)
|
||
);
|
||
CREATE TABLE IF NOT EXISTS event_calibration_overrides (
|
||
analysis_id INTEGER PRIMARY KEY,
|
||
calibration_json TEXT NOT NULL,
|
||
updated_at TEXT DEFAULT (datetime('now'))
|
||
);
|
||
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()
|
||
|
||
|
||
def seed_instrument_models(conn):
|
||
"""Seed/update models on startup. Safe to call repeatedly."""
|
||
init_instrument_model_tables(conn)
|
||
for inst, model in INSTRUMENT_MODELS.items():
|
||
graph_json = json.dumps({
|
||
"name": model["name"],
|
||
"description": model["description"],
|
||
"output_node": model["output_node"],
|
||
"nodes": model["nodes"],
|
||
"col_labels": model.get("col_labels", []),
|
||
})
|
||
conn.execute("""
|
||
INSERT INTO instrument_models (instrument, graph_json)
|
||
VALUES (?,?)
|
||
ON CONFLICT(instrument) DO UPDATE SET graph_json=excluded.graph_json, updated_at=datetime('now')
|
||
""", (inst, graph_json))
|
||
conn.commit()
|
||
|
||
|
||
# ── Event contribution by category (with lifecycle) ────────────────────────────
|
||
|
||
def _compute_event_by_category(conn, instrument: str, ref_date: date_type) -> dict[str, float]:
|
||
"""Sum active event contributions per template category, applying lifecycle."""
|
||
inst_upper = instrument.upper()
|
||
inst_lower = inst_upper.lower()
|
||
extended_from = ref_date - timedelta(days=365)
|
||
|
||
rows = conn.execute("""
|
||
SELECT a.id as analysis_id,
|
||
a.prediction_json,
|
||
e.start_date, e.end_date, e.sub_type, e.name as title,
|
||
t.category,
|
||
COALESCE(o.calibration_json, t.calibration_json) as calibration_json
|
||
FROM causal_event_analyses a
|
||
JOIN market_events e ON e.id = a.market_event_id
|
||
JOIN causal_graph_templates t ON t.id = a.template_id
|
||
LEFT JOIN event_calibration_overrides o ON o.analysis_id = a.id
|
||
WHERE a.instrument = ?
|
||
AND e.start_date >= ?
|
||
AND e.start_date <= ?
|
||
""", (inst_upper, str(extended_from), str(ref_date))).fetchall()
|
||
|
||
by_cat: dict[str, float] = {}
|
||
for row in rows:
|
||
r = dict(row)
|
||
try:
|
||
predictions = json.loads(r["prediction_json"] or "{}")
|
||
calib = json.loads(r["calibration_json"] or "{}")
|
||
except Exception:
|
||
continue
|
||
|
||
pips: Optional[float] = None
|
||
if inst_lower in predictions:
|
||
pips = float(predictions[inst_lower])
|
||
else:
|
||
for k, v in predictions.items():
|
||
if inst_lower in k.lower():
|
||
try: pips = float(v); break
|
||
except (TypeError, ValueError): pass
|
||
if pips is None or pips == 0:
|
||
continue
|
||
|
||
absorption = max(1, int(calib.get("absorption_days", 7)))
|
||
dtype = str(calib.get("decay_type", "exp"))
|
||
rise = max(0, int(calib.get("rise_days", 0)))
|
||
plateau = max(0, int(calib.get("plateau_days", 0)))
|
||
|
||
# Guidance events: dynamic absorption until meeting
|
||
ev_end = r.get("end_date")
|
||
if ev_end and str(r.get("sub_type", "")).startswith("rate_guidance"):
|
||
try:
|
||
meeting = date_type.fromisoformat(ev_end[:10])
|
||
ev_start = date_type.fromisoformat(r["start_date"][:10])
|
||
absorption = max(1, (meeting - ev_start).days)
|
||
dtype = "linear"; rise = 0; plateau = 0
|
||
except ValueError:
|
||
pass
|
||
|
||
try:
|
||
ev_date = date_type.fromisoformat(r["start_date"][:10])
|
||
except ValueError:
|
||
continue
|
||
|
||
days = (ref_date - ev_date).days
|
||
df = _lifecycle(days, rise, plateau, absorption, dtype)
|
||
if df < 0.01:
|
||
continue
|
||
|
||
cat = r["category"]
|
||
by_cat[cat] = round(by_cat.get(cat, 0.0) + pips * df, 2)
|
||
|
||
return by_cat
|
||
|
||
|
||
# ── Graph evaluation ───────────────────────────────────────────────────────────
|
||
|
||
def _build_inputs(
|
||
graph_def: dict, overrides: dict, ev_by_cat: dict,
|
||
saturation: bool = True,
|
||
) -> dict:
|
||
"""
|
||
Build inputs dict for evaluate_graph().
|
||
Phase 2 : les nœuds input_manual utilisent _saturate_pips() (tanh)
|
||
au lieu d'une conversion purement linéaire.
|
||
"""
|
||
inputs: dict[str, float] = {}
|
||
for node in graph_def["nodes"]:
|
||
nid = node["id"]
|
||
ntype = node.get("node_type", "")
|
||
ov = overrides.get(nid)
|
||
|
||
if ntype == "input_event":
|
||
# Baseline (niveau structurel user) + surprise event (lifecycle) → additif
|
||
base = float(ov["value"]) if ov else 0.0
|
||
events = ev_by_cat.get(node.get("event_category", ""), 0.0)
|
||
inputs[nid] = base + events
|
||
|
||
elif ntype == "input_manual":
|
||
coeff = float(node.get("coefficient_to_pips", 1.0))
|
||
native = float(ov["value"]) if ov else 0.0
|
||
if native == 0.0:
|
||
inputs[nid] = 0.0
|
||
elif saturation:
|
||
inputs[nid] = _saturate_pips(native, coeff, node.get("unit", ""), node.get("saturation_scale"))
|
||
else:
|
||
inputs[nid] = coeff * native
|
||
|
||
return inputs
|
||
|
||
|
||
def _graph_json_for_eval(graph_def: dict, regime_weights: Optional[dict] = None) -> dict:
|
||
"""
|
||
Convertit le graph_def au format attendu par evaluate_graph().
|
||
Phase 2 : si regime_weights est fourni, la formule du nœud output est
|
||
réécrite avec les multiplicateurs de régime.
|
||
"""
|
||
output_id = graph_def.get("output_node", "")
|
||
nodes = []
|
||
for n in graph_def["nodes"]:
|
||
entry: dict = {"id": n["id"]}
|
||
formula = n.get("formula")
|
||
if formula:
|
||
if n["id"] == output_id and regime_weights:
|
||
formula = _apply_regime_weights(formula, regime_weights)
|
||
entry["formula"] = formula
|
||
nodes.append(entry)
|
||
return {"nodes": nodes, "coefficients": {}}
|
||
|
||
|
||
# ── Public API ─────────────────────────────────────────────────────────────────
|
||
|
||
def get_active_event_details(conn, instrument: str, ref_date: date_type) -> dict:
|
||
"""
|
||
Détail des events actifs par catégorie — utilisé pour enrichir les nœuds event du DAG.
|
||
Retourne : {category: [{analysis_id, title, start_date, days_since,
|
||
pip_prediction, lifecycle_factor, remaining_pips, calibration}]}
|
||
"""
|
||
inst_upper = instrument.upper()
|
||
inst_lower = inst_upper.lower()
|
||
extended_from = ref_date - timedelta(days=365)
|
||
|
||
rows = conn.execute("""
|
||
SELECT a.id as analysis_id,
|
||
a.prediction_json,
|
||
e.id as event_id, e.name as title, e.start_date, e.end_date, e.sub_type,
|
||
t.category,
|
||
COALESCE(o.calibration_json, t.calibration_json) as calibration_json
|
||
FROM causal_event_analyses a
|
||
JOIN market_events e ON e.id = a.market_event_id
|
||
JOIN causal_graph_templates t ON t.id = a.template_id
|
||
LEFT JOIN event_calibration_overrides o ON o.analysis_id = a.id
|
||
WHERE a.instrument = ?
|
||
AND e.start_date >= ?
|
||
AND e.start_date <= ?
|
||
ORDER BY e.start_date DESC
|
||
""", (inst_upper, str(extended_from), str(ref_date))).fetchall()
|
||
|
||
by_cat: dict[str, list] = {}
|
||
for row in rows:
|
||
r = dict(row)
|
||
try:
|
||
preds = json.loads(r["prediction_json"] or "{}")
|
||
calib = json.loads(r["calibration_json"] or "{}")
|
||
except Exception:
|
||
continue
|
||
|
||
pips: Optional[float] = None
|
||
if inst_lower in preds:
|
||
pips = float(preds[inst_lower])
|
||
else:
|
||
for k, v in preds.items():
|
||
if inst_lower in k.lower():
|
||
try: pips = float(v); break
|
||
except (TypeError, ValueError): pass
|
||
if pips is None:
|
||
continue
|
||
|
||
absorption = max(1, int(calib.get("absorption_days", 7)))
|
||
dtype = str(calib.get("decay_type", "exp"))
|
||
rise = max(0, int(calib.get("rise_days", 0)))
|
||
plateau = max(0, int(calib.get("plateau_days", 0)))
|
||
|
||
ev_end = r.get("end_date")
|
||
if ev_end and str(r.get("sub_type", "")).startswith("rate_guidance"):
|
||
try:
|
||
meeting = date_type.fromisoformat(ev_end[:10])
|
||
ev_start = date_type.fromisoformat(r["start_date"][:10])
|
||
absorption = max(1, (meeting - ev_start).days)
|
||
dtype = "linear"; rise = 0; plateau = 0
|
||
except ValueError:
|
||
pass
|
||
|
||
try:
|
||
ev_date = date_type.fromisoformat(r["start_date"][:10])
|
||
except ValueError:
|
||
continue
|
||
|
||
days = (ref_date - ev_date).days
|
||
lf = _lifecycle(days, rise, plateau, absorption, dtype)
|
||
if lf < 0.01:
|
||
continue
|
||
|
||
cat = r["category"]
|
||
by_cat.setdefault(cat, []).append({
|
||
"analysis_id": r["analysis_id"],
|
||
"event_id": r.get("event_id"),
|
||
"title": r.get("title", ""),
|
||
"start_date": r["start_date"][:10],
|
||
"days_since": days,
|
||
"pip_prediction": round(pips, 1),
|
||
"lifecycle_factor": round(lf, 3),
|
||
"remaining_pips": round(pips * lf, 1),
|
||
"calibration": {
|
||
"absorption_days": absorption,
|
||
"rise_days": rise,
|
||
"plateau_days": plateau,
|
||
"decay_type": dtype,
|
||
},
|
||
})
|
||
|
||
return by_cat
|
||
|
||
|
||
def get_model_state(conn, instrument: str, at_date: Optional[str] = None) -> Optional[dict]:
|
||
"""
|
||
Full model state : DAG evaluation avec saturation (Phase 2) + poids de régime.
|
||
Retourne aussi {regime: {regime, label, weights, scores}}.
|
||
"""
|
||
inst_upper = instrument.upper()
|
||
row = conn.execute(
|
||
"SELECT graph_json FROM instrument_models WHERE instrument=?", (inst_upper,)
|
||
).fetchone()
|
||
if not row:
|
||
return None
|
||
|
||
graph_def = json.loads(row["graph_json"])
|
||
|
||
try:
|
||
ref_date = date_type.fromisoformat(at_date) if at_date else datetime.utcnow().date()
|
||
except ValueError:
|
||
ref_date = datetime.utcnow().date()
|
||
|
||
overrides = {r["node_id"]: dict(r) for r in conn.execute(
|
||
"SELECT node_id, value, note, set_at FROM instrument_node_overrides WHERE instrument=?",
|
||
(inst_upper,)
|
||
).fetchall()}
|
||
|
||
# Machine structurelle pure — aucune injection automatique depuis market_events.
|
||
# Les events du CausalLab ne touchent plus le graphe; seuls les overrides manuels
|
||
# (Sync Marche + saisie) et les events virtuels (What-if) contribuent.
|
||
ev_by_cat: dict[str, float] = {}
|
||
|
||
regime_info = detect_regime(ev_by_cat) # toujours BALANCED hors What-if
|
||
regime_weights = regime_info["weights"]
|
||
|
||
from services.causal_graphs import evaluate_graph
|
||
inputs = _build_inputs(graph_def, overrides, ev_by_cat, saturation=True)
|
||
gj = _graph_json_for_eval(graph_def, regime_weights)
|
||
all_vals = evaluate_graph(gj, inputs)
|
||
|
||
output_id = graph_def["output_node"]
|
||
net_pips = round(float(all_vals.get(output_id, 0.0)), 1)
|
||
structural_pips = net_pips # identique — pas d'events injectés
|
||
event_pips = round(net_pips - structural_pips, 1)
|
||
|
||
meta = INSTRUMENT_MODELS.get(inst_upper, {})
|
||
price_intercept = meta.get("price_intercept", 0.0)
|
||
pip_to_price = meta.get("pip_to_price", 0.0001)
|
||
yf_ticker = meta.get("yf_ticker", "")
|
||
fundamental_level = round(price_intercept + structural_pips * pip_to_price, 6)
|
||
synthetic_price = round(price_intercept + net_pips * pip_to_price, 6)
|
||
|
||
nodes_out = []
|
||
for node in graph_def["nodes"]:
|
||
nid = node["id"]
|
||
ntype = node.get("node_type", "")
|
||
val = round(float(all_vals.get(nid, 0.0)), 1)
|
||
ov = overrides.get(nid)
|
||
st = dict(node)
|
||
st["computed_value"] = val
|
||
st["pip_contribution"] = val
|
||
|
||
if ntype == "input_event":
|
||
cat = node.get("event_category", "")
|
||
event_surprise = round(ev_by_cat.get(cat, 0.0), 1)
|
||
baseline = float(ov["value"]) if ov else 0.0
|
||
st["raw_value"] = baseline
|
||
st["baseline_value"] = baseline
|
||
st["event_surprise"] = event_surprise
|
||
st["source"] = "manual" if ov else ("events" if event_surprise != 0.0 else "neutral")
|
||
if ov:
|
||
st["override_note"] = ov.get("note", "")
|
||
st["override_set_at"] = ov.get("set_at", "")
|
||
|
||
elif ntype == "input_manual":
|
||
coeff = float(node.get("coefficient_to_pips", 1.0))
|
||
native = float(ov["value"]) if ov else 0.0
|
||
# Retourne la valeur native sans saturation (pour affichage)
|
||
st["source"] = "manual" if ov else "neutral"
|
||
st["raw_value"] = native
|
||
# pip_contribution inclut la saturation (= all_vals[nid])
|
||
# On expose aussi la valeur linéaire pour comparaison
|
||
st["pip_linear"] = round(coeff * native, 1)
|
||
st["pip_saturated"] = val
|
||
st["saturation_pct"] = (
|
||
round((1.0 - val / (coeff * native)) * 100, 1)
|
||
if native != 0 and coeff != 0
|
||
else 0.0
|
||
)
|
||
if ov:
|
||
st["override_note"] = ov.get("note", "")
|
||
st["override_set_at"] = ov.get("set_at", "")
|
||
|
||
elif ntype in ("intermediate", "output"):
|
||
st["source"] = "computed"
|
||
# Pour les intermédiaires, expose le multiplicateur de régime
|
||
if ntype == "intermediate":
|
||
st["regime_weight"] = regime_weights.get(nid, 1.0)
|
||
|
||
nodes_out.append(st)
|
||
|
||
direction = "bullish" if net_pips > 5 else "bearish" if net_pips < -5 else "neutral"
|
||
|
||
event_details = get_active_event_details(conn, inst_upper, ref_date)
|
||
|
||
return {
|
||
"instrument": inst_upper,
|
||
"name": graph_def["name"],
|
||
"description": graph_def.get("description", ""),
|
||
"at_date": str(ref_date),
|
||
"net_pips": net_pips,
|
||
"structural_pips": structural_pips,
|
||
"event_pips": event_pips,
|
||
"price_intercept": price_intercept,
|
||
"pip_to_price": pip_to_price,
|
||
"yf_ticker": yf_ticker,
|
||
"fundamental_level": fundamental_level,
|
||
"synthetic_price": synthetic_price,
|
||
"direction": direction,
|
||
"nodes": nodes_out,
|
||
"output_node": output_id,
|
||
"regime": regime_info,
|
||
"event_details": event_details,
|
||
"col_labels": graph_def.get("col_labels", []),
|
||
}
|
||
|
||
|
||
def simulate_timeline(
|
||
conn, instrument: str, period: str = "1y",
|
||
virtual_events: Optional[list] = None,
|
||
start_date: Optional[str] = None,
|
||
) -> list[dict]:
|
||
"""
|
||
Simulate all node values day by day over the period.
|
||
Returns [{date, nodes: {id: value}, net_pips}].
|
||
Uses lifecycle (rise/plateau/decay) for event contributions.
|
||
Manual overrides are static (applied uniformly across the period).
|
||
start_date overrides the period-based date_from when provided.
|
||
"""
|
||
inst_upper = instrument.upper()
|
||
row = conn.execute(
|
||
"SELECT graph_json FROM instrument_models WHERE instrument=?", (inst_upper,)
|
||
).fetchone()
|
||
if not row:
|
||
return []
|
||
|
||
graph_def = json.loads(row["graph_json"])
|
||
output_id = graph_def["output_node"]
|
||
|
||
period_days = {"5d":7,"1mo":35,"3mo":95,"6mo":190,"1y":370,"2y":740}
|
||
lookback = period_days.get(period, 370)
|
||
today = datetime.utcnow().date()
|
||
if start_date:
|
||
try:
|
||
date_from = date_type.fromisoformat(start_date[:10])
|
||
except ValueError:
|
||
date_from = today - timedelta(days=lookback)
|
||
else:
|
||
date_from = today - timedelta(days=lookback)
|
||
|
||
# Load overrides (static)
|
||
overrides = {r["node_id"]: dict(r) for r in conn.execute(
|
||
"SELECT node_id, value, note, set_at FROM instrument_node_overrides WHERE instrument=?",
|
||
(inst_upper,)
|
||
).fetchall()}
|
||
|
||
# Machine structurelle — seuls les events virtuels (What-if) entrent dans le calcul.
|
||
# La timeline de base ne dépend plus de causal_event_analyses.
|
||
events: list[dict] = []
|
||
|
||
for ve in (virtual_events or []):
|
||
try:
|
||
ev_date = date_type.fromisoformat(str(ve["date"])[:10])
|
||
events.append({
|
||
"ev_date": ev_date,
|
||
"category": ve.get("category", "unclassified"),
|
||
"pips": float(ve.get("pips", 0.0)),
|
||
"rise": int(ve.get("rise_days", 1)),
|
||
"plateau": int(ve.get("plateau_days", 0)),
|
||
"absorption": int(ve.get("absorption_days", 14)),
|
||
"dtype": ve.get("decay_type", "exp"),
|
||
"virtual": True,
|
||
"label": ve.get("label", "Event virtuel"),
|
||
})
|
||
except (KeyError, ValueError):
|
||
continue
|
||
|
||
meta = INSTRUMENT_MODELS.get(inst_upper, {})
|
||
price_intercept = meta.get("price_intercept", 0.0)
|
||
pip_to_price = meta.get("pip_to_price", 0.0001)
|
||
|
||
from services.causal_graphs import evaluate_graph
|
||
|
||
# ── 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", []):
|
||
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)
|
||
|
||
# ── Structural baseline (static si pas de macro nodes) ─────────────────────
|
||
gj_struct = _graph_json_for_eval(graph_def, {})
|
||
inputs_struct = _build_inputs(graph_def, overrides, {}, saturation=True)
|
||
vals_struct = evaluate_graph(gj_struct, inputs_struct)
|
||
static_structural = round(float(vals_struct.get(output_id, 0.0)), 1)
|
||
|
||
# Unités par nœud input_manual — pour corriger les valeurs brutes de ff_calendar
|
||
# (NFP: raw=228000 → attendu=228 K; PMI: raw=54.7 → attendu=4.7 pts écart/50)
|
||
macro_node_units: dict[str, str] = {
|
||
n["id"]: n.get("unit", "")
|
||
for n in graph_def.get("nodes", [])
|
||
if n.get("node_type") == "input_manual"
|
||
}
|
||
|
||
def _unit_correct(v: float, unit: str) -> float:
|
||
"""Convertit les valeurs brutes ff_calendar en valeur attendue par le nœud."""
|
||
if unit == "K" and abs(v) >= 1_000:
|
||
return round(v / 1_000, 1)
|
||
if unit == "pts" and v > 10:
|
||
return round(v - 50.0, 1)
|
||
return v
|
||
|
||
# ── Auto-anchor : offset pour que la synthétique parte du prix réel à date_from ──
|
||
# Calculé une seule fois sur le prix réel à date_from (ou le plus proche disponible).
|
||
# Cet offset compense l'écart de calibration sans changer la dynamique du modèle.
|
||
start_offset = 0.0
|
||
try:
|
||
anchor_row = conn.execute(
|
||
"""SELECT close FROM price_history_cache
|
||
WHERE instrument=? AND date>=? ORDER BY date ASC LIMIT 1""",
|
||
(inst_upper, str(date_from))
|
||
).fetchone()
|
||
if anchor_row:
|
||
actual_start = float(anchor_row["close"])
|
||
if has_macro:
|
||
# Structural avec valeurs macro au moment de l'ancrage
|
||
anchor_overrides = dict(overrides)
|
||
for node_id, tl in macro_node_timelines.items():
|
||
v = tl.get(str(date_from))
|
||
if v is None and tl:
|
||
# Valeur la plus proche avant date_from
|
||
v = next((tl[d] for d in sorted(tl) if d <= str(date_from)), next(iter(tl.values()), None))
|
||
if v is not None:
|
||
v = _unit_correct(v, macro_node_units.get(node_id, ""))
|
||
anchor_overrides[node_id] = {"value": v, "note": "anchor", "set_at": ""}
|
||
gj_a = _graph_json_for_eval(graph_def, {})
|
||
in_a = _build_inputs(graph_def, anchor_overrides, {}, saturation=True)
|
||
vs_a = evaluate_graph(gj_a, in_a)
|
||
struct_t0 = float(vs_a.get(output_id, 0.0))
|
||
else:
|
||
struct_t0 = static_structural
|
||
model_start = price_intercept + struct_t0 * pip_to_price
|
||
start_offset = round(actual_start - model_start, 6)
|
||
except Exception:
|
||
start_offset = 0.0
|
||
|
||
# Dernier override macro connu (gap-fill pour weekends/jours sans données)
|
||
macro_last: dict[str, float] = {}
|
||
for node_id, tl in macro_node_timelines.items():
|
||
v0 = tl.get(str(date_from))
|
||
if v0 is not None:
|
||
macro_last[node_id] = _unit_correct(v0, macro_node_units.get(node_id, ""))
|
||
|
||
# Catégories d'events gérées par les nœuds input_event du graphe
|
||
# Les catégories sans nœud correspondant sont appliquées comme choc direct sur l'output.
|
||
graph_event_cats: set[str] = {
|
||
n.get("event_category", "")
|
||
for n in graph_def.get("nodes", [])
|
||
if n.get("node_type") == "input_event"
|
||
} - {""}
|
||
|
||
# Baseline linéaire pour la sensibilité temporelle des nœuds macro
|
||
# Utilisé uniquement pour calculer le DELTA par rapport au point d'ancrage.
|
||
# Le linéaire amplifie la sensibilité aux changements de taux/CPI/NFP qui
|
||
# sinon disparaissent dans la zone de saturation tanh.
|
||
base_linear_pips = float(evaluate_graph(
|
||
_graph_json_for_eval(graph_def, {}),
|
||
_build_inputs(graph_def, overrides, {}, saturation=False)
|
||
).get(output_id, 0.0)) if has_macro else 0.0
|
||
|
||
timeline = []
|
||
cur = date_from
|
||
while cur <= today:
|
||
cur_str = str(cur)
|
||
|
||
# ── Accumule les events virtuels (What-if) actifs ce jour ─────────────
|
||
ev_by_cat: dict[str, float] = {}
|
||
active_events_detail: list[dict] = []
|
||
for ev in events:
|
||
if ev["ev_date"] > cur or ev["ev_date"] < date_from:
|
||
continue
|
||
days = (cur - ev["ev_date"]).days
|
||
df = _lifecycle(days, ev["rise"], ev["plateau"], ev["absorption"], ev["dtype"])
|
||
if df < 0.01:
|
||
continue
|
||
cat = ev["category"]
|
||
contribution = round(ev["pips"] * df, 2)
|
||
ev_by_cat[cat] = ev_by_cat.get(cat, 0.0) + contribution
|
||
active_events_detail.append({
|
||
"label": ev["label"],
|
||
"pips": ev["pips"],
|
||
"lifecycle_factor": round(df, 3),
|
||
"contribution": contribution,
|
||
"date": str(ev["ev_date"]),
|
||
"category": cat,
|
||
})
|
||
|
||
# Split ev_by_cat : catégories avec nœud dédié vs choc direct
|
||
routed_ev = {cat: v for cat, v in ev_by_cat.items() if cat in graph_event_cats}
|
||
direct_shock = sum(v for cat, v in ev_by_cat.items() if cat not in graph_event_cats)
|
||
|
||
# ── Overrides pour ce jour (statiques + time-varying macro) ───────────
|
||
if has_macro:
|
||
cur_overrides = dict(overrides)
|
||
for node_id, tl in macro_node_timelines.items():
|
||
v = tl.get(cur_str)
|
||
if v is not None:
|
||
v = _unit_correct(v, macro_node_units.get(node_id, ""))
|
||
macro_last[node_id] = v
|
||
else:
|
||
v = macro_last.get(node_id) # gap-fill (weekend)
|
||
if v is not None:
|
||
cur_overrides[node_id] = {"value": v, "note": "macro_guidance", "set_at": ""}
|
||
|
||
# Structural pips : baseline saturé + delta linéaire (sensibilité amplifiée)
|
||
gj_s = _graph_json_for_eval(graph_def, {})
|
||
in_s = _build_inputs(graph_def, cur_overrides, {}, saturation=True)
|
||
vs_s = evaluate_graph(gj_s, in_s)
|
||
sat_pips_t = float(vs_s.get(output_id, 0.0))
|
||
|
||
# Delta linéaire = (linéaire_courant − linéaire_base) → sensibilité temporelle
|
||
in_s_lin = _build_inputs(graph_def, cur_overrides, {}, saturation=False)
|
||
vs_s_lin = evaluate_graph(gj_s, in_s_lin)
|
||
lin_pips_t = float(vs_s_lin.get(output_id, 0.0))
|
||
delta_lin = lin_pips_t - base_linear_pips
|
||
|
||
structural_pips_t = round(sat_pips_t + delta_lin, 1)
|
||
|
||
if ev_by_cat:
|
||
ri = detect_regime(ev_by_cat)
|
||
gj_ = _graph_json_for_eval(graph_def, ri["weights"])
|
||
in_ = _build_inputs(graph_def, cur_overrides, routed_ev, saturation=True)
|
||
vals = evaluate_graph(gj_, in_)
|
||
net = round(float(vals.get(output_id, 0.0)) + delta_lin + direct_shock, 1)
|
||
regime_label = ri["regime"]
|
||
else:
|
||
vals = vs_s
|
||
net = structural_pips_t
|
||
regime_label = "BALANCED"
|
||
|
||
else:
|
||
structural_pips_t = static_structural
|
||
if ev_by_cat:
|
||
ri = detect_regime(ev_by_cat)
|
||
gj = _graph_json_for_eval(graph_def, ri["weights"])
|
||
inputs = _build_inputs(graph_def, overrides, routed_ev, saturation=True)
|
||
vals = evaluate_graph(gj, inputs)
|
||
net = round(float(vals.get(output_id, 0.0)) + direct_shock, 1)
|
||
regime_label = ri["regime"]
|
||
else:
|
||
vals = vals_struct
|
||
net = static_structural
|
||
regime_label = "BALANCED"
|
||
|
||
event_pips = round(net - structural_pips_t, 1)
|
||
fundamental_level = round(price_intercept + structural_pips_t * pip_to_price + start_offset, 6)
|
||
synthetic_price = round(fundamental_level + event_pips * pip_to_price, 6)
|
||
|
||
timeline.append({
|
||
"date": cur_str,
|
||
"net_pips": net,
|
||
"structural_pips": structural_pips_t,
|
||
"event_pips": event_pips,
|
||
"fundamental_level": fundamental_level,
|
||
"synthetic_price": synthetic_price,
|
||
"regime": regime_label,
|
||
"nodes": {k: round(float(v), 1) for k, v in vals.items()},
|
||
"active_events": active_events_detail,
|
||
})
|
||
cur += timedelta(days=1)
|
||
|
||
return timeline
|
||
|
||
|
||
def set_node_override(conn, instrument: str, node_id: str, value: float, note: str = "") -> bool:
|
||
conn.execute("""
|
||
INSERT INTO instrument_node_overrides (instrument, node_id, value, note, set_at)
|
||
VALUES (?,?,?,?,datetime('now'))
|
||
ON CONFLICT(instrument, node_id) DO UPDATE SET value=excluded.value, note=excluded.note, set_at=datetime('now')
|
||
""", (instrument.upper(), node_id, value, note))
|
||
conn.commit()
|
||
return True
|
||
|
||
|
||
def clear_node_override(conn, instrument: str, node_id: str) -> bool:
|
||
conn.execute(
|
||
"DELETE FROM instrument_node_overrides WHERE instrument=? AND node_id=?",
|
||
(instrument.upper(), node_id)
|
||
)
|
||
conn.commit()
|
||
return True
|