diff --git a/backend/routers/instrument_models.py b/backend/routers/instrument_models.py
index 17955f1..fc7a71f 100644
--- a/backend/routers/instrument_models.py
+++ b/backend/routers/instrument_models.py
@@ -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:
diff --git a/frontend/src/pages/InstrumentModels.tsx b/frontend/src/pages/InstrumentModels.tsx
index 31d09b0..eb02805 100644
--- a/frontend/src/pages/InstrumentModels.tsx
+++ b/frontend/src/pages/InstrumentModels.tsx
@@ -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 (
canEdit && onEdit(node)}
title={node.description}
>
- {node.source === 'manual' &&
}
- {node.source === 'events' &&
}
+ {node.source === 'manual' &&
}
+ {node.source === 'events' &&
}
-
{node.label}
+ {/* Label — truncated, single line */}
+
{node.label}
- {isManual ? (
-
- {hasValue ? (
-
- {node.raw_value! > 0 ? '+' : ''}{node.raw_value} {node.unit}
-
+ {/* Value row — compact, single line */}
+
+ {node.node_type === 'input_manual' ? (() => {
+ const hasValue = node.source === 'manual' && node.raw_value !== undefined && node.raw_value !== 0
+ return hasValue ? (
+ <>
+
+ {node.raw_value! > 0 ? '+' : ''}{node.raw_value} {node.unit}
+
+ ={fmt(v)}p
+ >
) : (
- — {node.unit}
- )}
-
- w:{coeff}
-
- {hasValue && (
-
- ={fmt(v)}p
-
- )}
-
- ) : 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 (
-
- {hasBaseline ? (
-
-
- base:{fmt(baseline)}p
- {surprise !== 0 && (
- surp:{fmt(surprise)}p
- )}
-
-
={fmt(v)}p
-
- ) : (
-
- {fmt(v)} pips
- {evList.length > 0 && {evList.length} ev}
-
- )}
- {evList.length > 0 && (
-
- )
- })() : (
-
- {fmt(v)} pips
-
- )}
-
- {Math.abs(v) > 0.05 && !isManual && (
-
- )}
- {isManual && Math.abs(v) > 0.05 && (
-
- )}
-
- {node.node_type === 'intermediate' && (
-
- {node.formula && {node.formula.split('+').length} sources}
- {node.regime_weight !== undefined && node.regime_weight !== 1.0 && (
- 1.0 ? 'text-amber-400' : 'text-slate-500')}>
- ×{node.regime_weight.toFixed(2)}
-
- )}
-
- )}
+
— {node.unit}
+ )
+ })() : 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 (
+ <>
+
{fmt(v)}p
+ {baseline !== 0 && (
+
b:{fmt(baseline)}
+ )}
+ {evList.length > 0 && (
+ <>
+
+
+
+
{evList.length}ev
+ >
+ )}
+ >
+ )
+ })() : node.node_type === 'intermediate' ? (
+ <>
+
{fmt(v)}p
+
+ >
+ ) : (
+ /* output */
+
{fmt(v)} pips
+ )}
+
)
}
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
()
- for (const n of nodes) m.set(n.id, n.node_type)
+ const nodeMap = useMemo(() => {
+ const m = new Map()
+ 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 (
- {/* SVG arêtes superposées */}
+ {/* SVG arêtes — z:5, sous les cartes z:10 */}
- {/* Grille 4 colonnes */}
-
+ {/* Grille — z:10 */}
+
{COL_LABELS.map((lbl, ci) => (
- {lbl}
- ({cols[ci].length})
+ {lbl} ({cols[ci].length})
{cols[ci].map(n => (
{/* Légende */}
-
-
-
Events → couches
+
+
+ Events → couches
-
-
Manuel → couches
+
+ Manuel → couches (w: au survol)
-
-
Couches → output
+
+ Couches → output (w: toujours)
-
· survol = highlight
-
- {(Object.entries(SOURCE_META) as [string, { dot: string; label: string }][]).map(([src, m]) => (
-
- {m.label}
-
- ))}
-
-
-
Cliquer pour modifier
+
+ Cliquer nœud pour éditer
@@ -890,10 +884,28 @@ function TimelineView({ instrument }: { instrument: string }) {
const [whatifData, setWhatifData] = useState
(null)
const [importingCal, setImportingCal] = useState(false)
const [calMsg, setCalMsg] = useState(null)
+ // What-if filters
+ const [filterImpact, setFilterImpact] = useState(['high', 'medium'])
+ const [filterCurrency, setFilterCurrency] = useState([])
+ const [filterSurprise, setFilterSurprise] = useState(false)
+ const [filterMinPips, setFilterMinPips] = useState(0)
const canvasRef = useRef(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(`/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 }) {
{activeData.length} jours
@@ -1198,9 +1210,17 @@ function TimelineView({ instrument }: { instrument: string }) {
{/* Virtual Events Panel */}
{showVirtual && (
-
+
+ {/* Header */}
-
Events virtuels (What-if)
+
+ Events What-if
+ {virtuals.length > 0 && (
+
+ {activeVirtuals.length} actif{activeVirtuals.length > 1 ? 's' : ''} / {virtuals.length} total
+
+ )}
+
+
{calMsg && (
{calMsg}
)}
- {virtuals.length === 0 && !calMsg && (
-
- Ajoute des events hypothétiques pour simuler leur impact sur la courbe synthétique.
+
+ {/* Filter bar */}
+ {virtuals.length > 0 && (
+
+ {/* Impact filter */}
+
+ {(['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 (
+
+ )
+ })}
+
+
+ {/* Currency filter */}
+ {availCurrencies.length > 0 && (
+
+ {availCurrencies.map(cur => (
+
+ ))}
+
+ )}
+
+ {/* Surprise toggle */}
+
+
+ {/* Min pips */}
+
+ |pip|≥
+ 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"/>
+
)}
- {virtuals.map((ve, idx) => (
-
-
-
- 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"/>
-
-
-
-
-
-
-
- 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"/>
-
-
-
-
- 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"/>
-
-
-
-
- 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"/>
-
+
+ {/* Event list */}
+ {virtuals.length === 0 && !calMsg ? (
+
+ Importe le calendrier ou ajoute des events manuels pour simuler leur impact.
- ))}
+ ) : (
+
+ {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 (
+
+ {/* Impact dot */}
+
+ {/* Currency */}
+ {ve.currency && (
+ {ve.currency}
+ )}
+ {/* Date */}
+ {dispDate}
+ {/* Event name */}
+
+ {(ve.event_name || ve.label).replace(/^\[.*?\]\s*/, '')}
+
+ {/* Surprise star */}
+ {ve.has_surprise && ★}
+ {/* Actual vs forecast */}
+ {ve.actual_value && (
+
+ {ve.actual_value}{ve.forecast_value ? `/${ve.forecast_value}` : ''}
+
+ )}
+ {/* Pips input */}
+ 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'
+ )}/>
+ p
+ {/* Delete */}
+
+
+ )
+ })}
+
+ )}
+
+ {/* Actions */}
{virtuals.length > 0 && (
-
+
-
)}