From a8ee808937ae947cfb6a6c3d44642ef4426383ca Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Fri, 3 Jul 2026 00:04:37 +0200 Subject: [PATCH] feat: instrument model --- backend/routers/instrument_models.py | 96 +++++ backend/services/instrument_models.py | 107 ++++- backend/services/price_history.py | 176 ++++++++ frontend/src/pages/InstrumentModels.tsx | 542 ++++++++++++++++++++---- 4 files changed, 817 insertions(+), 104 deletions(-) create mode 100644 backend/services/price_history.py diff --git a/backend/routers/instrument_models.py b/backend/routers/instrument_models.py index 47ae4d7..141d6ec 100644 --- a/backend/routers/instrument_models.py +++ b/backend/routers/instrument_models.py @@ -23,6 +23,26 @@ class BulkOverrideBody(BaseModel): overrides: List[BulkOverrideItem] +class VirtualEvent(BaseModel): + date: str + category: str = "unclassified" + pips: float = 0.0 + label: str = "Event virtuel" + absorption_days: int = 14 + rise_days: int = 1 + plateau_days: int = 0 + decay_type: str = "exp" + + +class WhatIfBody(BaseModel): + period: str = "1y" + virtual_events: List[VirtualEvent] = [] + + +class CalibrateBody(BaseModel): + ref_date: Optional[str] = None + + @router.get("", response_model=List[Dict[str, Any]]) def list_instrument_models(): from services.database import get_conn @@ -136,6 +156,32 @@ def get_instrument_regime( conn.close() +@router.get("/{instrument}/price-history") +def get_price_history( + instrument: str, + period: str = Query("1y", description="5d|1mo|3mo|6mo|1y|2y"), + refresh: bool = Query(False), +) -> Dict[str, Any]: + """Cours historiques réels depuis yfinance (cache SQLite 6h).""" + from services.database import get_conn + from services.price_history import get_price_history as fetch_prices + from services.instrument_models import INSTRUMENT_MODELS + conn = get_conn() + try: + inst = instrument.upper() + prices = fetch_prices(conn, inst, period, force_refresh=refresh) + meta = INSTRUMENT_MODELS.get(inst, {}) + return { + "instrument": inst, + "ticker": meta.get("yf_ticker", ""), + "period": period, + "n_points": len(prices), + "prices": prices, + } + finally: + conn.close() + + @router.get("/{instrument}/timeline") def get_instrument_timeline( instrument: str, @@ -154,6 +200,56 @@ def get_instrument_timeline( conn.close() +@router.post("/{instrument}/timeline-whatif") +def timeline_whatif( + instrument: str, + body: WhatIfBody, +) -> List[Dict[str, Any]]: + """Simulation what-if avec events virtuels injectés dans la timeline.""" + from services.database import get_conn + from services.instrument_models import simulate_timeline + conn = get_conn() + try: + ve_list = [ve.dict() for ve in body.virtual_events] + data = simulate_timeline(conn, instrument.upper(), body.period, virtual_events=ve_list) + if not data: + raise HTTPException(status_code=404, detail=f"Modèle introuvable pour {instrument.upper()}") + return data + finally: + conn.close() + + +@router.post("/{instrument}/calibrate") +def calibrate_intercept( + instrument: str, + body: CalibrateBody, +) -> Dict[str, Any]: + """Auto-calcule l'intercept depuis le cours réel à une date de référence.""" + from services.database import get_conn + from services.instrument_models import get_model_state, INSTRUMENT_MODELS + from services.price_history import calibrate_intercept as do_calibrate, get_price_history + conn = get_conn() + try: + inst = instrument.upper() + state = get_model_state(conn, inst, body.ref_date) + if not state: + raise HTTPException(status_code=404, detail=f"Modèle introuvable") + # Make sure prices are cached + get_price_history(conn, inst, "3mo") + intercept = do_calibrate(conn, inst, state["structural_pips"], body.ref_date) + meta = INSTRUMENT_MODELS.get(inst, {}) + return { + "instrument": inst, + "ref_date": body.ref_date, + "structural_pips": state["structural_pips"], + "pip_to_price": meta.get("pip_to_price", 0.0001), + "calibrated_intercept": intercept, + "current_intercept": meta.get("price_intercept", 0.0), + } + 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 7a3f09e..23ae7ca 100644 --- a/backend/services/instrument_models.py +++ b/backend/services/instrument_models.py @@ -193,6 +193,9 @@ INSTRUMENT_MODELS: dict[str, dict] = { "name": "EUR/USD", "description": "Taux de change Euro/Dollar — modèle causal 3 couches avec 4 domaines d'influence", "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"}, @@ -246,6 +249,9 @@ INSTRUMENT_MODELS: dict[str, dict] = { "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."}, @@ -283,6 +289,9 @@ INSTRUMENT_MODELS: dict[str, dict] = { "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."}, @@ -324,6 +333,9 @@ INSTRUMENT_MODELS: dict[str, dict] = { "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."}, @@ -366,6 +378,9 @@ INSTRUMENT_MODELS: dict[str, dict] = { "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."}, @@ -406,6 +421,9 @@ INSTRUMENT_MODELS: dict[str, dict] = { "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."}, @@ -441,6 +459,9 @@ INSTRUMENT_MODELS: dict[str, dict] = { "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."}, @@ -478,6 +499,9 @@ INSTRUMENT_MODELS: dict[str, dict] = { "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."}, @@ -804,6 +828,20 @@ def get_model_state(conn, instrument: str, at_date: Optional[str] = None) -> Opt output_id = graph_def["output_node"] net_pips = round(float(all_vals.get(output_id, 0.0)), 1) + # Compute structural pips (manual inputs only, no events, BALANCED regime) + inputs_struct = _build_inputs(graph_def, overrides, {}, saturation=True) + gj_struct = _graph_json_for_eval(graph_def, {}) + vals_struct = evaluate_graph(gj_struct, inputs_struct) + structural_pips = round(float(vals_struct.get(output_id, 0.0)), 1) + 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"] @@ -852,20 +890,28 @@ def get_model_state(conn, instrument: str, at_date: Optional[str] = None) -> Opt direction = "bullish" if net_pips > 5 else "bearish" if net_pips < -5 else "neutral" return { - "instrument": inst_upper, - "name": graph_def["name"], - "description": graph_def.get("description", ""), - "at_date": str(ref_date), - "net_pips": net_pips, - "direction": direction, - "nodes": nodes_out, - "output_node": output_id, - "regime": regime_info, + "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, } def simulate_timeline( - conn, instrument: str, period: str = "1y" + conn, instrument: str, period: str = "1y", + virtual_events: Optional[list] = None, ) -> list[dict]: """ Simulate all node values day by day over the period. @@ -945,8 +991,31 @@ def simulate_timeline( events.append({ "ev_date": ev_date, "category": r["category"], "pips": pips, "rise": rise, "plateau": plateau, "absorption": absorption, "dtype": dtype, + "virtual": False, }) + # Inject virtual events + 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 timeline = [] @@ -970,11 +1039,21 @@ def simulate_timeline( vals = evaluate_graph(gj, inputs) net = round(float(vals.get(output_id, 0.0)), 1) + # Structural pips (manual only, no events) + inputs_struct = _build_inputs(graph_def, overrides, {}, saturation=True) + gj_struct = _graph_json_for_eval(graph_def, {}) + vals_struct = evaluate_graph(gj_struct, inputs_struct) + structural_pips = round(float(vals_struct.get(output_id, 0.0)), 1) + timeline.append({ - "date": str(cur), - "net_pips": net, - "regime": ri["regime"], - "nodes": {k: round(float(v), 1) for k, v in vals.items()}, + "date": str(cur), + "net_pips": net, + "structural_pips": structural_pips, + "event_pips": round(net - structural_pips, 1), + "fundamental_level": round(price_intercept + structural_pips * pip_to_price, 6), + "synthetic_price": round(price_intercept + net * pip_to_price, 6), + "regime": ri["regime"], + "nodes": {k: round(float(v), 1) for k, v in vals.items()}, }) cur += timedelta(days=1) diff --git a/backend/services/price_history.py b/backend/services/price_history.py new file mode 100644 index 0000000..ca322c1 --- /dev/null +++ b/backend/services/price_history.py @@ -0,0 +1,176 @@ +""" +Price History — fetch + cache des cours historiques via yfinance. +Cache SQLite dans la table price_history_cache. +""" +import json +import sqlite3 +from datetime import datetime, timedelta, date as date_type +from typing import Optional + + +YF_TICKERS: dict[str, str] = { + "EURUSD": "EURUSD=X", + "USDJPY": "USDJPY=X", + "XAUUSD": "GC=F", + "SP500": "^GSPC", + "TLT": "TLT", + "GBPUSD": "GBPUSD=X", + "EEM": "EEM", + "QQQ": "QQQ", +} + +PRICE_LABELS: dict[str, str] = { + "EURUSD": "EUR/USD", + "USDJPY": "USD/JPY", + "XAUUSD": "Or ($/oz)", + "SP500": "S&P 500", + "TLT": "TLT ETF", + "GBPUSD": "GBP/USD", + "EEM": "EEM ETF", + "QQQ": "QQQ ETF", +} + +PERIOD_DAYS: dict[str, int] = { + "5d": 7, "1mo": 35, "3mo": 95, "6mo": 190, "1y": 370, "2y": 740, +} + + +def _ensure_cache_table(conn): + conn.execute(""" + CREATE TABLE IF NOT EXISTS price_history_cache ( + id INTEGER PRIMARY KEY, + instrument TEXT NOT NULL, + date TEXT NOT NULL, + close REAL NOT NULL, + fetched_at TEXT DEFAULT (datetime('now')), + UNIQUE(instrument, date) + ) + """) + conn.execute(""" + CREATE INDEX IF NOT EXISTS idx_price_cache_inst_date + ON price_history_cache(instrument, date) + """) + conn.commit() + + +def _fetch_yf(instrument: str, period_days: int) -> list[dict]: + """Fetch from yfinance. Returns [{date, close}] sorted ascending.""" + try: + import yfinance as yf + ticker = YF_TICKERS.get(instrument.upper()) + if not ticker: + return [] + # yfinance period string + if period_days <= 7: p = "5d" + elif period_days <= 35: p = "1mo" + elif period_days <= 95: p = "3mo" + elif period_days <= 190: p = "6mo" + elif period_days <= 370: p = "1y" + else: p = "2y" + + df = yf.download(ticker, period=p, interval="1d", progress=False, auto_adjust=True) + if df is None or df.empty: + return [] + + # Handle MultiIndex columns (yfinance 0.2+) + if hasattr(df.columns, 'levels'): + df.columns = df.columns.get_level_values(0) + + close_col = next((c for c in ["Close", "Adj Close", "close"] if c in df.columns), None) + if not close_col: + return [] + + result = [] + for idx, row in df.iterrows(): + dt = str(idx)[:10] + v = float(row[close_col]) + if v and v == v: # not NaN + result.append({"date": dt, "close": round(v, 6)}) + return result + + except Exception: + return [] + + +def get_price_history( + conn, + instrument: str, + period: str = "1y", + force_refresh: bool = False, +) -> list[dict]: + """ + Retourne [{date, close}] pour l'instrument sur la période. + Cache SQLite — rafraîchit si les données datent de plus de 6h. + """ + _ensure_cache_table(conn) + inst = instrument.upper() + days = PERIOD_DAYS.get(period, 370) + date_from = (datetime.utcnow().date() - timedelta(days=days)).isoformat() + + # Check cache freshness + cache_ok = False + if not force_refresh: + newest = conn.execute( + "SELECT fetched_at FROM price_history_cache WHERE instrument=? ORDER BY fetched_at DESC LIMIT 1", + (inst,) + ).fetchone() + if newest: + try: + age_h = (datetime.utcnow() - datetime.fromisoformat(str(newest[0])[:19])).total_seconds() / 3600 + cache_ok = age_h < 6.0 + except Exception: + pass + + if not cache_ok: + rows = _fetch_yf(inst, days + 30) + if rows: + conn.executemany( + "INSERT OR REPLACE INTO price_history_cache (instrument, date, close) VALUES (?,?,?)", + [(inst, r["date"], r["close"]) for r in rows] + ) + conn.commit() + + # Read from cache + rows_db = conn.execute( + "SELECT date, close FROM price_history_cache WHERE instrument=? AND date>=? ORDER BY date ASC", + (inst, date_from) + ).fetchall() + return [{"date": r[0], "close": r[1]} for r in rows_db] + + +def calibrate_intercept( + conn, + instrument: str, + model_pips_at_ref: float, + ref_date: Optional[str] = None, +) -> Optional[float]: + """ + Calcule l'intercept = real_price - model_pips × pip_to_price au point de référence. + Si ref_date non fourni, utilise il y a 30 jours. + """ + from services.instrument_models import INSTRUMENT_MODELS + meta = INSTRUMENT_MODELS.get(instrument.upper(), {}) + pip_to_price = meta.get("pip_to_price", 0.0001) + + if ref_date is None: + ref_date = (datetime.utcnow().date() - timedelta(days=30)).isoformat() + + # Find nearest price to ref_date + row = conn.execute( + """SELECT date, close FROM price_history_cache + WHERE instrument=? AND date<=? ORDER BY date DESC LIMIT 1""", + (instrument.upper(), ref_date) + ).fetchone() + if not row: + # Try fetching + history = get_price_history(conn, instrument, "3mo", force_refresh=True) + row = conn.execute( + "SELECT date, close FROM price_history_cache WHERE instrument=? AND date<=? ORDER BY date DESC LIMIT 1", + (instrument.upper(), ref_date) + ).fetchone() + if not row: + return None + + real_price = float(row[1]) + intercept = real_price - model_pips_at_ref * pip_to_price + return round(intercept, 6) diff --git a/frontend/src/pages/InstrumentModels.tsx b/frontend/src/pages/InstrumentModels.tsx index 7317f16..53d0612 100644 --- a/frontend/src/pages/InstrumentModels.tsx +++ b/frontend/src/pages/InstrumentModels.tsx @@ -7,6 +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, } from 'lucide-react' import clsx from 'clsx' import axios from 'axios' @@ -56,6 +57,13 @@ interface ModelState { description: string at_date: string net_pips: number + structural_pips: number + event_pips: number + price_intercept: number + pip_to_price: number + yf_ticker: string + fundamental_level: number + synthetic_price: number direction: 'bullish' | 'bearish' | 'neutral' nodes: ModelNode[] output_node: string @@ -65,10 +73,31 @@ interface ModelState { interface TimelinePoint { date: string net_pips: number + structural_pips: number + event_pips: number + fundamental_level: number + synthetic_price: number regime?: string nodes: Record } +interface PricePoint { + date: string + close: number +} + +interface VirtualEventForm { + id: string + date: string + category: string + pips: number + label: string + absorption_days: number + rise_days: number + plateau_days: number + decay_type: string +} + // ── Constants ───────────────────────────────────────────────────────────────── const INSTRUMENTS = ['EURUSD','USDJPY','XAUUSD','SP500','TLT','GBPUSD','EEM','QQQ'] @@ -151,6 +180,26 @@ function fmt(v: number) { return `${s}${v.toFixed(1)}` } +function fmtPrice(v: number, pipToPrice: number): string { + if (pipToPrice <= 0.0001) return v.toFixed(4) + if (pipToPrice <= 0.01) return v.toFixed(2) + if (pipToPrice <= 0.1) return v.toFixed(2) + return v.toFixed(0) +} + +const EVENT_CATEGORIES = [ + { value: 'central_bank', label: 'Banque Centrale' }, + { value: 'monetary_shock', label: 'Surprise Macro' }, + { value: 'geopolitical', label: 'Géopolitique' }, + { value: 'trade_policy', label: 'Commerce / Tarifs' }, + { value: 'growth_shock', label: 'Choc Croissance' }, + { value: 'credit_stress', label: 'Stress Crédit' }, + { value: 'commodity', label: 'Commodités' }, + { value: 'sentiment', label: 'Sentiment' }, + { value: 'technical', label: 'Technique' }, + { value: 'unclassified', label: 'Non classifié' }, +] + function DirectionBadge({ direction, pips }: { direction: string; pips: number }) { const icon = direction === 'bullish' ? : @@ -316,6 +365,10 @@ function NodeCard({ node, onEdit }: { node: ModelNode; onEdit: (n: ModelNode) => const meta = NODE_TYPE_META[node.node_type] const v = node.pip_contribution const canEdit = node.node_type === 'input_event' || node.node_type === 'input_manual' + const isManual = node.node_type === 'input_manual' + const hasValue = node.source === 'manual' && node.raw_value !== undefined && node.raw_value !== 0 + const coeff = node.coefficient_to_pips ?? 1 + return (
{node.label}
-
- {fmt(v)} pips -
+ {isManual ? ( +
+ {hasValue ? ( + + {node.raw_value! > 0 ? '+' : ''}{node.raw_value} {node.unit} + + ) : ( + — {node.unit} + )} + + w:{coeff} + + {hasValue && ( + + ={fmt(v)}p + + )} +
+ ) : ( +
+ {fmt(v)} pips +
+ )} - {Math.abs(v) > 0.05 && ( + {Math.abs(v) > 0.05 && !isManual && (
)} + {isManual && Math.abs(v) > 0.05 && ( +
+
+
+ )} {node.node_type === 'intermediate' && (
@@ -562,93 +640,150 @@ function TableView({ nodes, onEdit }: { nodes: ModelNode[]; onEdit: (n: ModelNod // ── Timeline Chart ───────────────────────────────────────────────────────────── +const REGIME_CANVAS_COLORS: Record = { + MONETARY_DOMINANCE: 'rgba(59,130,246,0.07)', + GEOPOLITICAL_RISK: 'rgba(249,115,22,0.07)', + CREDIT_STRESS: 'rgba(239,68,68,0.07)', + GROWTH_SCARE: 'rgba(245,158,11,0.07)', + COMMODITY_SHOCK: 'rgba(234,179,8,0.07)', + BALANCED: 'rgba(0,0,0,0)', +} + +function newVirtualEvent(): VirtualEventForm { + return { + id: Math.random().toString(36).slice(2), + date: new Date().toISOString().slice(0, 10), + category: 'central_bank', + pips: 50, + label: 'Event virtuel', + absorption_days: 14, + rise_days: 1, + plateau_days: 0, + decay_type: 'exp', + } +} + function TimelineView({ instrument }: { instrument: string }) { const [period, setPeriod] = useState('3mo') const [data, setData] = useState([]) + const [realPrices, setRealPrices] = useState([]) const [loading, setLoading] = useState(false) + const [showReal, setShowReal] = useState(true) + const [showFund, setShowFund] = useState(true) + const [showPips, setShowPips] = useState(false) // toggle: price vs pips const [layerKeys, setLayerKeys] = useState([]) - const [shown, setShown] = useState>(new Set(['net_pips'])) + const [shownLayers, setShownLayers] = useState>(new Set()) + const [showVirtual, setShowVirtual] = useState(false) + const [virtuals, setVirtuals] = useState([]) + const [whatifLoading, setWhatifLoading] = useState(false) + const [whatifData, setWhatifData] = useState(null) const canvasRef = useRef(null) + const activeData = whatifData ?? data + + // Load timeline useEffect(() => { setLoading(true) + setWhatifData(null) api.get(`/instrument-models/${instrument}/timeline?period=${period}`) .then(r => { - const pts: TimelinePoint[] = r.data + const pts = r.data setData(pts) if (pts.length > 0) { const keys = Object.keys(pts[0].nodes).filter(k => k.startsWith('layer_')) setLayerKeys(keys) - setShown(new Set(['net_pips', ...keys])) } }) .catch(() => setData([])) .finally(() => setLoading(false)) }, [instrument, period]) + // Load real price history + useEffect(() => { + api.get<{ prices: PricePoint[] }>(`/instrument-models/${instrument}/price-history?period=${period}`) + .then(r => setRealPrices(r.data.prices || [])) + .catch(() => setRealPrices([])) + }, [instrument, period]) + + // Draw canvas useEffect(() => { const canvas = canvasRef.current - if (!canvas || data.length === 0) return + if (!canvas || activeData.length === 0) return const ctx = canvas.getContext('2d') if (!ctx) return const W = canvas.width = canvas.offsetWidth - const H = canvas.height = 260 + const H = canvas.height = 300 ctx.clearRect(0, 0, W, H) - const keys = Array.from(shown) - if (keys.length === 0) return + // Build date→index map for real prices alignment + const dateToIdx = new Map() + activeData.forEach((pt, i) => dateToIdx.set(pt.date, i)) + + // Value extractor depending on mode + const getV = (pt: TimelinePoint) => showPips ? pt.net_pips : pt.synthetic_price + const getFundV = (pt: TimelinePoint) => showPips ? pt.structural_pips : pt.fundamental_level let minV = Infinity, maxV = -Infinity - for (const pt of data) { - for (const k of keys) { - const v = k === 'net_pips' ? pt.net_pips : (pt.nodes[k] ?? 0) + for (const pt of activeData) { + const v = getV(pt) + if (v < minV) minV = v + if (v > maxV) maxV = v + if (showFund) { + const f = getFundV(pt) + if (f < minV) minV = f + if (f > maxV) maxV = f + } + } + if (showReal && realPrices.length > 0 && !showPips) { + for (const p of realPrices) { + if (p.close < minV) minV = p.close + if (p.close > maxV) maxV = p.close + } + } + for (const k of shownLayers) { + for (const pt of activeData) { + const v = pt.nodes[k] ?? 0 if (v < minV) minV = v if (v > maxV) maxV = v } } - const pad = Math.max(5, 0.15 * (maxV - minV)) + + const pad = Math.max(showPips ? 5 : 0.001, 0.08 * (maxV - minV)) minV -= pad; maxV += pad - const mL = 52, mR = 16, mT = 12, mB = 28 + const mL = 64, mR = 16, mT = 12, mB = 28 const cW = W - mL - mR, cH = H - mT - mB - const toX = (i: number) => mL + (i / Math.max(data.length - 1, 1)) * cW + const toX = (i: number) => mL + (i / Math.max(activeData.length - 1, 1)) * cW const toY = (v: number) => mT + (1 - (v - minV) / (maxV - minV)) * cH - // Regime background bands - const REGIME_CANVAS_COLORS: Record = { - MONETARY_DOMINANCE: 'rgba(59,130,246,0.07)', - GEOPOLITICAL_RISK: 'rgba(249,115,22,0.07)', - CREDIT_STRESS: 'rgba(239,68,68,0.07)', - GROWTH_SCARE: 'rgba(245,158,11,0.07)', - COMMODITY_SHOCK: 'rgba(234,179,8,0.07)', - BALANCED: 'rgba(0,0,0,0)', - } - let bandStart = 0, bandRegime = data[0]?.regime || 'BALANCED' - for (let i = 1; i <= data.length; i++) { - const r = i < data.length ? (data[i]?.regime || 'BALANCED') : null - if (r !== bandRegime || i === data.length) { + // Regime bands + let bandStart = 0, bandRegime = activeData[0]?.regime || 'BALANCED' + for (let i = 1; i <= activeData.length; i++) { + const r = i < activeData.length ? (activeData[i]?.regime || 'BALANCED') : null + if (r !== bandRegime || i === activeData.length) { const color = REGIME_CANVAS_COLORS[bandRegime] - if (color !== 'rgba(0,0,0,0)') { + if (color && color !== 'rgba(0,0,0,0)') { ctx.fillStyle = color - ctx.fillRect(toX(bandStart), mT, toX(i - 1) - toX(bandStart), cH) + ctx.fillRect(toX(bandStart), mT, toX(Math.max(i - 1, bandStart)) - toX(bandStart) + 1, cH) } bandStart = i; bandRegime = r || 'BALANCED' } } // Grid - ctx.lineWidth = 1 const nG = 5 for (let i = 0; i <= nG; i++) { const v = minV + (i / nG) * (maxV - minV) const y = toY(v) - ctx.strokeStyle = '#1e293b'; ctx.beginPath(); ctx.moveTo(mL, y); ctx.lineTo(W - mR, y); ctx.stroke() + ctx.strokeStyle = '#1e293b'; ctx.lineWidth = 1 + ctx.beginPath(); ctx.moveTo(mL, y); ctx.lineTo(W - mR, y); ctx.stroke() ctx.fillStyle = '#64748b'; ctx.font = '10px sans-serif'; ctx.textAlign = 'right' - ctx.fillText(fmt(v), mL - 4, y + 3.5) + const lbl = showPips ? fmt(v) : v.toFixed(v > 100 ? 1 : 4) + ctx.fillText(lbl, mL - 4, y + 3.5) } - if (minV < 0 && maxV > 0) { + if (showPips && minV < 0 && maxV > 0) { const y0 = toY(0) ctx.strokeStyle = '#334155'; ctx.setLineDash([3,3]); ctx.lineWidth = 1 ctx.beginPath(); ctx.moveTo(mL, y0); ctx.lineTo(W - mR, y0); ctx.stroke() @@ -656,42 +791,125 @@ function TimelineView({ instrument }: { instrument: string }) { } // X labels - const step = Math.max(1, Math.floor(data.length / Math.floor(cW / 60))) + const step = Math.max(1, Math.floor(activeData.length / Math.floor(cW / 60))) ctx.fillStyle = '#64748b'; ctx.font = '10px sans-serif'; ctx.textAlign = 'center' - for (let i = 0; i < data.length; i += step) { - ctx.fillText(data[i].date.slice(5), toX(i), H - 4) + for (let i = 0; i < activeData.length; i += step) { + ctx.fillText(activeData[i].date.slice(5), toX(i), H - 4) } - // Lines - for (const k of keys) { - const color = CANVAS_COLORS[k] || '#94a3b8' - const isNet = k === 'net_pips' - ctx.strokeStyle = color; ctx.lineWidth = isNet ? 2.5 : 1.5 - ctx.setLineDash(isNet ? [] : [4, 3]) + // Virtual event markers + if (virtuals.length > 0) { + ctx.fillStyle = '#f59e0b' + for (const ve of virtuals) { + const idx = dateToIdx.get(ve.date) + if (idx !== undefined) { + const x = toX(idx) + ctx.beginPath() + ctx.moveTo(x, mT + cH) + ctx.lineTo(x - 4, mT + cH + 6) + ctx.lineTo(x + 4, mT + cH + 6) + ctx.closePath() + ctx.fill() + } + } + } + + // Real price line (light grey) + if (showReal && !showPips && realPrices.length > 0) { + ctx.strokeStyle = 'rgba(148,163,184,0.5)'; ctx.lineWidth = 1.5; ctx.setLineDash([]) ctx.beginPath() - data.forEach((pt, i) => { - const v = k === 'net_pips' ? pt.net_pips : (pt.nodes[k] ?? 0) - i === 0 ? ctx.moveTo(toX(i), toY(v)) : ctx.lineTo(toX(i), toY(v)) - }) + let started = false + for (const p of realPrices) { + const idx = dateToIdx.get(p.date) + if (idx !== undefined) { + const x = toX(idx); const y = toY(p.close) + if (!started) { ctx.moveTo(x, y); started = true } else { ctx.lineTo(x, y) } + } + } ctx.stroke() } - ctx.setLineDash([]) - }, [data, shown]) + + // Fundamental level (dashed amber) + if (showFund) { + ctx.strokeStyle = '#f59e0b'; ctx.lineWidth = 1; ctx.setLineDash([5, 4]) + ctx.beginPath() + activeData.forEach((pt, i) => { + const y = toY(getFundV(pt)) + i === 0 ? ctx.moveTo(toX(i), y) : ctx.lineTo(toX(i), y) + }) + ctx.stroke() + ctx.setLineDash([]) + } + + // Layer lines (dashed, thin) + for (const k of shownLayers) { + const color = CANVAS_COLORS[k] || '#94a3b8' + ctx.strokeStyle = color; ctx.lineWidth = 1.2; ctx.setLineDash([4, 3]) + ctx.beginPath() + activeData.forEach((pt, i) => { + const v = showPips ? (pt.nodes[k] ?? 0) : (pt.synthetic_price + (pt.nodes[k] ?? 0) * (1 / Math.max(activeData.length, 1))) + i === 0 ? ctx.moveTo(toX(i), toY(showPips ? v : (pt.nodes[k] ?? 0))) : ctx.lineTo(toX(i), toY(showPips ? v : (pt.nodes[k] ?? 0))) + }) + ctx.stroke() + ctx.setLineDash([]) + } + + // Synthetic price (main bold line — green/red) + const lastPt = activeData[activeData.length - 1] + const lastFirst = activeData[0] + const trending = getV(lastPt) > getV(lastFirst) + ctx.strokeStyle = trending ? '#10b981' : '#f87171'; ctx.lineWidth = 2.5; ctx.setLineDash([]) + ctx.beginPath() + activeData.forEach((pt, i) => { + i === 0 ? ctx.moveTo(toX(i), toY(getV(pt))) : ctx.lineTo(toX(i), toY(getV(pt))) + }) + ctx.stroke() + + // What-if overlay (if exists, draw original in grey too) + if (whatifData) { + ctx.strokeStyle = '#6366f1'; ctx.lineWidth = 2; ctx.setLineDash([6, 3]) + ctx.beginPath() + whatifData.forEach((pt, i) => { + i === 0 ? ctx.moveTo(toX(i), toY(getV(pt))) : ctx.lineTo(toX(i), toY(getV(pt))) + }) + ctx.stroke() + ctx.setLineDash([]) + } + + }, [activeData, realPrices, showReal, showFund, showPips, shownLayers, virtuals, whatifData]) + + async function runWhatIf() { + if (virtuals.length === 0) return + setWhatifLoading(true) + try { + const r = await api.post(`/instrument-models/${instrument}/timeline-whatif`, { + period, + virtual_events: virtuals.map(ve => ({ + date: ve.date, + category: ve.category, + pips: ve.pips, + label: ve.label, + absorption_days: ve.absorption_days, + rise_days: ve.rise_days, + plateau_days: ve.plateau_days, + decay_type: ve.decay_type, + })), + }) + setWhatifData(r.data) + } finally { setWhatifLoading(false) } + } function toggleLayer(k: string) { - setShown(prev => { - const n = new Set(prev) - n.has(k) ? n.delete(k) : n.add(k) - return n - }) + setShownLayers(prev => { const n = new Set(prev); n.has(k) ? n.delete(k) : n.add(k); return n }) } if (loading) return
Calcul timeline…
if (!loading && data.length === 0) return
Aucune donnée pour {instrument}
return ( -
-
+
+ {/* Controls */} +
{PERIODS.map(p => ( ))} - {data.length} jours -
- -
- {/* Net pips toggle */} - - {layerKeys.map(k => ( - - ))} + + + +
+ {activeData.length} jours
+ {/* Layer toggles */} + {layerKeys.length > 0 && ( +
+ {layerKeys.map(k => ( + + ))} +
+ )} + + {/* Canvas */}
- + + {/* Legend */} +
+
+ Prix synthétique +
+ {showReal && ( +
+ Prix réel +
+ )} + {showFund && ( +
+ Fondamental +
+ )} + {whatifData && ( +
+ What-if + +
+ )} +
+ + {/* Virtual Events Panel */} + {showVirtual && ( +
+
+
Events virtuels (What-if)
+ +
+ {virtuals.length === 0 && ( +
+ Ajoute des events hypothétiques pour simuler leur impact sur la courbe synthétique. +
+ )} + {virtuals.map((ve, idx) => ( +
+
+ + setVirtuals(v => v.map((x, i) => i === idx ? {...x, date: e.target.value} : x))} + className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none"/> +
+
+ + +
+
+ + setVirtuals(v => v.map((x, i) => i === idx ? {...x, pips: parseFloat(e.target.value) || 0} : x))} + className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none"/> +
+
+ +
+ setVirtuals(v => v.map((x, i) => i === idx ? {...x, absorption_days: parseInt(e.target.value)||14} : x))} + className="flex-1 bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none"/> + +
+
+
+ setVirtuals(v => v.map((x, i) => i === idx ? {...x, label: e.target.value} : x))} + className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none"/> +
+
+ ))} + {virtuals.length > 0 && ( +
+ + +
+ )} +
+ )}
) } @@ -1001,27 +1333,57 @@ export default function InstrumentModels() { ))}
- {/* Net pressure banner */} + {/* Synthetic Price Banner */} {state && ( -
+
+ {/* Row 1 — synthetic price + direction + regime */}
- {state.name} — Pression Nette Cumulée + {state.name} — Prix Synthétique +
+
+ + {fmtPrice(state.synthetic_price, state.pip_to_price)} + + +
+ {/* Structural vs event breakdown */} +
+
+ Fondamental + + {fmtPrice(state.fundamental_level, state.pip_to_price)} + + ({fmt(state.structural_pips)}p) +
+
+ Surprises + + {fmt(state.event_pips)}p + +
+
+ Intercept + + {fmtPrice(state.price_intercept, state.pip_to_price)} + +
- -
{state.description}
- {/* Layer contributions summary */} + {/* Layer contributions */} {layerSummary.length > 0 && (
{layerSummary.map(l => ( -
+
- {l.label.replace('▶ ', '').split(' ').slice(0, 3).join(' ')} + {l.label.replace('▶ ', '').split(' ').slice(0, 2).join(' ')}
-
{fmt(l.pip_contribution)}
+
{fmt(l.pip_contribution)}
{l.pct > 0 ? '+' : ''}{l.pct}%
))} @@ -1085,14 +1447,14 @@ export default function InstrumentModels() { {state && view !== 'timeline' && (
{[ - { label: 'Inputs Events', value: state.nodes.filter(n=>n.node_type==='input_event').length, sub: `${state.nodes.filter(n=>n.source==='events').length} actifs` }, - { label: 'Inputs Manuels', value: state.nodes.filter(n=>n.node_type==='input_manual').length, sub: `${state.nodes.filter(n=>n.source==='manual').length} overrides` }, - { label: 'Couches interméd.',value: state.nodes.filter(n=>n.node_type==='intermediate').length, sub: 'propagation DAG' }, - { label: 'Pression nette', value: `${fmt(state.net_pips)} pips`, sub: state.direction }, + { label: 'Prix synthétique', value: fmtPrice(state.synthetic_price, state.pip_to_price), sub: state.direction, color: state.direction === 'bullish' ? 'text-emerald-400' : state.direction === 'bearish' ? 'text-red-400' : 'text-white' }, + { label: 'Niveau fondamental',value: fmtPrice(state.fundamental_level, state.pip_to_price), sub: `struct: ${fmt(state.structural_pips)}p`, color: 'text-amber-400' }, + { label: 'Surprises events', value: `${fmt(state.event_pips)} pips`, sub: `sur ${state.nodes.filter(n=>n.source==='events').length} events actifs`, color: pipColor(state.event_pips) }, + { label: 'Variables manuelles',value: `${state.nodes.filter(n=>n.source==='manual').length} actives`, sub: `sur ${state.nodes.filter(n=>n.node_type==='input_manual').length} disponibles`, color: 'text-violet-400' }, ].map(card => (
{card.label}
-
{card.value}
+
{card.value}
{card.sub}
))}