From d9e9d30889c07e7cace40b2128f689ae6179c681 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Fri, 3 Jul 2026 00:31:30 +0200 Subject: [PATCH] feat: instrument model --- backend/routers/instrument_models.py | 158 +++++++++++- backend/services/gauge_sync.py | 36 ++- backend/services/instrument_models.py | 116 ++++++++- frontend/src/pages/InstrumentModels.tsx | 329 +++++++++++++++++++++--- 4 files changed, 591 insertions(+), 48 deletions(-) diff --git a/backend/routers/instrument_models.py b/backend/routers/instrument_models.py index 141d6ec..522cf1c 100644 --- a/backend/routers/instrument_models.py +++ b/backend/routers/instrument_models.py @@ -43,6 +43,13 @@ class CalibrateBody(BaseModel): ref_date: Optional[str] = None +class CalibrationOverrideBody(BaseModel): + absorption_days: int = 7 + rise_days: int = 0 + plateau_days: int = 0 + decay_type: str = "exp" + + @router.get("", response_model=List[Dict[str, Any]]) def list_instrument_models(): from services.database import get_conn @@ -75,8 +82,11 @@ def list_instrument_models(): @router.get("/{instrument}/gauge-suggestions") -def get_gauge_suggestions(instrument: str) -> Dict[str, Any]: - """Suggestions de valeurs depuis les derniers gauges de marché (macro_regime_history).""" +def get_gauge_suggestions( + instrument: str, + at_date: Optional[str] = Query(None, description="YYYY-MM-DD — snapshot le plus proche de cette date"), +) -> Dict[str, Any]: + """Suggestions de valeurs depuis les gauges de marché (optionnellement à une date historique).""" from services.database import get_conn from services.gauge_sync import suggest_from_gauges, get_latest_gauges from services.instrument_models import INSTRUMENT_MODELS @@ -85,14 +95,14 @@ def get_gauge_suggestions(instrument: str) -> Dict[str, Any]: inst = instrument.upper() if inst not in INSTRUMENT_MODELS: raise HTTPException(status_code=404, detail=f"Instrument {inst} non supporté") - gauges, snap_date = get_latest_gauges(conn) + gauges, snap_date = get_latest_gauges(conn, at_date=at_date) if not gauges: raise HTTPException(status_code=404, detail="Aucun snapshot de gauges disponible") suggestions = suggest_from_gauges(inst, gauges) - # Enrich with node label/unit from graph if missing return { "instrument": inst, "gauge_date": snap_date, + "requested_date": at_date, "n_suggestions": len(suggestions), "suggestions": suggestions, "gauges_available": list(gauges.keys()), @@ -219,6 +229,146 @@ def timeline_whatif( conn.close() +@router.get("/{instrument}/calendar-events") +def get_calendar_events( + instrument: str, + days_back: int = Query(365, description="Jours en arrière depuis at_date"), + days_forward: int = Query(90, description="Jours en avant depuis at_date"), + at_date: Optional[str]= Query(None), +) -> List[Dict[str, Any]]: + """Events calendrier analysés pour cet instrument — alimente le panneau What-if.""" + from services.database import get_conn + from services.instrument_models import _lifecycle, init_instrument_model_tables + from datetime import datetime, date as date_type, timedelta + import json as _json + conn = get_conn() + try: + inst_upper = instrument.upper() + inst_lower = inst_upper.lower() + init_instrument_model_tables(conn) + try: + ref = date_type.fromisoformat(at_date) if at_date else datetime.utcnow().date() + except ValueError: + ref = datetime.utcnow().date() + date_from = ref - timedelta(days=days_back) + date_to = ref + timedelta(days=days_forward) + + rows = conn.execute(""" + SELECT a.id as analysis_id, + a.prediction_json, + e.id as event_id, e.name as title, e.start_date, e.end_date, e.sub_type, + t.category, + COALESCE(o.calibration_json, t.calibration_json) as calibration_json + FROM causal_event_analyses a + JOIN market_events e ON e.id = a.market_event_id + JOIN causal_graph_templates t ON t.id = a.template_id + LEFT JOIN event_calibration_overrides o ON o.analysis_id = a.id + WHERE a.instrument = ? + AND e.start_date >= ? + AND e.start_date <= ? + ORDER BY e.start_date DESC + """, (inst_upper, str(date_from), str(date_to))).fetchall() + + result = [] + for row in rows: + r = dict(row) + try: + preds = _json.loads(r["prediction_json"] or "{}") + calib = _json.loads(r["calibration_json"] or "{}") + except Exception: + continue + + pips: float = 0.0 + if inst_lower in preds: + pips = float(preds[inst_lower]) + else: + for k, v in preds.items(): + if inst_lower in k.lower(): + try: pips = float(v); break + except (TypeError, ValueError): pass + + try: + ev_date = date_type.fromisoformat(r["start_date"][:10]) + except ValueError: + continue + + days = (ref - ev_date).days + absorption = max(1, int(calib.get("absorption_days", 7))) + dtype = str(calib.get("decay_type", "exp")) + rise = max(0, int(calib.get("rise_days", 0))) + plateau = max(0, int(calib.get("plateau_days", 0))) + lf = _lifecycle(days, rise, plateau, absorption, dtype) if days >= 0 else 0.0 + + result.append({ + "analysis_id": r["analysis_id"], + "event_id": r.get("event_id"), + "title": r.get("title") or r.get("sub_type", ""), + "start_date": r["start_date"][:10], + "category": r["category"], + "is_future": ev_date > ref, + "days_offset": -days, # positive = future, negative = past + "pip_prediction": round(pips, 1), + "lifecycle_factor": round(lf, 3), + "remaining_pips": round(pips * lf, 1), + "calibration": { + "absorption_days": absorption, + "rise_days": rise, + "plateau_days": plateau, + "decay_type": dtype, + }, + }) + return result + finally: + conn.close() + + +@router.put("/{instrument}/events/{analysis_id}/calibration") +def update_event_calibration( + instrument: str, + analysis_id: int, + body: CalibrationOverrideBody, +) -> Dict[str, Any]: + """Écrase les paramètres de lifecycle d'une analyse event (override par-event).""" + from services.database import get_conn + import json as _json + conn = get_conn() + try: + row = conn.execute( + "SELECT id FROM causal_event_analyses WHERE id=? AND instrument=?", + (analysis_id, instrument.upper()) + ).fetchone() + if not row: + raise HTTPException(status_code=404, detail="Analysis not found") + calib = { + "absorption_days": body.absorption_days, + "rise_days": body.rise_days, + "plateau_days": body.plateau_days, + "decay_type": body.decay_type, + } + conn.execute(""" + INSERT INTO event_calibration_overrides (analysis_id, calibration_json) + VALUES (?, ?) + ON CONFLICT(analysis_id) DO UPDATE SET calibration_json=excluded.calibration_json, updated_at=datetime('now') + """, (analysis_id, _json.dumps(calib))) + conn.commit() + return {"ok": True, "analysis_id": analysis_id, "calibration": calib} + finally: + conn.close() + + +@router.delete("/{instrument}/events/{analysis_id}/calibration") +def reset_event_calibration(instrument: str, analysis_id: int) -> Dict[str, Any]: + """Supprime l'override de calibration — retour aux paramètres du template.""" + from services.database import get_conn + conn = get_conn() + try: + conn.execute("DELETE FROM event_calibration_overrides WHERE analysis_id=?", (analysis_id,)) + conn.commit() + return {"ok": True, "analysis_id": analysis_id} + finally: + conn.close() + + @router.post("/{instrument}/calibrate") def calibrate_intercept( instrument: str, diff --git a/backend/services/gauge_sync.py b/backend/services/gauge_sync.py index 8495e43..4093564 100644 --- a/backend/services/gauge_sync.py +++ b/backend/services/gauge_sync.py @@ -247,12 +247,21 @@ _SUGGEST_FN = { # ── Public API ───────────────────────────────────────────────────────────────── -def get_latest_gauges(conn) -> tuple[dict, str]: - """Retourne (gauges_dict, snapshot_date) depuis macro_regime_history.""" - row = conn.execute( - "SELECT gauges_summary_json, timestamp FROM macro_regime_history " - "ORDER BY timestamp DESC LIMIT 1" - ).fetchone() +def get_latest_gauges(conn, at_date: Optional[str] = None) -> tuple[dict, str]: + """Retourne (gauges_dict, snapshot_date) depuis macro_regime_history. + Si at_date fourni, retourne le snapshot le plus proche <= at_date.""" + if at_date: + row = conn.execute( + "SELECT gauges_summary_json, timestamp FROM macro_regime_history " + "WHERE timestamp <= ? ORDER BY timestamp DESC LIMIT 1", + (at_date,) + ).fetchone() + else: + row = conn.execute( + "SELECT gauges_summary_json, timestamp FROM macro_regime_history " + "ORDER BY timestamp DESC LIMIT 1" + ).fetchone() + if row: r = dict(row) gauges = json.loads(r.get("gauges_summary_json") or "{}") @@ -261,10 +270,17 @@ def get_latest_gauges(conn) -> tuple[dict, str]: return gauges, date # Fallback : macro_gauge_snapshots - row2 = conn.execute( - "SELECT gauges_json, snapshot_date FROM macro_gauge_snapshots " - "ORDER BY snapshot_date DESC LIMIT 1" - ).fetchone() + if at_date: + row2 = conn.execute( + "SELECT gauges_json, snapshot_date FROM macro_gauge_snapshots " + "WHERE snapshot_date <= ? ORDER BY snapshot_date DESC LIMIT 1", + (at_date,) + ).fetchone() + else: + row2 = conn.execute( + "SELECT gauges_json, snapshot_date FROM macro_gauge_snapshots " + "ORDER BY snapshot_date DESC LIMIT 1" + ).fetchone() if row2: return json.loads(row2["gauges_json"] or "{}"), str(row2["snapshot_date"]) diff --git a/backend/services/instrument_models.py b/backend/services/instrument_models.py index 23ae7ca..36438dd 100644 --- a/backend/services/instrument_models.py +++ b/backend/services/instrument_models.py @@ -638,6 +638,11 @@ def init_instrument_model_tables(conn): set_at TEXT DEFAULT (datetime('now')), UNIQUE(instrument, node_id) ); + CREATE TABLE IF NOT EXISTS event_calibration_overrides ( + analysis_id INTEGER PRIMARY KEY, + calibration_json TEXT NOT NULL, + updated_at TEXT DEFAULT (datetime('now')) + ); """) conn.commit() @@ -669,12 +674,15 @@ def _compute_event_by_category(conn, instrument: str, ref_date: date_type) -> di extended_from = ref_date - timedelta(days=365) rows = conn.execute(""" - SELECT a.prediction_json, - e.start_date, e.end_date, e.sub_type, - t.category, t.calibration_json + SELECT a.id as analysis_id, + a.prediction_json, + e.start_date, e.end_date, e.sub_type, e.name as title, + t.category, + COALESCE(o.calibration_json, t.calibration_json) as calibration_json FROM causal_event_analyses a JOIN market_events e ON e.id = a.market_event_id JOIN causal_graph_templates t ON t.id = a.template_id + LEFT JOIN event_calibration_overrides o ON o.analysis_id = a.id WHERE a.instrument = ? AND e.start_date >= ? AND e.start_date <= ? @@ -787,6 +795,98 @@ def _graph_json_for_eval(graph_def: dict, regime_weights: Optional[dict] = None) # ── Public API ───────────────────────────────────────────────────────────────── +def get_active_event_details(conn, instrument: str, ref_date: date_type) -> dict: + """ + Détail des events actifs par catégorie — utilisé pour enrichir les nœuds event du DAG. + Retourne : {category: [{analysis_id, title, start_date, days_since, + pip_prediction, lifecycle_factor, remaining_pips, calibration}]} + """ + inst_upper = instrument.upper() + inst_lower = inst_upper.lower() + extended_from = ref_date - timedelta(days=365) + + rows = conn.execute(""" + SELECT a.id as analysis_id, + a.prediction_json, + e.id as event_id, e.name as title, e.start_date, e.end_date, e.sub_type, + t.category, + COALESCE(o.calibration_json, t.calibration_json) as calibration_json + FROM causal_event_analyses a + JOIN market_events e ON e.id = a.market_event_id + JOIN causal_graph_templates t ON t.id = a.template_id + LEFT JOIN event_calibration_overrides o ON o.analysis_id = a.id + WHERE a.instrument = ? + AND e.start_date >= ? + AND e.start_date <= ? + ORDER BY e.start_date DESC + """, (inst_upper, str(extended_from), str(ref_date))).fetchall() + + by_cat: dict[str, list] = {} + for row in rows: + r = dict(row) + try: + preds = json.loads(r["prediction_json"] or "{}") + calib = json.loads(r["calibration_json"] or "{}") + except Exception: + continue + + pips: Optional[float] = None + if inst_lower in preds: + pips = float(preds[inst_lower]) + else: + for k, v in preds.items(): + if inst_lower in k.lower(): + try: pips = float(v); break + except (TypeError, ValueError): pass + if pips is None: + continue + + absorption = max(1, int(calib.get("absorption_days", 7))) + dtype = str(calib.get("decay_type", "exp")) + rise = max(0, int(calib.get("rise_days", 0))) + plateau = max(0, int(calib.get("plateau_days", 0))) + + ev_end = r.get("end_date") + if ev_end and str(r.get("sub_type", "")).startswith("rate_guidance"): + try: + meeting = date_type.fromisoformat(ev_end[:10]) + ev_start = date_type.fromisoformat(r["start_date"][:10]) + absorption = max(1, (meeting - ev_start).days) + dtype = "linear"; rise = 0; plateau = 0 + except ValueError: + pass + + try: + ev_date = date_type.fromisoformat(r["start_date"][:10]) + except ValueError: + continue + + days = (ref_date - ev_date).days + lf = _lifecycle(days, rise, plateau, absorption, dtype) + if lf < 0.01: + continue + + cat = r["category"] + by_cat.setdefault(cat, []).append({ + "analysis_id": r["analysis_id"], + "event_id": r.get("event_id"), + "title": r.get("title", ""), + "start_date": r["start_date"][:10], + "days_since": days, + "pip_prediction": round(pips, 1), + "lifecycle_factor": round(lf, 3), + "remaining_pips": round(pips * lf, 1), + "calibration": { + "absorption_days": absorption, + "rise_days": rise, + "plateau_days": plateau, + "decay_type": dtype, + }, + }) + + return by_cat + + def get_model_state(conn, instrument: str, at_date: Optional[str] = None) -> Optional[dict]: """ Full model state : DAG evaluation avec saturation (Phase 2) + poids de régime. @@ -889,6 +989,8 @@ def get_model_state(conn, instrument: str, at_date: Optional[str] = None) -> Opt direction = "bullish" if net_pips > 5 else "bearish" if net_pips < -5 else "neutral" + event_details = get_active_event_details(conn, inst_upper, ref_date) + return { "instrument": inst_upper, "name": graph_def["name"], @@ -906,6 +1008,7 @@ def get_model_state(conn, instrument: str, at_date: Optional[str] = None) -> Opt "nodes": nodes_out, "output_node": output_id, "regime": regime_info, + "event_details": event_details, } @@ -944,11 +1047,14 @@ def simulate_timeline( inst_lower = inst_upper.lower() extended = date_from - timedelta(days=365) rows = conn.execute(""" - SELECT a.prediction_json, e.start_date, e.end_date, e.sub_type, - t.category, t.calibration_json + SELECT a.id as analysis_id, + a.prediction_json, e.start_date, e.end_date, e.sub_type, + t.category, + COALESCE(o.calibration_json, t.calibration_json) as calibration_json FROM causal_event_analyses a JOIN market_events e ON e.id = a.market_event_id JOIN causal_graph_templates t ON t.id = a.template_id + LEFT JOIN event_calibration_overrides o ON o.analysis_id = a.id WHERE a.instrument=? AND e.start_date>=? AND e.start_date<=? """, (inst_upper, str(extended), str(today))).fetchall() diff --git a/frontend/src/pages/InstrumentModels.tsx b/frontend/src/pages/InstrumentModels.tsx index 53d0612..e9197b6 100644 --- a/frontend/src/pages/InstrumentModels.tsx +++ b/frontend/src/pages/InstrumentModels.tsx @@ -68,6 +68,7 @@ interface ModelState { nodes: ModelNode[] output_node: string regime: RegimeInfo + event_details: Record } interface TimelinePoint { @@ -98,6 +99,42 @@ interface VirtualEventForm { decay_type: string } +interface EventDetail { + analysis_id: number + event_id: number + title: string + start_date: string + days_since: number + pip_prediction: number + lifecycle_factor: number + remaining_pips: number + calibration: { + absorption_days: number + rise_days: number + plateau_days: number + decay_type: string + } +} + +interface CalendarEvent { + analysis_id: number + event_id: number + title: string + start_date: string + category: string + is_future: boolean + days_offset: number + pip_prediction: number + lifecycle_factor: number + remaining_pips: number + calibration: { + absorption_days: number + rise_days: number + plateau_days: number + decay_type: string + } +} + // ── Constants ───────────────────────────────────────────────────────────────── const INSTRUMENTS = ['EURUSD','USDJPY','XAUUSD','SP500','TLT','GBPUSD','EEM','QQQ'] @@ -361,7 +398,10 @@ function NodeEditModal({ node, instrument, onClose, onSaved }: { const COL_LABELS = ['Events actifs', 'Variables manuelles', 'Couches intermédiaires', 'Résultat net'] -function NodeCard({ node, onEdit }: { node: ModelNode; onEdit: (n: ModelNode) => void }) { +function NodeCard({ node, onEdit, eventDetails }: { + node: ModelNode; onEdit: (n: ModelNode) => void + eventDetails?: Record +}) { 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' @@ -403,7 +443,30 @@ function NodeCard({ node, onEdit }: { node: ModelNode; onEdit: (n: ModelNode) => )} - ) : ( + ) : 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) + return ( +
+
+ {fmt(v)} pips + {evList.length > 0 && ( + {evList.length} ev + )} +
+ {evList.length > 0 && ( +
+
+
+
+ {barW}% +
+ )} +
+ ) + })() : (
{fmt(v)} pips
@@ -435,8 +498,9 @@ function NodeCard({ node, onEdit }: { node: ModelNode; onEdit: (n: ModelNode) => ) } -function DagView({ nodes, instrument, onEdit }: { +function DagView({ nodes, instrument, onEdit, eventDetails }: { nodes: ModelNode[]; instrument: string; onEdit: (n: ModelNode) => void + eventDetails: Record }) { const cols: ModelNode[][] = [[], [], [], []] for (const n of nodes) cols[Math.min(n.display_col ?? 0, 3)].push(n) @@ -450,7 +514,7 @@ function DagView({ nodes, instrument, onEdit }: { {lbl} ({cols[ci].length})
- {cols[ci].map(n => )} + {cols[ci].map(n => )} ))} @@ -677,6 +741,7 @@ function TimelineView({ instrument }: { instrument: string }) { const [virtuals, setVirtuals] = useState([]) const [whatifLoading, setWhatifLoading] = useState(false) const [whatifData, setWhatifData] = useState(null) + const [importingCal, setImportingCal] = useState(false) const canvasRef = useRef(null) const activeData = whatifData ?? data @@ -986,12 +1051,42 @@ function TimelineView({ instrument }: { instrument: string }) { {/* Virtual Events Panel */} {showVirtual && (
-
+
Events virtuels (What-if)
- +
+ + +
{virtuals.length === 0 && (
@@ -1085,25 +1180,28 @@ const CONF_META: Record = { } function SyncPanel({ instrument, onClose, onApplied }: { - instrument: string; onClose: () => void; onApplied: () => void + instrument: string; onClose: () => void; onApplied: (atDate?: string) => void }) { - const [data, setData] = useState(null) - const [loading, setLoading] = useState(true) + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) const [selected, setSelected] = useState>(new Set()) const [applying, setApplying] = useState(false) + const [atDate, setAtDate] = useState('') - useEffect(() => { + function fetchSuggestions(date?: string) { setLoading(true) - api.get(`/instrument-models/${instrument}/gauge-suggestions`) + const qs = date ? `?at_date=${date}` : '' + api.get(`/instrument-models/${instrument}/gauge-suggestions${qs}`) .then(r => { setData(r.data) - // Pre-select HIGH confidence items const highs = new Set(r.data.suggestions.filter(s => s.confidence === 'HIGH').map(s => s.node_id)) setSelected(highs) }) .catch(() => setData(null)) .finally(() => setLoading(false)) - }, [instrument]) + } + + useEffect(() => { fetchSuggestions() }, [instrument]) function toggleAll(conf: string) { if (!data) return @@ -1122,9 +1220,9 @@ function SyncPanel({ instrument, onClose, onApplied }: { try { const overrides = data.suggestions .filter(s => selected.has(s.node_id)) - .map(s => ({ node_id: s.node_id, value: s.value, note: `[Auto] ${s.note}` })) + .map(s => ({ node_id: s.node_id, value: s.value, note: `[Auto${atDate ? ' @'+atDate : ''}] ${s.note}` })) await api.post(`/instrument-models/${instrument}/apply-suggestions`, { overrides }) - onApplied() + onApplied(atDate || undefined) onClose() } finally { setApplying(false) } } @@ -1139,15 +1237,39 @@ function SyncPanel({ instrument, onClose, onApplied }: { {/* Header */}
-
+
Sync Marché — {instrument}
{data && (
- Gauges du {data.gauge_date} · {data.gauges_available.length} indicateurs disponibles + Snapshot: {data.gauge_date} + {(data as any).requested_date && (data as any).requested_date !== data.gauge_date && ( + (demandé: {(data as any).requested_date}) + )} + · {data.gauges_available.length} indicateurs
)} + {/* Date picker to initialize at a historical date */} +
+ + setAtDate(e.target.value)} + className="bg-dark-700 border border-slate-700/40 rounded px-2 py-0.5 text-xs text-white focus:outline-none focus:border-blue-500/60" + /> + + {atDate && ( + + )} +
-
@@ -1254,9 +1376,153 @@ function SyncPanel({ instrument, onClose, onApplied }: { ) } +// ── Calibration View ────────────────────────────────────────────────────────── + +const DECAY_OPTIONS = ['exp', 'linear', 'step'] as const + +function CalibrationView({ instrument, eventDetails }: { + instrument: string + eventDetails: Record +}) { + const [localCalib, setLocalCalib] = useState>({}) + const [saving, setSaving] = useState>({}) + const [saved, setSaved] = useState>({}) + + const allEvents = Object.values(eventDetails).flat() + .sort((a, b) => b.lifecycle_factor - a.lifecycle_factor) + + if (allEvents.length === 0) { + return ( +
+ Aucun event actif actuellement — les paramètres d'arêtes s'affichent ici quand des events sont en cours d'absorption. +
+ ) + } + + function getCalib(ev: EventDetail) { + return localCalib[ev.analysis_id] ?? ev.calibration + } + + function setField(id: number, field: keyof EventDetail['calibration'], val: string | number) { + setLocalCalib(prev => ({ + ...prev, + [id]: { ...(prev[id] ?? allEvents.find(e => e.analysis_id === id)!.calibration), [field]: val }, + })) + setSaved(prev => ({ ...prev, [id]: false })) + } + + async function saveCalib(ev: EventDetail) { + const calib = getCalib(ev) + setSaving(prev => ({ ...prev, [ev.analysis_id]: true })) + try { + await api.put(`/instrument-models/${instrument}/events/${ev.analysis_id}/calibration`, calib) + setSaved(prev => ({ ...prev, [ev.analysis_id]: true })) + } finally { + setSaving(prev => ({ ...prev, [ev.analysis_id]: false })) + } + } + + async function resetCalib(ev: EventDetail) { + await api.delete(`/instrument-models/${instrument}/events/${ev.analysis_id}/calibration`) + setLocalCalib(prev => { const n = {...prev}; delete n[ev.analysis_id]; return n }) + setSaved(prev => ({ ...prev, [ev.analysis_id]: false })) + } + + return ( +
+
+ Paramètres de decay (arêtes) des events actifs — modifiez le lifecycle par event indépendamment du template. +
+ {allEvents.map(ev => { + const cal = getCalib(ev) + const lf = ev.lifecycle_factor + const isSaving = saving[ev.analysis_id] + const isDirty = !!localCalib[ev.analysis_id] + const wasSaved = saved[ev.analysis_id] + return ( +
+ {/* Event header */} +
+
+ {ev.start_date} + {ev.title || ev.analysis_id} + {ev.days_since}j +
+
+ + {fmt(ev.pip_prediction)}p + + + →{fmt(ev.remaining_pips)}p + + {/* Lifecycle bar */} +
+
+
0.5 ? 'bg-emerald-500' : lf > 0.2 ? 'bg-amber-500' : 'bg-red-500')} + style={{width:`${Math.round(lf*100)}%`}}/> +
+ {Math.round(lf*100)}% +
+
+
+ + {/* Calibration inputs */} +
+
+ + setField(ev.analysis_id, 'absorption_days', parseInt(e.target.value)||1)} + className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-blue-500/60"/> +
+
+ + setField(ev.analysis_id, 'rise_days', parseInt(e.target.value)||0)} + className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-blue-500/60"/> +
+
+ + setField(ev.analysis_id, 'plateau_days', parseInt(e.target.value)||0)} + className="w-full bg-dark-700 border border-slate-700/40 rounded px-2 py-1 text-xs text-white focus:outline-none focus:border-blue-500/60"/> +
+
+ + +
+
+ + {/* Actions */} +
+ {isDirty && !wasSaved && ( + + )} + +
+
+ ) + })} +
+ ) +} + // ── Main Page ────────────────────────────────────────────────────────────────── -type ViewMode = 'dag' | 'table' | 'timeline' +type ViewMode = 'dag' | 'table' | 'timeline' | 'calibration' export default function InstrumentModels() { const [instrument, setInstrument] = useState('EURUSD') @@ -1293,9 +1559,10 @@ export default function InstrumentModels() { }, [state]) const VIEW_BTNS: { key: ViewMode; Icon: typeof Network; label: string }[] = [ - { key: 'dag', Icon: Network, label: 'DAG' }, - { key: 'table', Icon: Table2, label: 'Tableau' }, - { key: 'timeline', Icon: LineChart, label: 'Timeline' }, + { key: 'dag', Icon: Network, label: 'DAG' }, + { key: 'table', Icon: Table2, label: 'Tableau' }, + { key: 'timeline', Icon: LineChart, label: 'Timeline' }, + { key: 'calibration', Icon: Activity, label: 'Calibration' }, ] return ( @@ -1437,9 +1704,10 @@ export default function InstrumentModels() { {/* Main content */} {state && (
- {view === 'dag' && } - {view === 'table' && } - {view === 'timeline' && } + {view === 'dag' && } + {view === 'table' && } + {view === 'timeline' && } + {view === 'calibration' && }
)} @@ -1484,7 +1752,10 @@ export default function InstrumentModels() { setShowSync(false)} - onApplied={() => { setShowSync(false); setRefreshKey(k => k + 1) }} + onApplied={(atDate) => { + setShowSync(false) + setRefreshKey(k => k + 1) + }} /> )}