From 716d8fa56c702189d5804ac565587017aa396ce4 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Fri, 3 Jul 2026 17:39:44 +0200 Subject: [PATCH] feat: instrument model --- backend/routers/instrument_models.py | 37 ++++ backend/services/instrument_models.py | 126 ++++++++++++ frontend/src/pages/InstrumentModels.tsx | 252 +++++++++++++++++++++++- 3 files changed, 405 insertions(+), 10 deletions(-) diff --git a/backend/routers/instrument_models.py b/backend/routers/instrument_models.py index ccecde9..32b1816 100644 --- a/backend/routers/instrument_models.py +++ b/backend/routers/instrument_models.py @@ -148,6 +148,43 @@ def apply_gauge_suggestions( conn.close() +@router.get("/{instrument}/macro-sync") +def get_macro_sync(instrument: str) -> Dict[str, Any]: + """Retourne les valeurs actuelles + forecasts FF Calendar pour tous les nœuds macro-liés.""" + from services.database import get_conn + from services.instrument_models import get_macro_sync_items + conn = get_conn() + try: + items = get_macro_sync_items(conn, instrument) + return {"instrument": instrument.upper(), "items": items, "count": len(items)} + finally: + conn.close() + + +class MacroSyncApplyBody(BaseModel): + items: list[dict] # [{node_id, value, note}] + +@router.post("/{instrument}/macro-sync/apply") +def apply_macro_sync(instrument: str, body: MacroSyncApplyBody) -> Dict[str, Any]: + """Applique les valeurs macro (actuelles ou forecasts) comme node overrides.""" + from services.database import get_conn + from services.instrument_models import set_node_override + conn = get_conn() + try: + inst = instrument.upper() + saved = [] + for item in body.items: + nid = item.get("node_id", "") + value = item.get("value") + note = item.get("note", "[Sync Marché FF Calendar]") + if nid and value is not None: + set_node_override(conn, inst, nid, float(value), note) + saved.append(nid) + return {"ok": True, "instrument": inst, "saved": saved, "count": len(saved)} + finally: + conn.close() + + @router.delete("/{instrument}/overrides") def clear_all_overrides(instrument: str) -> Dict[str, Any]: """Supprime TOUTES les overrides d'un instrument (reset à zéro).""" diff --git a/backend/services/instrument_models.py b/backend/services/instrument_models.py index e183a7f..95fb5f9 100644 --- a/backend/services/instrument_models.py +++ b/backend/services/instrument_models.py @@ -315,6 +315,132 @@ def build_node_combined_timeline( return result +# ── Macro sync — lecture directe ff_calendar → overrides ────────────────────── + +def get_macro_sync_items(conn, instrument: str) -> list[dict]: + """ + Pour chaque nœud avec macro_key dans le graphe, retourne : + - actual_value : dernière valeur publiée (passé) + - actual_date : date de cette publication + - forecast_value: forecast du prochain événement (futur) + - forecast_date / next_event_name / days_until + - current_override : valeur déjà settée en DB (ou None) + Utilisé par le bouton "Sync Marché" pour pré-remplir le graphe. + """ + inst_upper = instrument.upper() + row = conn.execute( + "SELECT graph_json FROM instrument_models WHERE instrument=?", (inst_upper,) + ).fetchone() + if not row: + return [] + + graph_def = json.loads(row["graph_json"]) + today = date_type.today() + today_str = str(today) + q_from = str(today - timedelta(days=730)) + q_to = str(today + timedelta(days=180)) + + overrides = {r["node_id"]: float(r["value"]) for r in conn.execute( + "SELECT node_id, value FROM instrument_node_overrides WHERE instrument=?", (inst_upper,) + ).fetchall()} + + items = [] + for node in graph_def.get("nodes", []): + mk = node.get("macro_key") + if not mk or node.get("node_type") != "input_manual": + continue + mk_info = FF_MACRO_KEYS.get(mk) + if not mk_info: + continue + + currency = mk_info["currency"] + patterns = mk_info["names"] + + try: + rows = conn.execute( + """SELECT event_date, event_name, actual_value, forecast_value, previous_value + FROM ff_calendar + WHERE currency=? AND event_date>=? AND event_date<=? + ORDER BY event_date ASC""", + (currency, q_from, q_to) + ).fetchall() + except Exception: + rows = [] + + matched = [ + dict(r) for r in rows + if any(p in str(r["event_name"]).lower() for p in patterns) + ] + + # Deduplicate by date + seen: set[str] = set() + deduped = [] + for ev in matched: + if ev["event_date"] not in seen: + seen.add(ev["event_date"]) + deduped.append(ev) + + # Latest actual (most recent past event with actual_value) + past = [e for e in deduped if e["event_date"] <= today_str] + actual_val: Optional[float] = None + actual_date: Optional[str] = None + for ev in reversed(past): + v = _parse_ff_num(ev.get("actual_value")) + if v is not None: + actual_val = v + actual_date = ev["event_date"] + break + + # Next forecast (first future event) + future = [e for e in deduped if e["event_date"] > today_str] + forecast_val: Optional[float] = None + forecast_date: Optional[str] = None + next_event_name: Optional[str] = None + days_until: Optional[int] = None + if future: + nxt = future[0] + forecast_val = ( + _parse_ff_num(nxt.get("forecast_value")) + or _parse_ff_num(nxt.get("previous_value")) + ) + forecast_date = nxt["event_date"] + next_event_name = nxt["event_name"] + try: + days_until = (date_type.fromisoformat(forecast_date) - today).days + except Exception: + days_until = None + + # Unit-aware post-conversion + # The FF Calendar stores NFP in absolute (e.g. 228000), node expects K (228) + # PMI stored as absolute value (54.7), node expects deviation from 50 (4.7) + unit = node.get("unit", "") + def _convert(v: Optional[float]) -> Optional[float]: + if v is None: + return None + if unit == "K" and abs(v) >= 1_000: + return round(v / 1_000, 1) + if unit == "pts" and v > 10: # PMI > 10 → is absolute, subtract 50 + return round(v - 50.0, 1) + return round(v, 4) + + items.append({ + "node_id": node["id"], + "node_label": node["label"], + "macro_key": mk, + "unit": unit, + "coefficient_to_pips": node.get("coefficient_to_pips", 1), + "actual_value": _convert(actual_val), + "actual_date": actual_date, + "forecast_value": _convert(forecast_val), + "forecast_date": forecast_date, + "next_event_name": next_event_name, + "days_until_forecast": days_until, + "current_override": overrides.get(node["id"]), + }) + + return items + + # ── Scenario CRUD ────────────────────────────────────────────────────────────── def get_node_scenarios(conn, instrument: str, node_id: Optional[str] = None) -> list[dict]: diff --git a/frontend/src/pages/InstrumentModels.tsx b/frontend/src/pages/InstrumentModels.tsx index 4bd3f40..2ae8e74 100644 --- a/frontend/src/pages/InstrumentModels.tsx +++ b/frontend/src/pages/InstrumentModels.tsx @@ -1705,7 +1705,7 @@ const CONF_META: Record = { } function SyncPanel({ instrument, onClose, onApplied }: { - instrument: string; onClose: () => void; onApplied: (atDate?: string) => void + instrument: string; onClose: () => void; onApplied: () => void }) { const [data, setData] = useState(null) const [loading, setLoading] = useState(true) @@ -1747,7 +1747,7 @@ function SyncPanel({ instrument, onClose, onApplied }: { .filter(s => selected.has(s.node_id)) .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(atDate || undefined) + onApplied() onClose() } finally { setApplying(false) } } @@ -1901,6 +1901,235 @@ function SyncPanel({ instrument, onClose, onApplied }: { ) } +// ── Macro Sync Panel (FF Calendar → node overrides) ─────────────────────────── + +interface MacroSyncItem { + node_id: string; node_label: string; macro_key: string; unit: string + coefficient_to_pips: number + actual_value: number | null; actual_date: string | null + forecast_value: number | null; forecast_date: string | null + next_event_name: string | null; days_until_forecast: number | null + current_override: number | null +} + +function MacroSyncPanel({ instrument, onClose, onApplied }: { + instrument: string; onClose: () => void; onApplied: () => void +}) { + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + // per-node choice: 'actual' | 'forecast' | null (skip) + const [choices, setChoices] = useState>({}) + const [applying, setApplying] = useState(false) + + useEffect(() => { + setLoading(true) + api.get(`/instrument-models/${instrument}/macro-sync`) + .then(r => { + const its: MacroSyncItem[] = r.data.items + setItems(its) + // Default: select 'actual' when available + const init: Record = {} + for (const it of its) { + init[it.node_id] = it.actual_value != null ? 'actual' : it.forecast_value != null ? 'forecast' : null + } + setChoices(init) + }) + .catch(() => setItems([])) + .finally(() => setLoading(false)) + }, [instrument]) + + async function apply() { + setApplying(true) + try { + const payload = items.flatMap(it => { + const choice = choices[it.node_id] + if (!choice) return [] + const value = choice === 'actual' ? it.actual_value : it.forecast_value + if (value == null) return [] + const source = choice === 'actual' + ? `Actual ${it.actual_date ?? ''}` + : `Forecast ${it.forecast_date ?? ''} — ${it.next_event_name ?? ''}` + return [{ node_id: it.node_id, value, note: `[FF Calendar] ${source}` }] + }) + await api.post(`/instrument-models/${instrument}/macro-sync/apply`, { items: payload }) + onApplied() + onClose() + } finally { setApplying(false) } + } + + const applyCount = Object.values(choices).filter(v => v != null).length + + function setAllActual() { + const n = { ...choices } + for (const it of items) { + if (it.actual_value != null) n[it.node_id] = 'actual' + } + setChoices(n) + } + function setAllForecast() { + const n = { ...choices } + for (const it of items) { + if (it.forecast_value != null) n[it.node_id] = 'forecast' + } + setChoices(n) + } + + return ( +
+
e.stopPropagation()}> + + {/* Header */} +
+
+
Sync Marché — {instrument} (FF Calendar)
+
+ Synchronise les nœuds macro avec les dernières valeurs publiées + forecasts prochains événements. +
+
+ +
+ + {/* Content */} +
+ {loading && ( +
Lecture FF Calendar…
+ )} + {!loading && items.length === 0 && ( +
+ Aucun nœud macro-lié trouvé, ou aucune donnée FF Calendar disponible. +
+ )} + {!loading && items.length > 0 && ( +
+ {/* Quick actions */} +
+ Sélectionner tout : + + + + {applyCount} sélectionné{applyCount > 1 ? 's' : ''} +
+ + {/* Node rows */} +
+ + + + + + + + + + + {items.map(it => { + const ch = choices[it.node_id] + return ( + + + + {/* Actual value cell */} + + + {/* Forecast cell */} + + + {/* Current override */} + + + ) + })} + +
Variable + Actuel + + Prochain forecast + Override actuel
+
{it.node_label}
+
{it.macro_key}
+
+ {it.actual_value != null ? ( + + ) : ( + + )} + + {it.forecast_value != null ? ( + + ) : ( + + )} + + {it.current_override != null ? ( + + {it.current_override.toFixed(2)} {it.unit} + + ) : ( + + )} +
+
+ +
+ Actuel = dernière valeur publiée dans FF Calendar.  + Forecast = estimation du prochain événement. + Cliquer sur une cellule pour la sélectionner / désélectionner. +
+
+ )} +
+ + {/* Footer */} +
+ + +
+
+
+ ) +} + // ── Calibration View ────────────────────────────────────────────────────────── const DECAY_OPTIONS = ['exp', 'linear', 'step'] as const @@ -2530,14 +2759,17 @@ export default function InstrumentModels() { )} {showSync && ( - setShowSync(false)} - onApplied={(atDate) => { - setShowSync(false) - setRefreshKey(k => k + 1) - }} - /> + state?.nodes.some(n => n.macro_key) + ? setShowSync(false)} + onApplied={() => { setShowSync(false); setRefreshKey(k => k + 1) }} + /> + : setShowSync(false)} + onApplied={() => { setShowSync(false); setRefreshKey(k => k + 1) }} + /> )} )