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

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