Files
OpenFin/backend/routers/instrument_models.py
2026-07-03 00:31:30 +02:00

460 lines
17 KiB
Python

"""
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] = []
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.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()
@router.get("/{instrument}/timeline")
def get_instrument_timeline(
instrument: str,
period: str = Query("1y", description="5d|1mo|3mo|6mo|1y|2y"),
) -> 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
conn = get_conn()
try:
data = simulate_timeline(conn, instrument.upper(), period)
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
conn = get_conn()
try:
ve_list = [ve.dict() for ve in body.virtual_events]
data = simulate_timeline(conn, instrument.upper(), body.period, virtual_events=ve_list)
if not data:
raise HTTPException(status_code=404, detail=f"Modèle introuvable pour {instrument.upper()}")
return data
finally:
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,
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}")
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()