Files
OpenFin/backend/routers/instrument_models.py
2026-07-14 11:21:43 +02:00

870 lines
32 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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