feat: instrument model

This commit is contained in:
OpenSquared
2026-07-03 00:04:37 +02:00
parent ff6f0b390d
commit a8ee808937
4 changed files with 817 additions and 104 deletions

View File

@@ -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,

View File

@@ -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)

View File

@@ -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)

View File

@@ -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<string, number>
}
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' ? <TrendingUp className="w-3.5 h-3.5"/> :
@@ -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 (
<div
className={clsx(
@@ -332,15 +385,40 @@ function NodeCard({ node, onEdit }: { node: ModelNode; onEdit: (n: ModelNode) =>
<div className="text-xs text-slate-300 font-medium leading-tight line-clamp-2 pr-3">{node.label}</div>
<div className={clsx('text-xs font-bold mt-1', node.node_type === 'output' ? 'text-base' : '', pipColor(v))}>
{fmt(v)} pips
</div>
{isManual ? (
<div className="mt-1 flex items-center gap-1.5 flex-wrap">
{hasValue ? (
<span className={clsx('text-xs font-bold font-mono', pipColor(v))}>
{node.raw_value! > 0 ? '+' : ''}{node.raw_value} {node.unit}
</span>
) : (
<span className="text-xs text-slate-600"> {node.unit}</span>
)}
<span className="text-xs text-slate-600 bg-slate-800/60 px-1.5 py-0.5 rounded font-mono">
w:{coeff}
</span>
{hasValue && (
<span className={clsx('text-xs font-mono', pipColor(v))}>
={fmt(v)}p
</span>
)}
</div>
) : (
<div className={clsx('text-xs font-bold mt-1', node.node_type === 'output' ? 'text-base' : '', pipColor(v))}>
{fmt(v)} pips
</div>
)}
{Math.abs(v) > 0.05 && (
{Math.abs(v) > 0.05 && !isManual && (
<div className="mt-1 h-1 rounded-full bg-slate-700/40 overflow-hidden">
<div className={clsx('h-full rounded-full', pipBg(v))} style={{width: `${Math.min(100, Math.abs(v / 50) * 100)}%`}}/>
</div>
)}
{isManual && Math.abs(v) > 0.05 && (
<div className="mt-1 h-0.5 rounded-full bg-slate-700/40 overflow-hidden">
<div className={clsx('h-full rounded-full', pipBg(v))} style={{width: `${Math.min(100, Math.abs(v / 50) * 100)}%`}}/>
</div>
)}
{node.node_type === 'intermediate' && (
<div className="mt-1 flex items-center gap-2">
@@ -562,93 +640,150 @@ function TableView({ nodes, onEdit }: { nodes: ModelNode[]; onEdit: (n: ModelNod
// ── Timeline Chart ─────────────────────────────────────────────────────────────
const REGIME_CANVAS_COLORS: Record<string, string> = {
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<Period>('3mo')
const [data, setData] = useState<TimelinePoint[]>([])
const [realPrices, setRealPrices] = useState<PricePoint[]>([])
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<string[]>([])
const [shown, setShown] = useState<Set<string>>(new Set(['net_pips']))
const [shownLayers, setShownLayers] = useState<Set<string>>(new Set())
const [showVirtual, setShowVirtual] = useState(false)
const [virtuals, setVirtuals] = useState<VirtualEventForm[]>([])
const [whatifLoading, setWhatifLoading] = useState(false)
const [whatifData, setWhatifData] = useState<TimelinePoint[] | null>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const activeData = whatifData ?? data
// Load timeline
useEffect(() => {
setLoading(true)
setWhatifData(null)
api.get<TimelinePoint[]>(`/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<string, number>()
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<string, string> = {
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<TimelinePoint[]>(`/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 <div className="flex items-center justify-center h-64 text-slate-500 text-sm">Calcul timeline</div>
if (!loading && data.length === 0) return <div className="text-center py-8 text-slate-500 text-sm">Aucune donnée pour {instrument}</div>
return (
<div>
<div className="flex items-center gap-2 mb-3 flex-wrap">
<div className="space-y-3">
{/* Controls */}
<div className="flex items-center gap-2 flex-wrap">
{PERIODS.map(p => (
<button key={p} onClick={() => setPeriod(p)}
className={clsx('px-2 py-1 rounded text-xs font-medium transition-colors',
@@ -699,29 +917,143 @@ function TimelineView({ instrument }: { instrument: string }) {
{p}
</button>
))}
<span className="ml-auto text-xs text-slate-500">{data.length} jours</span>
</div>
<div className="flex flex-wrap gap-1.5 mb-3">
{/* Net pips toggle */}
<button onClick={() => toggleLayer('net_pips')}
className={clsx('flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium border transition-all',
shown.has('net_pips') ? 'border-emerald-600/50 text-emerald-400 bg-emerald-900/20' : 'border-slate-700/40 text-slate-600')}>
<span className="w-2 h-2 rounded-full bg-emerald-500"/> Net pips
</button>
{layerKeys.map(k => (
<button key={k} onClick={() => toggleLayer(k)}
className={clsx('flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium border transition-all',
shown.has(k) ? 'border-slate-600/50 text-slate-300 bg-slate-800/40' : 'border-slate-700/30 text-slate-600')}>
<span className={clsx('w-2 h-2 rounded-full', LAYER_COLORS[k] || 'bg-slate-500')}/>
{k.replace('layer_', '')}
<div className="ml-2 flex gap-1.5">
<button onClick={() => setShowPips(v => !v)}
className={clsx('px-2 py-1 rounded text-xs border transition-all',
showPips ? 'border-blue-600/50 text-blue-400 bg-blue-900/20' : 'border-slate-700/40 text-slate-400')}>
{showPips ? 'Mode pips' : 'Mode prix'}
</button>
))}
<button onClick={() => setShowReal(v => !v)}
className={clsx('flex items-center gap-1 px-2 py-1 rounded text-xs border transition-all',
showReal ? 'border-slate-600/50 text-slate-300' : 'border-slate-700/30 text-slate-600')}>
<span className="w-2 h-0.5 bg-slate-400 inline-block"/> Réel
</button>
<button onClick={() => setShowFund(v => !v)}
className={clsx('flex items-center gap-1 px-2 py-1 rounded text-xs border transition-all',
showFund ? 'border-amber-600/50 text-amber-400' : 'border-slate-700/30 text-slate-600')}>
<span className="w-2 h-0.5 bg-amber-400 inline-block" style={{borderTop:'1px dashed #f59e0b'}}/> Fondamental
</button>
<button onClick={() => setShowVirtual(v => !v)}
className={clsx('flex items-center gap-1.5 px-2 py-1 rounded text-xs border transition-all',
showVirtual ? 'border-violet-600/50 text-violet-400 bg-violet-900/20' : 'border-slate-700/30 text-slate-500')}>
<Zap className="w-3 h-3"/> What-if {virtuals.length > 0 && `(${virtuals.length})`}
</button>
</div>
<span className="ml-auto text-xs text-slate-500">{activeData.length} jours</span>
</div>
{/* Layer toggles */}
{layerKeys.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{layerKeys.map(k => (
<button key={k} onClick={() => toggleLayer(k)}
className={clsx('flex items-center gap-1.5 px-2 py-1 rounded text-xs font-medium border transition-all',
shownLayers.has(k) ? 'border-slate-600/50 text-slate-300 bg-slate-800/40' : 'border-slate-700/30 text-slate-600')}>
<span className={clsx('w-2 h-2 rounded-full', LAYER_COLORS[k] || 'bg-slate-500')}/>
{k.replace('layer_', '')}
</button>
))}
</div>
)}
{/* Canvas */}
<div className="rounded-lg bg-dark-900/50 border border-slate-700/30 p-2">
<canvas ref={canvasRef} className="w-full" style={{height: 260, display: 'block'}}/>
<canvas ref={canvasRef} className="w-full" style={{height: 300, display: 'block'}}/>
{/* Legend */}
<div className="flex flex-wrap gap-3 mt-2 px-1">
<div className="flex items-center gap-1.5 text-xs text-slate-500">
<span className="w-4 h-0.5 bg-emerald-500 inline-block"/> Prix synthétique
</div>
{showReal && (
<div className="flex items-center gap-1.5 text-xs text-slate-500">
<span className="w-4 h-0.5 bg-slate-400 inline-block opacity-50"/> Prix réel
</div>
)}
{showFund && (
<div className="flex items-center gap-1.5 text-xs text-slate-500">
<span className="w-4 h-0.5 border-t border-dashed border-amber-500 inline-block"/> Fondamental
</div>
)}
{whatifData && (
<div className="flex items-center gap-1.5 text-xs text-violet-400">
<span className="w-4 h-0.5 border-t border-dashed border-violet-500 inline-block"/> What-if
<button onClick={() => setWhatifData(null)} className="ml-1 text-slate-600 hover:text-slate-400"></button>
</div>
)}
</div>
</div>
{/* Virtual Events Panel */}
{showVirtual && (
<div className="rounded-lg border border-violet-700/30 bg-violet-900/10 p-3 space-y-3">
<div className="flex items-center justify-between">
<div className="text-xs font-semibold text-violet-400">Events virtuels (What-if)</div>
<button onClick={() => setVirtuals(v => [...v, newVirtualEvent()])}
className="flex items-center gap-1 text-xs text-violet-400 hover:text-violet-300 border border-violet-700/40 rounded px-2 py-0.5 transition-colors">
<Plus className="w-3 h-3"/> Ajouter
</button>
</div>
{virtuals.length === 0 && (
<div className="text-xs text-slate-500 text-center py-2">
Ajoute des events hypothétiques pour simuler leur impact sur la courbe synthétique.
</div>
)}
{virtuals.map((ve, idx) => (
<div key={ve.id} className="grid grid-cols-2 md:grid-cols-4 gap-2 items-end bg-dark-800/40 rounded p-2">
<div>
<label className="text-xs text-slate-500 block mb-0.5">Date</label>
<input type="date" value={ve.date}
onChange={e => 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"/>
</div>
<div>
<label className="text-xs text-slate-500 block mb-0.5">Catégorie</label>
<select value={ve.category}
onChange={e => setVirtuals(v => v.map((x, i) => i === idx ? {...x, category: 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">
{EVENT_CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
</div>
<div>
<label className="text-xs text-slate-500 block mb-0.5">Magnitude (pips)</label>
<input type="number" step="any" value={ve.pips}
onChange={e => 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"/>
</div>
<div>
<label className="text-xs text-slate-500 block mb-0.5">Absorption (j)</label>
<div className="flex gap-1">
<input type="number" value={ve.absorption_days}
onChange={e => 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"/>
<button onClick={() => setVirtuals(v => v.filter((_, i) => i !== idx))}
className="text-red-500/60 hover:text-red-400 px-1.5 transition-colors">
<X className="w-3 h-3"/>
</button>
</div>
</div>
<div className="col-span-2 md:col-span-4">
<input type="text" value={ve.label} placeholder="Label de l'event"
onChange={e => 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"/>
</div>
</div>
))}
{virtuals.length > 0 && (
<div className="flex gap-2 justify-end">
<button onClick={() => { setVirtuals([]); setWhatifData(null) }}
className="text-xs text-slate-500 hover:text-slate-300 transition-colors">
Effacer tout
</button>
<button onClick={runWhatIf} disabled={whatifLoading}
className="flex items-center gap-1.5 px-3 py-1.5 bg-violet-600 hover:bg-violet-500 disabled:opacity-40 text-white text-xs rounded font-medium transition-colors">
<Zap className="w-3 h-3"/>
{whatifLoading ? 'Simulation…' : 'Simuler'}
</button>
</div>
)}
</div>
)}
</div>
)
}
@@ -1001,27 +1333,57 @@ export default function InstrumentModels() {
))}
</div>
{/* Net pressure banner */}
{/* Synthetic Price Banner */}
{state && (
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4 space-y-3">
{/* Row 1 — synthetic price + direction + regime */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-1">
{state.name} — Pression Nette Cumulée
{state.name} — Prix Synthétique
</div>
<div className="flex items-baseline gap-3">
<span className={clsx('text-3xl font-bold font-mono tracking-tight',
state.direction === 'bullish' ? 'text-emerald-400'
: state.direction === 'bearish' ? 'text-red-400'
: 'text-slate-300')}>
{fmtPrice(state.synthetic_price, state.pip_to_price)}
</span>
<DirectionBadge direction={state.direction} pips={state.net_pips}/>
</div>
{/* Structural vs event breakdown */}
<div className="flex gap-4 mt-2 text-xs">
<div>
<span className="text-slate-500">Fondamental </span>
<span className="text-amber-400 font-mono font-semibold">
{fmtPrice(state.fundamental_level, state.pip_to_price)}
</span>
<span className="text-slate-600 ml-1">({fmt(state.structural_pips)}p)</span>
</div>
<div>
<span className="text-slate-500">Surprises </span>
<span className={clsx('font-mono font-semibold', pipColor(state.event_pips))}>
{fmt(state.event_pips)}p
</span>
</div>
<div>
<span className="text-slate-600">Intercept </span>
<span className="text-slate-500 font-mono">
{fmtPrice(state.price_intercept, state.pip_to_price)}
</span>
</div>
</div>
<DirectionBadge direction={state.direction} pips={state.net_pips}/>
<div className="text-xs text-slate-500 mt-1.5 max-w-sm">{state.description}</div>
</div>
{/* Layer contributions summary */}
{/* Layer contributions */}
{layerSummary.length > 0 && (
<div className="flex gap-4 flex-wrap">
{layerSummary.map(l => (
<div key={l.id} className="text-center min-w-[80px]">
<div key={l.id} className="text-center min-w-[72px]">
<div className="text-xs text-slate-500 leading-tight mb-0.5">
{l.label.replace(' ', '').split(' ').slice(0, 3).join(' ')}
{l.label.replace(' ', '').split(' ').slice(0, 2).join(' ')}
</div>
<div className={clsx('text-sm font-bold', pipColor(l.pip_contribution))}>{fmt(l.pip_contribution)}</div>
<div className={clsx('text-sm font-bold font-mono', pipColor(l.pip_contribution))}>{fmt(l.pip_contribution)}</div>
<div className="text-xs text-slate-600">{l.pct > 0 ? '+' : ''}{l.pct}%</div>
</div>
))}
@@ -1085,14 +1447,14 @@ export default function InstrumentModels() {
{state && view !== 'timeline' && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ 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 => (
<div key={card.label} className="rounded-lg border border-slate-700/30 bg-dark-800/50 p-3">
<div className="text-xs text-slate-500 mb-1">{card.label}</div>
<div className="text-lg font-bold text-white">{card.value}</div>
<div className={clsx('text-lg font-bold font-mono', card.color)}>{card.value}</div>
<div className="text-xs text-slate-600">{card.sub}</div>
</div>
))}