From 2bb8109eb43599fa211de320f5e3d9c83575063f Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Fri, 3 Jul 2026 16:03:59 +0200 Subject: [PATCH] feat: instrument model --- backend/routers/instrument_models.py | 54 ++++ backend/services/instrument_models.py | 281 +++++++++++++++---- frontend/src/pages/InstrumentModels.tsx | 349 ++++++++++++++++-------- 3 files changed, 525 insertions(+), 159 deletions(-) diff --git a/backend/routers/instrument_models.py b/backend/routers/instrument_models.py index 921e5b5..ccecde9 100644 --- a/backend/routers/instrument_models.py +++ b/backend/routers/instrument_models.py @@ -44,6 +44,18 @@ class NodeConfigBody(BaseModel): macro_key: Optional[str] = None # "" to clear, None = no-op +class NodeScenarioBody(BaseModel): + node_id: str + label: str + horizon: str = "mt" # 'ct' | 'mt' | 'lt' + target_date: str # YYYY-MM-DD + target_value: float + confidence: float = 0.7 # 0.0 – 1.0 + trajectory: str = "linear" # 'step' | 'linear' | 'exp' + absorption_days: int = 30 + notes: Optional[str] = "" + + class CalibrateBody(BaseModel): ref_date: Optional[str] = None @@ -539,6 +551,48 @@ def calibrate_intercept( conn.close() +@router.get("/{instrument}/scenarios") +def list_scenarios(instrument: str, node_id: Optional[str] = Query(None)) -> List[Dict[str, Any]]: + """Tous les scénarios CT/MT/LT d'un instrument (ou d'un nœud spécifique).""" + from services.database import get_conn + from services.instrument_models import get_node_scenarios + conn = get_conn() + try: + return get_node_scenarios(conn, instrument.upper(), node_id) + finally: + conn.close() + + +@router.post("/{instrument}/scenarios") +def create_scenario(instrument: str, body: NodeScenarioBody) -> Dict[str, Any]: + """Crée un scénario forecast sur un nœud.""" + from services.database import get_conn + from services.instrument_models import add_node_scenario + conn = get_conn() + try: + sid = add_node_scenario( + conn, instrument.upper(), body.node_id, body.label, + body.horizon, body.target_date, body.target_value, + body.confidence, body.trajectory, body.absorption_days, body.notes or "", + ) + return {"ok": True, "id": sid, "node_id": body.node_id} + finally: + conn.close() + + +@router.delete("/{instrument}/scenarios/{scenario_id}") +def remove_scenario(instrument: str, scenario_id: int) -> Dict[str, Any]: + """Supprime un scénario forecast.""" + from services.database import get_conn + from services.instrument_models import delete_node_scenario + conn = get_conn() + try: + delete_node_scenario(conn, scenario_id) + return {"ok": True, "id": scenario_id} + finally: + conn.close() + + @router.patch("/{instrument}/nodes/{node_id}") def update_node_config( instrument: str, diff --git a/backend/services/instrument_models.py b/backend/services/instrument_models.py index c7a6a89..caf544a 100644 --- a/backend/services/instrument_models.py +++ b/backend/services/instrument_models.py @@ -194,6 +194,170 @@ def build_macro_node_timeline( return result +def build_node_combined_timeline( + conn, instrument: str, node_id: str, macro_key: Optional[str], + date_from: date_type, date_to: date_type, +) -> dict[str, float]: + """ + Construit la timeline complète d'un nœud en fusionnant : + 1. Valeurs passées (ff_calendar actuals via macro_key) + 2. Scénarios utilisateur futurs (CT/MT/LT) avec interpolation pondérée par confidence + + Logique : + - Pour les dates ≤ aujourd'hui : ff_calendar actuals (si disponibles) + - Pour les dates futures : interpolation depuis la dernière valeur connue + vers chaque scénario, pondérée par confidence + - Si plusieurs scénarios se chevauchent : moyenne pondérée par confidence + """ + today = date_type.today() + + # 1. Baseline depuis ff_calendar (passé uniquement en pratique) + ff_tl = build_macro_node_timeline(conn, macro_key, date_from, date_to) if macro_key else {} + + # 2. Scénarios utilisateur + try: + scen_rows = conn.execute( + """SELECT id, label, horizon, target_date, target_value, confidence, trajectory, absorption_days + FROM node_forecast_scenarios + WHERE instrument=? AND node_id=? + ORDER BY target_date ASC""", + (instrument.upper(), node_id) + ).fetchall() + scenarios = [dict(r) for r in scen_rows] + except Exception: + scenarios = [] + + if not scenarios: + return ff_tl # Pas de scénarios → juste ff_calendar + + # 3. Valeur de départ (dernière connue depuis ff_calendar ou override statique) + base_value: Optional[float] = None + if ff_tl: + # Dernière valeur connue avant ou à aujourd'hui + for d in sorted(ff_tl.keys(), reverse=True): + if d <= str(today): + base_value = ff_tl[d] + break + if base_value is None: + base_value = next(iter(ff_tl.values()), None) + + # Fallback sur override statique si pas de ff_calendar + if base_value is None: + try: + ov = conn.execute( + "SELECT value FROM instrument_node_overrides WHERE instrument=? AND node_id=?", + (instrument.upper(), node_id) + ).fetchone() + if ov: + base_value = float(ov["value"]) + except Exception: + pass + + if base_value is None: + return ff_tl # Pas de base → on ne peut pas projeter + + # 4. Construire les waypoints (date, value, confidence) depuis les scénarios + # Le point de départ est toujours (today, base_value, 1.0) + waypoints: list[tuple[date_type, float, float]] = [(today, base_value, 1.0)] + for s in scenarios: + try: + td = date_type.fromisoformat(s["target_date"]) + if td > today: + waypoints.append((td, float(s["target_value"]), float(s["confidence"]))) + except (ValueError, KeyError): + continue + waypoints.sort(key=lambda x: x[0]) + + # 5. Construire la timeline journalière + result: dict[str, float] = {} + cur = date_from + while cur <= date_to: + cur_str = str(cur) + + if cur <= today and cur_str in ff_tl: + # Passé → priorité ff_calendar + result[cur_str] = ff_tl[cur_str] + else: + # Futur → interpolation entre waypoints + prev_wp: Optional[tuple[date_type, float, float]] = None + next_wp: Optional[tuple[date_type, float, float]] = None + for wp in waypoints: + if wp[0] <= cur: + prev_wp = wp + elif next_wp is None: + next_wp = wp + break + + if prev_wp is None and next_wp is None: + if ff_tl: + # Dernier connu + result[cur_str] = ff_tl.get(str(today)) or list(ff_tl.values())[-1] + elif prev_wp is None: + result[cur_str] = round(next_wp[1], 4) # type: ignore[index] + elif next_wp is None: + result[cur_str] = round(prev_wp[1], 4) # hold last value + else: + # Interpolation linéaire entre prev et next, pondérée par confidence + total_d = (next_wp[0] - prev_wp[0]).days + elapsed = (cur - prev_wp[0]).days + frac = elapsed / total_d if total_d > 0 else 0.0 + # Valeur interpolée brute + interp = prev_wp[1] + frac * (next_wp[1] - prev_wp[1]) + # La confidence du prochain scénario pondère le drift : + # confidence=1.0 → drift complet vers interp + # confidence=0.5 → 50% du drift, reste à mi-chemin entre prev et interp + conf = next_wp[2] + blended = prev_wp[1] + (interp - prev_wp[1]) * conf + result[cur_str] = round(blended, 4) + + cur += timedelta(days=1) + + return result + + +# ── Scenario CRUD ────────────────────────────────────────────────────────────── + +def get_node_scenarios(conn, instrument: str, node_id: Optional[str] = None) -> list[dict]: + """Retourne tous les scénarios d'un instrument (ou d'un nœud spécifique).""" + if node_id: + rows = conn.execute( + """SELECT * FROM node_forecast_scenarios + WHERE instrument=? AND node_id=? ORDER BY target_date ASC""", + (instrument.upper(), node_id) + ).fetchall() + else: + rows = conn.execute( + "SELECT * FROM node_forecast_scenarios WHERE instrument=? ORDER BY node_id, target_date ASC", + (instrument.upper(),) + ).fetchall() + return [dict(r) for r in rows] + + +def add_node_scenario( + conn, instrument: str, node_id: str, label: str, + horizon: str, target_date: str, target_value: float, + confidence: float = 0.7, trajectory: str = "linear", + absorption_days: int = 30, notes: str = "", +) -> int: + """Ajoute un scénario forecast sur un nœud. Retourne l'id créé.""" + cur = conn.execute( + """INSERT INTO node_forecast_scenarios + (instrument, node_id, label, horizon, target_date, target_value, + confidence, trajectory, absorption_days, notes) + VALUES (?,?,?,?,?,?,?,?,?,?)""", + (instrument.upper(), node_id, label, horizon, target_date, float(target_value), + float(confidence), trajectory, int(absorption_days), notes or "") + ) + conn.commit() + return cur.lastrowid + + +def delete_node_scenario(conn, scenario_id: int) -> bool: + conn.execute("DELETE FROM node_forecast_scenarios WHERE id=?", (scenario_id,)) + conn.commit() + return True + + # ── Saturation scales (tanh) par unité native ───────────────────────────────── # tanh(x/scale) : slope=1 à l'origine, sature asymptotiquement à ±1 # pips = coefficient_to_pips * scale * tanh(x / scale) @@ -201,10 +365,11 @@ def build_macro_node_timeline( # → max pips : coefficient_to_pips * scale (jamais dépassé) _SATURATION_SCALES: dict[str, float] = { "bps": 200.0, # différentiels de taux : sature autour ±300bps - "%": 3.0, # CPI / PIB différentiels : sature autour ±5% + "%": 3.0, # CPI / PIB / taux en % absolu : sature autour ±5% "pts%": 3.0, "pts": 15.0, # PMI écart depuis 50 : sature autour ±20pts "score": 3.0, # scores subjectifs -5 à +5 + "K": 200.0, # emplois en milliers : sature autour ±400K "tonnes": 80.0, # tonnes or / CB buying "Mds$": 40.0, "Mds$/sem": 15.0, @@ -364,57 +529,49 @@ INSTRUMENT_MODELS: dict[str, dict] = { # ══════════════════════════════════════════════════════════════════════════════ "EURUSD": { "name": "EUR/USD", - "description": "Taux de change Euro/Dollar — modèle causal 3 couches avec 4 domaines d'influence", + "description": "EUR/USD — Graphe causal macro-natif : noeuds = variables macro réelles auto-synchronisées depuis FF Calendar", "output_node": "eurusd", "price_intercept": 1.10, "pip_to_price": 0.0001, "yf_ticker": "EURUSD=X", "nodes": [ - # ── Layer 0a : event inputs ─────────────────────────────────────────────── - {"id":"in_cb", "label":"Banques Centrales", "node_type":"input_event", "category":"central_bank", "unit":"pips","display_col":0,"description":"Décisions Fed/BCE, minutes, forward guidance. Décroissance exp ~14j.","event_category":"central_bank"}, - {"id":"in_macro", "label":"Surprises Macro (données)","node_type":"input_event","category":"monetary_shock","unit":"pips","display_col":0,"description":"CPI, NFP, PIB, PMI US/EU. Décroissance rapide ~12j.","event_category":"monetary_shock"}, - {"id":"in_geo", "label":"Risque Géopolitique", "node_type":"input_event", "category":"geopolitical", "unit":"pips","display_col":0,"description":"Conflits, sanctions, tensions. Décroissance linéaire ~21j.","event_category":"geopolitical"}, - {"id":"in_trade", "label":"Choc Commercial/Tarifs", "node_type":"input_event", "category":"trade_policy", "unit":"pips","display_col":0,"description":"Tarifs US-UE, accords commerciaux.","event_category":"trade_policy"}, - {"id":"in_growth", "label":"Choc Croissance", "node_type":"input_event", "category":"growth_shock", "unit":"pips","display_col":0,"description":"Chocs perspectives croissance.","event_category":"growth_shock"}, - {"id":"in_credit", "label":"Stress Crédit/Liquidité", "node_type":"input_event", "category":"credit_stress", "unit":"pips","display_col":0,"description":"Banking stress, spreads IG/HY.","event_category":"credit_stress"}, - {"id":"in_technical", "label":"Momentum Technique", "node_type":"input_event", "category":"technical", "unit":"pips","display_col":0,"description":"Cassures niveaux clés, tendances.","event_category":"technical"}, - {"id":"in_commodity", "label":"Choc Commodités", "node_type":"input_event", "category":"commodity", "unit":"pips","display_col":0,"description":"Pétrole, gaz, métaux → inflation EU.","event_category":"commodity"}, - {"id":"in_sentiment", "label":"Sentiment/Positionnement","node_type":"input_event", "category":"sentiment", "unit":"pips","display_col":0,"description":"Flux institutionnels, risk-on/off.","event_category":"sentiment"}, - # ── Layer 0b : manual structural inputs ────────────────────────────────── - {"id":"m_rate_diff_2y", "label":"Spread OIS 2Y USD-EUR", "node_type":"input_manual","category":"monetary", "unit":"bps", "coefficient_to_pips": 1.5, "display_col":1,"description":"Principal driver court terme. Positif = USD plus rémunérateur → pair ↓"}, - {"id":"m_fed_path", "label":"Anticipation Fed 12m", "node_type":"input_manual","category":"monetary", "unit":"bps", "coefficient_to_pips": 0.8, "display_col":1,"description":"Cuts attendus → USD ↓ → pair ↑. Hikes → USD ↑ → pair ↓"}, - {"id":"m_ecb_path", "label":"Anticipation BCE 12m", "node_type":"input_manual","category":"monetary", "unit":"bps", "coefficient_to_pips":-0.8, "display_col":1,"description":"Cuts BCE → EUR ↓. Hikes → EUR ↑"}, - {"id":"m_us_real_rate", "label":"Taux réel US 10Y (TIPS)", "node_type":"input_manual","category":"monetary", "unit":"bps", "coefficient_to_pips":-0.5, "display_col":1,"description":"Hausse → USD attractif → pair ↓"}, - {"id":"m_eu_real_rate", "label":"Taux réel EU 10Y", "node_type":"input_manual","category":"monetary", "unit":"bps", "coefficient_to_pips": 0.5, "display_col":1,"description":"Hausse → EUR attractif → pair ↑"}, - {"id":"m_carry", "label":"Score carry EUR/USD", "node_type":"input_manual","category":"monetary", "unit":"score", "coefficient_to_pips": 0.6, "display_col":1,"description":"Score attractivité carry (+= EUR avantageux)"}, - {"id":"m_us_growth_adv", "label":"Avantage croissance US vs EU","node_type":"input_manual","category":"macro", "unit":"pts%", "coefficient_to_pips":-15.0,"display_col":1,"description":"Différentiel PIB US-EU. US surperform → USD ↑ → pair ↓"}, - {"id":"m_eu_pmi", "label":"PMI composite Eurozone", "node_type":"input_manual","category":"macro", "unit":"pts", "coefficient_to_pips": 0.3, "display_col":1,"description":"Expansion EU → EUR ↑"}, - {"id":"m_energy_price", "label":"Prix énergie ($/bbl delta)", "node_type":"input_manual","category":"macro", "unit":"$/bbl", "coefficient_to_pips":-0.25,"display_col":1,"description":"Énergie chère → déficit commercial EU → EUR ↓"}, - {"id":"m_us_cpi", "label":"CPI US YoY", "node_type":"input_manual","category":"inflation", "unit":"%", "coefficient_to_pips":-0.8, "display_col":1,"description":"Inflation US → Fed hawkish → USD ↑ → pair ↓"}, - {"id":"m_eu_cpi", "label":"HICP Eurozone YoY", "node_type":"input_manual","category":"inflation", "unit":"%", "coefficient_to_pips": 0.8, "display_col":1,"description":"Inflation EU → BCE hawkish → EUR ↑"}, - {"id":"m_risk_appetite", "label":"Appétit risque mondial", "node_type":"input_manual","category":"sentiment", "unit":"score", "coefficient_to_pips": 0.5, "display_col":1,"description":"Risk-on → sorties USD → pair ↑"}, - {"id":"m_vix", "label":"Niveau VIX", "node_type":"input_manual","category":"sentiment", "unit":"pts", "coefficient_to_pips":-0.4, "display_col":1,"description":"VIX spike → flight to USD → pair ↓"}, - {"id":"m_eu_fragmentation","label":"Risque fragmentation EU", "node_type":"input_manual","category":"political", "unit":"score", "coefficient_to_pips":-0.5, "display_col":1,"description":"Risque politique EU → EUR ↓"}, - {"id":"m_us_political", "label":"Incertitude politique US", "node_type":"input_manual","category":"political", "unit":"score", "coefficient_to_pips": 0.3, "display_col":1,"description":"Incertitude US → USD ↓ → pair ↑"}, - {"id":"m_cftc_eur", "label":"Positions nettes EUR (CoT)", "node_type":"input_manual","category":"positioning","unit":"k lots","coefficient_to_pips": 0.1, "display_col":1,"description":"Net long EUR CFTC. Extrême → risque retournement"}, - {"id":"m_dollar_reserve", "label":"Demande réserves USD", "node_type":"input_manual","category":"flows", "unit":"score", "coefficient_to_pips":-0.4, "display_col":1,"description":"Demande réserves → USD structurellement fort → pair ↓"}, - # ── Layer 1 : intermediate (4 domaines) ────────────────────────────────── - {"id":"layer_monetary", "label":"▶ Pression Monétaire & Taux", "node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2, - "formula":"in_cb + in_macro + m_rate_diff_2y + m_fed_path + m_ecb_path + m_us_real_rate + m_eu_real_rate + m_carry", - "description":"Domaine monétaire : taux directeurs, OIS, anticipations Fed/BCE, carry. Driver n°1 de l'EURUSD."}, - {"id":"layer_growth", "label":"▶ Pression Macro & Croissance", "node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2, - "formula":"in_growth + in_commodity + m_us_growth_adv + m_eu_pmi + m_energy_price + m_us_cpi + m_eu_cpi", - "description":"Domaine macro : croissance relative, inflation, énergie. Impact via anticipations BC."}, - {"id":"layer_risk", "label":"▶ Pression Risque & Géopolitique", "node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2, - "formula":"in_geo + in_credit + in_sentiment + m_risk_appetite + m_vix + m_eu_fragmentation", - "description":"Domaine risque : géopolitique, stress crédit, aversion au risque (USD safe haven)."}, - {"id":"layer_positioning","label":"▶ Pression Positionnement & Flux", "node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2, - "formula":"in_trade + in_technical + m_cftc_eur + m_dollar_reserve + m_us_political", - "description":"Domaine positionnement : flux spéculatifs, technicalités, réserves, politique."}, + # ── Layer 0a : chocs événementiels (surprises court terme) ─────────────── + {"id":"in_cb", "label":"Choc Banques Centrales", "node_type":"input_event","category":"central_bank", "unit":"pips","display_col":0,"event_category":"central_bank", "description":"Surprises Fed/BCE : décisions inattendues, guidance hawkish/dovish, minutes."}, + {"id":"in_geo", "label":"Choc Géopolitique", "node_type":"input_event","category":"geopolitical", "unit":"pips","display_col":0,"event_category":"geopolitical", "description":"Conflits, sanctions, tensions → flight to USD."}, + {"id":"in_trade", "label":"Choc Commercial/Tarifs", "node_type":"input_event","category":"trade_policy", "unit":"pips","display_col":0,"event_category":"trade_policy", "description":"Tarifs US-UE, représailles → USD/EUR volatilité."}, + {"id":"in_credit","label":"Stress Crédit", "node_type":"input_event","category":"credit_stress","unit":"pips","display_col":0,"event_category":"credit_stress", "description":"Stress bancaire, spreads → USD safe haven."}, + # ── Layer 0b : politique monétaire (taux directeurs absolus) ───────────── + # macro_key = id → auto-sync depuis ff_calendar sans mapping manuel + {"id":"fed_rate","label":"Taux Fed (%)", "node_type":"input_manual","category":"monetary","unit":"%","coefficient_to_pips":-30, "macro_key":"fed_rate", "display_col":1,"description":"Taux directeur Fed en %. Hausse → USD fort → pair ↓. Auto-sync depuis FF Calendar."}, + {"id":"ecb_rate","label":"Taux BCE (%)", "node_type":"input_manual","category":"monetary","unit":"%","coefficient_to_pips":+30, "macro_key":"ecb_rate", "display_col":1,"description":"Taux directeur BCE en %. Hausse → EUR fort → pair ↑. Auto-sync depuis FF Calendar."}, + # ── Layer 0c : inflation ───────────────────────────────────────────────── + {"id":"us_cpi", "label":"CPI US YoY (%)", "node_type":"input_manual","category":"inflation","unit":"%","coefficient_to_pips":-10, "macro_key":"us_cpi_yoy","display_col":1,"description":"Inflation US YoY. Hausse → anticipations Fed hawkish → USD ↑ → pair ↓."}, + {"id":"eu_cpi", "label":"HICP Eurozone (%)", "node_type":"input_manual","category":"inflation","unit":"%","coefficient_to_pips":+10, "macro_key":"eu_cpi_yoy","display_col":1,"description":"Inflation EU YoY. Hausse → BCE hawkish → EUR ↑ → pair ↑."}, + # ── Layer 0d : croissance ───────────────────────────────────────────────── + {"id":"us_gdp", "label":"GDP US QoQ (%)", "node_type":"input_manual","category":"growth","unit":"%","coefficient_to_pips":-15, "macro_key":"us_gdp", "display_col":1,"description":"Croissance US trimestrielle. Surperformance → USD ↑ → pair ↓."}, + {"id":"eu_gdp", "label":"GDP Eurozone QoQ (%)","node_type":"input_manual","category":"growth","unit":"%","coefficient_to_pips":+15, "macro_key":"eu_gdp", "display_col":1,"description":"Croissance EU trimestrielle. Surperformance → EUR ↑ → pair ↑."}, + # ── Layer 0e : emploi & activité ───────────────────────────────────────── + {"id":"us_nfp", "label":"NFP US (K/mois)", "node_type":"input_manual","category":"labor","unit":"K","coefficient_to_pips":-0.05, "macro_key":"us_nfp", "display_col":1,"description":"Emplois non-agricoles US en K. 150K= neutre. Plus → USD ↑ → pair ↓."}, + {"id":"eu_pmi", "label":"PMI EU (écart/50)", "node_type":"input_manual","category":"activity","unit":"pts","coefficient_to_pips":+2.5,"macro_key":"eu_pmi", "display_col":1,"description":"PMI Composite Eurozone MOINS 50 (+4 = PMI=54, expansion → EUR ↑)."}, + {"id":"us_pmi", "label":"PMI US (écart/50)", "node_type":"input_manual","category":"activity","unit":"pts","coefficient_to_pips":-2.5,"macro_key":"us_pmi", "display_col":1,"description":"PMI ISM US MOINS 50 (+3 = PMI=53, expansion → USD ↑ → pair ↓)."}, + # ── Layer 0f : sentiment & risque (pas de macro_key — saisi ou events) ─── + {"id":"vix", "label":"VIX (niveau)", "node_type":"input_manual","category":"risk","unit":"pts","coefficient_to_pips":-1.5, "display_col":1,"description":"Volatilité equity US. Spike → safe haven USD → pair ↓."}, + {"id":"us_equity","label":"S&P 500 momentum", "node_type":"input_manual","category":"risk","unit":"score","coefficient_to_pips":+0.3, "display_col":1,"description":"Risk-on US. Hausse → appétit risque → EUR ↑. Score -5/+5."}, + {"id":"eu_fragm","label":"Fragmentation EU", "node_type":"input_manual","category":"political","unit":"score","coefficient_to_pips":-0.5, "display_col":1,"description":"Risque fragmentation zone euro, stress BTP/Bund. Score 0-5 → EUR ↓."}, + # ── Layer 1 : domaines synthèse ─────────────────────────────────────────── + {"id":"layer_monetary","label":"▶ Différentiel Monétaire","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2, + "formula":"in_cb + fed_rate + ecb_rate + us_cpi + eu_cpi", + "description":"Taux directeurs + inflation → différentiel de politique monétaire Fed/BCE."}, + {"id":"layer_growth", "label":"▶ Différentiel Croissance","node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2, + "formula":"us_gdp + eu_gdp + us_nfp + eu_pmi + us_pmi", + "description":"Croissance relative US vs EU. Positif = EU surperform → EUR ↑."}, + {"id":"layer_risk", "label":"▶ Sentiment & Risque", "node_type":"intermediate","category":"intermediate","unit":"pips","display_col":2, + "formula":"in_geo + in_trade + in_credit + vix + us_equity + eu_fragm", + "description":"Risque géopolitique, sentiment, appétit risque → impact EUR/USD."}, # ── Layer 2 : output ────────────────────────────────────────────────────── - {"id":"eurusd","label":"EUR/USD — Impact Net","node_type":"output","category":"output","unit":"pips","display_col":3, - "formula":"layer_monetary + layer_growth + layer_risk + layer_positioning", - "description":"Pression nette cumulée = Σ(4 domaines). Positif = biais haussier EUR/USD."}, + {"id":"eurusd","label":"EUR/USD — Biais Net","node_type":"output","category":"output","unit":"pips","display_col":3, + "formula":"layer_monetary + layer_growth + layer_risk", + "description":"Biais net EUR/USD. Positif = haussier EUR. Divisé en 3 piliers : monétaire, croissance, risque."}, ] }, @@ -816,6 +973,22 @@ def init_instrument_model_tables(conn): calibration_json TEXT NOT NULL, updated_at TEXT DEFAULT (datetime('now')) ); + CREATE TABLE IF NOT EXISTS node_forecast_scenarios ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instrument TEXT NOT NULL, + node_id TEXT NOT NULL, + label TEXT NOT NULL, + horizon TEXT NOT NULL DEFAULT 'mt', + target_date TEXT NOT NULL, + target_value REAL NOT NULL, + confidence REAL NOT NULL DEFAULT 0.7, + trajectory TEXT NOT NULL DEFAULT 'linear', + absorption_days INTEGER NOT NULL DEFAULT 30, + notes TEXT, + created_at TEXT DEFAULT (datetime('now')) + ); + CREATE INDEX IF NOT EXISTS idx_nfs_inst_node + ON node_forecast_scenarios(instrument, node_id); """) conn.commit() @@ -1251,14 +1424,18 @@ def simulate_timeline( from services.causal_graphs import evaluate_graph - # ── Macro-guidance : noeuds input_manual avec macro_key → overrides time-varying ── + # ── Timelines combinées (ff_calendar actuals + scénarios CT/MT/LT) ──────── + # Pour chaque nœud input_manual : fusion de la baseline ff_calendar et des + # scénarios utilisateur (trajectoire blended par confidence vers chaque waypoint) macro_node_timelines: dict[str, dict[str, float]] = {} for node in graph_def.get("nodes", []): - mk = node.get("macro_key") - if mk and node.get("node_type") == "input_manual": - tl = build_macro_node_timeline(conn, mk, date_from, today) - if tl: - macro_node_timelines[node["id"]] = tl + if node.get("node_type") != "input_manual": + continue + mk = node.get("macro_key") + nid = node["id"] + tl = build_node_combined_timeline(conn, inst_upper, nid, mk, date_from, today) + if tl: + macro_node_timelines[nid] = tl has_macro = bool(macro_node_timelines) diff --git a/frontend/src/pages/InstrumentModels.tsx b/frontend/src/pages/InstrumentModels.tsx index 2a96c88..230cdd0 100644 --- a/frontend/src/pages/InstrumentModels.tsx +++ b/frontend/src/pages/InstrumentModels.tsx @@ -168,6 +168,21 @@ interface MacroGuidanceItem { } | null } +interface NodeScenario { + id: number + instrument: string + node_id: string + label: string + horizon: 'ct' | 'mt' | 'lt' + target_date: string + target_value: number + confidence: number + trajectory: 'step' | 'linear' | 'exp' + absorption_days: number + notes?: string + created_at: string +} + // ── Constants ───────────────────────────────────────────────────────────────── const INSTRUMENTS = ['EURUSD','USDJPY','XAUUSD','SP500','TLT','GBPUSD','EEM','QQQ'] @@ -1990,134 +2005,254 @@ function CalibrationView({ instrument, eventDetails }: { // ── MacroConfigView ──────────────────────────────────────────────────────────── -function MacroConfigView({ instrument, nodes }: { instrument: string; nodes: ModelNode[] }) { - const [guidance, setGuidance] = useState([]) - const [localKeys, setLocalKeys] = useState>({}) - const [saved, setSaved] = useState>({}) - const [saving, setSaving] = useState(null) +const HORIZON_META = { + ct: { label: 'CT', color: 'text-sky-400', bg: 'bg-sky-900/30 border-sky-700/40', desc: 'Court terme (< 3 mois)' }, + mt: { label: 'MT', color: 'text-amber-400', bg: 'bg-amber-900/30 border-amber-700/40', desc: 'Moyen terme (3-12 mois)' }, + lt: { label: 'LT', color: 'text-violet-400', bg: 'bg-violet-900/30 border-violet-700/40',desc: 'Long terme (> 12 mois)' }, +} +const TRAJ_OPTIONS = [ + { value: 'step', label: 'Choc immédiat (step)' }, + { value: 'linear', label: 'Dérive linéaire' }, + { value: 'exp', label: 'Convergence exp.' }, +] - const manualNodes = nodes.filter(n => n.node_type === 'input_manual') +interface AddScenarioForm { + label: string; horizon: 'ct'|'mt'|'lt'; target_date: string; target_value: string + confidence: string; trajectory: string; absorption_days: string; notes: string +} +const EMPTY_FORM: AddScenarioForm = { + label: '', horizon: 'mt', target_date: '', target_value: '', confidence: '0.7', + trajectory: 'linear', absorption_days: '30', notes: '', +} + +function NodeScenarioSection({ + instrument, node, guidance, +}: { instrument: string; node: ModelNode; guidance: MacroGuidanceItem | undefined }) { + const [scenarios, setScenarios] = useState([]) + const [expanded, setExpanded] = useState(false) + const [addForm, setAddForm] = useState(null) + const [saving, setSaving] = useState(false) useEffect(() => { - const init: Record = {} - for (const n of manualNodes) init[n.id] = n.macro_key ?? '' - setLocalKeys(init) - }, [nodes]) + if (!expanded) return + api.get(`/instrument-models/${instrument}/scenarios`, { params: { node_id: node.id } }) + .then(r => setScenarios(r.data)) + .catch(() => {}) + }, [expanded, instrument, node.id]) - const refreshGuidance = useCallback(() => { + async function submitScenario() { + if (!addForm) return + setSaving(true) + try { + await api.post(`/instrument-models/${instrument}/scenarios`, { + node_id: node.id, + label: addForm.label || `${node.label} ${addForm.horizon.toUpperCase()}`, + horizon: addForm.horizon, + target_date: addForm.target_date, + target_value: parseFloat(addForm.target_value), + confidence: parseFloat(addForm.confidence), + trajectory: addForm.trajectory, + absorption_days: parseInt(addForm.absorption_days, 10), + notes: addForm.notes, + }) + const r = await api.get(`/instrument-models/${instrument}/scenarios`, { params: { node_id: node.id } }) + setScenarios(r.data) + setAddForm(null) + } catch {} + setSaving(false) + } + + async function deleteScenario(id: number) { + await api.delete(`/instrument-models/${instrument}/scenarios/${id}`) + setScenarios(prev => prev.filter(s => s.id !== id)) + } + + const hm = HORIZON_META + const mk = node.macro_key + const curV = guidance?.current_value + const nxt = guidance?.next_event + + return ( +
0) + ? 'border-violet-700/30 bg-violet-900/10' + : 'border-slate-700/20 bg-dark-800/30')}> + + {/* Row header */} +
+
+
{node.label}
+
+ {node.unit} · ×{node.coefficient_to_pips} + {mk && {mk}} +
+
+ + {/* Current value from ff_calendar */} + {curV != null && ( +
+
{curV.toFixed(2)}{node.unit}
+ {nxt && ( +
+ → {nxt.forecast != null ? nxt.forecast.toFixed(2) : '?'} dans {nxt.days_until}j +
+ )} +
+ )} + + {/* Scenario count + expand */} + +
+ + {/* Expanded section */} + {expanded && ( +
+ + {/* Existing scenarios */} + {scenarios.map(s => { + const hMeta = hm[s.horizon] ?? hm.mt + return ( +
+ {hMeta.label} + {s.label} + {s.target_date} + {s.target_value.toFixed(2)} + {Math.round(s.confidence*100)}% + {s.trajectory} + +
+ ) + })} + + {/* Add form */} + {addForm ? ( +
+
+ {(['ct','mt','lt'] as const).map(h => ( + + ))} +
+
+ setAddForm(f => f && ({...f, label: e.target.value}))} + className="col-span-2 text-xs bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-white placeholder-slate-600 focus:outline-none focus:border-violet-500/50"/> +
+
Date cible
+ setAddForm(f => f && ({...f, target_date: e.target.value}))} + className="w-full text-xs bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-white focus:outline-none"/> +
+
+
Valeur cible ({node.unit})
+ setAddForm(f => f && ({...f, target_value: e.target.value}))} + className="w-full text-xs bg-dark-900 border border-slate-700/40 rounded px-2 py-1.5 text-white focus:outline-none"/> +
+
+
Confidence
+ setAddForm(f => f && ({...f, confidence: e.target.value}))} + className="w-full accent-violet-500"/> +
{Math.round(parseFloat(addForm.confidence)*100)}%
+
+
+
Trajectoire
+ +
+
+
+ + +
+
+ ) : ( + + )} +
+ )} +
+ ) +} + +function MacroConfigView({ instrument, nodes }: { instrument: string; nodes: ModelNode[] }) { + const [guidance, setGuidance] = useState([]) + + useEffect(() => { api.get(`/instrument-models/${instrument}/macro-guidance`) .then(r => setGuidance(r.data)) .catch(() => {}) }, [instrument]) - useEffect(() => { refreshGuidance() }, [refreshGuidance]) - - async function saveMacroKey(nodeId: string, mk: string) { - setSaving(nodeId) - try { - await api.patch(`/instrument-models/${instrument}/nodes/${nodeId}`, { macro_key: mk }) - setSaved(prev => ({ ...prev, [nodeId]: true })) - setTimeout(() => setSaved(prev => ({ ...prev, [nodeId]: false })), 2000) - refreshGuidance() - } catch {} - setSaving(null) - } - - const linkedCount = Object.values(localKeys).filter(Boolean).length + const manualNodes = nodes.filter(n => n.node_type === 'input_manual') + const linkedCount = manualNodes.filter(n => n.macro_key).length + const scenarioCount = guidance.length // approximate return ( -
-
+
+
-
Paramètres macro des nœuds
+
Paramètres macro & scénarios
- Liez chaque nœud manuel à une variable macro-économique. La machine interpolera - entre les publications FF Calendar pour créer une guidance temporelle des fondamentaux. + Chaque nœud macro-lié reçoit ses valeurs depuis FF Calendar. + Ajoutez des scénarios CT/MT/LT pour projeter la guidance fondamentale dans le temps.
- - {linkedCount}/{manualNodes.length} liés + + {linkedCount}/{manualNodes.length} liés FF
- {/* Guidance summary cards — nodes that already have macro keys */} - {guidance.length > 0 && ( -
- {guidance.map(g => ( -
-
{g.node_label}
-
{g.macro_key}
-
- - {g.current_value != null ? g.current_value.toFixed(2) : '—'} - - {g.unit} -
- {g.next_event && ( -
- → {g.next_event.forecast != null ? g.next_event.forecast.toFixed(2) : '?'} - dans {g.next_event.days_until}j -
- )} - {g.next_event && ( -
- {g.next_event.name} -
- )} -
- ))} -
- )} - - {/* Node mapping table */}
-
Mapping nœud → clé macro
- {manualNodes.map(node => { - const mk = localKeys[node.id] ?? '' - const isSaving = saving === node.id - const isSaved = saved[node.id] - - return ( -
- -
-
{node.label}
-
{node.unit} · ×{node.coefficient_to_pips}
-
- - - -
- {isSaving && } - {isSaved && } -
-
- ) - })} + {manualNodes.map(node => ( + g.node_id === node.id)} + /> + ))}
- {linkedCount > 0 && ( -
- {linkedCount} nœud{linkedCount > 1 ? 's' : ''} macro-lié{linkedCount > 1 ? 's' : ''} - {' '}— la simulation Timeline utilisera des valeurs time-varying pour ces nœuds, - interpolant entre les publications passées et les forecasts des prochains events. - Le niveau fondamental ne sera plus statique mais guidé par les données FF Calendar. -
- )} +
+
Comment fonctionne la guidance ?
+
CT — choc/event imminent (ex: réunion Fed dans 3 sem → surprise cut à 3.75%)
+
MT — régime macro 3-12 mois (ex: regain inflation → stagnation à 3.80%)
+
LT — vision structurelle (ex: cycle de baisse → 2.00% fin 2027)
+
La Timeline interpolera chaque nœud vers sa cible avec la confidence pondérée. Relancez la simulation pour voir la courbe fondamentale évoluer.
+
) }