""" Instrument Models Router — graphes causaux exhaustifs par instrument. """ from typing import Any, Dict, List, Optional from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel router = APIRouter(prefix="/api/instrument-models", tags=["instrument-models"]) class OverrideBody(BaseModel): value: float note: Optional[str] = "" class BulkOverrideItem(BaseModel): node_id: str value: float note: Optional[str] = "" class BulkOverrideBody(BaseModel): overrides: List[BulkOverrideItem] class VirtualEvent(BaseModel): date: str category: str = "unclassified" pips: float = 0.0 label: str = "Event virtuel" absorption_days: int = 14 rise_days: int = 1 plateau_days: int = 0 decay_type: str = "exp" class WhatIfBody(BaseModel): period: str = "1y" virtual_events: List[VirtualEvent] = [] start_date: Optional[str] = None class NodeConfigBody(BaseModel): macro_key: Optional[str] = None # "" to clear, None = no-op class NodeScenarioBody(BaseModel): node_id: str label: str horizon: str = "mt" # 'ct' | 'mt' | 'lt' target_date: str # YYYY-MM-DD target_value: float confidence: float = 0.7 # 0.0 – 1.0 trajectory: str = "linear" # 'step' | 'linear' | 'exp' absorption_days: int = 30 notes: Optional[str] = "" 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 from services.instrument_models import INSTRUMENT_MODELS conn = get_conn() try: rows = conn.execute( "SELECT instrument, updated_at FROM instrument_models ORDER BY instrument" ).fetchall() result = [] for r in rows: inst = r["instrument"] meta = INSTRUMENT_MODELS.get(inst, {}) counts: dict[str, int] = {} for n in meta.get("nodes", []): t = n.get("node_type", "unknown") counts[t] = counts.get(t, 0) + 1 result.append({ "instrument": inst, "name": meta.get("name", inst), "description": meta.get("description", ""), "n_input_event": counts.get("input_event", 0), "n_input_manual": counts.get("input_manual", 0), "n_intermediate": counts.get("intermediate", 0), "updated_at": r["updated_at"], }) return result finally: conn.close() @router.get("/{instrument}/gauge-suggestions") 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 conn = get_conn() try: 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, at_date=at_date) if not gauges: raise HTTPException(status_code=404, detail="Aucun snapshot de gauges disponible") suggestions = suggest_from_gauges(inst, gauges) return { "instrument": inst, "gauge_date": snap_date, "requested_date": at_date, "n_suggestions": len(suggestions), "suggestions": suggestions, "gauges_available": list(gauges.keys()), } finally: conn.close() @router.post("/{instrument}/apply-suggestions") def apply_gauge_suggestions( instrument: str, body: BulkOverrideBody, ) -> Dict[str, Any]: """Applique une liste d'overrides en bulk (depuis gauge sync ou saisie rapide).""" 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.overrides: set_node_override(conn, inst, item.node_id, item.value, item.note or "") saved.append(item.node_id) return {"ok": True, "instrument": inst, "saved": saved, "count": len(saved)} finally: 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).""" from services.database import get_conn conn = get_conn() try: inst = instrument.upper() conn.execute("DELETE FROM instrument_node_overrides WHERE instrument=?", (inst,)) conn.commit() return {"ok": True, "instrument": inst} finally: conn.close() @router.get("/{instrument}/regime") def get_instrument_regime( instrument: str, at_date: Optional[str] = Query(None), ) -> Dict[str, Any]: """Régime de marché courant pour cet instrument (détecté depuis events actifs).""" from services.database import get_conn from services.instrument_models import _compute_event_by_category, detect_regime from datetime import datetime, date as date_type conn = get_conn() try: try: ref_date = date_type.fromisoformat(at_date) if at_date else datetime.utcnow().date() except ValueError: ref_date = datetime.utcnow().date() ev_by_cat = _compute_event_by_category(conn, instrument.upper(), ref_date) return detect_regime(ev_by_cat) finally: conn.close() @router.get("/{instrument}/price-history") def get_price_history( instrument: str, period: str = Query("1y", description="5d|1mo|3mo|6mo|1y|2y"), refresh: bool = Query(False), ) -> Dict[str, Any]: """Cours historiques réels depuis yfinance (cache SQLite 6h).""" from services.database import get_conn from services.price_history import get_price_history as fetch_prices from services.instrument_models import INSTRUMENT_MODELS conn = get_conn() try: inst = instrument.upper() prices = fetch_prices(conn, inst, period, force_refresh=refresh) meta = INSTRUMENT_MODELS.get(inst, {}) return { "instrument": inst, "ticker": meta.get("yf_ticker", ""), "period": period, "n_points": len(prices), "prices": prices, } finally: conn.close() def _ph_period(start_date: Optional[str], fallback_period: str) -> str: """Compute the price-history period string needed to cover start_date → today.""" if not start_date: return fallback_period try: from datetime import datetime as _dt days = (_dt.utcnow().date() - _dt.fromisoformat(start_date[:10]).date()).days + 10 for p, d in [("5d", 7), ("1mo", 35), ("3mo", 95), ("6mo", 190), ("1y", 370), ("2y", 740)]: if days <= d: return p return "2y" except Exception: return fallback_period @router.get("/{instrument}/timeline") def get_instrument_timeline( instrument: str, period: str = Query("1y", description="5d|1mo|3mo|6mo|1y|2y"), start_date: Optional[str] = Query(None, description="Date de début ISO (override period)"), ) -> List[Dict[str, Any]]: """Simulation jour par jour de tous les nœuds du modèle sur la période.""" from services.database import get_conn from services.instrument_models import simulate_timeline from services.price_history import get_price_history conn = get_conn() try: get_price_history(conn, instrument.upper(), _ph_period(start_date, period)) data = simulate_timeline(conn, instrument.upper(), period, start_date=start_date) if not data: raise HTTPException(status_code=404, detail=f"Modèle introuvable pour {instrument.upper()}") return data finally: conn.close() @router.post("/{instrument}/timeline-whatif") def timeline_whatif( instrument: str, body: WhatIfBody, ) -> List[Dict[str, Any]]: """Simulation what-if avec events virtuels injectés dans la timeline.""" from services.database import get_conn from services.instrument_models import simulate_timeline from services.price_history import get_price_history conn = get_conn() try: get_price_history(conn, instrument.upper(), _ph_period(body.start_date, body.period)) ve_list = [ve.dict() for ve in body.virtual_events] data = simulate_timeline( conn, instrument.upper(), body.period, virtual_events=ve_list, start_date=body.start_date ) if not data: raise HTTPException(status_code=404, detail=f"Modèle introuvable pour {instrument.upper()}") return data finally: 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"], "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(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 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 datetime import datetime, date as date_type, timedelta import json as _json conn = get_conn() try: inst_upper = instrument.upper() try: ref = date_type.fromisoformat(at_date) if at_date else datetime.utcnow().date() except ValueError: ref = datetime.utcnow().date() 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"] # Build event_category → node_label map from instrument graph event_node_map: dict[str, str] = {} try: graph_row = conn.execute( "SELECT graph_json FROM instrument_models WHERE instrument=?", [inst_upper] ).fetchone() if graph_row: gdef = _json.loads(graph_row["graph_json"]) event_node_map = { n.get("event_category", ""): n.get("label", n["id"]) for n in gdef.get("nodes", []) if n.get("node_type") == "input_event" and n.get("event_category") } event_node_map.pop("", None) except Exception: pass # 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) 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, "routed_node": event_node_map.get(category, "direct"), "is_future": is_future, "estimated_pips": est_pips, "has_surprise": has_surprise, "surprise_delta": surp_delta, }) 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, body: CalibrateBody, ) -> Dict[str, Any]: """Auto-calcule l'intercept depuis le cours réel à une date de référence.""" from services.database import get_conn from services.instrument_models import get_model_state, INSTRUMENT_MODELS from services.price_history import calibrate_intercept as do_calibrate, get_price_history conn = get_conn() try: inst = instrument.upper() state = get_model_state(conn, inst, body.ref_date) if not state: raise HTTPException(status_code=404, detail=f"Modèle introuvable") # Make sure prices are cached get_price_history(conn, inst, "3mo") intercept = do_calibrate(conn, inst, state["structural_pips"], body.ref_date) meta = INSTRUMENT_MODELS.get(inst, {}) return { "instrument": inst, "ref_date": body.ref_date, "structural_pips": state["structural_pips"], "pip_to_price": meta.get("pip_to_price", 0.0001), "calibrated_intercept": intercept, "current_intercept": meta.get("price_intercept", 0.0), } finally: conn.close() @router.get("/{instrument}/scenarios") def list_scenarios(instrument: str, node_id: Optional[str] = Query(None)) -> List[Dict[str, Any]]: """Tous les scénarios CT/MT/LT d'un instrument (ou d'un nœud spécifique).""" from services.database import get_conn from services.instrument_models import get_node_scenarios conn = get_conn() try: return get_node_scenarios(conn, instrument.upper(), node_id) finally: conn.close() @router.post("/{instrument}/scenarios") def create_scenario(instrument: str, body: NodeScenarioBody) -> Dict[str, Any]: """Crée un scénario forecast sur un nœud.""" from services.database import get_conn from services.instrument_models import add_node_scenario conn = get_conn() try: sid = add_node_scenario( conn, instrument.upper(), body.node_id, body.label, body.horizon, body.target_date, body.target_value, body.confidence, body.trajectory, body.absorption_days, body.notes or "", ) return {"ok": True, "id": sid, "node_id": body.node_id} finally: conn.close() @router.delete("/{instrument}/scenarios/{scenario_id}") def remove_scenario(instrument: str, scenario_id: int) -> Dict[str, Any]: """Supprime un scénario forecast.""" from services.database import get_conn from services.instrument_models import delete_node_scenario conn = get_conn() try: delete_node_scenario(conn, scenario_id) return {"ok": True, "id": scenario_id} finally: conn.close() @router.patch("/{instrument}/nodes/{node_id}") def update_node_config( instrument: str, node_id: str, body: NodeConfigBody, ) -> Dict[str, Any]: """Met à jour la configuration d'un nœud (macro_key) dans le graph_json persisté.""" import json as _json from services.database import get_conn conn = get_conn() try: inst = instrument.upper() row = conn.execute( "SELECT graph_json FROM instrument_models WHERE instrument=?", (inst,) ).fetchone() if not row: raise HTTPException(status_code=404, detail=f"Instrument {inst} introuvable") graph_def = _json.loads(row["graph_json"]) found = False for node in graph_def.get("nodes", []): if node["id"] == node_id: if body.macro_key is not None: if body.macro_key == "": node.pop("macro_key", None) # clear else: node["macro_key"] = body.macro_key found = True break if not found: raise HTTPException(status_code=404, detail=f"Nœud {node_id} introuvable") conn.execute( "UPDATE instrument_models SET graph_json=?, updated_at=datetime('now') WHERE instrument=?", (_json.dumps(graph_def), inst) ) conn.commit() return {"ok": True, "node_id": node_id, "macro_key": body.macro_key} finally: conn.close() @router.get("/{instrument}/macro-guidance") def get_macro_guidance(instrument: str) -> List[Dict[str, Any]]: """ Retourne l'état courant et le prochain forecast pour chaque nœud input_manual avec un macro_key configuré. """ import json as _json from services.database import get_conn from services.instrument_models import build_macro_node_timeline, FF_MACRO_KEYS, _parse_ff_num from datetime import datetime, timedelta, date as date_type conn = get_conn() try: inst = instrument.upper() row = conn.execute( "SELECT graph_json FROM instrument_models WHERE instrument=?", (inst,) ).fetchone() if not row: return [] graph_def = _json.loads(row["graph_json"]) today = datetime.utcnow().date() result = [] for node in graph_def.get("nodes", []): if node.get("node_type") != "input_manual": continue macro_key = node.get("macro_key") if not macro_key: continue meta = FF_MACRO_KEYS.get(macro_key, {}) currency = meta.get("currency", "") patterns = meta.get("names", []) # Current interpolated value date_from = today - timedelta(days=90) date_to = today + timedelta(days=180) tl = build_macro_node_timeline(conn, macro_key, date_from, date_to) current_v = tl.get(str(today)) # Next scheduled event from ff_calendar next_event = None if currency and patterns: try: ff_rows = conn.execute( """SELECT event_date, event_name, forecast_value, previous_value FROM ff_calendar WHERE currency=? AND event_date > ? ORDER BY event_date ASC LIMIT 20""", (currency, str(today)) ).fetchall() for r in ff_rows: if any(p in r["event_name"].lower() for p in patterns): ev_date = date_type.fromisoformat(r["event_date"]) forecast_v = _parse_ff_num(r.get("forecast_value") or r.get("previous_value")) next_event = { "date": r["event_date"], "name": r["event_name"], "forecast": forecast_v, "days_until": (ev_date - today).days, } break except Exception: pass # Unit-aware conversion (same logic as get_macro_sync_items) unit = node.get("unit", "") def _uc(v): 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: return round(v - 50.0, 1) return round(v, 4) next_fv = _uc(next_event.get("forecast") if next_event else None) if next_event: next_event = {**next_event, "forecast": next_fv} result.append({ "node_id": node["id"], "node_label": node.get("label", node["id"]), "macro_key": macro_key, "unit": unit, "current_value": _uc(current_v), "next_event": next_event, }) return result finally: conn.close() @router.get("/{instrument}") def get_instrument_model( instrument: str, at_date: Optional[str] = Query(None, description="YYYY-MM-DD (défaut: aujourd'hui)"), ) -> Dict[str, Any]: """Graphe complet avec valeurs courantes des nœuds (toutes couches).""" from services.database import get_conn from services.instrument_models import get_model_state conn = get_conn() try: state = get_model_state(conn, instrument.upper(), at_date) if not state: raise HTTPException(status_code=404, detail=f"Modèle introuvable pour {instrument.upper()}") return state finally: conn.close() @router.put("/{instrument}/nodes/{node_id}/override") def set_override(instrument: str, node_id: str, body: OverrideBody) -> Dict[str, Any]: """Définit ou met à jour la valeur manuelle d'un nœud.""" from services.database import get_conn from services.instrument_models import set_node_override conn = get_conn() try: set_node_override(conn, instrument.upper(), node_id, body.value, body.note or "") return {"ok": True, "instrument": instrument.upper(), "node_id": node_id, "value": body.value} finally: conn.close() @router.delete("/{instrument}/nodes/{node_id}/override") def clear_override(instrument: str, node_id: str) -> Dict[str, Any]: """Supprime l'override manuel d'un nœud (retour neutre/events).""" from services.database import get_conn from services.instrument_models import clear_node_override conn = get_conn() try: clear_node_override(conn, instrument.upper(), node_id) return {"ok": True, "instrument": instrument.upper(), "node_id": node_id} finally: conn.close() @router.get("/{instrument}/nodes/overrides") def get_all_overrides(instrument: str) -> List[Dict[str, Any]]: """Toutes les overrides manuelles pour un instrument.""" from services.database import get_conn conn = get_conn() try: rows = conn.execute( "SELECT node_id, value, note, set_at FROM instrument_node_overrides WHERE instrument=? ORDER BY set_at DESC", (instrument.upper(),) ).fetchall() return [dict(r) for r in rows] finally: conn.close() class CalendarBackfillBody(BaseModel): from_date: str # ISO date string "YYYY-MM-DD" to_date: Optional[str] = None # defaults to today @router.post("/ff-calendar/backfill") def backfill_calendar(body: CalendarBackfillBody) -> Dict[str, Any]: """ Backfill ff_calendar with historical data from ForexFactory HTML scraper. Scrapes week by week from from_date to to_date (or today). Skips weeks already populated. Runs synchronously — may take several minutes for long ranges. """ from datetime import date as date_type from services.ff_calendar import sync_historical_range try: from_d = date_type.fromisoformat(body.from_date[:10]) to_d = date_type.fromisoformat(body.to_date[:10]) if body.to_date else date_type.today() except ValueError as e: raise HTTPException(status_code=400, detail=f"Invalid date: {e}") if (to_d - from_d).days > 730: raise HTTPException(status_code=400, detail="Range too large (max 2 years)") result = sync_historical_range(from_d, to_d) return result