diff --git a/backend/routers/instrument_models.py b/backend/routers/instrument_models.py index 3e111ad..921e5b5 100644 --- a/backend/routers/instrument_models.py +++ b/backend/routers/instrument_models.py @@ -40,6 +40,10 @@ class WhatIfBody(BaseModel): start_date: Optional[str] = None +class NodeConfigBody(BaseModel): + macro_key: Optional[str] = None # "" to clear, None = no-op + + class CalibrateBody(BaseModel): ref_date: Optional[str] = None @@ -535,6 +539,128 @@ def calibrate_intercept( conn.close() +@router.patch("/{instrument}/nodes/{node_id}") +def update_node_config( + instrument: str, + node_id: str, + body: NodeConfigBody, +) -> Dict[str, Any]: + """Met à jour la configuration d'un nœud (macro_key) dans le graph_json persisté.""" + import json as _json + from services.database import get_conn + conn = get_conn() + try: + inst = instrument.upper() + row = conn.execute( + "SELECT graph_json FROM instrument_models WHERE instrument=?", (inst,) + ).fetchone() + if not row: + raise HTTPException(status_code=404, detail=f"Instrument {inst} introuvable") + + graph_def = _json.loads(row["graph_json"]) + found = False + for node in graph_def.get("nodes", []): + if node["id"] == node_id: + if body.macro_key is not None: + if body.macro_key == "": + node.pop("macro_key", None) # clear + else: + node["macro_key"] = body.macro_key + found = True + break + + if not found: + raise HTTPException(status_code=404, detail=f"Nœud {node_id} introuvable") + + conn.execute( + "UPDATE instrument_models SET graph_json=?, updated_at=datetime('now') WHERE instrument=?", + (_json.dumps(graph_def), inst) + ) + conn.commit() + return {"ok": True, "node_id": node_id, "macro_key": body.macro_key} + finally: + conn.close() + + +@router.get("/{instrument}/macro-guidance") +def get_macro_guidance(instrument: str) -> List[Dict[str, Any]]: + """ + Retourne l'état courant et le prochain forecast pour chaque nœud input_manual + avec un macro_key configuré. + """ + import json as _json + from services.database import get_conn + from services.instrument_models import build_macro_node_timeline, FF_MACRO_KEYS, _parse_ff_num + from datetime import datetime, timedelta, date as date_type + conn = get_conn() + try: + inst = instrument.upper() + row = conn.execute( + "SELECT graph_json FROM instrument_models WHERE instrument=?", (inst,) + ).fetchone() + if not row: + return [] + + graph_def = _json.loads(row["graph_json"]) + today = datetime.utcnow().date() + result = [] + + for node in graph_def.get("nodes", []): + if node.get("node_type") != "input_manual": + continue + macro_key = node.get("macro_key") + if not macro_key: + continue + + meta = FF_MACRO_KEYS.get(macro_key, {}) + currency = meta.get("currency", "") + patterns = meta.get("names", []) + + # Current interpolated value + date_from = today - timedelta(days=90) + date_to = today + timedelta(days=180) + tl = build_macro_node_timeline(conn, macro_key, date_from, date_to) + current_v = tl.get(str(today)) + + # Next scheduled event from ff_calendar + next_event = None + if currency and patterns: + try: + ff_rows = conn.execute( + """SELECT event_date, event_name, forecast_value, previous_value + FROM ff_calendar + WHERE currency=? AND event_date > ? + ORDER BY event_date ASC LIMIT 20""", + (currency, str(today)) + ).fetchall() + for r in ff_rows: + if any(p in r["event_name"].lower() for p in patterns): + ev_date = date_type.fromisoformat(r["event_date"]) + forecast_v = _parse_ff_num(r.get("forecast_value") or r.get("previous_value")) + next_event = { + "date": r["event_date"], + "name": r["event_name"], + "forecast": forecast_v, + "days_until": (ev_date - today).days, + } + break + except Exception: + pass + + result.append({ + "node_id": node["id"], + "node_label": node.get("label", node["id"]), + "macro_key": macro_key, + "unit": node.get("unit", ""), + "current_value": round(current_v, 4) if current_v is not None else None, + "next_event": next_event, + }) + + return result + finally: + conn.close() + + @router.get("/{instrument}") def get_instrument_model( instrument: str, diff --git a/backend/services/instrument_models.py b/backend/services/instrument_models.py index 2a2c081..c7a6a89 100644 --- a/backend/services/instrument_models.py +++ b/backend/services/instrument_models.py @@ -14,6 +14,12 @@ Nouveautés Phase 2 : - 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 @@ -21,6 +27,173 @@ 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 + + # ── Saturation scales (tanh) par unité native ───────────────────────────────── # tanh(x/scale) : slope=1 à l'origine, sature asymptotiquement à ±1 # pips = coefficient_to_pips * scale * tanh(x / scale) @@ -1078,51 +1251,73 @@ def simulate_timeline( from services.causal_graphs import evaluate_graph - # Structural pips — calculé une seule fois (overrides statiques, pas d'events) - 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) - structural_pips = round(float(vals_struct.get(output_id, 0.0)), 1) - fundamental_level_base = round(price_intercept + structural_pips * pip_to_price, 6) + # ── Macro-guidance : noeuds input_manual avec macro_key → overrides time-varying ── + 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 - # Guidance EMA : la baseline de la synthétique est l'EMA lissée du prix réel. - # synthetic_price(t) = EMA(t) + event_pips(t) × pip_to_price - # → sans event perturbateur : synthétique colle au lissé historique - # → avec events : déviation proportionnelle à leur contribution - ema_prices: dict[str, float] = {} - last_ema: float = fundamental_level_base # fallback si pas de données prix + 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) + + # ── 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: - # 30j de warmup avant date_from pour que l'EMA soit stabilisée dès le début - warmup_from = str(date_from - timedelta(days=30)) - ph_rows = conn.execute( - """SELECT date, close FROM price_history_cache - WHERE instrument=? AND date>=? ORDER BY date ASC""", - (inst_upper, warmup_from) - ).fetchall() - alpha = 0.15 # lissage EMA (~6j de demi-vie) - ema_val: Optional[float] = None - for r in ph_rows: - c = float(r["close"]) - ema_val = c if ema_val is None else alpha * c + (1.0 - alpha) * ema_val - if r["date"] >= str(date_from): - ema_prices[r["date"]] = round(ema_val, 6) - if ema_prices: - last_ema = list(ema_prices.values())[-1] + 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: + 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: - pass + 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] = v0 timeline = [] cur = date_from while cur <= today: - # Accumule les events virtuels (What-if) actifs ce jour + 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: - continue - if ev["ev_date"] < date_from: - # Events avant la fenêtre ne portent pas de lifecycle dans la simu - # (leur impact est absorbé dans l'auto-anchor du prix de départ) + 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"]) @@ -1140,40 +1335,64 @@ def simulate_timeline( "category": cat, }) - if ev_by_cat: - # Des events virtuels sont actifs → recalcul complet avec régime - ri = detect_regime(ev_by_cat) - gj = _graph_json_for_eval(graph_def, ri["weights"]) - inputs = _build_inputs(graph_def, overrides, ev_by_cat, saturation=True) - vals = evaluate_graph(gj, inputs) - net = round(float(vals.get(output_id, 0.0)), 1) - regime_label = ri["regime"] - else: - # Pas d'events — on réutilise les valeurs structurelles - vals = vals_struct - net = structural_pips - regime_label = "BALANCED" + # ── 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: + 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": ""} - # Guide price : EMA du prix réel si disponible, sinon dernier EMA connu (futur) - date_str = str(cur) - if date_str in ema_prices: - guide_price = ema_prices[date_str] - last_ema = guide_price - else: - guide_price = last_ema # dates futures : tient le dernier EMA connu + # Structural pips time-varying (sans events, avec macro overrides du jour) + 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) + structural_pips_t = round(float(vs_s.get(output_id, 0.0)), 1) - event_pips = round(net - structural_pips, 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, ev_by_cat, saturation=True) + vals = evaluate_graph(gj_, in_) + net = round(float(vals.get(output_id, 0.0)), 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, ev_by_cat, saturation=True) + vals = evaluate_graph(gj, inputs) + net = round(float(vals.get(output_id, 0.0)), 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": date_str, - "net_pips": net, - "structural_pips": structural_pips, - "event_pips": event_pips, - "fundamental_level": guide_price, - "synthetic_price": round(guide_price + event_pips * pip_to_price, 6), - "regime": regime_label, - "nodes": {k: round(float(v), 1) for k, v in vals.items()}, - "active_events": active_events_detail, + "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) diff --git a/frontend/src/pages/InstrumentModels.tsx b/frontend/src/pages/InstrumentModels.tsx index 799ee11..2a96c88 100644 --- a/frontend/src/pages/InstrumentModels.tsx +++ b/frontend/src/pages/InstrumentModels.tsx @@ -7,7 +7,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react' import { RefreshCw, Edit3, X, Trash2, ChevronDown, ChevronUp, TrendingUp, TrendingDown, Minus, LineChart, Table2, Network, Activity, - Plus, Zap, + Plus, Zap, Settings2, Check, } from 'lucide-react' import clsx from 'clsx' import axios from 'axios' @@ -29,6 +29,7 @@ interface ModelNode { event_category?: string formula?: string display_col: number + macro_key?: string // computed computed_value: number pip_contribution: number @@ -153,10 +154,43 @@ interface CalendarEvent { surprise_delta: number | null } +interface MacroGuidanceItem { + node_id: string + node_label: string + macro_key: string + unit: string + current_value: number | null + next_event: { + date: string + name: string + forecast: number | null + days_until: number + } | null +} + // ── Constants ───────────────────────────────────────────────────────────────── const INSTRUMENTS = ['EURUSD','USDJPY','XAUUSD','SP500','TLT','GBPUSD','EEM','QQQ'] +const MACRO_KEY_OPTIONS = [ + { value: '', label: '— aucune clé macro —' }, + { value: 'fed_rate', label: 'Taux Fed (USD)' }, + { value: 'ecb_rate', label: 'Taux BCE (EUR)' }, + { value: 'boe_rate', label: 'Taux BoE (GBP)' }, + { value: 'boj_rate', label: 'Taux BoJ (JPY)' }, + { value: 'us_cpi', label: 'CPI US MoM' }, + { value: 'us_cpi_yoy', label: 'CPI US YoY' }, + { value: 'eu_cpi_yoy', label: 'HICP Eurozone YoY' }, + { value: 'us_nfp', label: 'NFP US (emplois K)' }, + { value: 'us_pmi', label: 'PMI US (ISM)' }, + { value: 'eu_pmi', label: 'PMI Eurozone' }, + { value: 'us_gdp', label: 'GDP US QoQ %' }, + { value: 'eu_gdp', label: 'GDP Eurozone %' }, + { value: 'us_unemployment', label: 'Chômage US %' }, + { value: 'eu_unemployment', label: 'Chômage Eurozone %' }, + { value: 'us_retail_sales', label: 'Ventes détail US MoM' }, +] + const NODE_TYPE_META: Record = { input_event: { label: 'Events', color: 'text-sky-400', bg: 'bg-sky-900/30 border-sky-700/40' }, input_manual: { label: 'Manuel', color: 'text-violet-400', bg: 'bg-violet-900/30 border-violet-700/40' }, @@ -1954,9 +1988,143 @@ 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 manualNodes = nodes.filter(n => n.node_type === 'input_manual') + + useEffect(() => { + const init: Record = {} + for (const n of manualNodes) init[n.id] = n.macro_key ?? '' + setLocalKeys(init) + }, [nodes]) + + const refreshGuidance = useCallback(() => { + 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 + + return ( +
+
+
+
Paramètres macro des nœuds
+
+ 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. +
+
+ + {linkedCount}/{manualNodes.length} liés + +
+ + {/* 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 && } +
+
+ ) + })} +
+ + {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. +
+ )} +
+ ) +} + // ── Main Page ────────────────────────────────────────────────────────────────── -type ViewMode = 'dag' | 'table' | 'timeline' | 'calibration' +type ViewMode = 'dag' | 'table' | 'timeline' | 'calibration' | 'params' export default function InstrumentModels() { const [instrument, setInstrument] = useState('EURUSD') @@ -1997,6 +2165,7 @@ export default function InstrumentModels() { { key: 'table', Icon: Table2, label: 'Tableau' }, { key: 'timeline', Icon: LineChart, label: 'Timeline' }, { key: 'calibration', Icon: Activity, label: 'Calibration' }, + { key: 'params', Icon: Settings2, label: 'Paramètres' }, ] return ( @@ -2142,6 +2311,7 @@ export default function InstrumentModels() { {view === 'table' && } {view === 'timeline' && } {view === 'calibration' && } + {view === 'params' && } )}