""" 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