feat: instrument model

This commit is contained in:
OpenSquared
2026-07-03 11:55:51 +02:00
parent 352fd48e46
commit 90f894e5c6
2 changed files with 128 additions and 3 deletions

View File

@@ -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)

View File

@@ -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 */}