feat: instrument model
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -74,6 +74,15 @@ interface ModelState {
|
||||
event_details: Record<string, EventDetail[]>
|
||||
}
|
||||
|
||||
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<string, number>
|
||||
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<HTMLCanvasElement>(null)
|
||||
const [hoverIdx, setHoverIdx] = useState<number | null>(null)
|
||||
const [hoverX, setHoverX] = useState<number | null>(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<HTMLCanvasElement>) {
|
||||
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 */}
|
||||
<div className="rounded-lg bg-dark-900/50 border border-slate-700/30 p-2">
|
||||
<canvas ref={canvasRef} className="w-full" style={{height: 300, display: 'block'}}/>
|
||||
{/* Canvas wrapper — relative for crosshair overlay */}
|
||||
<div className="relative"
|
||||
onMouseLeave={() => { setHoverIdx(null); setHoverX(null) }}>
|
||||
<canvas ref={canvasRef} className="w-full cursor-crosshair"
|
||||
style={{height: 300, display: 'block'}}
|
||||
onMouseMove={handleCanvasMouseMove}
|
||||
/>
|
||||
{/* Crosshair vertical line */}
|
||||
{hoverX !== null && (
|
||||
<div className="absolute top-3 pointer-events-none"
|
||||
style={{left: hoverX, bottom: 28, width: 1, background: 'rgba(148,163,184,0.35)'}}/>
|
||||
)}
|
||||
</div>
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-3 mt-2 px-1">
|
||||
<div className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||
@@ -1235,6 +1272,83 @@ function TimelineView({ instrument }: { instrument: string }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 (
|
||||
<div className="mt-2 rounded border border-slate-700/30 bg-dark-800/60 px-2.5 py-2 text-xs space-y-1.5">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-slate-400 font-mono font-semibold">{pt.date}</span>
|
||||
{!showPips && (
|
||||
<span className="text-white font-medium">{pt.synthetic_price.toFixed(4)}</span>
|
||||
)}
|
||||
<span className={clsx('font-medium', pt.net_pips >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{pt.net_pips >= 0 ? '+' : ''}{pt.net_pips}p
|
||||
</span>
|
||||
<span className="text-slate-500">struct:{pt.structural_pips}p</span>
|
||||
<span className={clsx(pt.event_pips >= 0 ? 'text-blue-400' : 'text-orange-400')}>
|
||||
ev:{pt.event_pips >= 0 ? '+' : ''}{pt.event_pips}p
|
||||
{dEvent !== 0 && (
|
||||
<span className="text-slate-500 ml-0.5">
|
||||
(Δ{dEvent > 0 ? '+' : ''}{dEvent})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{pt.regime && pt.regime !== 'BALANCED' && (
|
||||
<span className="text-slate-500 ml-auto">{pt.regime}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active events */}
|
||||
{pt.active_events && pt.active_events.length > 0 && (
|
||||
<div className="space-y-0.5 border-t border-slate-700/30 pt-1.5">
|
||||
{pt.active_events
|
||||
.sort((a, b) => Math.abs(b.contribution) - Math.abs(a.contribution))
|
||||
.map((ev, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full flex-shrink-0',
|
||||
ev.contribution >= 0 ? 'bg-emerald-500' : 'bg-red-500')}/>
|
||||
<span className="flex-1 truncate text-slate-300">{ev.label}</span>
|
||||
<span className="text-slate-600 font-mono">lf:{ev.lifecycle_factor.toFixed(2)}</span>
|
||||
<span className={clsx('font-mono w-14 text-right',
|
||||
ev.contribution >= 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
{ev.contribution >= 0 ? '+' : ''}{ev.contribution}p
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Node deltas */}
|
||||
{nodeDeltas.length > 0 && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 border-t border-slate-700/30 pt-1.5">
|
||||
<span className="text-slate-600 w-full text-xs">Nœuds Δ vs J-1 :</span>
|
||||
{nodeDeltas.map(({ k, v, d }) => (
|
||||
<span key={k} className="text-slate-500">
|
||||
{k.replace('layer_', '').replace(/_/g, ' ')}:
|
||||
<span className="text-slate-300 ml-0.5">{v.toFixed(1)}</span>
|
||||
<span className={clsx('ml-0.5', d > 0 ? 'text-emerald-400' : 'text-red-400')}>
|
||||
({d > 0 ? '+' : ''}{d})
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Virtual Events Panel */}
|
||||
|
||||
Reference in New Issue
Block a user