feat: instrument model
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user