From e8fb7c13aabe333d212cc93bffeff1b1f8ff1952 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Sun, 28 Jun 2026 09:28:43 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20causal=20lab=20=E2=80=94=20=C3=A9diteur?= =?UTF-8?q?=20visuel,=20types=20n=C5=93uds,=20force/signe=20ar=C3=AAtes,?= =?UTF-8?q?=20NFP=20v2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/routers/causal_lab.py | 70 ++- backend/services/causal_graphs.py | 106 ++-- frontend/src/pages/CausalLab.tsx | 861 ++++++++++++++++++++++++------ 3 files changed, 838 insertions(+), 199 deletions(-) diff --git a/backend/routers/causal_lab.py b/backend/routers/causal_lab.py index e88d9fc..fbc14bf 100644 --- a/backend/routers/causal_lab.py +++ b/backend/routers/causal_lab.py @@ -306,6 +306,16 @@ class UpdateCoefRequest(BaseModel): coefficients: dict # {coef_key: float} +class CreateTemplateRequest(BaseModel): + name: str + category: str + sub_type: str = "" + description: str = "" + instruments: list = ["EURUSD"] + graph_json: dict + ai_rationale: str = "" + + class RecommendRequest(BaseModel): market_event_id: int @@ -314,8 +324,8 @@ class AnalyzeRequest(BaseModel): market_event_id: int template_id: int instrument: str = "EURUSD" - inputs: dict = {} # Inputs manuels (ex: tone_score) - coef_overrides: dict = {} # Coefficients overrides pour cet run + inputs: dict = {} + coef_overrides: dict = {} # ── Endpoints ───────────────────────────────────────────────────────────────── @@ -359,6 +369,62 @@ def list_templates(category: str = Query("")): raise HTTPException(500, str(e)) +@router.post("/api/causal-lab/template") +def create_template(body: CreateTemplateRequest): + """Crée un nouveau template utilisateur.""" + try: + from services.database import get_conn + from services.causal_graphs import init_tables, seed_templates + conn = get_conn() + init_tables(conn); seed_templates(conn) + existing = conn.execute( + "SELECT id FROM causal_graph_templates WHERE name = ?", (body.name,) + ).fetchone() + if existing: + raise HTTPException(409, f"Un template nommé '{body.name}' existe déjà") + cur = conn.execute(""" + INSERT INTO causal_graph_templates + (name, category, sub_type, instruments, description, graph_json, ai_rationale, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, 'user') + """, ( + body.name, body.category, body.sub_type, + json.dumps(body.instruments), body.description, + json.dumps(body.graph_json), body.ai_rationale, + )) + conn.commit() + new_id = cur.lastrowid + conn.close() + return {"id": new_id, "name": body.name} + except HTTPException: + raise + except Exception as e: + logger.error(f"[causal_lab] create_template: {e}") + raise HTTPException(500, str(e)) + + +@router.delete("/api/causal-lab/template/{template_id}") +def delete_template(template_id: int): + """Supprime un template utilisateur (les templates system sont protégés).""" + try: + from services.database import get_conn + conn = get_conn() + row = conn.execute( + "SELECT created_by FROM causal_graph_templates WHERE id = ?", (template_id,) + ).fetchone() + if not row: + conn.close(); raise HTTPException(404, "Template introuvable") + if row["created_by"] == "system": + conn.close(); raise HTTPException(403, "Les templates système ne peuvent pas être supprimés") + conn.execute("DELETE FROM causal_graph_templates WHERE id = ?", (template_id,)) + conn.commit(); conn.close() + return {"ok": True} + except HTTPException: + raise + except Exception as e: + logger.error(f"[causal_lab] delete_template {template_id}: {e}") + raise HTTPException(500, str(e)) + + @router.get("/api/causal-lab/template/{template_id}") def get_template_detail(template_id: int): try: diff --git a/backend/services/causal_graphs.py b/backend/services/causal_graphs.py index 66f0ca4..83e2fa8 100644 --- a/backend/services/causal_graphs.py +++ b/backend/services/causal_graphs.py @@ -30,8 +30,13 @@ def _n(id_, label, type_, x, y, formula=None, unit="", instrument=None, descript if description: n["description"] = description return n -def _e(from_, to_, style="solid", type_="causal", label=""): - e = {"from": from_, "to": to_, "style": style, "type": type_} +def _e(from_, to_, style="solid", type_="causal", strength=2, sign="neutral", label=""): + """ + strength : 1=fin, 2=normal, 3=épais + sign : "positive" (teal), "negative" (orange), "neutral" (gris) + """ + e = {"from": from_, "to": to_, "style": style, "type": type_, + "strength": strength, "sign": sign} if label: e["label"] = label return e @@ -82,42 +87,65 @@ BUILT_IN_TEMPLATES = [ }, }, - # ── 2. NFP surprise ───────────────────────────────────────────────────────── + # ── 2. NFP surprise v2 (dual-channel, spread US-DE, OIS) ──────────────────── { "name": "NFP — surprise emploi non-agricole", "category": "macro_us", "sub_type": "NFP", + "heuristic_ver": 2, "instruments": ["EURUSD", "SP500"], - "description": "Surprise créations d'emploi → signal FED → 2Y + 10Y → EUR/USD & S&P", + "description": ( + "NFP surprise → OIS repricing → US 2Y (fort) + US 10Y (faible) → " + "spread US-DE 2Y → EUR/USD | NFP → croissance attendue → S&P500 (2 canaux)" + ), "ai_rationale": ( - "Un NFP supérieur aux attentes signale une économie robuste et renforce " - "les anticipations restrictives FED. Le dollar s'apprécie via le canal des taux courts. " - "L'impact sur le S&P500 est négatif net en contexte hawkish (taux > croissance)." + "Suite à la critique : (1) le nœud 'Signal FED anticipations' est remplacé par un " + "proxy OIS observable (Fed Funds Futures / OIS pricing) ; " + "(2) EUR/USD dépend du spread US-DE 2Y plutôt du seul 2Y US ; " + "(3) S&P500 reçoit deux effets opposés — croissance (positif) via canal bénéfices " + "et taux 10Y (négatif) via canal valorisation. Le lien NFP→10Y est plus faible " + "que NFP→2Y (prime de terme + croissance modèrent l'effet)." ), "graph_json": { "nodes": [ - _n("nfp_surprise", "NFP surprise", "input", 200, 45, unit="k"), - _n("fed_fwd", "Signal FED anticipations", "intermediate", 200, 155, formula="nfp_surprise / 100 * {{coef_nfp_fwd}}"), - _n("us_2y", "Δ US 2Y", "intermediate", 110, 265, formula="fed_fwd * {{coef_fwd_2y}}", unit="%"), - _n("us_10y", "Δ US 10Y", "intermediate", 290, 265, formula="fed_fwd * {{coef_fwd_10y}}", unit="%"), - _n("eurusd", "EUR/USD", "output", 110, 380, formula="-(us_2y * {{coef_2y_fx}} + us_10y * {{coef_10y_fx}})", unit="pips", instrument="EURUSD"), - _n("sp500", "S&P 500", "output", 290, 380, formula="-fed_fwd * {{coef_fwd_sp}}", unit="pts", instrument="SP500"), + _n("nfp_surprise", "NFP surprise", "macro_event", 200, 50, unit="k"), + _n("ois_pricing", "Fed OIS / probabilités", "observable", 200, 165, + formula="nfp_surprise / 100 * {{coef_nfp_ois}}", + description="OIS ou Fed Funds Futures — probabilité implicite décision FED"), + _n("us_2y", "Δ US 2Y", "observable", 90, 295, + formula="ois_pricing * {{coef_ois_2y}}", unit="%"), + _n("us_10y", "Δ US 10Y", "observable", 310, 295, + formula="ois_pricing * {{coef_ois_10y}}", unit="%"), + _n("rate_diff", "Spread US-DE 2Y", "observable", 90, 405, + formula="us_2y * {{coef_diff_factor}}", + description="proxy: US 2Y seul, idéalement US2Y − DE2Y"), + _n("growth_exp", "Croissance attendue", "latent", 310, 405, + formula="nfp_surprise * {{coef_nfp_growth}}"), + _n("eurusd", "EUR/USD", "market_asset", 90, 515, + formula="-rate_diff * {{coef_diff_eurusd}}", unit="pips", instrument="EURUSD"), + _n("sp500", "S&P 500", "market_asset", 310, 515, + formula="growth_exp * {{coef_growth_sp}} - us_10y * {{coef_10y_sp}}", + unit="pts", instrument="SP500"), ], "edges": [ - _e("nfp_surprise", "fed_fwd", "dashed", "fwd_guidance"), - _e("fed_fwd", "us_2y", "solid", "rate_channel"), - _e("fed_fwd", "us_10y", "dashed", "expectations"), - _e("us_2y", "eurusd", "solid", "rate_diff"), - _e("us_10y", "eurusd", "dashed", "rate_diff"), - _e("fed_fwd", "sp500", "dashed", "risk_asset"), + _e("nfp_surprise", "ois_pricing", "solid", "repricing", strength=3, sign="positive", label="repricing"), + _e("ois_pricing", "us_2y", "solid", "rate_anchor", strength=3, sign="positive", label="canal court"), + _e("ois_pricing", "us_10y", "dashed", "expectations", strength=1, sign="positive", label="canal terme"), + _e("us_2y", "rate_diff", "solid", "spread", strength=2, sign="positive"), + _e("nfp_surprise", "growth_exp", "dashed", "growth", strength=2, sign="positive", label="bénéfices"), + _e("rate_diff", "eurusd", "solid", "fx_channel", strength=3, sign="negative", label="USD s'apprécie"), + _e("growth_exp", "sp500", "solid", "growth_sp", strength=2, sign="positive", label="bénéfices +"), + _e("us_10y", "sp500", "dashed", "discount", strength=2, sign="negative", label="discount rate"), ], "coefficients": { - "coef_nfp_fwd": _c(0.35, "100k NFP → signal FED"), - "coef_fwd_2y": _c(0.030, "Signal FED → Δ US 2Y (%)"), - "coef_fwd_10y": _c(0.070, "Signal FED → Δ US 10Y (%)"), - "coef_2y_fx": _c(500, "1% spread 2Y → EUR/USD (pips)"), - "coef_10y_fx": _c(200, "1% spread 10Y → EUR/USD (pips)"), - "coef_fwd_sp": _c(20.0, "Unité signal FED hawkish → S&P500 (pts, négatif)"), + "coef_nfp_ois": _c(0.35, "100k NFP → signal OIS (probabilité remonté taux)"), + "coef_ois_2y": _c(0.030, "Signal OIS → Δ US 2Y (%) — canal court fort"), + "coef_ois_10y": _c(0.012, "Signal OIS → Δ US 10Y (%) — plus faible que 2Y"), + "coef_diff_factor":_c(1.0, "US 2Y → spread US-DE (proxy sans données EU)"), + "coef_nfp_growth": _c(0.003, "100k NFP → signal croissance attendue"), + "coef_diff_eurusd":_c(500, "1% spread → EUR/USD pips (négatif pour EUR)"), + "coef_growth_sp": _c(80, "Signal croissance → S&P500 pts (positif)"), + "coef_10y_sp": _c(150, "1% hausse 10Y → S&P500 pts (négatif, valorisation)"), }, "instruments": ["EURUSD", "SP500"], "input_mapping": { @@ -518,25 +546,39 @@ def init_tables(conn): def seed_templates(conn): - """Insère les templates built-in s'ils n'existent pas encore.""" + """Insère les templates built-in ; met à jour si heuristic_ver a été augmentée.""" for t in BUILT_IN_TEMPLATES: + ver = t.get("heuristic_ver", 1) existing = conn.execute( - "SELECT id FROM causal_graph_templates WHERE name = ?", (t["name"],) + "SELECT id, heuristic_ver FROM causal_graph_templates WHERE name = ?", (t["name"],) ).fetchone() if existing: + if (existing["heuristic_ver"] or 1) < ver: + conn.execute(""" + UPDATE causal_graph_templates + SET graph_json=?, description=?, ai_rationale=?, instruments=?, + heuristic_ver=?, updated_at=datetime('now') + WHERE name=? + """, ( + json.dumps(t["graph_json"]), + t.get("description", ""), + t.get("ai_rationale", ""), + json.dumps(t.get("instruments", [])), + ver, t["name"], + )) continue conn.execute(""" INSERT INTO causal_graph_templates - (name, category, sub_type, instruments, description, graph_json, ai_rationale, created_by) - VALUES (?, ?, ?, ?, ?, ?, ?, 'system') + (name, category, sub_type, instruments, description, graph_json, + ai_rationale, heuristic_ver, created_by) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'system') """, ( - t["name"], - t["category"], - t.get("sub_type", ""), + t["name"], t["category"], t.get("sub_type", ""), json.dumps(t.get("instruments", [])), t.get("description", ""), json.dumps(t["graph_json"]), t.get("ai_rationale", ""), + ver, )) conn.commit() diff --git a/frontend/src/pages/CausalLab.tsx b/frontend/src/pages/CausalLab.tsx index 9cfe3e5..56d387d 100644 --- a/frontend/src/pages/CausalLab.tsx +++ b/frontend/src/pages/CausalLab.tsx @@ -1,23 +1,29 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef, useCallback } from 'react' import { - FlaskConical, Library, Zap, BarChart3, RefreshCw, ChevronRight, - Sliders, Brain, Search, Filter, CheckCircle, XCircle, Minus, AlertCircle, + FlaskConical, Library, Zap, BarChart3, RefreshCw, Edit3, + Sliders, Brain, Search, CheckCircle, XCircle, Minus, AlertCircle, + Plus, Trash2, Save, Copy, Info, } from 'lucide-react' import clsx from 'clsx' // ── Types ───────────────────────────────────────────────────────────────────── -interface CausalNode { - id: string; label: string; type: 'input' | 'intermediate' | 'output' - x: number; y: number; formula?: string; unit?: string; instrument?: string +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 } -interface CausalEdge { - from: string; to: string; style: 'solid' | 'dashed'; type?: string; label?: string +export interface CausalEdge { + from: string; to: string + style: 'solid' | 'dashed' + type?: string + strength?: 1 | 2 | 3 // 1=fin, 2=normal, 3=épais + sign?: 'positive' | 'negative' | 'neutral' + label?: string } -interface Coefficient { - value: number; calibrated: number | null; description: string -} -interface GraphJson { +interface Coefficient { value: number; calibrated: number | null; description: string } +export interface GraphJson { nodes: CausalNode[]; edges: CausalEdge[] coefficients: Record instruments: string[] @@ -27,7 +33,7 @@ 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 + heuristic_ver: number; created_by?: string } interface MarketEvent { id: number; name: string; category: string; sub_type: string @@ -36,27 +42,22 @@ interface MarketEvent { 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 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 - } + 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 } interface Recommendation { template_id: number | null; template_name: string; confidence: number rationale: string; coefficient_suggestions: Record - instrument_focus: string[]; alternative_template_id: number | null; notes: string - error?: string + instrument_focus: string[]; notes: string; error?: string } interface CalibRow { - id: number; name: string; category: string; sub_type: string + id: number; name: string; category: string n_analyses: number; avg_activation: number | null calibration: Record } @@ -69,84 +70,156 @@ const api = async (path: string, opts?: RequestInit) => { return r.json() } -// ── Couleurs / labels ───────────────────────────────────────────────────────── +// ── Visuels ─────────────────────────────────────────────────────────────────── -const NODE_COLORS: Record = { +/** 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', -} -const STATUS_COLOR: Record = { - correct: '#10b981', wrong: '#ef4444', neutral: '#64748b', unknown: '#64748b', -} +}[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 + const CAT_LABELS: Record = { macro_us: 'Macro US', macro_eu: 'Macro EU', geopolitical: 'Géopolitique', commodity: 'Matières premières', report: 'Report', sentiment: 'Sentiment', } +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 ──────────────────────────────────────────────────────── +// ── SVG Graph renderer (partagé) ────────────────────────────────────────────── -function GraphSVG({ +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 nodes = graph.nodes || [] - const edges = graph.edges || [] - const xs = nodes.map(n => n.x) - const ys = nodes.map(n => n.y) - const minX = Math.min(...xs, 0) - 40 - const minY = Math.min(...ys, 0) - 30 - const maxX = Math.max(...xs, 400) + 120 - const maxY = Math.max(...ys, 400) + 60 - const W = maxX - minX; const H = maxY - minY - const nodeMap = Object.fromEntries(nodes.map(n => [n.id, n])) - const NW = compact ? 90 : 115; const NH = compact ? 28 : 38 + const svgRef = useRef(null) + const nodes = graph.nodes || [] + const edges = graph.edges || [] - function nodeColor(n: CausalNode) { - if (activationNodes?.[n.id]) return STATUS_COLOR[activationNodes[n.id].status] ?? NODE_COLORS[n.type] - return NODE_COLORS[n.type] ?? '#64748b' + const xs = nodes.map(n => n.x) + const ys = nodes.map(n => n.y) + const minX = (xs.length ? Math.min(...xs) : 0) - 60 + const minY = (ys.length ? Math.min(...ys) : 0) - 40 + const maxX = (xs.length ? Math.max(...xs) : 400) + 130 + const maxY = (ys.length ? Math.max(...ys) : 400) + 70 + const W = maxX - minX; const H = maxY - minY + + const nodeMap = Object.fromEntries(nodes.map(n => [n.id, n])) + const NW = compact ? 88 : 112; const NH = compact ? 28 : 38 + + 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 mx = (x1 + x2) / 2 - return `M${x1},${y1} C${x1},${mx} ${x2},${mx} ${x2},${y2}` + const my = (y1 + y2) / 2 + return `M${x1},${y1} C${x1},${my} ${x2},${my} ${x2},${y2}` + } + + 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 && ( + + + + )} + {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) return ( - + + + {e.label && (() => { + const s2 = nodeMap[e.from]; const t2 = nodeMap[e.to] + if (!s2 || !t2) return null + const mx = (s2.x + t2.x) / 2; const my = (s2.y + t2.y) / 2 + return ( + {e.label} + ) + })()} + ) })} + + {/* Nodes */} {nodes.map(n => { - const col = nodeColor(n) + const col = getNodeColor(n) const val = nodeValues?.[n.id] - const unit = n.unit || '' + const isSel = selectedNodeId === n.id return ( - - + { e.stopPropagation(); onNodeClick?.(n.id, e) }} + style={{ cursor: editorMode ? 'pointer' : 'default' }}> + {isSel && ( + + )} + {n.label.length > 18 ? n.label.slice(0, 17) + '…' : n.label} @@ -154,7 +227,12 @@ function GraphSVG({ {val != null && ( - {val > 0 ? '+' : ''}{val.toFixed(1)}{unit ? ` ${unit}` : ''} + {val > 0 ? '+' : ''}{val.toFixed(1)}{n.unit ? ` ${n.unit}` : ''} + + )} + {editorMode && ( + + {n.type?.replace('_', ' ')} )} @@ -164,7 +242,42 @@ function GraphSVG({ ) } -// ── Tab Bibliothèque ────────────────────────────────────────────────────────── +// ── 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} +
+ ))} +
+
+ ) +} + +// ── Tab: Bibliothèque ───────────────────────────────────────────────────────── function TabLibrary() { const [templates, setTemplates] = useState([]) @@ -181,10 +294,7 @@ function TabLibrary() { api(`/api/causal-lab/templates${catFilter ? `?category=${catFilter}` : ''}`) .then(data => { setTemplates(data) - if (data.length === 0) { - // Auto-run debug to show diagnostic - api('/api/causal-lab/debug').then(setDebugInfo).catch(() => {}) - } + if (data.length === 0) api('/api/causal-lab/debug').then(setDebugInfo).catch(() => {}) }) .catch(e => setApiError(String(e))) .finally(() => setLoading(false)) @@ -202,8 +312,7 @@ function TabLibrary() { setSaving(true) try { await api(`/api/causal-lab/template/${selected.id}`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ coefficients: editCoefs }), }) const fresh = await api(`/api/causal-lab/template/${selected.id}`) @@ -215,7 +324,6 @@ function TabLibrary() { return (
- {/* Liste templates */}
loadTemplate(Number(e.target.value))} defaultValue="" + className="flex-1 bg-dark-800 border border-slate-600 rounded px-2 py-1.5 text-xs text-slate-300"> + + {templates.map(t => )} + + +
+
+ + {/* Métadonnées */} +
+
Métadonnées
+ {[ + { label: 'Nom', key: 'name' as const }, + { label: 'Description', key: 'description' as const }, + ].map(({ label, key }) => ( +
+ + setState(s => ({ ...s, [key]: e.target.value }))} + className="w-full bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 mt-0.5" /> +
+ ))} +
+
+ + +
+
+ + setState(s => ({ ...s, subType: e.target.value }))} + className="w-full bg-dark-800 border border-slate-600 rounded px-2 py-1 text-xs text-slate-200 mt-0.5" /> +
+
+
+ +
+ {INSTRUMENTS_LIST.map(i => ( + + ))} +
+
+
+ + {/* Nœuds */} +
+
+ + Nœuds ({state.nodes.length}) + + +
+ + {addNodeOpen && ( +
+
+
+ + setNewNode(p => ({ ...p, id: e.target.value }))} + className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" /> +
+
+ + +
+
+
+ + setNewNode(p => ({ ...p, label: e.target.value }))} + className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" /> +
+
+ + setNewNode(p => ({ ...p, formula: e.target.value }))} + className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5 font-mono" /> +
+
+
+ + setNewNode(p => ({ ...p, unit: e.target.value }))} + className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" /> +
+
+ + setNewNode(p => ({ ...p, x: parseInt(e.target.value) }))} + className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" /> +
+
+ + setNewNode(p => ({ ...p, y: parseInt(e.target.value) }))} + className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" /> +
+
+ +
+ )} + +
+ {state.nodes.map(n => ( +
setSelNode(selNode === n.id ? null : n.id)}> +
+ {n.id} + {n.label} + +
+ ))} + {state.nodes.length === 0 && ( +
Aucun nœud — cliquez "+ Ajouter"
+ )} +
+ + {selNode && ( +
+ + Nœud {selNode} sélectionné — cliquez sur le graphe (droite) pour le repositionner +
+ )} +
+ + {/* Arêtes */} +
+
+ + Arêtes ({state.edges.length}) + + +
+ + {addEdgeOpen && ( +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + setNewEdge(p => ({ ...p, label: e.target.value }))} + className="w-full bg-dark-700 border border-slate-600 rounded px-1.5 py-1 text-slate-200 text-xs mt-0.5" /> +
+ +
+ )} + +
+ {state.edges.map((e, i) => ( +
+
+ + {e.from} → {e.to} + + {e.label && {e.label}} + +
+ ))} + {state.edges.length === 0 && ( +
Aucune arête
+ )} +
+
+ + {/* Coefficients */} +
+
+ + Coefficients ({Object.keys(state.coefficients).length}) + + +
+
+ {Object.entries(state.coefficients).map(([k, c]) => ( +
+ {k} + setState(s => ({ + ...s, coefficients: { ...s.coefficients, [k]: { ...c, value: parseFloat(e.target.value) || 0 } } + }))} + className="w-20 bg-dark-800 border border-slate-600 rounded px-1.5 py-1 text-xs text-slate-200 text-right" /> + +
+ ))} +
+
+ + {/* Sauvegarde */} +
+ + {msg && ( +
+ {msg.text} +
+ )} +
+
+ + {/* Panneau droit — SVG live */} +
+
+ + {selNode + ? Cliquez sur le graphe pour repositionner {selNode} + : 'Cliquez sur un nœud dans le graphe pour le sélectionner et le repositionner' + } + +
+
+ setSelNode(selNode === id ? null : id)} + onBgClick={handleBgClick} + /> +
+ +
+
+ ) +} + +// ── Tab: Analyser ───────────────────────────────────────────────────────────── function TabAnalyze() { const [events, setEvents] = useState([]) @@ -360,9 +943,8 @@ function TabAnalyze() { }) setRec(r.recommendation) if (r.recommendation?.template_id) setSelTmpl(r.recommendation.template_id) - } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)) - } finally { setLoadRec(false) } + } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)) } + finally { setLoadRec(false) } } async function analyze() { @@ -371,26 +953,20 @@ function TabAnalyze() { try { const r = await api('/api/causal-lab/analyze', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - market_event_id: selEvent.id, - template_id: selTmpl, instrument, - inputs: manualInputs, coef_overrides: {}, - }), + body: JSON.stringify({ market_event_id: selEvent.id, template_id: selTmpl, instrument, inputs: manualInputs, coef_overrides: {} }), }) setResult(r) - } catch (e: unknown) { - setError(e instanceof Error ? e.message : String(e)) - } finally { setLoadAn(false) } + } catch (e: unknown) { setError(e instanceof Error ? e.message : String(e)) } + finally { setLoadAn(false) } } return (
- {/* Colonne gauche */}
- setQ(e.target.value)} placeholder="Rechercher un événement…" + setQ(e.target.value)} placeholder="Rechercher…" className="w-full bg-dark-700 border border-slate-700 rounded pl-7 pr-2 py-1.5 text-xs text-slate-300" />
-
+
{events.length === 0 ?
Aucun événement
: events.map(ev => ( @@ -466,15 +1042,10 @@ function TabAnalyze() { {loadingAn ? 'Analyse…' : 'Lancer l\'analyse'} - - {error && ( -
{error}
- )} + {error &&
{error}
}
- {/* Colonne droite: résultats */}
- {/* Recommandation IA */} {rec && (
@@ -506,26 +1077,19 @@ function TabAnalyze() {
)} - {/* Résultat */} {result && ( <>

{result.event.name}

-
- {result.event.start_date?.slice(0, 10)} - = 0.7 ? 'text-emerald-400 bg-emerald-900/30' : - result.activation.score >= 0.4 ? 'text-yellow-400 bg-yellow-900/30' : - 'text-red-400 bg-red-900/30')}> - {result.activation.score != null - ? `${(result.activation.score * 100).toFixed(0)}% activation` - : 'N/A'} - -
+ = 0.7 ? 'text-emerald-400 bg-emerald-900/30' : + result.activation.score >= 0.4 ? 'text-yellow-400 bg-yellow-900/30' : + 'text-red-400 bg-red-900/30')}> + {result.activation.score != null ? `${(result.activation.score * 100).toFixed(0)}% activation` : 'N/A'} +
-
{[ { label: 'Pré-drift', val: fmtPips(result.drift?.pre_pips) }, @@ -539,7 +1103,6 @@ function TabAnalyze() {
))}
-
{Object.entries(result.activation.nodes).map(([id, node]) => { const Icon = node.status === 'correct' ? CheckCircle : node.status === 'wrong' ? XCircle : Minus @@ -553,8 +1116,7 @@ function TabAnalyze() { {node.label} {node.pred != null && ( - P:{node.pred > 0 ? '+' : ''}{node.pred.toFixed(1)} / - A:{node.act != null ? (node.act > 0 ? '+' : '') + node.act.toFixed(1) : '—'} + P:{node.pred > 0 ? '+' : ''}{node.pred.toFixed(1)} / A:{node.act != null ? (node.act > 0 ? '+' : '') + node.act.toFixed(1) : '—'} )}
@@ -562,58 +1124,27 @@ function TabAnalyze() { })}
-
-

- Graphe causal — valeurs calculées -

+

Graphe — valeurs calculées

{currentTemplate && ( )}
- - {result.yields && ( -
-

- Taux réels -

-
- {[ - { label: 'US 10Y', v: result.yields.us10y }, - { label: 'EU 10Y', v: result.yields.eu10y }, - { label: 'US 2Y', v: result.yields.us2y }, - ].map(y => ( -
-
{y.label}
-
0 ? 'text-red-400' : 'text-emerald-400')}> - {y.v == null ? '—' : `${y.v > 0 ? '+' : ''}${y.v.toFixed(3)}%`} -
-
- ))} -
-
- )} )} {(loadingAn || loadingRec) && (
- - {loadingAn ? 'Récupération des prix (yfinance)…' : 'GPT-4o en cours…'} - + {loadingAn ? 'Récupération des prix (yfinance)…' : 'GPT-4o en cours…'}
)} - {!result && !loadingAn && !loadingRec && !rec && (
-
- Sélectionnez un événement, choisissez un template et lancez l'analyse -
+
Sélectionnez un événement, choisissez un template et lancez l'analyse
)}
@@ -621,10 +1152,10 @@ function TabAnalyze() { ) } -// ── Tab Calibration ─────────────────────────────────────────────────────────── +// ── Tab: Calibration ────────────────────────────────────────────────────────── function TabCalibration() { - const [data, setData] = useState([]) + const [data, setData] = useState([]) const [loading, setLoading] = useState(true) useEffect(() => { @@ -637,23 +1168,21 @@ function TabCalibration() {
- Statistiques agrégées de toutes les analyses par template. - Le ratio pred/réel converge vers 1.0 quand les coefficients sont bien calibrés. + Statistiques agrégées par template. Le ratio pred/réel converge vers 1.0 quand les coefficients sont bien calibrés.
- {['Template', 'Catégorie', 'Nb analyses', 'Activation moy.', 'Pred. moy. (pip)', 'Réel moy. (pip)', 'Ratio'].map(h => ( + {['Template', 'Catégorie', 'Nb analyses', 'Activation moy.', 'Pred. moy.', 'Réel moy.', 'Ratio'].map(h => ( ))} {data.map(row => { - const calib = row.calibration || {} - const instKey = Object.keys(calib).find(k => - !['n_events', 'avg_activation', 'last_analyzed'].includes(k)) + const calib = row.calibration || {} + const instKey = Object.keys(calib).find(k => !['n_events', 'avg_activation', 'last_analyzed'].includes(k)) const instData = instKey ? calib[instKey] : null return ( @@ -687,7 +1216,7 @@ function TabCalibration() {
{h}
{data.length === 0 && (
- Aucune analyse effectuée pour l'instant. Lancez des analyses dans l'onglet "Analyser". + Aucune analyse effectuée. Lancez des analyses dans l'onglet "Analyser".
)}
@@ -698,9 +1227,10 @@ function TabCalibration() { // ── Page principale ─────────────────────────────────────────────────────────── const TABS = [ - { id: 'library', label: 'Bibliothèque', icon: Library }, + { id: 'library', label: 'Bibliothèque', icon: Library }, + { id: 'editor', label: 'Editeur', icon: Edit3 }, { id: 'analyze', label: 'Analyser', icon: FlaskConical }, - { id: 'calib', label: 'Calibration', icon: BarChart3 }, + { id: 'calib', label: 'Calibration', icon: BarChart3 }, ] export default function CausalLab() { @@ -713,7 +1243,7 @@ export default function CausalLab() {

Lab Causal

- Bibliothèque de graphes causaux · Analyse empirique · Calibration IA + Bibliothèque · Editeur visuel · Analyse empirique · Calibration IA

@@ -731,6 +1261,7 @@ export default function CausalLab() {
{tab === 'library' && } + {tab === 'editor' && } {tab === 'analyze' && } {tab === 'calib' && }