/** * 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 weights: Record } 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 } interface TimelinePoint { date: string net_pips: number structural_pips: number event_pips: number fundamental_level: number synthetic_price: number regime?: string nodes: Record } interface PricePoint { date: string close: number } interface VirtualEventForm { id: string date: string category: string pips: number label: string absorption_days: number rise_days: number plateau_days: number decay_type: string } 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 = { 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 = { 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 = { '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 = { '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' ? : direction === 'bearish' ? : 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 ( {icon} {fmt(pips)} pips · {direction.toUpperCase()} ) } // ── Regime Badge ────────────────────────────────────────────────────────────── const REGIME_META: Record = { 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 (
{meta.icon} {regime.label} {regime.dominant_cat && ( · {regime.dominant_cat} )}
{topWeights.length > 0 && (
{topWeights.map(([layer, w]) => ( {layer.replace('layer_', '')} ×{w.toFixed(2)} ))}
)}
) } // ── Node Edit Modal ─────────────────────────────────────────────────────────── function NodeEditModal({ node, instrument, onClose, onSaved }: { node: ModelNode; instrument: string; onClose: () => void; onSaved: () => void }) { const [val, setVal] = useState(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 (
e.stopPropagation()}>
{node.label}
{node.description}
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" />
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" />
{val !== '' && (
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
)}
{node.source === 'manual' && ( )}
) } // ── 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 }) { 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 (
canEdit && onEdit(node)} title={node.description} > {node.source === 'manual' && } {node.source === 'events' && }
{node.label}
{isManual ? (
{hasValue ? ( {node.raw_value! > 0 ? '+' : ''}{node.raw_value} {node.unit} ) : ( — {node.unit} )} w:{coeff} {hasValue && ( ={fmt(v)}p )}
) : 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 (
{hasBaseline ? (
base:{fmt(baseline)}p {surprise !== 0 && ( surp:{fmt(surprise)}p )}
={fmt(v)}p
) : (
{fmt(v)} pips {evList.length > 0 && {evList.length} ev}
)} {evList.length > 0 && (
{barW}%
)}
) })() : (
{fmt(v)} pips
)} {Math.abs(v) > 0.05 && !isManual && (
)} {isManual && Math.abs(v) > 0.05 && (
)} {node.node_type === 'intermediate' && (
{node.formula && {node.formula.split('+').length} sources} {node.regime_weight !== undefined && node.regime_weight !== 1.0 && ( 1.0 ? 'text-amber-400' : 'text-slate-500')}> ×{node.regime_weight.toFixed(2)} )}
)}
) } 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 }) { const wrapperRef = useRef(null) const nodeRefs = useRef>(new Map()) const [svgEdges, setSvgEdges] = useState([]) const [hoveredId, setHoveredId] = useState(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() 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 (
{/* SVG arêtes superposées */} {/* Grille 4 colonnes */}
{COL_LABELS.map((lbl, ci) => (
{lbl} ({cols[ci].length})
{cols[ci].map(n => (
{ if (el) nodeRefs.current.set(n.id, el); else nodeRefs.current.delete(n.id) }} onMouseEnter={() => { setHoveredId(n.id); recompute() }} onMouseLeave={() => setHoveredId(null)} >
))}
))}
{/* Légende */}
Events → couches
Manuel → couches
Couches → output
· survol = highlight
{(Object.entries(SOURCE_META) as [string, { dot: string; label: string }][]).map(([src, m]) => (
{m.label}
))}
Cliquer pour modifier
) } // ── Table View ──────────────────────────────────────────────────────────────── type SortKey = 'label' | 'category' | 'pip_contribution' | 'source' function TableView({ nodes, onEdit }: { nodes: ModelNode[]; onEdit: (n: ModelNode) => void }) { const [sort, setSort] = useState('pip_contribution') const [sortDir, setSortDir] = useState<1 | -1>(-1) const [filterType, setFilterType] = useState('all') const [filterCat, setFilterCat] = useState('all') const [expanded, setExpanded] = useState(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 ( ) } 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 (
{TYPE_OPTS.map(o => ( ))}
{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 [ , isExp && ( ), ].filter(Boolean) })}
T Bar
{node.node_type === 'input_event' ? 'EV' : node.node_type === 'input_manual' ? 'M' : node.node_type === 'intermediate' ? 'INT' : 'OUT'} setExpanded(isExp ? null : node.id)}> {node.label} {node.source === 'manual' && } {node.category} {fmt(v)} {SOURCE_META[node.source]?.label}
{canEdit && ( )}
{node.description}
{node.node_type === 'input_manual' && node.coefficient_to_pips !== undefined && (
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 && ( {' '}→ linéaire {fmt(node.pip_linear)} {' '}/ saturé {fmt(node.pip_saturated)} {node.saturation_pct !== undefined && node.saturation_pct > 1 && ( (sat. {node.saturation_pct.toFixed(0)}%) )} )}
)} {node.node_type === 'intermediate' && node.formula && (
{node.formula}
)} {node.override_note &&
Note : {node.override_note}
} {node.override_set_at &&
Modifié : {node.override_set_at.slice(0,10)}
}
{sorted.length === 0 && (
Aucun nœud pour ce filtre
)}
) } // ── Timeline Chart ───────────────────────────────────────────────────────────── const REGIME_CANVAS_COLORS: Record = { MONETARY_DOMINANCE: 'rgba(59,130,246,0.07)', GEOPOLITICAL_RISK: 'rgba(249,115,22,0.07)', CREDIT_STRESS: 'rgba(239,68,68,0.07)', GROWTH_SCARE: 'rgba(245,158,11,0.07)', COMMODITY_SHOCK: 'rgba(234,179,8,0.07)', BALANCED: 'rgba(0,0,0,0)', } function newVirtualEvent(): VirtualEventForm { return { id: Math.random().toString(36).slice(2), date: new Date().toISOString().slice(0, 10), category: 'central_bank', pips: 50, label: 'Event virtuel', absorption_days: 14, rise_days: 1, plateau_days: 0, decay_type: 'exp', } } function TimelineView({ instrument }: { instrument: string }) { const [period, setPeriod] = useState('3mo') const [data, setData] = useState([]) const [realPrices, setRealPrices] = useState([]) const [loading, setLoading] = useState(false) const [showReal, setShowReal] = useState(true) const [showFund, setShowFund] = useState(true) const [showPips, setShowPips] = useState(false) // toggle: price vs pips const [layerKeys, setLayerKeys] = useState([]) const [shownLayers, setShownLayers] = useState>(new Set()) const [showVirtual, setShowVirtual] = useState(false) const [virtuals, setVirtuals] = useState([]) const [whatifLoading, setWhatifLoading] = useState(false) const [whatifData, setWhatifData] = useState(null) const [importingCal, setImportingCal] = useState(false) const [calMsg, setCalMsg] = useState(null) const canvasRef = useRef(null) const activeData = whatifData ?? data // Load timeline useEffect(() => { setLoading(true) setWhatifData(null) api.get(`/instrument-models/${instrument}/timeline?period=${period}`) .then(r => { const pts = 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() 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(`/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
Calcul timeline…
if (!loading && data.length === 0) return
Aucune donnée pour {instrument}
return (
{/* Controls */}
{PERIODS.map(p => ( ))}
{activeData.length} jours
{/* Layer toggles */} {layerKeys.length > 0 && (
{layerKeys.map(k => ( ))}
)} {/* Canvas */}
{/* Legend */}
Prix synthétique
{showReal && (
Prix réel
)} {showFund && (
Fondamental
)} {whatifData && (
What-if
)}
{/* Virtual Events Panel */} {showVirtual && (
Events virtuels (What-if)
{calMsg && (
{calMsg}
)} {virtuals.length === 0 && !calMsg && (
Ajoute des events hypothétiques pour simuler leur impact sur la courbe synthétique.
)} {virtuals.map((ve, idx) => (
setVirtuals(v => v.map((x, i) => i === idx ? {...x, date: e.target.value} : x))} className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none"/>
setVirtuals(v => v.map((x, i) => i === idx ? {...x, pips: parseFloat(e.target.value) || 0} : x))} className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none"/>
setVirtuals(v => v.map((x, i) => i === idx ? {...x, absorption_days: parseInt(e.target.value)||14} : x))} className="flex-1 bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none"/>
setVirtuals(v => v.map((x, i) => i === idx ? {...x, label: e.target.value} : x))} className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none"/>
))} {virtuals.length > 0 && (
)}
)}
) } // ── 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 = { 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(null) const [loading, setLoading] = useState(true) const [selected, setSelected] = useState>(new Set()) const [applying, setApplying] = useState(false) const [atDate, setAtDate] = useState('') function fetchSuggestions(date?: string) { setLoading(true) const qs = date ? `?at_date=${date}` : '' api.get(`/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 (
e.stopPropagation()}> {/* Header */}
Sync Marché — {instrument}
{data && (
Snapshot: {data.gauge_date} {(data as any).requested_date && (data as any).requested_date !== data.gauge_date && ( (demandé: {(data as any).requested_date}) )} · {data.gauges_available.length} indicateurs
)} {/* Date picker to initialize at a historical date */}
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" /> {atDate && ( )}
{/* Content */}
{loading && (
Chargement des suggestions…
)} {!loading && !data && (
Aucun snapshot de gauges disponible. Actualiser les données marché d'abord.
)} {data && (
{/* Confidence group filters */}
{(['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 ? ( ) : null })} {selectedCount} sélectionné{selectedCount > 1 ? 's' : ''}
{/* Suggestion rows */}
{data.suggestions.map(s => { const isSel = selected.has(s.node_id) const meta = CONF_META[s.confidence] return ( 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')}> ) })}
Variable Valeur Conf. Détail
{isSel && }
{s.label} {s.value > 0 ? '+' : ''}{s.value} {s.unit} {meta.label} {s.note}
{/* Warning LOW confidence */} {data.suggestions.some(s => s.confidence === 'LOW' && selected.has(s.node_id)) && (
Certaines valeurs sélectionnées sont des proxies (Conf. Proxy) — revérifier avec les données réelles.
)}
)}
{/* Footer */}
) } // ── Calibration View ────────────────────────────────────────────────────────── const DECAY_OPTIONS = ['exp', 'linear', 'step'] as const function CalibrationView({ instrument, eventDetails }: { instrument: string eventDetails: Record }) { const [localCalib, setLocalCalib] = useState>({}) const [saving, setSaving] = useState>({}) const [saved, setSaved] = useState>({}) const allEvents = Object.values(eventDetails).flat() .sort((a, b) => b.lifecycle_factor - a.lifecycle_factor) if (allEvents.length === 0) { return (
Aucun event actif actuellement — les paramètres d'arêtes s'affichent ici quand des events sont en cours d'absorption.
) } 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 (
Paramètres de decay (arêtes) des events actifs — modifiez le lifecycle par event indépendamment du template.
{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 (
{/* Event header */}
{ev.start_date} {ev.title || ev.analysis_id} {ev.days_since}j
{fmt(ev.pip_prediction)}p →{fmt(ev.remaining_pips)}p {/* Lifecycle bar */}
0.5 ? 'bg-emerald-500' : lf > 0.2 ? 'bg-amber-500' : 'bg-red-500')} style={{width:`${Math.round(lf*100)}%`}}/>
{Math.round(lf*100)}%
{/* Calibration inputs */}
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"/>
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"/>
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"/>
{/* Actions */}
{isDirty && !wasSaved && ( )}
) })}
) } // ── Main Page ────────────────────────────────────────────────────────────────── type ViewMode = 'dag' | 'table' | 'timeline' | 'calibration' export default function InstrumentModels() { const [instrument, setInstrument] = useState('EURUSD') const [view, setView] = useState('dag') const [state, setState] = useState(null) const [loading, setLoading] = useState(false) const [editNode, setEditNode] = useState(null) const [refreshKey, setRefreshKey] = useState(0) const [showSync, setShowSync] = useState(false) const load = useCallback(() => { setLoading(true) api.get(`/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 (
{/* Header */}

