feat: instrument model
This commit is contained in:
@@ -1112,6 +1112,7 @@ def simulate_timeline(
|
|||||||
while cur <= today:
|
while cur <= today:
|
||||||
# Accumule les events virtuels (What-if) actifs ce jour
|
# Accumule les events virtuels (What-if) actifs ce jour
|
||||||
ev_by_cat: dict[str, float] = {}
|
ev_by_cat: dict[str, float] = {}
|
||||||
|
active_events_detail: list[dict] = []
|
||||||
for ev in events:
|
for ev in events:
|
||||||
if ev["ev_date"] > cur:
|
if ev["ev_date"] > cur:
|
||||||
continue
|
continue
|
||||||
@@ -1123,8 +1124,17 @@ def simulate_timeline(
|
|||||||
df = _lifecycle(days, ev["rise"], ev["plateau"], ev["absorption"], ev["dtype"])
|
df = _lifecycle(days, ev["rise"], ev["plateau"], ev["absorption"], ev["dtype"])
|
||||||
if df < 0.01:
|
if df < 0.01:
|
||||||
continue
|
continue
|
||||||
cat = ev["category"]
|
cat = ev["category"]
|
||||||
ev_by_cat[cat] = ev_by_cat.get(cat, 0.0) + round(ev["pips"] * df, 2)
|
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:
|
if ev_by_cat:
|
||||||
# Des events virtuels sont actifs → recalcul complet avec régime
|
# 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),
|
"synthetic_price": round(price_intercept + start_offset + net * pip_to_price, 6),
|
||||||
"regime": regime_label,
|
"regime": regime_label,
|
||||||
"nodes": {k: round(float(v), 1) for k, v in vals.items()},
|
"nodes": {k: round(float(v), 1) for k, v in vals.items()},
|
||||||
|
"active_events": active_events_detail,
|
||||||
})
|
})
|
||||||
cur += timedelta(days=1)
|
cur += timedelta(days=1)
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,15 @@ interface ModelState {
|
|||||||
event_details: Record<string, EventDetail[]>
|
event_details: Record<string, EventDetail[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ActiveEvent {
|
||||||
|
label: string
|
||||||
|
pips: number
|
||||||
|
lifecycle_factor: number
|
||||||
|
contribution: number
|
||||||
|
date: string
|
||||||
|
category: string
|
||||||
|
}
|
||||||
|
|
||||||
interface TimelinePoint {
|
interface TimelinePoint {
|
||||||
date: string
|
date: string
|
||||||
net_pips: number
|
net_pips: number
|
||||||
@@ -83,6 +92,7 @@ interface TimelinePoint {
|
|||||||
synthetic_price: number
|
synthetic_price: number
|
||||||
regime?: string
|
regime?: string
|
||||||
nodes: Record<string, number>
|
nodes: Record<string, number>
|
||||||
|
active_events?: ActiveEvent[]
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PricePoint {
|
interface PricePoint {
|
||||||
@@ -891,6 +901,8 @@ function TimelineView({ instrument }: { instrument: string }) {
|
|||||||
const [filterSurprise, setFilterSurprise] = useState(false)
|
const [filterSurprise, setFilterSurprise] = useState(false)
|
||||||
const [filterMinPips, setFilterMinPips] = useState(0)
|
const [filterMinPips, setFilterMinPips] = useState(0)
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
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
|
const activeData = whatifData ?? data
|
||||||
|
|
||||||
@@ -1114,6 +1126,19 @@ function TimelineView({ instrument }: { instrument: string }) {
|
|||||||
|
|
||||||
}, [activeData, realPrices, showReal, showFund, showPips, shownLayers, virtuals, whatifData])
|
}, [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() {
|
async function runWhatIf() {
|
||||||
if (activeVirtuals.length === 0) return
|
if (activeVirtuals.length === 0) return
|
||||||
setWhatifLoading(true)
|
setWhatifLoading(true)
|
||||||
@@ -1212,7 +1237,19 @@ function TimelineView({ instrument }: { instrument: string }) {
|
|||||||
|
|
||||||
{/* Canvas */}
|
{/* Canvas */}
|
||||||
<div className="rounded-lg bg-dark-900/50 border border-slate-700/30 p-2">
|
<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 */}
|
{/* Legend */}
|
||||||
<div className="flex flex-wrap gap-3 mt-2 px-1">
|
<div className="flex flex-wrap gap-3 mt-2 px-1">
|
||||||
<div className="flex items-center gap-1.5 text-xs text-slate-500">
|
<div className="flex items-center gap-1.5 text-xs text-slate-500">
|
||||||
@@ -1235,6 +1272,83 @@ function TimelineView({ instrument }: { instrument: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* Virtual Events Panel */}
|
{/* Virtual Events Panel */}
|
||||||
|
|||||||
Reference in New Issue
Block a user