Files
OpenFin/frontend/src/pages/InstrumentModels.tsx
2026-07-03 09:43:39 +02:00

1929 lines
84 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* InstrumentModels — graphes causaux exhaustifs par instrument.
* Architecture 3 couches : inputs (events + manuels) → intermédiaires → output
* Vues : DAG visuel | Tableau éditable | Timeline évolution
*/
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'
const api = axios.create({ baseURL: '/api' })
// ── Types ─────────────────────────────────────────────────────────────────────
type NodeType = 'input_event' | 'input_manual' | 'intermediate' | 'output'
interface ModelNode {
id: string
label: string
node_type: NodeType
category: string
unit: string
description: string
coefficient_to_pips?: number
event_category?: string
formula?: string
display_col: number
// computed
computed_value: number
pip_contribution: number
raw_value?: number
source: 'manual' | 'events' | 'neutral' | 'computed'
override_note?: string
override_set_at?: string
// Phase 2
pip_linear?: number
pip_saturated?: number
saturation_pct?: number
regime_weight?: number
// Phase 3 — event nodes: baseline (structurel) + surprise (événement)
baseline_value?: number
event_surprise?: number
}
interface RegimeInfo {
regime: string
label: string
dominant_cat: string | null
scores: Record<string, number>
weights: Record<string, number>
}
interface ModelState {
instrument: string
name: string
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
regime: RegimeInfo
event_details: Record<string, EventDetail[]>
}
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
}
interface EventDetail {
analysis_id: number
event_id: number
title: string
start_date: string
days_since: number
pip_prediction: number
lifecycle_factor: number
remaining_pips: number
calibration: {
absorption_days: number
rise_days: number
plateau_days: number
decay_type: string
}
}
interface CalendarEvent {
analysis_id: number
event_id: number
title: string
start_date: string
category: string
is_future: boolean
days_offset: number
pip_prediction: number
lifecycle_factor: number
remaining_pips: number
calibration: {
absorption_days: number
rise_days: number
plateau_days: number
decay_type: string
}
}
// ── Constants ─────────────────────────────────────────────────────────────────
const INSTRUMENTS = ['EURUSD','USDJPY','XAUUSD','SP500','TLT','GBPUSD','EEM','QQQ']
const NODE_TYPE_META: Record<NodeType, { label: string; color: string; bg: string }> = {
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' },
intermediate: { label: 'Intermédiaire', color: 'text-amber-400', bg: 'bg-amber-900/20 border-amber-700/40' },
output: { label: 'Résultat', color: 'text-emerald-400', bg: 'bg-emerald-900/20 border-emerald-700/40' },
}
const SOURCE_META: Record<string, { dot: string; label: string }> = {
manual: { dot: 'bg-violet-400', label: 'Override' },
events: { dot: 'bg-sky-400', label: 'Events' },
neutral: { dot: 'bg-slate-600', label: 'Neutre' },
computed: { dot: 'bg-amber-400', label: 'Calculé' },
}
const PERIODS = ['5d','1mo','3mo','6mo','1y','2y'] as const
type Period = typeof PERIODS[number]
const LAYER_COLORS: Record<string, string> = {
'net_pips': 'bg-emerald-500',
'layer_monetary': 'bg-blue-400',
'layer_rates': 'bg-blue-400',
'layer_growth': 'bg-violet-400',
'layer_uk_macro': 'bg-violet-400',
'layer_risk': 'bg-red-400',
'layer_risk_credit': 'bg-red-400',
'layer_positioning': 'bg-orange-400',
'layer_policy': 'bg-orange-400',
'layer_flows': 'bg-orange-400',
'layer_demand': 'bg-teal-400',
'layer_refuge': 'bg-pink-400',
'layer_tech_fundamental': 'bg-purple-400',
'layer_supply': 'bg-slate-400',
'layer_china': 'bg-yellow-400',
'layer_dollar': 'bg-sky-400',
'layer_global': 'bg-gray-400',
'layer_macro': 'bg-lime-400',
}
const CANVAS_COLORS: Record<string, string> = {
'net_pips': '#10b981',
'layer_monetary': '#60a5fa',
'layer_rates': '#60a5fa',
'layer_growth': '#a78bfa',
'layer_uk_macro': '#a78bfa',
'layer_risk': '#f87171',
'layer_risk_credit': '#f87171',
'layer_positioning': '#fb923c',
'layer_policy': '#fb923c',
'layer_flows': '#fb923c',
'layer_demand': '#2dd4bf',
'layer_refuge': '#f472b6',
'layer_tech_fundamental': '#c084fc',
'layer_supply': '#94a3b8',
'layer_china': '#fbbf24',
'layer_dollar': '#38bdf8',
'layer_global': '#cbd5e1',
'layer_macro': '#a3e635',
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function pipColor(v: number) {
if (v > 0.5) return 'text-emerald-400'
if (v < -0.5) return 'text-red-400'
return 'text-slate-400'
}
function pipBg(v: number) {
if (v > 0.5) return 'bg-emerald-500'
if (v < -0.5) return 'bg-red-500'
return 'bg-slate-600'
}
function fmt(v: number) {
const s = v > 0 ? '+' : ''
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"/> :
direction === 'bearish' ? <TrendingDown className="w-3.5 h-3.5"/> :
<Minus className="w-3.5 h-3.5"/>
const cls =
direction === 'bullish' ? 'text-emerald-400 border-emerald-700/50 bg-emerald-900/30' :
direction === 'bearish' ? 'text-red-400 border-red-700/50 bg-red-900/30' :
'text-slate-400 border-slate-700/40 bg-slate-800/50'
return (
<span className={clsx('inline-flex items-center gap-1.5 px-2.5 py-1 rounded border text-sm font-semibold', cls)}>
{icon} {fmt(pips)} pips · {direction.toUpperCase()}
</span>
)
}
// ── Regime Badge ──────────────────────────────────────────────────────────────
const REGIME_META: Record<string, { color: string; bg: string; icon: string }> = {
MONETARY_DOMINANCE: { color: 'text-blue-400', bg: 'bg-blue-900/30 border-blue-700/40', icon: '🏦' },
GEOPOLITICAL_RISK: { color: 'text-orange-400', bg: 'bg-orange-900/30 border-orange-700/40', icon: '⚠️' },
CREDIT_STRESS: { color: 'text-red-400', bg: 'bg-red-900/30 border-red-700/40', icon: '🔴' },
GROWTH_SCARE: { color: 'text-amber-400', bg: 'bg-amber-900/30 border-amber-700/40', icon: '📉' },
COMMODITY_SHOCK: { color: 'text-yellow-400', bg: 'bg-yellow-900/30 border-yellow-700/40','icon': '🛢️' },
BALANCED: { color: 'text-slate-400', bg: 'bg-slate-800/50 border-slate-700/40', icon: '⚖️' },
}
function RegimeBadge({ regime }: { regime: RegimeInfo }) {
const meta = REGIME_META[regime.regime] || REGIME_META.BALANCED
const topWeights = Object.entries(regime.weights)
.filter(([, w]) => w > 1.0)
.sort(([, a], [, b]) => b - a)
.slice(0, 3)
return (
<div className={clsx('inline-flex flex-col gap-1 px-3 py-2 rounded-lg border text-xs', meta.bg)}>
<div className="flex items-center gap-1.5">
<span>{meta.icon}</span>
<span className={clsx('font-semibold', meta.color)}>{regime.label}</span>
{regime.dominant_cat && (
<span className="text-slate-500">· {regime.dominant_cat}</span>
)}
</div>
{topWeights.length > 0 && (
<div className="flex gap-2 flex-wrap">
{topWeights.map(([layer, w]) => (
<span key={layer} className={clsx('font-mono', meta.color)}>
{layer.replace('layer_', '')} ×{w.toFixed(2)}
</span>
))}
</div>
)}
</div>
)
}
// ── Node Edit Modal ───────────────────────────────────────────────────────────
function NodeEditModal({ node, instrument, onClose, onSaved }: {
node: ModelNode; instrument: string; onClose: () => void; onSaved: () => void
}) {
const [val, setVal] = useState<string>(node.raw_value !== undefined ? String(node.raw_value) : '')
const [note, setNote] = useState(node.override_note || '')
const [saving, setSaving] = useState(false)
const [clrg, setClrg] = useState(false)
const coeff = node.coefficient_to_pips ?? 1.0
const preview = node.node_type === 'input_manual'
? (parseFloat(val) || 0) * coeff
: (parseFloat(val) || 0)
async function save() {
setSaving(true)
try {
await api.put(`/instrument-models/${instrument}/nodes/${node.id}/override`, {
value: parseFloat(val) || 0, note
})
onSaved(); onClose()
} finally { setSaving(false) }
}
async function clear() {
setClrg(true)
try {
await api.delete(`/instrument-models/${instrument}/nodes/${node.id}/override`)
onSaved(); onClose()
} finally { setClrg(false) }
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
onClick={onClose}>
<div className="bg-dark-800 border border-slate-700/60 rounded-xl shadow-2xl w-full max-w-md mx-4 p-6"
onClick={e => e.stopPropagation()}>
<div className="flex items-start justify-between mb-4">
<div>
<div className="text-white font-semibold text-sm">{node.label}</div>
<div className="text-slate-500 text-xs mt-0.5 leading-relaxed max-w-xs">{node.description}</div>
</div>
<button onClick={onClose} className="text-slate-500 hover:text-white ml-2">
<X className="w-4 h-4"/>
</button>
</div>
<div className="space-y-3">
<div>
<label className="text-xs text-slate-400 mb-1 block">
Valeur ({node.unit})
{node.node_type === 'input_manual' && coeff !== 0 &&
<span className="ml-1.5 text-slate-500">× {coeff} = <span className={pipColor(preview)}>{fmt(preview)} pips</span></span>}
{node.node_type === 'input_event' && <span className="ml-1.5 text-slate-500">niveau structurel (les events s'y ajoutent)</span>}
</label>
<input
type="number" step="any" value={val} onChange={e => setVal(e.target.value)}
placeholder="0"
className="w-full bg-dark-700 border border-slate-700/50 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500/60"
/>
</div>
<div>
<label className="text-xs text-slate-400 mb-1 block">Note (optionnel)</label>
<input
type="text" value={note} onChange={e => setNote(e.target.value)}
placeholder="Ex: NFP très fort, marché hawkish…"
className="w-full bg-dark-700 border border-slate-700/50 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-blue-500/60"
/>
</div>
</div>
{val !== '' && (
<div className={clsx('mt-3 rounded px-3 py-2 text-sm font-semibold',
preview > 0 ? 'bg-emerald-900/30 text-emerald-400'
: preview < 0 ? 'bg-red-900/30 text-red-400'
: 'bg-slate-800 text-slate-400')}>
Impact estimé : {fmt(preview)} pips
</div>
)}
<div className="flex gap-2 mt-4">
<button onClick={save} disabled={saving || val === ''}
className="flex-1 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white text-sm rounded px-3 py-2 font-medium transition-colors">
{saving ? 'Enregistrement' : 'Enregistrer'}
</button>
{node.source === 'manual' && (
<button onClick={clear} disabled={clrg}
className="flex items-center gap-1.5 bg-red-900/40 hover:bg-red-800/50 disabled:opacity-40 text-red-400 text-sm rounded px-3 py-2 transition-colors">
<Trash2 className="w-3.5 h-3.5"/> {clrg ? '' : 'Effacer'}
</button>
)}
<button onClick={onClose} className="bg-dark-700 hover:bg-dark-600 text-slate-400 text-sm rounded px-3 py-2 transition-colors">
Annuler
</button>
</div>
</div>
</div>
)
}
// ── DAG View ──────────────────────────────────────────────────────────────────
const COL_LABELS = ['Events actifs', 'Variables manuelles', 'Couches intermédiaires', 'Résultat net']
function NodeCard({ node, onEdit, eventDetails }: {
node: ModelNode; onEdit: (n: ModelNode) => void
eventDetails?: Record<string, EventDetail[]>
}) {
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(
'relative rounded-lg border px-3 py-2 mb-1.5 transition-all',
meta.bg,
canEdit && 'cursor-pointer hover:border-blue-500/60 hover:shadow-md hover:shadow-blue-900/20',
node.source === 'manual' && 'ring-1 ring-violet-500/30',
)}
onClick={() => canEdit && onEdit(node)}
title={node.description}
>
{node.source === 'manual' && <span className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full bg-violet-400"/>}
{node.source === 'events' && <span className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full bg-sky-400"/>}
<div className="text-xs text-slate-300 font-medium leading-tight line-clamp-2 pr-3">{node.label}</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>
) : node.node_type === 'input_event' ? (() => {
const cat = node.event_category || ''
const evList = eventDetails?.[cat] || []
const maxLf = evList.length > 0 ? Math.max(...evList.map(e => e.lifecycle_factor)) : 0
const barW = Math.round(maxLf * 100)
const baseline = node.baseline_value ?? 0
const surprise = node.event_surprise ?? 0
const hasBaseline = baseline !== 0
return (
<div className="mt-1">
{hasBaseline ? (
<div className="space-y-0.5">
<div className="flex flex-wrap gap-x-2 gap-y-0">
<span className="text-xs text-violet-300/80 font-mono">base:{fmt(baseline)}p</span>
{surprise !== 0 && (
<span className={clsx('text-xs font-mono', pipColor(surprise))}>surp:{fmt(surprise)}p</span>
)}
</div>
<span className={clsx('text-xs font-bold font-mono', pipColor(v))}>={fmt(v)}p</span>
</div>
) : (
<div className="flex items-center gap-2">
<span className={clsx('text-xs font-bold font-mono', pipColor(v))}>{fmt(v)} pips</span>
{evList.length > 0 && <span className="text-xs text-slate-600">{evList.length} ev</span>}
</div>
)}
{evList.length > 0 && (
<div className="mt-0.5 flex items-center gap-1.5">
<div className="flex-1 h-0.5 rounded-full bg-slate-700/60 overflow-hidden">
<div className={clsx('h-full rounded-full', pipBg(surprise !== 0 ? surprise : v))} style={{width:`${barW}%`}}/>
</div>
<span className="text-slate-600 text-xs font-mono">{barW}%</span>
</div>
)}
</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 && !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">
{node.formula && <span className="text-slate-600 text-xs">{node.formula.split('+').length} sources</span>}
{node.regime_weight !== undefined && node.regime_weight !== 1.0 && (
<span className={clsx('text-xs font-mono font-bold',
node.regime_weight > 1.0 ? 'text-amber-400' : 'text-slate-500')}>
×{node.regime_weight.toFixed(2)}
</span>
)}
</div>
)}
</div>
)
}
interface EdgeDraw {
x1: number; y1: number; x2: number; y2: number
coeff: number; fromId: string; toId: string; key: string
fromType: NodeType
}
function DagView({ nodes, instrument, onEdit, eventDetails }: {
nodes: ModelNode[]; instrument: string; onEdit: (n: ModelNode) => void
eventDetails: Record<string, EventDetail[]>
}) {
const wrapperRef = useRef<HTMLDivElement>(null)
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map())
const [svgEdges, setSvgEdges] = useState<EdgeDraw[]>([])
const [hoveredId, setHoveredId] = useState<string | null>(null)
const [svgH, setSvgH] = useState(600)
const cols: ModelNode[][] = [[], [], [], []]
for (const n of nodes) cols[Math.min(n.display_col ?? 0, 3)].push(n)
const nodeTypeMap = useMemo(() => {
const m = new Map<string, NodeType>()
for (const n of nodes) m.set(n.id, n.node_type)
return m
}, [nodes])
// Parse directed connections from intermediate/output formula strings
const connections = useMemo(() => {
const result: { from: string; to: string; coeff: number }[] = []
for (const node of nodes) {
if (!node.formula) continue
for (const term of node.formula.split('+')) {
const t = term.trim()
const m1 = t.match(/^([\d.]+)\s*\*\s*(\w+)$/)
if (m1) {
result.push({ from: m1[2], to: node.id, coeff: parseFloat(m1[1]) })
} else if (/^\w+$/.test(t)) {
result.push({ from: t, to: node.id, coeff: 1.0 })
}
}
}
return result
}, [nodes])
const recompute = useCallback(() => {
const wrapper = wrapperRef.current
if (!wrapper) return
const rect0 = wrapper.getBoundingClientRect()
const sL = wrapper.scrollLeft
const sT = wrapper.scrollTop
const edges: EdgeDraw[] = []
for (const conn of connections) {
const fromEl = nodeRefs.current.get(conn.from)
const toEl = nodeRefs.current.get(conn.to)
if (!fromEl || !toEl) continue
const fr = fromEl.getBoundingClientRect()
const tr = toEl.getBoundingClientRect()
edges.push({
x1: fr.right - rect0.left + sL,
y1: fr.top + fr.height / 2 - rect0.top + sT,
x2: tr.left - rect0.left + sL,
y2: tr.top + tr.height / 2 - rect0.top + sT,
coeff: conn.coeff,
fromId: conn.from,
toId: conn.to,
key: `${conn.from}->${conn.to}`,
fromType: nodeTypeMap.get(conn.from) ?? 'input_event',
})
}
setSvgEdges(edges)
setSvgH(wrapper.scrollHeight)
}, [connections, nodeTypeMap])
useEffect(() => {
const t = setTimeout(recompute, 100)
const ro = new ResizeObserver(() => setTimeout(recompute, 80))
if (wrapperRef.current) ro.observe(wrapperRef.current)
return () => { clearTimeout(t); ro.disconnect() }
}, [recompute])
function edgeStroke(e: EdgeDraw): string {
if (e.fromType === 'input_event') return '#38bdf8' // sky — event source
if (e.fromType === 'input_manual') return '#a78bfa' // violet — manual source
return '#f59e0b' // amber — intermediate→output
}
return (
<div ref={wrapperRef} className="relative overflow-x-auto" onScroll={recompute}>
{/* SVG arêtes superposées */}
<svg
className="absolute top-0 left-0 pointer-events-none"
style={{ width: '100%', height: svgH, zIndex: 5, overflow: 'visible' }}
aria-hidden="true"
>
{svgEdges.map(e => {
const isHov = hoveredId === e.fromId || hoveredId === e.toId
const opacity = isHov ? 0.80 : 0.13
const stroke = edgeStroke(e)
const dx = Math.min(50, (e.x2 - e.x1) * 0.38)
return (
<g key={e.key} opacity={opacity}>
<path
d={`M ${e.x1} ${e.y1} C ${e.x1 + dx} ${e.y1}, ${e.x2 - dx} ${e.y2}, ${e.x2} ${e.y2}`}
fill="none" stroke={stroke}
strokeWidth={e.coeff > 1.05 ? 2 : 1}
/>
{/* Arrowhead */}
<polygon
points={`${e.x2},${e.y2} ${e.x2 - 5},${e.y2 - 3} ${e.x2 - 5},${e.y2 + 3}`}
fill={stroke} fillOpacity={0.7}
/>
{/* Coefficient label on hover */}
{isHov && e.coeff !== 1.0 && (
<text
x={(e.x1 + e.x2) / 2} y={(e.y1 + e.y2) / 2 - 4}
fill={stroke} fontSize="9" textAnchor="middle"
style={{ userSelect: 'none', fontFamily: 'monospace' }}
>
×{e.coeff.toFixed(2)}
</text>
)}
</g>
)
})}
</svg>
{/* Grille 4 colonnes */}
<div className="grid grid-cols-4 gap-3 min-w-[780px]" style={{ position: 'relative', zIndex: 10 }}>
{COL_LABELS.map((lbl, ci) => (
<div key={ci}>
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 pb-1 border-b border-slate-700/30">
{lbl}
<span className="ml-1.5 text-slate-600 font-normal normal-case">({cols[ci].length})</span>
</div>
{cols[ci].map(n => (
<div
key={n.id}
ref={el => { if (el) nodeRefs.current.set(n.id, el); else nodeRefs.current.delete(n.id) }}
onMouseEnter={() => { setHoveredId(n.id); recompute() }}
onMouseLeave={() => setHoveredId(null)}
>
<NodeCard node={n} onEdit={onEdit} eventDetails={eventDetails}/>
</div>
))}
</div>
))}
</div>
{/* Légende */}
<div className="flex items-center gap-4 mt-4 pt-3 border-t border-slate-700/20 flex-wrap">
<div className="flex items-center gap-1.5 text-xs text-sky-400">
<span className="w-3 h-px bg-sky-400 inline-block rounded"/> Events → couches
</div>
<div className="flex items-center gap-1.5 text-xs text-violet-400">
<span className="w-3 h-px bg-violet-400 inline-block rounded"/> Manuel → couches
</div>
<div className="flex items-center gap-1.5 text-xs text-amber-400">
<span className="w-3 h-px bg-amber-400 inline-block rounded"/> Couches → output
</div>
<span className="text-xs text-slate-600 ml-1">· survol = highlight</span>
<div className="ml-2 flex gap-3">
{(Object.entries(SOURCE_META) as [string, { dot: string; label: string }][]).map(([src, m]) => (
<div key={src} className="flex items-center gap-1.5 text-xs text-slate-500">
<span className={clsx('w-2 h-2 rounded-full', m.dot)}/>{m.label}
</div>
))}
</div>
<div className="flex items-center gap-1.5 text-xs text-slate-500 ml-auto">
<Edit3 className="w-3 h-3"/> Cliquer pour modifier
</div>
</div>
</div>
)
}
// ── Table View ────────────────────────────────────────────────────────────────
type SortKey = 'label' | 'category' | 'pip_contribution' | 'source'
function TableView({ nodes, onEdit }: { nodes: ModelNode[]; onEdit: (n: ModelNode) => void }) {
const [sort, setSort] = useState<SortKey>('pip_contribution')
const [sortDir, setSortDir] = useState<1 | -1>(-1)
const [filterType, setFilterType] = useState<string>('all')
const [filterCat, setFilterCat] = useState<string>('all')
const [expanded, setExpanded] = useState<string | null>(null)
const categories = useMemo(() => {
const s = new Set(nodes.map(n => n.category))
return ['all', ...Array.from(s).sort()]
}, [nodes])
const maxAbs = useMemo(() =>
Math.max(...nodes.map(n => Math.abs(n.pip_contribution)), 1), [nodes])
const sorted = useMemo(() => {
const filtered = nodes.filter(n =>
(filterType === 'all' || n.node_type === filterType) &&
(filterCat === 'all' || n.category === filterCat)
)
return [...filtered].sort((a, b) => {
if (sort === 'pip_contribution') return (a.pip_contribution - b.pip_contribution) * sortDir
const av = sort === 'label' ? a.label : sort === 'category' ? a.category : a.source
const bv = sort === 'label' ? b.label : sort === 'category' ? b.category : b.source
return String(av).localeCompare(String(bv)) * sortDir
})
}, [nodes, filterType, filterCat, sort, sortDir])
function toggle(key: SortKey) {
if (sort === key) setSortDir(d => d === 1 ? -1 : 1)
else { setSort(key); setSortDir(-1) }
}
function SortBtn({ k, label }: { k: SortKey; label: string }) {
return (
<button onClick={() => toggle(k)}
className={clsx('flex items-center gap-1', sort === k ? 'text-blue-400' : 'text-slate-500 hover:text-slate-300')}>
{label}
{sort === k && (sortDir === -1 ? <ChevronDown className="w-3 h-3"/> : <ChevronUp className="w-3 h-3"/>)}
</button>
)
}
const TYPE_OPTS = [
{ value:'all', label:'Tous' },
{ value:'input_event', label:'Events' },
{ value:'input_manual', label:'Manuels' },
{ value:'intermediate', label:'Interméd.' },
{ value:'output', label:'Output' },
]
return (
<div>
<div className="flex flex-wrap gap-2 mb-3">
{TYPE_OPTS.map(o => (
<button key={o.value} onClick={() => setFilterType(o.value)}
className={clsx('px-2.5 py-1 rounded text-xs font-medium transition-colors',
filterType === o.value ? 'bg-blue-600 text-white' : 'bg-dark-700 text-slate-400 hover:text-white')}>
{o.label}
</button>
))}
<select value={filterCat} onChange={e => setFilterCat(e.target.value)}
className="bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-slate-400 focus:outline-none ml-2">
{categories.map(c => <option key={c} value={c}>{c === 'all' ? 'Toutes catégories' : c}</option>)}
</select>
</div>
<div className="rounded-lg border border-slate-700/40 overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="bg-dark-700/60 border-b border-slate-700/40 text-slate-500">
<th className="px-3 py-2 text-left w-8">T</th>
<th className="px-3 py-2 text-left"><SortBtn k="label" label="Nœud"/></th>
<th className="px-3 py-2 text-left hidden sm:table-cell"><SortBtn k="category" label="Catégorie"/></th>
<th className="px-3 py-2 text-right"><SortBtn k="pip_contribution" label="Pips"/></th>
<th className="px-3 py-2 text-center"><SortBtn k="source" label="Source"/></th>
<th className="px-3 py-2 text-center w-24">Bar</th>
<th className="px-3 py-2 text-center w-8"/>
</tr>
</thead>
<tbody>
{sorted.map(node => {
const meta = NODE_TYPE_META[node.node_type]
const v = node.pip_contribution
const isExp = expanded === node.id
const canEdit = node.node_type === 'input_event' || node.node_type === 'input_manual'
return [
<tr key={node.id}
className={clsx('border-b border-slate-700/20 hover:bg-dark-700/20 transition-colors cursor-pointer', isExp && 'bg-dark-700/10')}>
<td className="px-3 py-2">
<span className={clsx('font-bold text-xs', meta.color)}>
{node.node_type === 'input_event' ? 'EV' : node.node_type === 'input_manual' ? 'M' : node.node_type === 'intermediate' ? 'INT' : 'OUT'}
</span>
</td>
<td className="px-3 py-2" onClick={() => setExpanded(isExp ? null : node.id)}>
<span className="text-slate-300 font-medium hover:text-white">
{node.label}
{node.source === 'manual' && <span className="ml-1 text-violet-400">●</span>}
</span>
</td>
<td className="px-3 py-2 text-slate-500 hidden sm:table-cell">{node.category}</td>
<td className={clsx('px-3 py-2 text-right font-mono font-bold', pipColor(v))}>{fmt(v)}</td>
<td className="px-3 py-2 text-center">
<span className="flex items-center justify-center gap-1">
<span className={clsx('w-1.5 h-1.5 rounded-full', SOURCE_META[node.source]?.dot || 'bg-slate-600')}/>
<span className="text-slate-500">{SOURCE_META[node.source]?.label}</span>
</span>
</td>
<td className="px-3 py-2">
<div className="h-1.5 rounded-full bg-slate-700/40 overflow-hidden w-full">
<div className={clsx('h-full rounded-full', pipBg(v))}
style={{width: `${Math.min(100, Math.abs(v / maxAbs) * 100)}%`}}/>
</div>
</td>
<td className="px-3 py-2 text-center">
{canEdit && (
<button onClick={() => onEdit(node)} className="text-slate-600 hover:text-blue-400 transition-colors">
<Edit3 className="w-3.5 h-3.5"/>
</button>
)}
</td>
</tr>,
isExp && (
<tr key={`${node.id}-exp`} className="bg-dark-800/40 border-b border-slate-700/10">
<td/>
<td colSpan={6} className="px-3 py-2 text-xs text-slate-400 space-y-1">
<div>{node.description}</div>
{node.node_type === 'input_manual' && node.coefficient_to_pips !== undefined && (
<div className="text-slate-500">
Coeff : {node.coefficient_to_pips} pips/{node.unit}
{node.raw_value !== undefined ? ` · ${node.raw_value} ${node.unit}` : ''}
{node.pip_linear !== undefined && node.pip_saturated !== undefined && (
<span>
{' '}→ linéaire <span className="text-slate-400">{fmt(node.pip_linear)}</span>
{' '}/ saturé <span className={pipColor(node.pip_saturated)}>{fmt(node.pip_saturated)}</span>
{node.saturation_pct !== undefined && node.saturation_pct > 1 && (
<span className="text-amber-500/70"> (sat. {node.saturation_pct.toFixed(0)}%)</span>
)}
</span>
)}
</div>
)}
{node.node_type === 'intermediate' && node.formula && (
<div className="text-slate-500 font-mono text-xs">{node.formula}</div>
)}
{node.override_note && <div className="text-violet-400 italic">Note : {node.override_note}</div>}
{node.override_set_at && <div className="text-slate-600">Modifié : {node.override_set_at.slice(0,10)}</div>}
</td>
</tr>
),
].filter(Boolean)
})}
</tbody>
</table>
{sorted.length === 0 && (
<div className="text-center py-6 text-slate-500 text-sm">Aucun nœud pour ce filtre</div>
)}
</div>
</div>
)
}
// ── 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 [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 [importingCal, setImportingCal] = useState(false)
const [calMsg, setCalMsg] = useState<string | 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 = r.data
setData(pts)
if (pts.length > 0) {
const keys = Object.keys(pts[0].nodes).filter(k => k.startsWith('layer_'))
setLayerKeys(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 || activeData.length === 0) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const W = canvas.width = canvas.offsetWidth
const H = canvas.height = 300
ctx.clearRect(0, 0, W, H)
// 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 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(showPips ? 5 : 0.001, 0.08 * (maxV - minV))
minV -= pad; maxV += pad
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(activeData.length - 1, 1)) * cW
const toY = (v: number) => mT + (1 - (v - minV) / (maxV - minV)) * cH
// 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 && color !== 'rgba(0,0,0,0)') {
ctx.fillStyle = color
ctx.fillRect(toX(bandStart), mT, toX(Math.max(i - 1, bandStart)) - toX(bandStart) + 1, cH)
}
bandStart = i; bandRegime = r || 'BALANCED'
}
}
// Grid
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.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'
const lbl = showPips ? fmt(v) : v.toFixed(v > 100 ? 1 : 4)
ctx.fillText(lbl, mL - 4, y + 3.5)
}
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()
ctx.setLineDash([])
}
// X labels
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 < activeData.length; i += step) {
ctx.fillText(activeData[i].date.slice(5), toX(i), H - 4)
}
// 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()
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()
}
// 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) {
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 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',
period === p ? 'bg-blue-600 text-white' : 'bg-dark-700 text-slate-400 hover:text-white')}>
{p}
</button>
))}
<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: 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 gap-2 flex-wrap">
<div className="text-xs font-semibold text-violet-400">Events virtuels (What-if)</div>
<div className="flex gap-1.5">
<button
disabled={importingCal}
onClick={async () => {
setImportingCal(true)
try {
const r = await api.get<CalendarEvent[]>(
`/instrument-models/${instrument}/calendar-events?days_back=365&days_forward=90`
)
const imported: VirtualEventForm[] = r.data.map(ev => ({
id: ev.analysis_id.toString(),
date: ev.start_date,
category: ev.category,
pips: ev.pip_prediction,
label: ev.title || ev.category,
absorption_days: ev.calibration.absorption_days,
rise_days: ev.calibration.rise_days,
plateau_days: ev.calibration.plateau_days,
decay_type: ev.calibration.decay_type,
}))
if (imported.length === 0) {
setCalMsg('Aucun event analysé pour cet instrument utilisez CausalLab d\'abord')
} else {
setVirtuals(prev => {
const existingIds = new Set(prev.map(v => v.id))
const added = imported.filter(x => !existingIds.has(x.id))
setCalMsg(`${added.length} event${added.length > 1 ? 's' : ''} importé${added.length > 1 ? 's' : ''}`)
return [...prev, ...added]
})
}
setTimeout(() => setCalMsg(null), 3500)
} finally { setImportingCal(false) }
}}
className="flex items-center gap-1 text-xs text-sky-400 hover:text-sky-300 border border-sky-700/40 rounded px-2 py-0.5 transition-colors disabled:opacity-40">
<Activity className="w-3 h-3"/> {importingCal ? '…' : 'Import calendrier'}
</button>
<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>
</div>
{calMsg && (
<div className={clsx('text-xs px-2 py-1 rounded', calMsg.startsWith('Aucun') ? 'text-amber-400 bg-amber-900/20' : 'text-emerald-400 bg-emerald-900/20')}>
{calMsg}
</div>
)}
{virtuals.length === 0 && !calMsg && (
<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>
)
}
// ── Gauge Sync Panel ──────────────────────────────────────────────────────────
interface GaugeSuggestion {
node_id: string
label: string
value: number
unit: string
source: string
confidence: 'HIGH' | 'MEDIUM' | 'LOW'
note: string
}
interface GaugeSuggestionsResponse {
instrument: string
gauge_date: string
n_suggestions: number
suggestions: GaugeSuggestion[]
gauges_available: string[]
}
const CONF_META: Record<string, { color: string; label: string }> = {
HIGH: { color: 'text-emerald-400 bg-emerald-900/20 border-emerald-700/40', label: 'Direct' },
MEDIUM: { color: 'text-amber-400 bg-amber-900/20 border-amber-700/40', label: 'Dérivé' },
LOW: { color: 'text-slate-400 bg-slate-800/40 border-slate-700/30', label: 'Proxy' },
}
function SyncPanel({ instrument, onClose, onApplied }: {
instrument: string; onClose: () => void; onApplied: (atDate?: string) => void
}) {
const [data, setData] = useState<GaugeSuggestionsResponse | null>(null)
const [loading, setLoading] = useState(true)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [applying, setApplying] = useState(false)
const [atDate, setAtDate] = useState<string>('')
function fetchSuggestions(date?: string) {
setLoading(true)
const qs = date ? `?at_date=${date}` : ''
api.get<GaugeSuggestionsResponse>(`/instrument-models/${instrument}/gauge-suggestions${qs}`)
.then(r => {
setData(r.data)
const highs = new Set(r.data.suggestions.filter(s => s.confidence === 'HIGH').map(s => s.node_id))
setSelected(highs)
})
.catch(() => setData(null))
.finally(() => setLoading(false))
}
useEffect(() => { fetchSuggestions() }, [instrument])
function toggleAll(conf: string) {
if (!data) return
const ids = data.suggestions.filter(s => s.confidence === conf).map(s => s.node_id)
const allSelected = ids.every(id => selected.has(id))
setSelected(prev => {
const n = new Set(prev)
ids.forEach(id => allSelected ? n.delete(id) : n.add(id))
return n
})
}
async function apply() {
if (!data) return
setApplying(true)
try {
const overrides = data.suggestions
.filter(s => selected.has(s.node_id))
.map(s => ({ node_id: s.node_id, value: s.value, note: `[Auto${atDate ? ' @'+atDate : ''}] ${s.note}` }))
await api.post(`/instrument-models/${instrument}/apply-suggestions`, { overrides })
onApplied(atDate || undefined)
onClose()
} finally { setApplying(false) }
}
const selectedCount = selected.size
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm"
onClick={onClose}>
<div className="bg-dark-800 border border-slate-700/60 rounded-xl shadow-2xl w-full max-w-2xl mx-4 max-h-[85vh] flex flex-col"
onClick={e => e.stopPropagation()}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-slate-700/40">
<div className="flex-1">
<div className="text-white font-semibold">Sync Marché {instrument}</div>
{data && (
<div className="text-xs text-slate-500 mt-0.5">
Snapshot: {data.gauge_date}
{(data as any).requested_date && (data as any).requested_date !== data.gauge_date && (
<span className="text-amber-500/70 ml-1">(demandé: {(data as any).requested_date})</span>
)}
· {data.gauges_available.length} indicateurs
</div>
)}
{/* Date picker to initialize at a historical date */}
<div className="flex items-center gap-2 mt-2">
<label className="text-xs text-slate-500">Date d'init :</label>
<input
type="date"
value={atDate}
onChange={e => setAtDate(e.target.value)}
className="bg-dark-700 border border-slate-700/40 rounded px-2 py-0.5 text-xs text-white focus:outline-none focus:border-blue-500/60"
/>
<button
onClick={() => fetchSuggestions(atDate || undefined)}
disabled={loading}
className="px-2 py-0.5 bg-blue-600/20 border border-blue-700/40 rounded text-xs text-blue-400 hover:bg-blue-600/30 transition-colors disabled:opacity-40">
{loading ? '' : 'Charger'}
</button>
{atDate && (
<button onClick={() => { setAtDate(''); fetchSuggestions() }}
className="text-xs text-slate-600 hover:text-slate-400">✕ Réinitialiser</button>
)}
</div>
</div>
<button onClick={onClose} className="text-slate-500 hover:text-white ml-4">
<X className="w-4 h-4"/>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{loading && (
<div className="text-center py-8 text-slate-500 text-sm">Chargement des suggestions…</div>
)}
{!loading && !data && (
<div className="text-center py-8 text-red-400 text-sm">
Aucun snapshot de gauges disponible. Actualiser les données marché d'abord.
</div>
)}
{data && (
<div className="space-y-3">
{/* Confidence group filters */}
<div className="flex gap-2 flex-wrap">
{(['HIGH','MEDIUM','LOW'] as const).map(conf => {
const items = data.suggestions.filter(s => s.confidence === conf)
const meta = CONF_META[conf]
return items.length > 0 ? (
<button key={conf} onClick={() => toggleAll(conf)}
className={clsx('flex items-center gap-1.5 px-2.5 py-1 rounded border text-xs font-medium transition-colors', meta.color)}>
<span>{items.every(s => selected.has(s.node_id)) ? '☑' : '☐'}</span>
{meta.label} ({items.length})
</button>
) : null
})}
<span className="ml-auto text-xs text-slate-500 self-center">
{selectedCount} sélectionné{selectedCount > 1 ? 's' : ''}
</span>
</div>
{/* Suggestion rows */}
<div className="rounded-lg border border-slate-700/40 overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="bg-dark-700/60 border-b border-slate-700/40 text-slate-500">
<th className="px-3 py-2 w-8"/>
<th className="px-3 py-2 text-left">Variable</th>
<th className="px-3 py-2 text-right">Valeur</th>
<th className="px-3 py-2 text-center w-16">Conf.</th>
<th className="px-3 py-2 text-left text-slate-600">Détail</th>
</tr>
</thead>
<tbody>
{data.suggestions.map(s => {
const isSel = selected.has(s.node_id)
const meta = CONF_META[s.confidence]
return (
<tr key={s.node_id}
onClick={() => setSelected(prev => { const n = new Set(prev); isSel ? n.delete(s.node_id) : n.add(s.node_id); return n })}
className={clsx('border-b border-slate-700/20 cursor-pointer transition-colors',
isSel ? 'bg-blue-900/10' : 'hover:bg-dark-700/20')}>
<td className="px-3 py-2 text-center">
<div className={clsx('w-3.5 h-3.5 rounded border mx-auto transition-colors',
isSel ? 'bg-blue-500 border-blue-400' : 'border-slate-600')}>
{isSel && <span className="text-white text-xs flex items-center justify-center h-full leading-none"></span>}
</div>
</td>
<td className="px-3 py-2 text-slate-300 font-medium">{s.label}</td>
<td className="px-3 py-2 text-right font-mono text-white">
{s.value > 0 ? '+' : ''}{s.value} <span className="text-slate-500">{s.unit}</span>
</td>
<td className="px-3 py-2 text-center">
<span className={clsx('px-1.5 py-0.5 rounded border text-xs', meta.color)}>
{meta.label}
</span>
</td>
<td className="px-3 py-2 text-slate-500 truncate max-w-[200px]" title={s.note}>
{s.note}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
{/* Warning LOW confidence */}
{data.suggestions.some(s => s.confidence === 'LOW' && selected.has(s.node_id)) && (
<div className="flex items-start gap-2 px-3 py-2 rounded bg-amber-900/20 border border-amber-700/30 text-xs text-amber-400">
<span className="shrink-0"></span>
Certaines valeurs sélectionnées sont des proxies (Conf. Proxy) revérifier avec les données réelles.
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex gap-2 p-4 border-t border-slate-700/40">
<button onClick={apply} disabled={applying || selectedCount === 0 || !data}
className="flex-1 bg-blue-600 hover:bg-blue-500 disabled:opacity-40 text-white text-sm rounded-lg px-4 py-2 font-medium transition-colors">
{applying ? 'Import…' : `Importer ${selectedCount} valeur${selectedCount > 1 ? 's' : ''}`}
</button>
<button onClick={onClose} className="bg-dark-700 hover:bg-dark-600 text-slate-400 text-sm rounded-lg px-4 py-2 transition-colors">
Annuler
</button>
</div>
</div>
</div>
)
}
// ── Calibration View ──────────────────────────────────────────────────────────
const DECAY_OPTIONS = ['exp', 'linear', 'step'] as const
function CalibrationView({ instrument, eventDetails }: {
instrument: string
eventDetails: Record<string, EventDetail[]>
}) {
const [localCalib, setLocalCalib] = useState<Record<number, EventDetail['calibration']>>({})
const [saving, setSaving] = useState<Record<number, boolean>>({})
const [saved, setSaved] = useState<Record<number, boolean>>({})
const allEvents = Object.values(eventDetails).flat()
.sort((a, b) => b.lifecycle_factor - a.lifecycle_factor)
if (allEvents.length === 0) {
return (
<div className="text-center py-12 text-slate-500 text-sm">
Aucun event actif actuellement les paramètres d'arêtes s'affichent ici quand des events sont en cours d'absorption.
</div>
)
}
function getCalib(ev: EventDetail) {
return localCalib[ev.analysis_id] ?? ev.calibration
}
function setField(id: number, field: keyof EventDetail['calibration'], val: string | number) {
setLocalCalib(prev => ({
...prev,
[id]: { ...(prev[id] ?? allEvents.find(e => e.analysis_id === id)!.calibration), [field]: val },
}))
setSaved(prev => ({ ...prev, [id]: false }))
}
async function saveCalib(ev: EventDetail) {
const calib = getCalib(ev)
setSaving(prev => ({ ...prev, [ev.analysis_id]: true }))
try {
await api.put(`/instrument-models/${instrument}/events/${ev.analysis_id}/calibration`, calib)
setSaved(prev => ({ ...prev, [ev.analysis_id]: true }))
} finally {
setSaving(prev => ({ ...prev, [ev.analysis_id]: false }))
}
}
async function resetCalib(ev: EventDetail) {
await api.delete(`/instrument-models/${instrument}/events/${ev.analysis_id}/calibration`)
setLocalCalib(prev => { const n = {...prev}; delete n[ev.analysis_id]; return n })
setSaved(prev => ({ ...prev, [ev.analysis_id]: false }))
}
return (
<div className="space-y-2">
<div className="text-xs text-slate-500 mb-3">
Paramètres de decay (arêtes) des events actifs — modifiez le lifecycle par event indépendamment du template.
</div>
{allEvents.map(ev => {
const cal = getCalib(ev)
const lf = ev.lifecycle_factor
const isSaving = saving[ev.analysis_id]
const isDirty = !!localCalib[ev.analysis_id]
const wasSaved = saved[ev.analysis_id]
return (
<div key={ev.analysis_id}
className="rounded-lg border border-slate-700/30 bg-dark-800/40 p-3 space-y-2">
{/* Event header */}
<div className="flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-slate-500 shrink-0">{ev.start_date}</span>
<span className="text-xs font-medium text-slate-300 truncate">{ev.title || ev.analysis_id}</span>
<span className="text-xs text-slate-600 bg-slate-800 rounded px-1.5 py-0.5 shrink-0">{ev.days_since}j</span>
</div>
<div className="flex items-center gap-3 shrink-0">
<span className={clsx('text-xs font-bold font-mono', pipColor(ev.pip_prediction))}>
{fmt(ev.pip_prediction)}p
</span>
<span className={clsx('text-xs font-mono', pipColor(ev.remaining_pips))}>
→{fmt(ev.remaining_pips)}p
</span>
{/* Lifecycle bar */}
<div className="flex items-center gap-1">
<div className="w-20 h-1.5 rounded-full bg-slate-700/60 overflow-hidden">
<div className={clsx('h-full rounded-full', lf > 0.5 ? 'bg-emerald-500' : lf > 0.2 ? 'bg-amber-500' : 'bg-red-500')}
style={{width:`${Math.round(lf*100)}%`}}/>
</div>
<span className="text-xs text-slate-500 font-mono">{Math.round(lf*100)}%</span>
</div>
</div>
</div>
{/* Calibration inputs */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
<div>
<label className="text-xs text-slate-500 block mb-0.5">Absorption (j)</label>
<input type="number" min={1} max={365} value={cal.absorption_days}
onChange={e => setField(ev.analysis_id, 'absorption_days', parseInt(e.target.value)||1)}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-blue-500/60"/>
</div>
<div>
<label className="text-xs text-slate-500 block mb-0.5">Rise (j)</label>
<input type="number" min={0} max={30} value={cal.rise_days}
onChange={e => setField(ev.analysis_id, 'rise_days', parseInt(e.target.value)||0)}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-blue-500/60"/>
</div>
<div>
<label className="text-xs text-slate-500 block mb-0.5">Plateau (j)</label>
<input type="number" min={0} max={30} value={cal.plateau_days}
onChange={e => setField(ev.analysis_id, 'plateau_days', parseInt(e.target.value)||0)}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-blue-500/60"/>
</div>
<div>
<label className="text-xs text-slate-500 block mb-0.5">Decay type</label>
<select value={cal.decay_type}
onChange={e => setField(ev.analysis_id, 'decay_type', e.target.value)}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none">
{DECAY_OPTIONS.map(d => <option key={d} value={d}>{d}</option>)}
</select>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 justify-end">
{isDirty && !wasSaved && (
<button onClick={() => resetCalib(ev)}
className="text-xs text-slate-600 hover:text-slate-400 transition-colors">
↩ Réinitialiser template
</button>
)}
<button onClick={() => saveCalib(ev)} disabled={isSaving}
className={clsx('flex items-center gap-1 px-3 py-1 rounded text-xs font-medium transition-colors',
wasSaved ? 'bg-emerald-700/40 text-emerald-400 border border-emerald-700/40'
: isDirty ? 'bg-blue-600 hover:bg-blue-500 text-white'
: 'bg-dark-700 text-slate-500 border border-slate-700/30')}>
{isSaving ? 'Sauvegarde' : wasSaved ? ' Sauvegardé' : 'Sauvegarder'}
</button>
</div>
</div>
)
})}
</div>
)
}
// ── Main Page ──────────────────────────────────────────────────────────────────
type ViewMode = 'dag' | 'table' | 'timeline' | 'calibration'
export default function InstrumentModels() {
const [instrument, setInstrument] = useState('EURUSD')
const [view, setView] = useState<ViewMode>('dag')
const [state, setState] = useState<ModelState | null>(null)
const [loading, setLoading] = useState(false)
const [editNode, setEditNode] = useState<ModelNode | null>(null)
const [refreshKey, setRefreshKey] = useState(0)
const [showSync, setShowSync] = useState(false)
const load = useCallback(() => {
setLoading(true)
api.get<ModelState>(`/instrument-models/${instrument}`)
.then(r => setState(r.data))
.catch(() => setState(null))
.finally(() => setLoading(false))
}, [instrument])
useEffect(() => { load() }, [load, refreshKey])
function onSaved() { setRefreshKey(k => k + 1) }
const layerSummary = useMemo(() => {
if (!state) return []
return state.nodes
.filter(n => n.node_type === 'intermediate')
.map(n => ({
...n,
pct: state.net_pips !== 0
? Math.round((n.pip_contribution / Math.abs(state.net_pips)) * 100)
: 0,
}))
.sort((a, b) => Math.abs(b.pip_contribution) - Math.abs(a.pip_contribution))
}, [state])
const VIEW_BTNS: { key: ViewMode; Icon: typeof Network; label: string }[] = [
{ key: 'dag', Icon: Network, label: 'DAG' },
{ key: 'table', Icon: Table2, label: 'Tableau' },
{ key: 'timeline', Icon: LineChart, label: 'Timeline' },
{ key: 'calibration', Icon: Activity, label: 'Calibration' },
]
return (
<div className="p-4 space-y-4">
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div>
<h1 className="text-xl font-bold text-white">Modèles Instruments</h1>
<p className="text-sm text-slate-500 mt-0.5">
Graphes causaux exhaustifs — propagation DAG 3 couches par instrument
</p>
</div>
<div className="flex gap-2">
<button onClick={() => setShowSync(true)}
className="flex items-center gap-2 px-3 py-2 bg-blue-600/20 border border-blue-700/40 rounded-lg text-blue-400 hover:bg-blue-600/30 text-sm transition-colors font-medium">
<Activity className="w-4 h-4"/> Sync Marché
</button>
<button onClick={() => setRefreshKey(k => k+1)} disabled={loading}
className="flex items-center gap-2 px-3 py-2 bg-dark-700 border border-slate-700/40 rounded-lg text-slate-400 hover:text-white text-sm transition-colors disabled:opacity-40">
<RefreshCw className={clsx('w-4 h-4', loading && 'animate-spin')}/> Actualiser
</button>
</div>
</div>
{/* Instrument tabs */}
<div className="flex gap-1.5 flex-wrap">
{INSTRUMENTS.map(inst => (
<button key={inst} onClick={() => setInstrument(inst)}
className={clsx('px-3 py-1.5 rounded-lg text-sm font-medium transition-all border',
instrument === inst
? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-900/30'
: 'bg-dark-700 border-slate-700/40 text-slate-400 hover:text-white hover:border-slate-600')}>
{inst}
</button>
))}
</div>
{/* Synthetic Price Banner */}
{state && (
<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} — 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>
</div>
{/* Layer contributions */}
{layerSummary.length > 0 && (
<div className="flex gap-4 flex-wrap">
{layerSummary.map(l => (
<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, 2).join(' ')}
</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>
))}
</div>
)}
<div className="flex flex-col gap-2 self-start">
{state.regime && <RegimeBadge regime={state.regime}/>}
<div className="text-xs text-slate-600 space-y-0.5">
<div>{state.nodes.filter(n => n.source === 'events').length} events actifs</div>
<div>{state.nodes.filter(n => n.source === 'manual').length} overrides manuels</div>
<div className="text-slate-700">{state.at_date}</div>
</div>
{state.nodes.some(n => n.source === 'manual') && (
<button
onClick={async () => {
if (!confirm('Effacer tous les overrides manuels ?')) return
await api.delete(`/instrument-models/${instrument}/overrides`)
setRefreshKey(k => k + 1)
}}
className="text-xs text-red-500/60 hover:text-red-400 transition-colors text-left">
✕ Reset toutes valeurs
</button>
)}
</div>
</div>
</div>
)}
{/* View selector */}
{state && (
<div className="flex items-center gap-1.5 border-b border-slate-700/30 pb-3">
{VIEW_BTNS.map(({ key, Icon, label }) => (
<button key={key} onClick={() => setView(key)}
className={clsx('flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition-colors',
view === key
? 'bg-blue-600/20 text-blue-400 border border-blue-700/40'
: 'text-slate-500 hover:text-white border border-transparent')}>
<Icon className="w-4 h-4"/> {label}
</button>
))}
<span className="ml-auto text-xs text-slate-600">
{state.nodes.filter(n=>n.node_type==='input_event').length} EV
· {state.nodes.filter(n=>n.node_type==='input_manual').length} M
· {state.nodes.filter(n=>n.node_type==='intermediate').length} INT
· {state.nodes.length} nœuds total
</span>
</div>
)}
{/* Main content */}
{state && (
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-4">
{view === 'dag' && <DagView nodes={state.nodes} instrument={instrument} onEdit={setEditNode} eventDetails={state.event_details || {}}/>}
{view === 'table' && <TableView nodes={state.nodes} onEdit={setEditNode}/>}
{view === 'timeline' && <TimelineView instrument={instrument}/>}
{view === 'calibration' && <CalibrationView instrument={instrument} eventDetails={state.event_details || {}}/>}
</div>
)}
{/* Quick stats */}
{state && view !== 'timeline' && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{[
{ 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={clsx('text-lg font-bold font-mono', card.color)}>{card.value}</div>
<div className="text-xs text-slate-600">{card.sub}</div>
</div>
))}
</div>
)}
{!state && !loading && (
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-8 text-center text-slate-500">
Modèle introuvable pour {instrument}. Vérifier que le backend est démarré.
</div>
)}
{loading && !state && (
<div className="rounded-xl border border-slate-700/40 bg-dark-800/60 p-8 text-center text-slate-500">
Chargement du modèle
</div>
)}
{editNode && (
<NodeEditModal
node={editNode} instrument={instrument}
onClose={() => setEditNode(null)} onSaved={onSaved}
/>
)}
{showSync && (
<SyncPanel
instrument={instrument}
onClose={() => setShowSync(false)}
onApplied={(atDate) => {
setShowSync(false)
setRefreshKey(k => k + 1)
}}
/>
)}
</div>
)
}