diff --git a/backend/services/instrument_models.py b/backend/services/instrument_models.py index ee3be49..2215cc1 100644 --- a/backend/services/instrument_models.py +++ b/backend/services/instrument_models.py @@ -1112,6 +1112,7 @@ def simulate_timeline( while cur <= today: # Accumule les events virtuels (What-if) actifs ce jour ev_by_cat: dict[str, float] = {} + active_events_detail: list[dict] = [] for ev in events: if ev["ev_date"] > cur: continue @@ -1123,8 +1124,17 @@ def simulate_timeline( df = _lifecycle(days, ev["rise"], ev["plateau"], ev["absorption"], ev["dtype"]) if df < 0.01: continue - cat = ev["category"] - ev_by_cat[cat] = ev_by_cat.get(cat, 0.0) + round(ev["pips"] * df, 2) + cat = ev["category"] + contribution = round(ev["pips"] * df, 2) + ev_by_cat[cat] = ev_by_cat.get(cat, 0.0) + contribution + active_events_detail.append({ + "label": ev["label"], + "pips": ev["pips"], + "lifecycle_factor": round(df, 3), + "contribution": contribution, + "date": str(ev["ev_date"]), + "category": cat, + }) if ev_by_cat: # Des events virtuels sont actifs → recalcul complet avec régime @@ -1149,6 +1159,7 @@ def simulate_timeline( "synthetic_price": round(price_intercept + start_offset + net * pip_to_price, 6), "regime": regime_label, "nodes": {k: round(float(v), 1) for k, v in vals.items()}, + "active_events": active_events_detail, }) cur += timedelta(days=1) diff --git a/frontend/src/pages/InstrumentModels.tsx b/frontend/src/pages/InstrumentModels.tsx index 4ef286a..e291169 100644 --- a/frontend/src/pages/InstrumentModels.tsx +++ b/frontend/src/pages/InstrumentModels.tsx @@ -74,6 +74,15 @@ interface ModelState { event_details: Record } +interface ActiveEvent { + label: string + pips: number + lifecycle_factor: number + contribution: number + date: string + category: string +} + interface TimelinePoint { date: string net_pips: number @@ -83,6 +92,7 @@ interface TimelinePoint { synthetic_price: number regime?: string nodes: Record + active_events?: ActiveEvent[] } interface PricePoint { @@ -891,6 +901,8 @@ function TimelineView({ instrument }: { instrument: string }) { const [filterSurprise, setFilterSurprise] = useState(false) const [filterMinPips, setFilterMinPips] = useState(0) const canvasRef = useRef(null) + const [hoverIdx, setHoverIdx] = useState(null) + const [hoverX, setHoverX] = useState(null) // CSS px, for crosshair const activeData = whatifData ?? data @@ -1114,6 +1126,19 @@ function TimelineView({ instrument }: { instrument: string }) { }, [activeData, realPrices, showReal, showFund, showPips, shownLayers, virtuals, whatifData]) + function handleCanvasMouseMove(e: React.MouseEvent) { + const canvas = canvasRef.current + if (!canvas || activeData.length === 0) return + const rect = canvas.getBoundingClientRect() + const cssX = e.clientX - rect.left + const mL = 64, mR = 16 + const cW = rect.width - mL - mR + const rawIdx = ((cssX - mL) / cW) * (activeData.length - 1) + const idx = Math.max(0, Math.min(activeData.length - 1, Math.round(rawIdx))) + setHoverIdx(idx) + setHoverX(cssX) + } + async function runWhatIf() { if (activeVirtuals.length === 0) return setWhatifLoading(true) @@ -1212,7 +1237,19 @@ function TimelineView({ instrument }: { instrument: string }) { {/* Canvas */}
- + {/* Canvas wrapper — relative for crosshair overlay */} +
{ setHoverIdx(null); setHoverX(null) }}> + + {/* Crosshair vertical line */} + {hoverX !== null && ( +
+ )} +
{/* Legend */}
@@ -1235,6 +1272,83 @@ function TimelineView({ instrument }: { instrument: string }) {
)}
+ + {/* Hover detail panel */} + {hoverIdx !== null && activeData[hoverIdx] && (() => { + const pt = activeData[hoverIdx] + const prev = hoverIdx > 0 ? activeData[hoverIdx - 1] : null + const dEvent = Math.round((pt.event_pips - (prev?.event_pips ?? 0)) * 10) / 10 + + // Nœuds avec delta significatif vs J-1 + const nodeDeltas = Object.entries(pt.nodes) + .map(([k, v]) => ({ k, v, d: Math.round((v - (prev?.nodes[k] ?? v)) * 10) / 10 })) + .filter(({ d }) => Math.abs(d) >= 0.5) + .sort((a, b) => Math.abs(b.d) - Math.abs(a.d)) + .slice(0, 8) + + return ( +
+ {/* Header row */} +
+ {pt.date} + {!showPips && ( + {pt.synthetic_price.toFixed(4)} + )} + = 0 ? 'text-emerald-400' : 'text-red-400')}> + {pt.net_pips >= 0 ? '+' : ''}{pt.net_pips}p + + struct:{pt.structural_pips}p + = 0 ? 'text-blue-400' : 'text-orange-400')}> + ev:{pt.event_pips >= 0 ? '+' : ''}{pt.event_pips}p + {dEvent !== 0 && ( + + (Δ{dEvent > 0 ? '+' : ''}{dEvent}) + + )} + + {pt.regime && pt.regime !== 'BALANCED' && ( + {pt.regime} + )} +
+ + {/* Active events */} + {pt.active_events && pt.active_events.length > 0 && ( +
+ {pt.active_events + .sort((a, b) => Math.abs(b.contribution) - Math.abs(a.contribution)) + .map((ev, i) => ( +
+ = 0 ? 'bg-emerald-500' : 'bg-red-500')}/> + {ev.label} + lf:{ev.lifecycle_factor.toFixed(2)} + = 0 ? 'text-emerald-400' : 'text-red-400')}> + {ev.contribution >= 0 ? '+' : ''}{ev.contribution}p + +
+ ))} +
+ )} + + {/* Node deltas */} + {nodeDeltas.length > 0 && ( +
+ Nœuds Δ vs J-1 : + {nodeDeltas.map(({ k, v, d }) => ( + + {k.replace('layer_', '').replace(/_/g, ' ')}: + {v.toFixed(1)} + 0 ? 'text-emerald-400' : 'text-red-400')}> + ({d > 0 ? '+' : ''}{d}) + + + ))} +
+ )} +
+ ) + })()}
{/* Virtual Events Panel */}