import { useState, useEffect, useRef, useCallback } from 'react' import { useSearchParams } from 'react-router-dom' import { FlaskConical, Library, Zap, BarChart3, RefreshCw, Edit3, Sliders, Brain, Search, CheckCircle, XCircle, Minus, AlertCircle, Plus, Trash2, Save, Copy, Info, Wand2, ExternalLink, Filter, X, } from 'lucide-react' import clsx from 'clsx' // ── Types ───────────────────────────────────────────────────────────────────── export interface CausalNode { id: string; label: string type: 'macro_event' | 'observable' | 'latent' | 'market_asset' | 'input' | 'intermediate' | 'output' x: number; y: number formula?: string; unit?: string; instrument?: string; description?: string } export interface CausalEdge { from: string; to: string style: 'solid' | 'dashed' type?: string strength?: 1 | 2 | 3 sign?: 'positive' | 'negative' | 'neutral' label?: string lag_min?: number // délai avant onset (minutes) — mode intraday 5m lag_days?: number // délai avant onset (jours) — mode journalier diffusion_min?: number // durée jusqu'à absorption complète (minutes) decay_days?: number | null // demi-vie (jours), null = shift permanent } interface Coefficient { value: number; calibrated: number | null; description: string } interface InputMapping { source: 'user_input' | 'market_watchlist' | 'economic_events' | 'ff_calendar' | 'surprise' | 'actual_value' | 'impact_score_scaled' | 'impact_score_pct' key?: string field?: string unit?: string range?: number[] } interface DataSources { prices: { key: string; label: string }[] macro_series: { key: string; label: string }[] ff_events: { key: string; label: string }[] } export interface GraphJson { nodes: CausalNode[]; edges: CausalEdge[] coefficients: Record instruments: string[] input_mapping: Record } interface Template { id: number; name: string; category: string; sub_type: string instruments: string[]; description: string; graph_json: GraphJson ai_rationale: string; calibration_json: Record heuristic_ver: number; created_by?: string; created_at?: string } interface MarketEvent { id: number; name: string; category: string; sub_type: string start_date: string; end_date: string | null; level: string; created_at: string | null impact_score: number; market_impact: string; affected_assets: string actual_value: number | null; expected_value: number | null; surprise_pct: number | null analysis_id: number | null; template_id: number | null; activation_score: number | null } interface NodeResult { pred: number | null; act: number | null; status: string; label: string } interface AnalysisResult { event: MarketEvent; template_id: number; template_name: string; instrument: string inputs: Record; node_values: Record actual_moves: Record; yields: Record activation: { score: number | null; nodes: Record; correct: number; total: number } drift: { pre_pips: number | null; post_pips: number | null; drift_ratio: number | null; leak: string } prices_mode: string; analyzed_at: string; effective_lag_min?: number } interface Recommendation { template_id: number | null; template_name: string; confidence: number rationale: string; coefficient_suggestions: Record instrument_focus: string[]; notes: string; error?: string } interface CalibRow { id: number; name: string; category: string n_analyses: number; avg_activation: number | null calibration: Record } // ── API ─────────────────────────────────────────────────────────────────────── const api = async (path: string, opts?: RequestInit) => { const r = await fetch(path, opts) if (!r.ok) { const t = await r.text(); throw new Error(t) } return r.json() } // ── Visuels ─────────────────────────────────────────────────────────────────── /** Couleur de nœud selon son type sémantique */ const nodeColor = (type: string): string => ({ macro_event: '#3b82f6', // blue — événements source (NFP, CPI, FOMC) observable: '#06b6d4', // cyan — variables marché observables (OIS, 2Y, spreads) latent: '#8b5cf6', // violet — variables latentes (anticipations, sentiment) market_asset: '#10b981', // green — instruments tradés (EUR/USD, S&P) // compat backward input: '#3b82f6', intermediate: '#8b5cf6', output: '#10b981', }[type] ?? '#64748b') /** Couleur de l'arête selon son signe */ const edgeColor = (sign?: string): string => ({ positive: '#34d399', // teal negative: '#f87171', // rouge-rose neutral: '#475569', // gris ardoise }[sign ?? 'neutral'] ?? '#475569') /** Épaisseur selon strength */ const edgeWidth = (s?: number) => s === 1 ? 1 : s === 3 ? 3.5 : 2 /** Format lag minutes → "+5m" / "+2h" */ function fmtLag(min: number): string { if (min < 60) return `+${min}m` return `+${(min / 60).toFixed(0)}h` } /** Format decay days → "↩3j" */ const fmtDecay = (d: number) => `↩${d}j` const CAT_LABELS: Record = { // Template categories macro_us: 'Macro US', macro_eu: 'Macro EU', geopolitical: 'Géopolitique', commodity: 'Matières 1ères', report: 'Report', sentiment: 'Sentiment', // Market event categories (aligned) event_calendar: 'Calendrier', calendar_event: 'Calendrier', fundamental: 'Fondamental', } // Chip color per category (inline styles to survive Tailwind purge) const CAT_CHIP: Record = { macro_us: { bg: '#1e3a5f', color: '#93c5fd', border: '#3b82f6' }, macro_eu: { bg: '#2d1b69', color: '#c4b5fd', border: '#8b5cf6' }, event_calendar: { bg: '#0c3535', color: '#67e8f9', border: '#06b6d4' }, calendar_event: { bg: '#0c3535', color: '#67e8f9', border: '#06b6d4' }, geopolitical: { bg: '#422006', color: '#fcd34d', border: '#d97706' }, commodity: { bg: '#14352a', color: '#6ee7b7', border: '#10b981' }, report: { bg: '#1e293b', color: '#94a3b8', border: '#475569' }, sentiment: { bg: '#4a1942', color: '#f9a8d4', border: '#ec4899' }, fundamental: { bg: '#1e1b4b', color: '#a5b4fc', border: '#6366f1' }, } const chipStyle = (cat: string, active: boolean) => { const c = CAT_CHIP[cat] ?? { bg: '#1e293b', color: '#94a3b8', border: '#475569' } return { background: active ? c.bg : 'transparent', color: active ? c.color : '#64748b', border: `1px solid ${active ? c.border : '#334155'}`, } } const NODE_TYPES = ['macro_event', 'observable', 'latent', 'market_asset'] as const const NODE_TYPE_LABELS: Record = { macro_event: 'Macro event', observable: 'Observable', latent: 'Latent', market_asset: 'Market asset', } const fmtPips = (v: number | null | undefined) => v == null ? '—' : `${v > 0 ? '+' : ''}${v.toFixed(0)} pip` // ── SVG Graph renderer (partagé) ────────────────────────────────────────────── export function GraphSVG({ graph, nodeValues, activationNodes, compact = false, selectedNodeId, onNodeClick, onBgClick, editorMode = false, }: { graph: GraphJson nodeValues?: Record activationNodes?: Record compact?: boolean selectedNodeId?: string | null onNodeClick?: (id: string, e: React.MouseEvent) => void onBgClick?: (e: React.MouseEvent) => void editorMode?: boolean }) { const svgRef = useRef(null) const nodes = graph.nodes || [] const edges = graph.edges || [] // Scale positions to reduce overlap between nodes and edge labels const POS_SCALE = compact ? 1 : 1.55 const scaledNodes = nodes.map(n => ({ ...n, x: n.x * POS_SCALE, y: n.y * POS_SCALE })) const nodeMap = Object.fromEntries(scaledNodes.map(n => [n.id, n])) const xs = scaledNodes.map(n => n.x) const ys = scaledNodes.map(n => n.y) const minX = (xs.length ? Math.min(...xs) : 0) - 80 const minY = (ys.length ? Math.min(...ys) : 0) - 50 const maxX = (xs.length ? Math.max(...xs) : 400) + 160 const maxY = (ys.length ? Math.max(...ys) : 400) + 90 const W = maxX - minX; const H = maxY - minY // Nodes larger in editor mode to accommodate type badge const NW = compact ? 92 : editorMode ? 140 : 130 const NH = compact ? 32 : editorMode ? 54 : 48 const statusColor = (s: string) => s === 'correct' ? '#10b981' : s === 'wrong' ? '#ef4444' : '#64748b' function getNodeColor(n: CausalNode) { if (activationNodes?.[n.id]) return statusColor(activationNodes[n.id].status) return nodeColor(n.type) } function edgePath(e: CausalEdge) { const s = nodeMap[e.from]; const t = nodeMap[e.to] if (!s || !t) return '' const x1 = s.x; const y1 = s.y + NH / 2 const x2 = t.x; const y2 = t.y - NH / 2 const my = (y1 + y2) / 2 return `M${x1},${y1} C${x1},${my} ${x2},${my} ${x2},${y2}` } function bezierPt(e: CausalEdge, t0: number): { x: number; y: number } | null { const s = nodeMap[e.from]; const t = nodeMap[e.to] if (!s || !t) return null const x1 = s.x; const y1 = s.y + NH / 2 const x2 = t.x; const y2 = t.y - NH / 2 const my = (y1 + y2) / 2 const dx = x2 - x1; const dy = y2 - y1; const len = Math.sqrt(dx*dx + dy*dy) || 1 const bx = (1-t0)**3*x1 + 3*(1-t0)**2*t0*x1 + 3*(1-t0)*t0**2*x2 + t0**3*x2 const by = (1-t0)**3*y1 + 3*(1-t0)**2*t0*my + 3*(1-t0)*t0**2*my + t0**3*y2 return { x: bx + (-dy/len)*16, y: by + (dx/len)*16 } } // Label position along bezier path with perpendicular offset to avoid the line function edgeLabelPos(e: CausalEdge): { x: number; y: number } | null { return bezierPt(e, 0.42) } function handleSvgClick(e: React.MouseEvent) { if (e.target === svgRef.current || (e.target as SVGElement).tagName === 'svg') { onBgClick?.(e) } } return ( {(['positive', 'negative', 'neutral'] as const).map(sign => ( ))} {/* Background grid in editor mode */} {editorMode && <> } {/* Edges */} {edges.map((e, i) => { const s = nodeMap[e.from]; const t = nodeMap[e.to] if (!s || !t) return null const sign = e.sign ?? 'neutral' const color = edgeColor(sign) const sw = edgeWidth(e.strength) const lpos = e.label ? edgeLabelPos(e) : null const labelText = e.label ?? '' const labelW = labelText.length * 5.8 const lagTxtMin = (!compact && e.lag_min) ? fmtLag(e.lag_min) : null const lagTxtDay = (!compact && e.lag_days) ? `+${e.lag_days}j` : null const lagTxt = lagTxtMin && lagTxtDay ? `${lagTxtMin}/${lagTxtDay}` : (lagTxtMin ?? lagTxtDay) const decayTxt = (!compact && e.decay_days != null) ? fmtDecay(e.decay_days) : null const lagPos = lagTxt ? bezierPt(e, 0.13) : null const decayPos = decayTxt ? bezierPt(e, 0.84) : null return ( {lpos && ( {labelText} )} {lagPos && lagTxt && ( {lagTxt} )} {decayPos && decayTxt && ( {decayTxt} )} ) })} {/* Nodes */} {scaledNodes.map(n => { const col = getNodeColor(n) const val = nodeValues?.[n.id] const isSel = selectedNodeId === n.id const maxLen = editorMode ? 22 : 21 const lFS = compact ? 9 : 12 // vertical layout inside node const labelY = compact ? 12 : editorMode ? 17 : 16 const valY = compact ? 22 : 34 const typeY = NH - 7 // type badge near bottom return ( { e.stopPropagation(); onNodeClick?.(n.id, e) }} style={{ cursor: editorMode ? 'pointer' : 'default' }}> {isSel && ( )} {/* Label */} {n.label.length > maxLen ? n.label.slice(0, maxLen - 1) + '…' : n.label} {/* Value (analyze mode) */} {val != null && ( {val > 0 ? '+' : ''}{val.toFixed(1)}{n.unit ? ` ${n.unit}` : ''} )} {/* Type badge (editor only) — inside node at bottom */} {editorMode && ( <> {n.type?.replace(/_/g, ' ')} )} ) })} ) } // ── Légende des conventions ─────────────────────────────────────────────────── function GraphLegend() { return (
Conventions
{[ { color: nodeColor('macro_event'), label: 'Macro event (source)' }, { color: nodeColor('observable'), label: 'Observable (OIS, taux…)' }, { color: nodeColor('latent'), label: 'Latent (anticipations…)' }, { color: nodeColor('market_asset'), label: 'Market asset (FX, S&P…)' }, ].map(({ color, label }) => (
{label}
))}
{[ { style: 'solid 3px', color: '#34d399', label: 'Causalité directe forte +' }, { style: 'solid 1.5px', color: '#f87171', label: 'Effet négatif direct' }, { style: 'dashed 2px', color: '#94a3b8', label: 'Transmission indirecte' }, ].map(({ style, color, label }) => (
{label}
))}
) } // ── Graph Patch Grammar ─────────────────────────────────────────────────────── // Syntax: (+|-|~)(node|edge|coef|input|instruments) key=val key="val with spaces" // sign shortcuts: + positive - negative = neutral interface GrammarOp { op: '+' | '-' | '~' entity: 'node' | 'edge' | 'coef' | 'input' | 'instruments' args: Record raw: string } function parseGrammarArgs(s: string): Record { const res: Record = {} const re = /(\w+)=(?:"([^"]*)"|([\S]*))/g let m: RegExpExecArray | null while ((m = re.exec(s)) !== null) res[m[1]] = m[2] !== undefined ? m[2] : m[3] return res } function parseGrammar(text: string): { ops: GrammarOp[]; errors: string[] } { const ops: GrammarOp[] = []; const errors: string[] = [] for (const rawLine of text.split('\n')) { const line = rawLine.trim() if (!line || line.startsWith('#')) continue const m = line.match(/^([+\-~])(node|edge|coef|input|instruments)\s*(.*)/i) if (!m) { errors.push(`Ligne non reconnue: "${line}"`); continue } const entity = m[2].toLowerCase() as GrammarOp['entity'] const args = entity === 'instruments' ? { list: m[3].trim() } : parseGrammarArgs(m[3]) ops.push({ op: m[1] as '+' | '-' | '~', entity, args, raw: line }) } return { ops, errors } } function resolveSign(s?: string): 'positive' | 'negative' | 'neutral' { if (!s) return 'neutral' if (s === '+' || s === 'positive') return 'positive' if (s === '-' || s === 'negative') return 'negative' return 'neutral' } function applyGrammarOps(state: EditorState, ops: GrammarOp[]): { state: EditorState; errors: string[] } { let s: EditorState = { ...state, nodes: [...state.nodes], edges: [...state.edges], coefficients: { ...state.coefficients }, inputMapping: { ...state.inputMapping }, instruments: [...state.instruments], } const errors: string[] = [] for (const op of ops) { try { if (op.entity === 'node') { const { id, label, type, x, y, formula, unit, instrument } = op.args if (op.op === '+') { if (!id) { errors.push('+node: id manquant'); continue } if (s.nodes.find(n => n.id === id)) { errors.push(`+node: "${id}" existe déjà`); continue } const autoY = s.nodes.length ? Math.max(...s.nodes.map(n => n.y)) + 120 : 200 s.nodes = [...s.nodes, { id, label: label ?? id, type: (type ?? 'observable') as CausalNode['type'], x: x ? +x : 300, y: y ? +y : autoY, ...(formula ? { formula } : {}), ...(unit ? { unit } : {}), ...(instrument ? { instrument } : {}), }] } else if (op.op === '-') { const tid = id ?? Object.values(op.args)[0] if (!tid) { errors.push('-node: id manquant'); continue } s.nodes = s.nodes.filter(n => n.id !== tid) s.edges = s.edges.filter(e => e.from !== tid && e.to !== tid) const im = { ...s.inputMapping }; delete im[tid]; s.inputMapping = im } else { if (!id) { errors.push('~node: id manquant'); continue } s.nodes = s.nodes.map(n => n.id !== id ? n : { ...n, ...(label !== undefined ? { label } : {}), ...(type !== undefined ? { type: type as CausalNode['type'] } : {}), ...(x !== undefined ? { x: +x } : {}), ...(y !== undefined ? { y: +y } : {}), ...(formula !== undefined ? { formula } : {}), ...(unit !== undefined ? { unit } : {}), ...(instrument !== undefined ? { instrument } : {}), }) } } else if (op.entity === 'edge') { const { from, to, sign, s: str, style, label, lag, decay } = op.args if (op.op === '+') { if (!from || !to) { errors.push('+edge: from/to manquants'); continue } s.edges = [...s.edges, { from, to, sign: resolveSign(sign), strength: (str ? +str : 2) as 1|2|3, style: (style ?? 'solid') as 'solid'|'dashed', ...(label ? { label } : {}), ...(lag !== undefined ? { lag_days: +lag } : {}), ...(decay !== undefined ? { decay_days: decay === 'null' ? null : +decay } : {}), }] } else if (op.op === '-') { if (!from || !to) { errors.push('-edge: from/to manquants'); continue } s.edges = s.edges.filter(e => !(e.from === from && e.to === to)) } else { if (!from || !to) { errors.push('~edge: from/to manquants'); continue } s.edges = s.edges.map(e => (e.from !== from || e.to !== to) ? e : { ...e, ...(sign !== undefined ? { sign: resolveSign(sign) } : {}), ...(str !== undefined ? { strength: +str as 1|2|3 } : {}), ...(style !== undefined ? { style: style as 'solid'|'dashed' } : {}), ...(label !== undefined ? { label } : {}), ...(lag !== undefined ? { lag_days: +lag } : {}), ...(decay !== undefined ? { decay_days: decay === 'null' ? null : +decay } : {}), }) } } else if (op.entity === 'coef') { const { key, value, desc } = op.args if (op.op === '+') { if (!key || value === undefined) { errors.push('+coef: key/value manquants'); continue } s.coefficients = { ...s.coefficients, [key]: { value: +value, calibrated: null, description: desc ?? '' } } } else if (op.op === '-') { if (!key) { errors.push('-coef: key manquant'); continue } const cc = { ...s.coefficients }; delete cc[key]; s.coefficients = cc } else { if (!key) { errors.push('~coef: key manquant'); continue } const ex = s.coefficients[key] ?? { value: 0, calibrated: null, description: '' } s.coefficients = { ...s.coefficients, [key]: { ...ex, ...(value !== undefined ? { value: +value } : {}), ...(desc !== undefined ? { description: desc } : {}), }} } } else if (op.entity === 'input') { const { node, source, key } = op.args if (op.op === '-') { if (!node) { errors.push('-input: node manquant'); continue } const im = { ...s.inputMapping }; delete im[node]; s.inputMapping = im } else { if (!node || !source) { errors.push(`${op.op}input: node/source manquants`); continue } s.inputMapping = { ...s.inputMapping, [node]: { source: source as InputMapping['source'], ...(key ? { key } : {}) } } } } else if (op.entity === 'instruments') { const list = op.args.list?.split(',').map(x => x.trim()).filter(Boolean) if (list?.length) s.instruments = list } } catch (e) { errors.push(`Erreur "${op.raw}": ${e}`) } } return { state: s, errors } } const GRAMMAR_REFERENCE = `# Nœuds +node id= label="" type= [x=] [y=] [instrument=] ~node id= [label="..."] [type=...] [x=] [y=] [instrument=] -node id= # Arêtes (sign: + positif - négatif = neutre) +edge from= to= [sign=+|-|=] [s=1|2|3] [style=solid|dashed] [label="..."] [lag=] [decay=] ~edge from= to= [...mêmes champs...] -edge from= to= # Coefficients +coef key= value= [desc="..."] ~coef key= [value=] [desc="..."] -coef key= # Mapping d'entrée ~input node= source= [key=] -input node= # Instruments de sortie ~instruments EURUSD,SP500,...` // ── Tab: Bibliothèque ───────────────────────────────────────────────────────── function TabLibrary({ initialTemplateId, onOpenEditor, }: { initialTemplateId?: number | null onOpenEditor: (id: number) => void }) { const [templates, setTemplates] = useState([]) const [selected, setSelected] = useState