870 lines
32 KiB
Python
870 lines
32 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] = []
|
||
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
|