feat: instrument model

This commit is contained in:
OpenSquared
2026-07-03 10:17:02 +02:00
parent f4e57e9d84
commit c3e9b21bda
2 changed files with 403 additions and 214 deletions

View File

@@ -229,6 +229,95 @@ def timeline_whatif(
conn.close()
# ── Magnitude estimation ──────────────────────────────────────────────────────
def _parse_num(s: Optional[str]) -> Optional[float]:
if not s:
return None
t = str(s).strip().upper()
try:
if t.endswith('K'): return float(t[:-1]) * 1_000
if t.endswith('M'): return float(t[:-1]) * 1_000_000
if t.endswith('B'): return float(t[:-1]) * 1_000_000_000
return float(s.replace('%', '').replace(',', ''))
except Exception:
return None
def _event_scale(event_name: str) -> tuple[float, float]:
"""(pips_per_unit, surprise_threshold) for rule-based estimation."""
n = event_name.lower()
if any(k in n for k in ['non-farm employment change', 'nfp']):
return 0.0007, 20_000 # per job
if 'adp non-farm' in n:
return 0.0005, 15_000
if 'unemployment claims' in n:
return -0.0003, 8_000 # more claims = worse = negative
if any(k in n for k in ['cpi', 'pce', 'ppi', 'inflation', 'hicp']):
return 80.0, 0.1 # per %
if any(k in n for k in ['interest rate', 'rate decision', 'fed fund']):
return 300.0, 0.1
if any(k in n for k in ['pmi', 'ism']):
return 6.0, 0.5
if 'gdp' in n:
return 70.0, 0.1
if any(k in n for k in ['retail sales', 'consumer spending']):
return 55.0, 0.2
if 'unemployment rate' in n:
return -100.0, 0.1 # higher rate = worse
if 'employment change' in n:
return 0.0005, 15_000
return 25.0, 0.5 # default
_INST_DIR: Dict[tuple, int] = {
("EURUSD", "USD"): -1, ("EURUSD", "EUR"): +1,
("USDJPY", "USD"): +1, ("USDJPY", "JPY"): -1,
("GBPUSD", "USD"): -1, ("GBPUSD", "GBP"): +1,
("XAUUSD", "USD"): -1,
("SP500", "USD"): +1,
("TLT", "USD"): -1,
("EEM", "USD"): -1,
("QQQ", "USD"): +1,
}
_INST_DIR_INFL: Dict[tuple, int] = {
("SP500", "USD"): -1, ("QQQ", "USD"): -1, ("TLT", "USD"): -1,
}
def _estimate_pip_magnitude(
event_name: str, currency: str, instrument: str,
actual: Optional[str], forecast: Optional[str], previous: Optional[str],
) -> tuple[float, bool, Optional[float]]:
"""Returns (estimated_pips, has_surprise, surprise_delta). No AI."""
a = _parse_num(actual)
f = _parse_num(forecast)
p = _parse_num(previous)
if a is None:
return 0.0, False, None # future event
ref = f if f is not None else p
if ref is None:
return 0.0, False, None
delta = a - ref
scale, threshold = _event_scale(event_name)
has_surprise = abs(delta) >= threshold
raw = max(-100.0, min(100.0, delta * scale))
n = event_name.lower()
is_inflation = any(k in n for k in ['cpi', 'pce', 'ppi', 'inflation', 'hicp'])
inst = instrument.upper()
cur = currency.upper()
direction = _INST_DIR_INFL.get((inst, cur), _INST_DIR.get((inst, cur), 0)) if is_inflation \
else _INST_DIR.get((inst, cur), 0)
if direction == 0:
return round(abs(raw), 1), has_surprise, round(delta, 4)
return round(raw * direction, 1), has_surprise, round(delta, 4)
_INSTRUMENT_CURRENCIES: Dict[str, List[str]] = {
"EURUSD": ["EUR", "USD"],
"USDJPY": ["USD", "JPY"],
@@ -315,22 +404,29 @@ def get_calendar_events(
result = []
for row in rows:
r = dict(row)
ev_date = r["event_date"]
r = dict(row)
ev_date = r["event_date"]
is_future = ev_date > str(ref)
category = _ff_event_to_category(r["event_name"])
est_pips, has_surprise, surp_delta = _estimate_pip_magnitude(
r["event_name"], r["currency"], inst_upper,
r.get("actual_value"), r.get("forecast_value"), r.get("previous_value"),
)
result.append({
"date": ev_date,
"event_time": r.get("event_time", ""),
"currency": r["currency"],
"impact": r["impact"],
"event_name": r["event_name"],
"label": f"[{r['currency']}] {r['event_name']}",
"actual_value": r.get("actual_value"),
"forecast_value": r.get("forecast_value"),
"previous_value": r.get("previous_value"),
"category": category,
"is_future": is_future,
"date": ev_date,
"event_time": r.get("event_time", ""),
"currency": r["currency"],
"impact": r["impact"],
"event_name": r["event_name"],
"label": f"[{r['currency']}] {r['event_name']}",
"actual_value": r.get("actual_value"),
"forecast_value": r.get("forecast_value"),
"previous_value": r.get("previous_value"),
"category": category,
"is_future": is_future,
"estimated_pips": est_pips,
"has_surprise": has_surprise,
"surprise_delta": surp_delta,
})
return result
finally:

View File

@@ -100,6 +100,13 @@ interface VirtualEventForm {
rise_days: number
plateau_days: number
decay_type: string
// calendar metadata (FF import)
currency?: string
impact?: string
has_surprise?: boolean
actual_value?: string | null
forecast_value?: string | null
event_name?: string
}
interface EventDetail {
@@ -131,6 +138,9 @@ interface CalendarEvent {
previous_value: string | null
category: string
is_future: boolean
estimated_pips: number
has_surprise: boolean
surprise_delta: number | null
}
// ── Constants ─────────────────────────────────────────────────────────────────
@@ -403,115 +413,82 @@ function NodeCard({ node, onEdit, eventDetails }: {
const meta = NODE_TYPE_META[node.node_type]
const v = node.pip_contribution
const canEdit = node.node_type === 'input_event' || node.node_type === 'input_manual'
const isManual = node.node_type === 'input_manual'
const hasValue = node.source === 'manual' && node.raw_value !== undefined && node.raw_value !== 0
const coeff = node.coefficient_to_pips ?? 1
const isOutput = node.node_type === 'output'
return (
<div
className={clsx(
'relative rounded-lg border px-3 py-2 mb-1.5 transition-all',
'relative rounded border px-2 py-1.5 mb-1 transition-all select-none',
meta.bg,
canEdit && 'cursor-pointer hover:border-blue-500/60 hover:shadow-md hover:shadow-blue-900/20',
node.source === 'manual' && 'ring-1 ring-violet-500/30',
canEdit && 'cursor-pointer hover:border-blue-500/60',
node.source === 'manual' && 'ring-1 ring-violet-500/25',
isOutput && 'py-2',
)}
onClick={() => canEdit && onEdit(node)}
title={node.description}
>
{node.source === 'manual' && <span className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full bg-violet-400"/>}
{node.source === 'events' && <span className="absolute top-1.5 right-1.5 w-1.5 h-1.5 rounded-full bg-sky-400"/>}
{node.source === 'manual' && <span className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-violet-400"/>}
{node.source === 'events' && <span className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full bg-sky-400"/>}
<div className="text-xs text-slate-300 font-medium leading-tight line-clamp-2 pr-3">{node.label}</div>
{/* Label — truncated, single line */}
<div className="text-xs text-slate-300 font-medium truncate pr-3 leading-tight">{node.label}</div>
{isManual ? (
<div className="mt-1 flex items-center gap-1.5 flex-wrap">
{hasValue ? (
<span className={clsx('text-xs font-bold font-mono', pipColor(v))}>
{node.raw_value! > 0 ? '+' : ''}{node.raw_value} {node.unit}
</span>
{/* Value row — compact, single line */}
<div className="flex items-center gap-1.5 mt-0.5 flex-wrap">
{node.node_type === 'input_manual' ? (() => {
const hasValue = node.source === 'manual' && node.raw_value !== undefined && node.raw_value !== 0
return hasValue ? (
<>
<span className={clsx('text-xs font-mono font-bold', pipColor(v))}>
{node.raw_value! > 0 ? '+' : ''}{node.raw_value} {node.unit}
</span>
<span className={clsx('text-xs font-mono', pipColor(v))}>={fmt(v)}p</span>
</>
) : (
<span className="text-xs text-slate-600">— {node.unit}</span>
)}
<span className="text-xs text-slate-600 bg-slate-800/60 px-1.5 py-0.5 rounded font-mono">
w:{coeff}
</span>
{hasValue && (
<span className={clsx('text-xs font-mono', pipColor(v))}>
={fmt(v)}p
</span>
)}
</div>
) : node.node_type === 'input_event' ? (() => {
const cat = node.event_category || ''
const evList = eventDetails?.[cat] || []
const maxLf = evList.length > 0 ? Math.max(...evList.map(e => e.lifecycle_factor)) : 0
const barW = Math.round(maxLf * 100)
const baseline = node.baseline_value ?? 0
const surprise = node.event_surprise ?? 0
const hasBaseline = baseline !== 0
return (
<div className="mt-1">
{hasBaseline ? (
<div className="space-y-0.5">
<div className="flex flex-wrap gap-x-2 gap-y-0">
<span className="text-xs text-violet-300/80 font-mono">base:{fmt(baseline)}p</span>
{surprise !== 0 && (
<span className={clsx('text-xs font-mono', pipColor(surprise))}>surp:{fmt(surprise)}p</span>
)}
</div>
<span className={clsx('text-xs font-bold font-mono', pipColor(v))}>={fmt(v)}p</span>
</div>
) : (
<div className="flex items-center gap-2">
<span className={clsx('text-xs font-bold font-mono', pipColor(v))}>{fmt(v)} pips</span>
{evList.length > 0 && <span className="text-xs text-slate-600">{evList.length} ev</span>}
</div>
)}
{evList.length > 0 && (
<div className="mt-0.5 flex items-center gap-1.5">
<div className="flex-1 h-0.5 rounded-full bg-slate-700/60 overflow-hidden">
<div className={clsx('h-full rounded-full', pipBg(surprise !== 0 ? surprise : v))} style={{width:`${barW}%`}}/>
</div>
<span className="text-slate-600 text-xs font-mono">{barW}%</span>
</div>
)}
</div>
)
})() : (
<div className={clsx('text-xs font-bold mt-1', node.node_type === 'output' ? 'text-base' : '', pipColor(v))}>
{fmt(v)} pips
</div>
)}
{Math.abs(v) > 0.05 && !isManual && (
<div className="mt-1 h-1 rounded-full bg-slate-700/40 overflow-hidden">
<div className={clsx('h-full rounded-full', pipBg(v))} style={{width: `${Math.min(100, Math.abs(v / 50) * 100)}%`}}/>
</div>
)}
{isManual && Math.abs(v) > 0.05 && (
<div className="mt-1 h-0.5 rounded-full bg-slate-700/40 overflow-hidden">
<div className={clsx('h-full rounded-full', pipBg(v))} style={{width: `${Math.min(100, Math.abs(v / 50) * 100)}%`}}/>
</div>
)}
{node.node_type === 'intermediate' && (
<div className="mt-1 flex items-center gap-2">
{node.formula && <span className="text-slate-600 text-xs">{node.formula.split('+').length} sources</span>}
{node.regime_weight !== undefined && node.regime_weight !== 1.0 && (
<span className={clsx('text-xs font-mono font-bold',
node.regime_weight > 1.0 ? 'text-amber-400' : 'text-slate-500')}>
×{node.regime_weight.toFixed(2)}
</span>
)}
</div>
)}
<span className="text-xs text-slate-600 font-mono">— {node.unit}</span>
)
})() : node.node_type === 'input_event' ? (() => {
const cat = node.event_category || ''
const evList = eventDetails?.[cat] || []
const maxLf = evList.length > 0 ? Math.max(...evList.map(e => e.lifecycle_factor)) : 0
const baseline = node.baseline_value ?? 0
return (
<>
<span className={clsx('text-xs font-mono font-bold', pipColor(v))}>{fmt(v)}p</span>
{baseline !== 0 && (
<span className="text-xs text-violet-300/70 font-mono">b:{fmt(baseline)}</span>
)}
{evList.length > 0 && (
<>
<div className="w-10 h-0.5 rounded bg-slate-700/60 overflow-hidden">
<div className={clsx('h-full rounded', pipBg(v))} style={{width:`${Math.round(maxLf*100)}%`}}/>
</div>
<span className="text-slate-600 text-xs">{evList.length}ev</span>
</>
)}
</>
)
})() : node.node_type === 'intermediate' ? (
<>
<span className={clsx('text-xs font-mono font-bold', pipColor(v))}>{fmt(v)}p</span>
<div className="flex-1 h-0.5 min-w-[20px] rounded bg-slate-700/40 overflow-hidden">
<div className={clsx('h-full rounded', pipBg(v))} style={{width:`${Math.min(100, Math.abs(v/60)*100)}%`}}/>
</div>
</>
) : (
/* output */
<span className={clsx('text-sm font-mono font-bold', pipColor(v))}>{fmt(v)} pips</span>
)}
</div>
</div>
)
}
interface EdgeDraw {
x1: number; y1: number; x2: number; y2: number
coeff: number; fromId: string; toId: string; key: string
formulaCoeff: number
displayWeight: number | null // weight shown on edge line
fromId: string; toId: string; key: string
fromType: NodeType
}
@@ -528,20 +505,19 @@ function DagView({ nodes, instrument, onEdit, eventDetails }: {
const cols: ModelNode[][] = [[], [], [], []]
for (const n of nodes) cols[Math.min(n.display_col ?? 0, 3)].push(n)
const nodeTypeMap = useMemo(() => {
const m = new Map<string, NodeType>()
for (const n of nodes) m.set(n.id, n.node_type)
const nodeMap = useMemo(() => {
const m = new Map<string, ModelNode>()
for (const n of nodes) m.set(n.id, n)
return m
}, [nodes])
// Parse directed connections from intermediate/output formula strings
const connections = useMemo(() => {
const result: { from: string; to: string; coeff: number }[] = []
for (const node of nodes) {
if (!node.formula) continue
for (const term of node.formula.split('+')) {
const t = term.trim()
const m1 = t.match(/^([\d.]+)\s*\*\s*(\w+)$/)
const m1 = t.match(/^([\d.+-]+)\s*\*\s*(\w+)$/)
if (m1) {
result.push({ from: m1[2], to: node.id, coeff: parseFloat(m1[1]) })
} else if (/^\w+$/.test(t)) {
@@ -559,27 +535,45 @@ function DagView({ nodes, instrument, onEdit, eventDetails }: {
const sL = wrapper.scrollLeft
const sT = wrapper.scrollTop
const edges: EdgeDraw[] = []
for (const conn of connections) {
const fromEl = nodeRefs.current.get(conn.from)
const toEl = nodeRefs.current.get(conn.to)
if (!fromEl || !toEl) continue
const fr = fromEl.getBoundingClientRect()
const tr = toEl.getBoundingClientRect()
const fr = fromEl.getBoundingClientRect()
const tr = toEl.getBoundingClientRect()
const fromNode = nodeMap.get(conn.from)
const fromType = fromNode?.node_type ?? 'input_event'
// Weight to display on edge:
// • input_manual → intermediate: coefficient_to_pips of the source node
// • intermediate → output: formula coefficient (regime-adjusted)
// • input_event → *: nothing (already in pips)
let displayWeight: number | null = null
if (fromType === 'input_manual') {
const c = fromNode?.coefficient_to_pips ?? 1
if (c !== 0) displayWeight = c
} else if (fromType === 'intermediate' && conn.coeff !== 1.0) {
displayWeight = conn.coeff
}
edges.push({
x1: fr.right - rect0.left + sL,
y1: fr.top + fr.height / 2 - rect0.top + sT,
x2: tr.left - rect0.left + sL,
y2: tr.top + tr.height / 2 - rect0.top + sT,
coeff: conn.coeff,
fromId: conn.from,
toId: conn.to,
key: `${conn.from}->${conn.to}`,
fromType: nodeTypeMap.get(conn.from) ?? 'input_event',
formulaCoeff: conn.coeff,
displayWeight,
fromId: conn.from,
toId: conn.to,
key: `${conn.from}->${conn.to}`,
fromType: fromType as NodeType,
})
}
setSvgEdges(edges)
setSvgH(wrapper.scrollHeight)
}, [connections, nodeTypeMap])
}, [connections, nodeMap])
useEffect(() => {
const t = setTimeout(recompute, 100)
@@ -588,45 +582,54 @@ function DagView({ nodes, instrument, onEdit, eventDetails }: {
return () => { clearTimeout(t); ro.disconnect() }
}, [recompute])
function edgeStroke(e: EdgeDraw): string {
if (e.fromType === 'input_event') return '#38bdf8' // sky — event source
if (e.fromType === 'input_manual') return '#a78bfa' // violet — manual source
return '#f59e0b' // amber — intermediate→output
function edgeColor(e: EdgeDraw): string {
if (e.fromType === 'input_event') return '#38bdf8'
if (e.fromType === 'input_manual') return '#a78bfa'
return '#f59e0b'
}
return (
<div ref={wrapperRef} className="relative overflow-x-auto" onScroll={recompute}>
{/* SVG arêtes superposées */}
{/* SVG arêtes — z:5, sous les cartes z:10 */}
<svg
className="absolute top-0 left-0 pointer-events-none"
style={{ width: '100%', height: svgH, zIndex: 5, overflow: 'visible' }}
aria-hidden="true"
>
{svgEdges.map(e => {
const isHov = hoveredId === e.fromId || hoveredId === e.toId
const opacity = isHov ? 0.80 : 0.13
const stroke = edgeStroke(e)
const dx = Math.min(50, (e.x2 - e.x1) * 0.38)
const isHov = hoveredId === e.fromId || hoveredId === e.toId
const isToOutput = e.fromType === 'intermediate'
// Output edges always visible; input edges ghosted unless hovered
const opacity = isHov ? 0.90 : isToOutput ? 0.60 : 0.18
const color = edgeColor(e)
const sw = isHov ? 2 : isToOutput ? 1.5 : 1
const dx = Math.min(52, (e.x2 - e.x1) * 0.38)
const curve = `M ${e.x1} ${e.y1} C ${e.x1+dx} ${e.y1}, ${e.x2-dx} ${e.y2}, ${e.x2} ${e.y2}`
const mx = (e.x1 + e.x2) / 2
const my = (e.y1 + e.y2) / 2
// Show weight: always on output edges, on hover for input edges
const showWeight = e.displayWeight !== null && (isToOutput || isHov)
const wLabel = e.displayWeight !== null
? `w:${Math.abs(e.displayWeight) >= 10 ? Math.round(e.displayWeight) : e.displayWeight.toFixed(1)}`
: ''
return (
<g key={e.key} opacity={opacity}>
<path
d={`M ${e.x1} ${e.y1} C ${e.x1 + dx} ${e.y1}, ${e.x2 - dx} ${e.y2}, ${e.x2} ${e.y2}`}
fill="none" stroke={stroke}
strokeWidth={e.coeff > 1.05 ? 2 : 1}
/>
<path d={curve} fill="none" stroke={color} strokeWidth={sw}/>
{/* Arrowhead */}
<polygon
points={`${e.x2},${e.y2} ${e.x2 - 5},${e.y2 - 3} ${e.x2 - 5},${e.y2 + 3}`}
fill={stroke} fillOpacity={0.7}
points={`${e.x2},${e.y2} ${e.x2-5},${e.y2-2.5} ${e.x2-5},${e.y2+2.5}`}
fill={color} fillOpacity={0.8}
/>
{/* Coefficient label on hover */}
{isHov && e.coeff !== 1.0 && (
{/* Weight label */}
{showWeight && (
<text
x={(e.x1 + e.x2) / 2} y={(e.y1 + e.y2) / 2 - 4}
fill={stroke} fontSize="9" textAnchor="middle"
style={{ userSelect: 'none', fontFamily: 'monospace' }}
x={mx} y={my - 5}
fill={color} fontSize="8" fontFamily="monospace"
textAnchor="middle" dominantBaseline="middle"
style={{ userSelect: 'none' }}
>
×{e.coeff.toFixed(2)}
{wLabel}
</text>
)}
</g>
@@ -634,13 +637,12 @@ function DagView({ nodes, instrument, onEdit, eventDetails }: {
})}
</svg>
{/* Grille 4 colonnes */}
<div className="grid grid-cols-4 gap-3 min-w-[780px]" style={{ position: 'relative', zIndex: 10 }}>
{/* Grille — z:10 */}
<div className="grid grid-cols-4 gap-x-4 min-w-[820px]" style={{ position: 'relative', zIndex: 10 }}>
{COL_LABELS.map((lbl, ci) => (
<div key={ci}>
<div className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2 pb-1 border-b border-slate-700/30">
{lbl}
<span className="ml-1.5 text-slate-600 font-normal normal-case">({cols[ci].length})</span>
{lbl} <span className="font-normal normal-case text-slate-600">({cols[ci].length})</span>
</div>
{cols[ci].map(n => (
<div
@@ -657,26 +659,18 @@ function DagView({ nodes, instrument, onEdit, eventDetails }: {
</div>
{/* Légende */}
<div className="flex items-center gap-4 mt-4 pt-3 border-t border-slate-700/20 flex-wrap">
<div className="flex items-center gap-1.5 text-xs text-sky-400">
<span className="w-3 h-px bg-sky-400 inline-block rounded"/> Events → couches
<div className="flex items-center gap-3 mt-3 pt-3 border-t border-slate-700/20 flex-wrap text-xs">
<div className="flex items-center gap-1 text-sky-400">
<span className="w-4 h-px bg-sky-400 inline-block"/> Events → couches
</div>
<div className="flex items-center gap-1.5 text-xs text-violet-400">
<span className="w-3 h-px bg-violet-400 inline-block rounded"/> Manuel → couches
<div className="flex items-center gap-1 text-violet-400">
<span className="w-4 h-px bg-violet-400 inline-block"/> Manuel → couches <span className="text-slate-600 ml-0.5">(w: au survol)</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-amber-400">
<span className="w-3 h-px bg-amber-400 inline-block rounded"/> Couches → output
<div className="flex items-center gap-1 text-amber-400">
<span className="w-4 h-px bg-amber-400 inline-block"/> Couches → output <span className="text-slate-600 ml-0.5">(w: toujours)</span>
</div>
<span className="text-xs text-slate-600 ml-1">· survol = highlight</span>
<div className="ml-2 flex gap-3">
{(Object.entries(SOURCE_META) as [string, { dot: string; label: string }][]).map(([src, m]) => (
<div key={src} className="flex items-center gap-1.5 text-xs text-slate-500">
<span className={clsx('w-2 h-2 rounded-full', m.dot)}/>{m.label}
</div>
))}
</div>
<div className="flex items-center gap-1.5 text-xs text-slate-500 ml-auto">
<Edit3 className="w-3 h-3"/> Cliquer pour modifier
<div className="ml-auto flex items-center gap-1 text-slate-500">
<Edit3 className="w-3 h-3"/> Cliquer nœud pour éditer
</div>
</div>
</div>
@@ -890,10 +884,28 @@ function TimelineView({ instrument }: { instrument: string }) {
const [whatifData, setWhatifData] = useState<TimelinePoint[] | null>(null)
const [importingCal, setImportingCal] = useState(false)
const [calMsg, setCalMsg] = useState<string | null>(null)
// What-if filters
const [filterImpact, setFilterImpact] = useState<string[]>(['high', 'medium'])
const [filterCurrency, setFilterCurrency] = useState<string[]>([])
const [filterSurprise, setFilterSurprise] = useState(false)
const [filterMinPips, setFilterMinPips] = useState(0)
const canvasRef = useRef<HTMLCanvasElement>(null)
const activeData = whatifData ?? data
// What-if: events actually sent to simulation (pass filters)
const availCurrencies = useMemo(() =>
[...new Set(virtuals.map(v => v.currency).filter(Boolean) as string[])].sort(),
[virtuals])
const activeVirtuals = useMemo(() => virtuals.filter(ve => {
if (filterImpact.length > 0 && ve.impact && !filterImpact.includes(ve.impact)) return false
if (filterCurrency.length > 0 && ve.currency && !filterCurrency.includes(ve.currency)) return false
if (filterSurprise && !ve.has_surprise) return false
if (filterMinPips > 0 && Math.abs(ve.pips) < filterMinPips) return false
return true
}), [virtuals, filterImpact, filterCurrency, filterSurprise, filterMinPips])
// Load timeline
useEffect(() => {
setLoading(true)
@@ -1092,12 +1104,12 @@ function TimelineView({ instrument }: { instrument: string }) {
}, [activeData, realPrices, showReal, showFund, showPips, shownLayers, virtuals, whatifData])
async function runWhatIf() {
if (virtuals.length === 0) return
if (activeVirtuals.length === 0) return
setWhatifLoading(true)
try {
const r = await api.post<TimelinePoint[]>(`/instrument-models/${instrument}/timeline-whatif`, {
period,
virtual_events: virtuals.map(ve => ({
virtual_events: activeVirtuals.map(ve => ({
date: ve.date,
category: ve.category,
pips: ve.pips,
@@ -1149,7 +1161,7 @@ function TimelineView({ instrument }: { instrument: string }) {
<button onClick={() => setShowVirtual(v => !v)}
className={clsx('flex items-center gap-1.5 px-2 py-1 rounded text-xs border transition-all',
showVirtual ? 'border-violet-600/50 text-violet-400 bg-violet-900/20' : 'border-slate-700/30 text-slate-500')}>
<Zap className="w-3 h-3"/> What-if {virtuals.length > 0 && `(${virtuals.length})`}
<Zap className="w-3 h-3"/> What-if {activeVirtuals.length > 0 ? `(${activeVirtuals.length}/${virtuals.length})` : virtuals.length > 0 ? `(${virtuals.length})` : ''}
</button>
</div>
<span className="ml-auto text-xs text-slate-500">{activeData.length} jours</span>
@@ -1198,9 +1210,17 @@ function TimelineView({ instrument }: { instrument: string }) {
{/* Virtual Events Panel */}
{showVirtual && (
<div className="rounded-lg border border-violet-700/30 bg-violet-900/10 p-3 space-y-3">
<div className="rounded-lg border border-violet-700/30 bg-violet-900/10 p-3 space-y-2">
{/* Header */}
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="text-xs font-semibold text-violet-400">Events virtuels (What-if)</div>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-violet-400">Events What-if</span>
{virtuals.length > 0 && (
<span className="text-xs text-slate-400">
{activeVirtuals.length} actif{activeVirtuals.length > 1 ? 's' : ''} / {virtuals.length} total
</span>
)}
</div>
<div className="flex gap-1.5">
<button
disabled={importingCal}
@@ -1214,15 +1234,21 @@ function TimelineView({ instrument }: { instrument: string }) {
id: `ff_${ev.date}_${ev.currency}_${i}`,
date: ev.date,
category: ev.category,
pips: 0, // utilisateur saisit la magnitude
pips: ev.estimated_pips,
label: ev.label,
absorption_days: 14,
rise_days: 1,
plateau_days: 0,
decay_type: 'exp',
currency: ev.currency,
impact: ev.impact,
has_surprise: ev.has_surprise,
actual_value: ev.actual_value,
forecast_value: ev.forecast_value,
event_name: ev.event_name,
}))
if (imported.length === 0) {
setCalMsg('Aucun event FF calendrier (high/medium) pour cette période sync en cours ou calendrier vide')
setCalMsg('Aucun event FF calendrier (high/medium) pour cette période')
} else {
setVirtuals(prev => {
const existingIds = new Set(prev.map(v => v.id))
@@ -1239,71 +1265,138 @@ function TimelineView({ instrument }: { instrument: string }) {
</button>
<button onClick={() => setVirtuals(v => [...v, newVirtualEvent()])}
className="flex items-center gap-1 text-xs text-violet-400 hover:text-violet-300 border border-violet-700/40 rounded px-2 py-0.5 transition-colors">
<Plus className="w-3 h-3"/> Ajouter
<Plus className="w-3 h-3"/> Manuel
</button>
</div>
</div>
{calMsg && (
<div className={clsx('text-xs px-2 py-1 rounded', calMsg.startsWith('Aucun') ? 'text-amber-400 bg-amber-900/20' : 'text-emerald-400 bg-emerald-900/20')}>
{calMsg}
</div>
)}
{virtuals.length === 0 && !calMsg && (
<div className="text-xs text-slate-500 text-center py-2">
Ajoute des events hypothétiques pour simuler leur impact sur la courbe synthétique.
{/* Filter bar */}
{virtuals.length > 0 && (
<div className="flex flex-wrap gap-2 items-center py-1.5 border-y border-slate-700/30">
{/* Impact filter */}
<div className="flex items-center gap-1">
{(['high', 'medium', 'low'] as const).map(imp => {
const active = filterImpact.includes(imp)
const cls = imp === 'high' ? 'border-red-700/60 text-red-400 bg-red-900/20' :
imp === 'medium' ? 'border-amber-700/60 text-amber-400 bg-amber-900/20' :
'border-slate-600/60 text-slate-400 bg-slate-800/30'
return (
<button key={imp} onClick={() => setFilterImpact(prev =>
prev.includes(imp) ? prev.filter(x => x !== imp) : [...prev, imp])}
className={clsx('px-1.5 py-0.5 rounded text-xs border transition-all',
active ? cls : 'border-slate-700/30 text-slate-600')}>
{imp === 'high' ? 'H' : imp === 'medium' ? 'M' : 'L'}
</button>
)
})}
</div>
{/* Currency filter */}
{availCurrencies.length > 0 && (
<div className="flex items-center gap-1">
{availCurrencies.map(cur => (
<button key={cur} onClick={() => setFilterCurrency(prev =>
prev.includes(cur) ? prev.filter(x => x !== cur) : [...prev, cur])}
className={clsx('px-1.5 py-0.5 rounded text-xs border transition-all',
filterCurrency.includes(cur)
? 'border-sky-700/60 text-sky-400 bg-sky-900/20'
: 'border-slate-700/30 text-slate-600')}>
{cur}
</button>
))}
</div>
)}
{/* Surprise toggle */}
<button onClick={() => setFilterSurprise(v => !v)}
className={clsx('px-1.5 py-0.5 rounded text-xs border transition-all',
filterSurprise ? 'border-yellow-700/60 text-yellow-400 bg-yellow-900/20' : 'border-slate-700/30 text-slate-600')}>
★ surprise
</button>
{/* Min pips */}
<div className="flex items-center gap-1 ml-auto">
<span className="text-xs text-slate-500">|pip|≥</span>
<input type="number" min={0} step={5} value={filterMinPips}
onChange={e => setFilterMinPips(Math.max(0, parseInt(e.target.value) || 0))}
className="w-12 bg-dark-700 border border-slate-700/40 rounded px-1.5 py-0.5 text-xs text-white focus:outline-none text-center"/>
</div>
</div>
)}
{virtuals.map((ve, idx) => (
<div key={ve.id} className="grid grid-cols-2 md:grid-cols-4 gap-2 items-end bg-dark-800/40 rounded p-2">
<div>
<label className="text-xs text-slate-500 block mb-0.5">Date</label>
<input type="date" value={ve.date}
onChange={e => setVirtuals(v => v.map((x, i) => i === idx ? {...x, date: e.target.value} : x))}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none"/>
</div>
<div>
<label className="text-xs text-slate-500 block mb-0.5">Catégorie</label>
<select value={ve.category}
onChange={e => setVirtuals(v => v.map((x, i) => i === idx ? {...x, category: e.target.value} : x))}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none">
{EVENT_CATEGORIES.map(c => <option key={c.value} value={c.value}>{c.label}</option>)}
</select>
</div>
<div>
<label className="text-xs text-slate-500 block mb-0.5">Magnitude (pips)</label>
<input type="number" step="any" value={ve.pips}
onChange={e => setVirtuals(v => v.map((x, i) => i === idx ? {...x, pips: parseFloat(e.target.value) || 0} : x))}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none"/>
</div>
<div>
<label className="text-xs text-slate-500 block mb-0.5">Absorption (j)</label>
<div className="flex gap-1">
<input type="number" value={ve.absorption_days}
onChange={e => setVirtuals(v => v.map((x, i) => i === idx ? {...x, absorption_days: parseInt(e.target.value)||14} : x))}
className="flex-1 bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none"/>
<button onClick={() => setVirtuals(v => v.filter((_, i) => i !== idx))}
className="text-red-500/60 hover:text-red-400 px-1.5 transition-colors">
<X className="w-3 h-3"/>
</button>
</div>
</div>
<div className="col-span-2 md:col-span-4">
<input type="text" value={ve.label} placeholder="Label de l'event"
onChange={e => setVirtuals(v => v.map((x, i) => i === idx ? {...x, label: e.target.value} : x))}
className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none"/>
</div>
{/* Event list */}
{virtuals.length === 0 && !calMsg ? (
<div className="text-xs text-slate-500 text-center py-3">
Importe le calendrier ou ajoute des events manuels pour simuler leur impact.
</div>
))}
) : (
<div className="space-y-0.5 max-h-72 overflow-y-auto pr-0.5">
{virtuals.map((ve, idx) => {
const isActive = activeVirtuals.some(a => a.id === ve.id)
const impDot = ve.impact === 'high' ? 'bg-red-500' : ve.impact === 'medium' ? 'bg-amber-500' : 'bg-slate-500'
const dispDate = ve.date.slice(5) // MM-DD
return (
<div key={ve.id}
className={clsx('flex items-center gap-1.5 rounded px-2 py-1 text-xs transition-all',
isActive ? 'bg-dark-800/60' : 'bg-dark-800/20 opacity-40')}>
{/* Impact dot */}
<span className={clsx('w-1.5 h-1.5 rounded-full flex-shrink-0', impDot)}/>
{/* Currency */}
{ve.currency && (
<span className="text-slate-500 font-mono w-7 flex-shrink-0">{ve.currency}</span>
)}
{/* Date */}
<span className="text-slate-500 font-mono w-10 flex-shrink-0">{dispDate}</span>
{/* Event name */}
<span className="flex-1 text-slate-300 truncate" title={ve.event_name || ve.label}>
{(ve.event_name || ve.label).replace(/^\[.*?\]\s*/, '')}
</span>
{/* Surprise star */}
{ve.has_surprise && <span className="text-yellow-400 flex-shrink-0" title="Surprise">★</span>}
{/* Actual vs forecast */}
{ve.actual_value && (
<span className="text-slate-500 hidden sm:inline flex-shrink-0 text-xs">
{ve.actual_value}{ve.forecast_value ? `/${ve.forecast_value}` : ''}
</span>
)}
{/* Pips input */}
<input type="number" step="any" value={ve.pips}
onChange={e => setVirtuals(v => v.map((x, i) => i === idx ? {...x, pips: parseFloat(e.target.value) || 0} : x))}
className={clsx(
'w-16 bg-dark-700 border rounded px-1.5 py-0.5 text-xs text-right focus:outline-none flex-shrink-0',
ve.pips > 0 ? 'text-emerald-400 border-emerald-700/40' :
ve.pips < 0 ? 'text-red-400 border-red-700/40' :
'text-slate-400 border-slate-700/40'
)}/>
<span className="text-slate-600 flex-shrink-0">p</span>
{/* Delete */}
<button onClick={() => setVirtuals(v => v.filter((_, i) => i !== idx))}
className="text-slate-600 hover:text-red-400 transition-colors flex-shrink-0 ml-0.5">
<X className="w-3 h-3"/>
</button>
</div>
)
})}
</div>
)}
{/* Actions */}
{virtuals.length > 0 && (
<div className="flex gap-2 justify-end">
<div className="flex gap-2 justify-end pt-1">
<button onClick={() => { setVirtuals([]); setWhatifData(null) }}
className="text-xs text-slate-500 hover:text-slate-300 transition-colors">
Effacer tout
</button>
<button onClick={runWhatIf} disabled={whatifLoading}
<button onClick={runWhatIf} disabled={whatifLoading || activeVirtuals.length === 0}
className="flex items-center gap-1.5 px-3 py-1.5 bg-violet-600 hover:bg-violet-500 disabled:opacity-40 text-white text-xs rounded font-medium transition-colors">
<Zap className="w-3 h-3"/>
{whatifLoading ? 'Simulation' : 'Simuler'}
{whatifLoading ? 'Simulation' : `Simuler (${activeVirtuals.length})`}
</button>
</div>
)}