From f4e57e9d849158f5c691ce2dd3e79791fa2b1f0f Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Fri, 3 Jul 2026 09:59:26 +0200 Subject: [PATCH] feat: instrument model --- backend/routers/instrument_models.py | 155 +++++++++++++----------- backend/services/instrument_models.py | 121 ++++++------------ frontend/src/pages/InstrumentModels.tsx | 43 +++---- 3 files changed, 139 insertions(+), 180 deletions(-) diff --git a/backend/routers/instrument_models.py b/backend/routers/instrument_models.py index 522cf1c..17955f1 100644 --- a/backend/routers/instrument_models.py +++ b/backend/routers/instrument_models.py @@ -229,93 +229,108 @@ def timeline_whatif( conn.close() +_INSTRUMENT_CURRENCIES: Dict[str, List[str]] = { + "EURUSD": ["EUR", "USD"], + "USDJPY": ["USD", "JPY"], + "XAUUSD": ["USD"], + "SP500": ["USD"], + "TLT": ["USD"], + "GBPUSD": ["GBP", "USD"], + "EEM": ["USD", "CNY"], + "QQQ": ["USD"], +} + + +def _ff_event_to_category(event_name: str) -> str: + n = event_name.lower() + if any(k in n for k in ["fomc", "fed ", "ecb", "boe", "boj", "rba", "interest rate", "rate decision", "monetary policy", "central bank"]): + return "central_bank" + if any(k in n for k in ["cpi", "pce", "ppi", "inflation", "core price"]): + return "monetary_shock" + if any(k in n for k in ["nfp", "non-farm", "payroll", "employment change", "unemployment"]): + return "monetary_shock" + if any(k in n for k in ["gdp", "growth", "retail sales", "manufacturing", "pmi", "ism"]): + return "growth_shock" + if any(k in n for k in ["oil", "opec", "crude", "energy", "natural gas"]): + return "commodity" + if any(k in n for k in ["tarif", "trade war", "sanction", "geopolit"]): + return "geopolitical" + return "unclassified" + + @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), + instrument: str, + days_back: int = Query(90, description="Jours en arriere depuis at_date"), + days_forward: int = Query(180, description="Jours en avant depuis at_date"), + impacts: Optional[str] = Query("high,medium", description="Filtre impact FF (high,medium,low)"), + at_date: Optional[str] = Query(None), ) -> List[Dict[str, Any]]: - """Events calendrier analysés pour cet instrument — alimente le panneau What-if.""" + """ + Events economiques du calendrier Forex Factory pour cet instrument. + Source : ff_calendar (Forex Factory). Decorrele des market_events / CausalLab. + Auto-sync live si ff_calendar vide pour la periode demandee. + """ 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() + date_from = str(ref - timedelta(days=days_back)) + date_to = str(ref + timedelta(days=days_forward)) + + currencies = _INSTRUMENT_CURRENCIES.get(inst_upper, ["USD"]) + impact_filter = [i.strip() for i in impacts.split(",")] if impacts else ["high", "medium"] + + # Auto-sync live si ff_calendar vide pour cette periode + ph = ",".join("?" * len(currencies)) + n_existing = conn.execute( + f"SELECT COUNT(*) FROM ff_calendar WHERE event_date>=? AND event_date<=? AND currency IN ({ph})", + [date_from, date_to, *currencies] + ).fetchone()[0] + + if n_existing == 0: + try: + from services.ff_calendar import sync_live + sync_live() + except Exception: + pass + + pi = ",".join("?" * len(impact_filter)) + rows = conn.execute( + f"""SELECT event_date, event_time, currency, impact, event_name, + actual_value, forecast_value, previous_value + FROM ff_calendar + WHERE event_date >= ? AND event_date <= ? + AND currency IN ({ph}) + AND impact IN ({pi}) + ORDER BY event_date ASC, event_time ASC""", + [date_from, date_to, *currencies, *impact_filter] + ).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 - + r = dict(row) + ev_date = r["event_date"] + is_future = ev_date > str(ref) + category = _ff_event_to_category(r["event_name"]) 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, - }, + "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, }) return result finally: diff --git a/backend/services/instrument_models.py b/backend/services/instrument_models.py index 4eac947..3e5d85d 100644 --- a/backend/services/instrument_models.py +++ b/backend/services/instrument_models.py @@ -913,28 +913,22 @@ def get_model_state(conn, instrument: str, at_date: Optional[str] = None) -> Opt (inst_upper,) ).fetchall()} - ev_by_cat = _compute_event_by_category(conn, inst_upper, ref_date) + # Machine structurelle pure — aucune injection automatique depuis market_events. + # Les events du CausalLab ne touchent plus le graphe; seuls les overrides manuels + # (Sync Marche + saisie) et les events virtuels (What-if) contribuent. + ev_by_cat: dict[str, float] = {} - # Phase 2 : détection régime + poids adaptatifs - regime_info = detect_regime(ev_by_cat) + regime_info = detect_regime(ev_by_cat) # toujours BALANCED hors What-if regime_weights = regime_info["weights"] - # Inputs avec saturation tanh - inputs = _build_inputs(graph_def, overrides, ev_by_cat, saturation=True) - - # DAG evaluation avec formule output pondérée par régime from services.causal_graphs import evaluate_graph + inputs = _build_inputs(graph_def, overrides, ev_by_cat, saturation=True) gj = _graph_json_for_eval(graph_def, regime_weights) all_vals = evaluate_graph(gj, inputs) - output_id = graph_def["output_node"] - net_pips = round(float(all_vals.get(output_id, 0.0)), 1) - - # Compute structural pips (manual inputs only, no events, BALANCED regime) - inputs_struct = _build_inputs(graph_def, overrides, {}, saturation=True) - gj_struct = _graph_json_for_eval(graph_def, {}) - vals_struct = evaluate_graph(gj_struct, inputs_struct) - structural_pips = round(float(vals_struct.get(output_id, 0.0)), 1) + output_id = graph_def["output_node"] + net_pips = round(float(all_vals.get(output_id, 0.0)), 1) + structural_pips = net_pips # identique — pas d'events injectés event_pips = round(net_pips - structural_pips, 1) meta = INSTRUMENT_MODELS.get(inst_upper, {}) @@ -1049,64 +1043,10 @@ def simulate_timeline( (inst_upper,) ).fetchall()} - # Load ALL relevant events (extended lookback for decay) - inst_lower = inst_upper.lower() - extended = date_from - timedelta(days=365) - rows = conn.execute(""" - 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() + # Machine structurelle — seuls les events virtuels (What-if) entrent dans le calcul. + # La timeline de base ne dépend plus de causal_event_analyses. + events: list[dict] = [] - # Pre-parse events - events = [] - for r in rows: - r = dict(r) - 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 not pips: - 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 - events.append({ - "ev_date": ev_date, "category": r["category"], "pips": pips, - "rise": rise, "plateau": plateau, "absorption": absorption, "dtype": dtype, - "virtual": False, - }) - - # Inject virtual events for ve in (virtual_events or []): try: ev_date = date_type.fromisoformat(str(ve["date"])[:10]) @@ -1130,9 +1070,17 @@ def simulate_timeline( from services.causal_graphs import evaluate_graph + # Structural pips — calculé une seule fois (overrides statiques, pas d'events) + gj_struct = _graph_json_for_eval(graph_def, {}) + inputs_struct = _build_inputs(graph_def, overrides, {}, saturation=True) + vals_struct = evaluate_graph(gj_struct, inputs_struct) + structural_pips = round(float(vals_struct.get(output_id, 0.0)), 1) + fundamental_level_base = round(price_intercept + structural_pips * pip_to_price, 6) + timeline = [] cur = date_from while cur <= today: + # Accumule les events virtuels (What-if) actifs ce jour ev_by_cat: dict[str, float] = {} for ev in events: if ev["ev_date"] > cur: @@ -1144,27 +1092,28 @@ def simulate_timeline( cat = ev["category"] ev_by_cat[cat] = ev_by_cat.get(cat, 0.0) + round(ev["pips"] * df, 2) - # Phase 2 : régime du jour → poids adaptatifs dans la formule output - ri = detect_regime(ev_by_cat) - gj = _graph_json_for_eval(graph_def, ri["weights"]) - inputs = _build_inputs(graph_def, overrides, ev_by_cat, saturation=True) - vals = evaluate_graph(gj, inputs) - net = round(float(vals.get(output_id, 0.0)), 1) - - # Structural pips (manual only, no events) - inputs_struct = _build_inputs(graph_def, overrides, {}, saturation=True) - gj_struct = _graph_json_for_eval(graph_def, {}) - vals_struct = evaluate_graph(gj_struct, inputs_struct) - structural_pips = round(float(vals_struct.get(output_id, 0.0)), 1) + if ev_by_cat: + # Des events virtuels sont actifs → recalcul complet avec régime + ri = detect_regime(ev_by_cat) + gj = _graph_json_for_eval(graph_def, ri["weights"]) + inputs = _build_inputs(graph_def, overrides, ev_by_cat, saturation=True) + vals = evaluate_graph(gj, inputs) + net = round(float(vals.get(output_id, 0.0)), 1) + regime_label = ri["regime"] + else: + # Pas d'events — on réutilise les valeurs structurelles + vals = vals_struct + net = structural_pips + regime_label = "BALANCED" timeline.append({ "date": str(cur), "net_pips": net, "structural_pips": structural_pips, "event_pips": round(net - structural_pips, 1), - "fundamental_level": round(price_intercept + structural_pips * pip_to_price, 6), + "fundamental_level": fundamental_level_base, "synthetic_price": round(price_intercept + net * pip_to_price, 6), - "regime": ri["regime"], + "regime": regime_label, "nodes": {k: round(float(v), 1) for k, v in vals.items()}, }) cur += timedelta(days=1) diff --git a/frontend/src/pages/InstrumentModels.tsx b/frontend/src/pages/InstrumentModels.tsx index 75324d1..31d09b0 100644 --- a/frontend/src/pages/InstrumentModels.tsx +++ b/frontend/src/pages/InstrumentModels.tsx @@ -120,22 +120,17 @@ interface EventDetail { } interface CalendarEvent { - analysis_id: number - event_id: number - title: string - start_date: string + date: string + event_time: string + currency: string + impact: string + event_name: string + label: string + actual_value: string | null + forecast_value: string | null + previous_value: string | null 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 ───────────────────────────────────────────────────────────────── @@ -1215,19 +1210,19 @@ function TimelineView({ instrument }: { instrument: string }) { const r = await api.get( `/instrument-models/${instrument}/calendar-events?days_back=365&days_forward=90` ) - const imported: VirtualEventForm[] = r.data.map(ev => ({ - id: ev.analysis_id.toString(), - date: ev.start_date, + const imported: VirtualEventForm[] = r.data.map((ev, i) => ({ + id: `ff_${ev.date}_${ev.currency}_${i}`, + date: ev.date, category: ev.category, - pips: ev.pip_prediction, - label: ev.title || ev.category, - absorption_days: ev.calibration.absorption_days, - rise_days: ev.calibration.rise_days, - plateau_days: ev.calibration.plateau_days, - decay_type: ev.calibration.decay_type, + pips: 0, // utilisateur saisit la magnitude + label: ev.label, + absorption_days: 14, + rise_days: 1, + plateau_days: 0, + decay_type: 'exp', })) if (imported.length === 0) { - setCalMsg('Aucun event analysé pour cet instrument — utilisez CausalLab d\'abord') + setCalMsg('Aucun event FF calendrier (high/medium) pour cette période — sync en cours ou calendrier vide') } else { setVirtuals(prev => { const existingIds = new Set(prev.map(v => v.id))