Modèles Instruments

Graphes causaux exhaustifs — propagation DAG 3 couches par instrument

{/* Instrument tabs */}
{INSTRUMENTS.map(inst => ( ))}
{/* Synthetic Price Banner */} {state && (
{/* Row 1 — synthetic price + direction + regime */}
{state.name} — Prix Synthétique
{fmtPrice(state.synthetic_price, state.pip_to_price)}
{/* Structural vs event breakdown */}
Fondamental {fmtPrice(state.fundamental_level, state.pip_to_price)} ({fmt(state.structural_pips)}p)
Surprises {fmt(state.event_pips)}p
Intercept {fmtPrice(state.price_intercept, state.pip_to_price)}
{/* Layer contributions */} {layerSummary.length > 0 && (
{layerSummary.map(l => (
{l.label.replace('▶ ', '').split(' ').slice(0, 2).join(' ')}
{fmt(l.pip_contribution)}
{l.pct > 0 ? '+' : ''}{l.pct}%
))}
)}
{state.regime && }
{state.nodes.filter(n => n.source === 'events').length} events actifs
{state.nodes.filter(n => n.source === 'manual').length} overrides manuels
{state.at_date}
{state.nodes.some(n => n.source === 'manual') && ( )}
)} {/* View selector */} {state && (
{VIEW_BTNS.map(({ key, Icon, label }) => ( ))} {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
)} {/* Main content */} {state && (
{view === 'dag' && } {view === 'table' && } {view === 'timeline' && } {view === 'calibration' && }
)} {/* Quick stats */} {state && view !== 'timeline' && (
{[ { label: 'Prix synthétique', value: fmtPrice(state.synthetic_price, state.pip_to_price), sub: state.direction, color: state.direction === 'bullish' ? 'text-emerald-400' : state.direction === 'bearish' ? 'text-red-400' : 'text-white' }, { label: 'Niveau fondamental',value: fmtPrice(state.fundamental_level, state.pip_to_price), sub: `struct: ${fmt(state.structural_pips)}p`, color: 'text-amber-400' }, { label: 'Surprises events', value: `${fmt(state.event_pips)} pips`, sub: `sur ${state.nodes.filter(n=>n.source==='events').length} events actifs`, color: pipColor(state.event_pips) }, { label: 'Variables manuelles',value: `${state.nodes.filter(n=>n.source==='manual').length} actives`, sub: `sur ${state.nodes.filter(n=>n.node_type==='input_manual').length} disponibles`, color: 'text-violet-400' }, ].map(card => (
{card.label}
{card.value}
{card.sub}
))}
)} {!state && !loading && (
Modèle introuvable pour {instrument}. Vérifier que le backend est démarré.
)} {loading && !state && (
Chargement du modèle…
)} {editNode && ( setEditNode(null)} onSaved={onSaved} /> )} {showSync && ( setShowSync(false)} onApplied={(atDate) => { setShowSync(false) setRefreshKey(k => k + 1) }} /> )}
) }