diff --git a/backend/routers/causal_lab.py b/backend/routers/causal_lab.py index b0f3dcb..b0dbedb 100644 --- a/backend/routers/causal_lab.py +++ b/backend/routers/causal_lab.py @@ -134,12 +134,12 @@ def _fetch_prices(event_date_str: str, instruments: list[str]) -> dict: return out -def _drift_metrics(prices: dict, event_date_str: str, inst: str, lag_min: int = 0) -> dict: +def _drift_metrics(prices: dict, event_date_str: str, inst: str, lag_min: int = 0, lag_days: 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", "lag_min": lag_min} + empty = {"pre_pips": None, "post_pips": None, "drift_ratio": None, "leak": "unknown", "lag_min": lag_min, "lag_days": lag_days} if not series: return empty @@ -154,13 +154,18 @@ def _drift_metrics(prices: dict, event_date_str: str, inst: str, lag_min: int = 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] - if not pre or not same: + # En mode journalier, lag_days décale la date de référence "post" + if lag_days > 0: + post_date = (datetime.strptime(edate, "%Y-%m-%d") + timedelta(days=lag_days)).strftime("%Y-%m-%d") + pre = [b for b in series if b["t"] <= edate] + post = [b for b in series if b["t"] >= post_date] + else: + pre = [b for b in series if b["t"] < edate] + post = [b for b in series if b["t"] == edate] + if not pre or not post: return empty - mult = 10000 if inst in ("EURUSD",) else 10 pre_pips = None - post_pips = round((same[0]["c"] - pre[-1]["c"]) * mult) + post_pips = round((post[0]["c"] - pre[-1]["c"]) * mult) ratio: Optional[float] = None if pre_pips is not None and post_pips and post_pips != 0: @@ -866,7 +871,7 @@ 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 + # Lag effectif : max des lag_min/lag_days 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")} @@ -874,6 +879,10 @@ def analyze_event(body: AnalyzeRequest): (e.get("lag_min", 0) or 0 for e in edges if e.get("to") in output_ids), default=0, ) + effective_lag_days = max( + (e.get("lag_days", 0) or 0 for e in edges if e.get("to") in output_ids), + default=0, + ) # Instruments : utiliser tous ceux du template si aucun spécifié if body.instrument: @@ -888,7 +897,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, lag_min=effective_lag) + drift = _drift_metrics(prices, event["start_date"], inst, lag_min=effective_lag, lag_days=effective_lag_days) drift_by_inst[inst] = drift if drift.get("post_pips") is not None: actual_moves[inst] = drift["post_pips"] @@ -919,6 +928,7 @@ def analyze_event(body: AnalyzeRequest): "drift": drift_by_inst.get(primary_inst, {}), "prices_mode": prices.get("mode", "none"), "effective_lag_min": effective_lag, + "effective_lag_days": effective_lag_days, "analyzed_at": analyzed_at, "graph_json": graph, # structure complète pour visualisation frontend } diff --git a/frontend/src/pages/CausalLab.tsx b/frontend/src/pages/CausalLab.tsx index 06c9c5e..ef99c93 100644 --- a/frontend/src/pages/CausalLab.tsx +++ b/frontend/src/pages/CausalLab.tsx @@ -22,7 +22,8 @@ export interface CausalEdge { strength?: 1 | 2 | 3 sign?: 'positive' | 'negative' | 'neutral' label?: string - lag_min?: number // délai avant onset (minutes, 0 = immédiat) + 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 } @@ -110,11 +111,10 @@ 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" */ +/** Format lag minutes → "+5m" / "+2h" */ 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` + return `+${(min / 60).toFixed(0)}h` } /** Format decay days → "↩3j" */ const fmtDecay = (d: number) => `↩${d}j` @@ -248,7 +248,9 @@ export function GraphSVG({ const lpos = e.label ? edgeLabelPos(e) : null const labelText = e.label ?? '' const labelW = labelText.length * 5 - const lagTxt = (!compact && e.lag_min) ? fmtLag(e.lag_min) : null + 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.1) : null const decayPos = decayTxt ? bezierPt(e, 0.88) : null @@ -466,7 +468,7 @@ function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null } } finally { setSavingTheory(false) } } - function updateEdgeLag(i: number, field: 'lag_min' | 'diffusion_min' | 'decay_days', val: string) { + function updateEdgeLag(i: number, field: 'lag_min' | 'lag_days' | 'diffusion_min' | 'decay_days', val: string) { setEdgesLag(prev => prev.map((e, idx) => idx !== i ? e : { ...e, [field]: val === '' ? (field === 'decay_days' ? null : undefined) @@ -532,7 +534,7 @@ function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null } {selected ? ( -
+
@@ -604,28 +606,38 @@ function TabLibrary({ initialTemplateId }: { initialTemplateId?: number | null } Paramètres temporels
-
- ArêteLag (m) - Diff. (m)Decay (j) +
+ Arête + Lag 5m + Lag j + Diff. m + Decay j
{editEdgesLag.map((e, i) => ( -
+
{e.from} → {e.to} updateEdgeLag(i, 'lag_min', ev.target.value)} placeholder="0" - className="w-full bg-dark-800 border border-slate-600 rounded px-1.5 py-1 text-xs text-slate-200 text-center" /> + title="Délai en minutes (intraday 5m)" + className="w-full bg-dark-800 border border-slate-600 rounded px-1 py-1 text-xs text-slate-200 text-center" /> + updateEdgeLag(i, 'lag_days', ev.target.value)} + placeholder="0" + title="Délai en jours (mode journalier)" + className="w-full bg-dark-800 border border-amber-600/40 rounded px-1 py-1 text-xs text-amber-200 text-center" /> updateEdgeLag(i, 'diffusion_min', ev.target.value)} placeholder="60" - className="w-full bg-dark-800 border border-slate-600 rounded px-1.5 py-1 text-xs text-slate-200 text-center" /> + className="w-full bg-dark-800 border border-slate-600 rounded px-1 py-1 text-xs text-slate-200 text-center" /> updateEdgeLag(i, 'decay_days', ev.target.value)} placeholder="∞" - className="w-full bg-dark-800 border border-slate-600 rounded px-1.5 py-1 text-xs text-slate-200 text-center" /> + className="w-full bg-dark-800 border border-slate-600 rounded px-1 py-1 text-xs text-slate-200 text-center" />
))}
@@ -1216,23 +1228,30 @@ function TabEditor() { value={newEdge.label ?? ''} onChange={e => 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" />
-
+
- + setNewEdge(p => ({ ...p, lag_min: e.target.value === '' ? undefined : 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" />
- + + setNewEdge(p => ({ ...p, lag_days: e.target.value === '' ? undefined : parseInt(e.target.value) }))} + className="w-full bg-dark-700 border border-amber-600/40 rounded px-1.5 py-1 text-amber-200 text-xs mt-0.5" /> +
+
+ setNewEdge(p => ({ ...p, diffusion_min: e.target.value === '' ? undefined : 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" />
- + setNewEdge(p => ({ ...p, decay_days: e.target.value === '' ? null : parseFloat(e.target.value) }))} @@ -1304,16 +1323,23 @@ function TabEditor() { onChange={ev => setEditEdgeVal(p => ({ ...p, label: ev.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" />
-
+
- + setEditEdgeVal(p => ({ ...p, lag_min: ev.target.value === '' ? undefined : parseInt(ev.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" />
- + + setEditEdgeVal(p => ({ ...p, lag_days: ev.target.value === '' ? undefined : parseInt(ev.target.value) }))} + className="w-full bg-dark-700 border border-amber-600/40 rounded px-1.5 py-1 text-amber-200 text-xs mt-0.5" /> +
+
+ setEditEdgeVal(p => ({ ...p, diffusion_min: ev.target.value === '' ? undefined : parseInt(ev.target.value) }))}