From c3e9b21bdaaef6840f24964c8f06ede536f2a7ca Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Fri, 3 Jul 2026 10:17:02 +0200 Subject: [PATCH] feat: instrument model --- backend/routers/instrument_models.py | 122 +++++- frontend/src/pages/InstrumentModels.tsx | 495 ++++++++++++++---------- 2 files changed, 403 insertions(+), 214 deletions(-) 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 && ( -
-
-
-
- {barW}% -
- )} -
- ) - })() : ( -
- {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 && ( -
+
-
)}