diff --git a/backend/routers/causal_lab.py b/backend/routers/causal_lab.py index 548da91..7ae4818 100644 --- a/backend/routers/causal_lab.py +++ b/backend/routers/causal_lab.py @@ -134,24 +134,25 @@ def _fetch_prices(event_date_str: str, instruments: list[str]) -> dict: return out -def _drift_metrics(prices: dict, event_date_str: str, inst: str) -> dict: +def _drift_metrics(prices: dict, event_date_str: str, inst: str, lag_min: int = 0) -> dict: series = prices.get(inst, []) mode = prices.get("mode", "none") edate = event_date_str[:10] - empty = {"pre_pips": None, "post_pips": None, "drift_ratio": None, "leak": "unknown"} + empty = {"pre_pips": None, "post_pips": None, "drift_ratio": None, "leak": "unknown", "lag_min": lag_min} if not series: return empty + mult = 10000 if inst in ("EURUSD",) else 10 if mode == "intraday_5m": n = len(series) if n < 8: return empty - mid = n // 2 - # Convertir en pips (×10000 pour FX, ×10 pour or/pétrole/SP500) - mult = 10000 if inst in ("EURUSD",) else 10 - pre_pips = round((series[mid - 1]["c"] - series[0]["c"]) * mult) - post_pips = round((series[-1]["c"] - series[mid]["c"]) * mult) + # Décale le mid en avant selon le lag (chaque barre = 5 min) + lag_bars = max(0, round(lag_min / 5)) + mid = min(n // 2 + lag_bars, n - 2) + pre_pips = round((series[mid - 1]["c"] - series[0]["c"]) * mult) + post_pips = round((series[-1]["c"] - series[mid]["c"]) * mult) else: pre = [b for b in series if b["t"] < edate] same = [b for b in series if b["t"] == edate] @@ -760,6 +761,15 @@ def analyze_event(body: AnalyzeRequest): # Évaluation du graphe node_values = evaluate_graph(graph, inputs, body.coef_overrides or {}) + # Lag effectif : max des lag_min sur toutes les arêtes menant à un market_asset + edges = graph.get("edges", []) + nodes_map = {n["id"]: n for n in graph.get("nodes", [])} + output_ids = {n["id"] for n in graph.get("nodes", []) if n.get("type") in ("market_asset", "output")} + effective_lag = max( + (e.get("lag_min", 0) or 0 for e in edges if e.get("to") in output_ids), + default=0, + ) + # Prix réels instruments = list({body.instrument} | set(tmpl.get("instruments", [body.instrument]))) prices = _fetch_prices(event["start_date"], instruments) @@ -768,7 +778,7 @@ def analyze_event(body: AnalyzeRequest): actual_moves: dict = {} drift_by_inst: dict = {} for inst in instruments: - drift = _drift_metrics(prices, event["start_date"], inst) + drift = _drift_metrics(prices, event["start_date"], inst, lag_min=effective_lag) drift_by_inst[inst] = drift if drift.get("post_pips") is not None: actual_moves[inst] = drift["post_pips"] @@ -786,18 +796,19 @@ def analyze_event(body: AnalyzeRequest): analyzed_at = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") result = { - "event": event, - "template_id": body.template_id, - "template_name": tmpl["name"], - "instrument": body.instrument, - "inputs": inputs, - "node_values": node_values, - "actual_moves": actual_moves, - "yields": yields, - "activation": activation, - "drift": drift_by_inst.get(body.instrument, {}), - "prices_mode": prices.get("mode", "none"), - "analyzed_at": analyzed_at, + "event": event, + "template_id": body.template_id, + "template_name": tmpl["name"], + "instrument": body.instrument, + "inputs": inputs, + "node_values": node_values, + "actual_moves": actual_moves, + "yields": yields, + "activation": activation, + "drift": drift_by_inst.get(body.instrument, {}), + "prices_mode": prices.get("mode", "none"), + "effective_lag_min": effective_lag, + "analyzed_at": analyzed_at, } # Persistance diff --git a/frontend/src/pages/CausalLab.tsx b/frontend/src/pages/CausalLab.tsx index d993341..53db8d5 100644 --- a/frontend/src/pages/CausalLab.tsx +++ b/frontend/src/pages/CausalLab.tsx @@ -18,9 +18,12 @@ export interface CausalEdge { from: string; to: string style: 'solid' | 'dashed' type?: string - strength?: 1 | 2 | 3 // 1=fin, 2=normal, 3=épais + strength?: 1 | 2 | 3 sign?: 'positive' | 'negative' | 'neutral' label?: string + lag_min?: number // délai avant onset (minutes, 0 = immédiat) + 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 { @@ -61,7 +64,7 @@ interface AnalysisResult { 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 + prices_mode: string; analyzed_at: string; effective_lag_min?: number } interface Recommendation { template_id: number | null; template_name: string; confidence: number @@ -106,6 +109,15 @@ const edgeColor = (sign?: string): string => ({ /** Épaisseur selon strength */ const edgeWidth = (s?: number) => s === 1 ? 1 : s === 3 ? 3.5 : 2 +/** Format lag minutes → "+5m" / "+2h" / "+3j" */ +function fmtLag(min: number): string { + if (min < 60) return `+${min}m` + if (min < 1440) return `+${(min / 60).toFixed(0)}h` + return `+${(min / 1440).toFixed(0)}j` +} +/** Format decay days → "↩3j" */ +const fmtDecay = (d: number) => `↩${d}j` + const CAT_LABELS: Record = { macro_us: 'Macro US', macro_eu: 'Macro EU', geopolitical: 'Géopolitique', commodity: 'Matières premières', report: 'Report', sentiment: 'Sentiment', @@ -166,6 +178,18 @@ export function GraphSVG({ 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)*10, y: by + (dx/len)*10 } + } + // Label position: 1/3 of the way from source (avoids node centers) function edgeLabelPos(e: CausalEdge): { x: number; y: number } | null { const s = nodeMap[e.from]; const t = nodeMap[e.to] @@ -220,9 +244,13 @@ export function GraphSVG({ const sign = e.sign ?? 'neutral' const color = edgeColor(sign) const sw = edgeWidth(e.strength) - const lpos = e.label ? edgeLabelPos(e) : null + const lpos = e.label ? edgeLabelPos(e) : null const labelText = e.label ?? '' - const labelW = labelText.length * 5 + const labelW = labelText.length * 5 + const lagTxt = (!compact && e.lag_min) ? fmtLag(e.lag_min) : null + const decayTxt = (!compact && e.decay_days != null) ? fmtDecay(e.decay_days) : null + const lagPos = lagTxt ? bezierPt(e, 0.1) : null + const decayPos = decayTxt ? bezierPt(e, 0.88) : null return ( {lpos && ( - {/* Dark bg pill behind label */} {labelText} )} + {lagPos && lagTxt && ( + + + {lagTxt} + + )} + {decayPos && decayTxt && ( + + + {decayTxt} + + )} ) })} @@ -335,9 +376,11 @@ function TabLibrary() { const [templates, setTemplates] = useState([]) const [selected, setSelected] = useState