393 lines
14 KiB
Python
393 lines
14 KiB
Python
"""
|
||
Instrument Dashboard Router.
|
||
Exposes per-instrument snapshot (price, indicators, regime, trend, events) and AI narrative.
|
||
"""
|
||
import json
|
||
import math
|
||
from datetime import datetime, timedelta, date as date_type
|
||
from fastapi import APIRouter, HTTPException, Query
|
||
from pydantic import BaseModel
|
||
from typing import List, Dict, Any, Optional
|
||
|
||
from services.instrument_service import (
|
||
get_all_instruments,
|
||
get_instrument,
|
||
get_snapshot,
|
||
get_narrative,
|
||
update_instrument_drivers,
|
||
)
|
||
|
||
class DriverUpdate(BaseModel):
|
||
drivers: List[Dict[str, Any]]
|
||
|
||
router = APIRouter(prefix="/api/instruments", tags=["instruments"])
|
||
|
||
|
||
@router.get("", response_model=List[Dict[str, Any]])
|
||
def list_instruments() -> List[Dict[str, Any]]:
|
||
"""
|
||
Return all instrument configurations (no price data).
|
||
"""
|
||
return get_all_instruments()
|
||
|
||
|
||
@router.get("/{instrument_id}/snapshot")
|
||
async def instrument_snapshot(
|
||
instrument_id: str,
|
||
period: str = Query(default="1y", description="yfinance period string (e.g. 1y, 6mo, 3mo)"),
|
||
interval: str = Query(default="1d", description="yfinance interval string (e.g. 1d, 1wk)"),
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Full snapshot for a single instrument: price data, indicators, regime, trend, events.
|
||
"""
|
||
config = get_instrument(instrument_id)
|
||
if not config:
|
||
raise HTTPException(status_code=404, detail=f"Instrument '{instrument_id}' not found")
|
||
|
||
snapshot = await get_snapshot(instrument_id, period=period, interval=interval)
|
||
|
||
if "error" in snapshot:
|
||
raise HTTPException(status_code=500, detail=snapshot["error"])
|
||
|
||
return snapshot
|
||
|
||
|
||
@router.post("/{instrument_id}/narrative")
|
||
async def generate_narrative(
|
||
instrument_id: str,
|
||
force: bool = Query(default=False, description="Force regeneration, bypass cache"),
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Generate (or return cached) a French AI narrative for the instrument.
|
||
"""
|
||
config = get_instrument(instrument_id)
|
||
if not config:
|
||
raise HTTPException(status_code=404, detail=f"Instrument '{instrument_id}' not found")
|
||
|
||
narrative = await get_narrative(instrument_id, force=force)
|
||
|
||
return {
|
||
"instrument_id": instrument_id.upper(),
|
||
"instrument_name": config.get("name", instrument_id),
|
||
"narrative": narrative,
|
||
}
|
||
|
||
|
||
@router.put("/{instrument_id}/drivers")
|
||
def update_drivers(instrument_id: str, body: DriverUpdate) -> Dict[str, Any]:
|
||
"""
|
||
Persist updated drivers (label, weight, keywords) for an instrument to instruments.json.
|
||
"""
|
||
config = get_instrument(instrument_id)
|
||
if not config:
|
||
raise HTTPException(status_code=404, detail=f"Instrument '{instrument_id}' not found")
|
||
|
||
try:
|
||
update_instrument_drivers(instrument_id, body.drivers)
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
return {"ok": True, "instrument_id": instrument_id.upper(), "drivers_count": len(body.drivers)}
|
||
|
||
|
||
# ── Instrument mult (pips → price conversion) ─────────────────────────────────
|
||
|
||
_INST_MULT: Dict[str, int] = {"EURUSD": 10000, "GBPUSD": 10000, "USDJPY": 100, "AUDUSD": 10000}
|
||
|
||
def _get_mult(inst: str) -> int:
|
||
return _INST_MULT.get(inst.upper(), 10)
|
||
|
||
|
||
def _decay(days_after: int, absorption_days: int, decay_type: str) -> float:
|
||
"""Decay factor ∈ [0,1] for a given number of days after the event."""
|
||
if days_after < 0:
|
||
return 0.0
|
||
if decay_type == "step":
|
||
return 1.0 if days_after <= absorption_days else 0.0
|
||
elif decay_type == "linear":
|
||
return max(0.0, 1.0 - days_after / max(absorption_days, 1))
|
||
else: # exp — 3 time-constants reach ~5% at absorption_days
|
||
lam = 3.0 / max(absorption_days, 1)
|
||
return math.exp(-lam * days_after)
|
||
|
||
|
||
@router.get("/{instrument_id}/theoretical-curve")
|
||
def get_theoretical_curve(
|
||
instrument_id: str,
|
||
period: str = Query("1y"),
|
||
) -> List[Dict[str, Any]]:
|
||
"""
|
||
Courbe théorique composite : pour chaque jour calendaire de la période,
|
||
somme des impacts décroissants des analyses causales stockées.
|
||
|
||
Retourne [{date, cumulative_pips, contributions: [{template_name, event_name, event_date, pips, decay_factor}]}]
|
||
"""
|
||
from services.database import get_conn
|
||
|
||
period_lookback: Dict[str, int] = {
|
||
"5d": 7, "1mo": 35, "3mo": 95, "6mo": 190,
|
||
"1y": 370, "2y": 740, "5y": 1830,
|
||
}
|
||
lookback = period_lookback.get(period, 370)
|
||
|
||
date_to = datetime.utcnow().date()
|
||
date_from = date_to - timedelta(days=lookback)
|
||
# Fetch events that started before date_from too — they may still be decaying into the window
|
||
extended_from = date_from - timedelta(days=90)
|
||
|
||
inst_upper = instrument_id.upper()
|
||
|
||
conn = get_conn()
|
||
try:
|
||
rows = conn.execute("""
|
||
SELECT a.id,
|
||
a.prediction_json,
|
||
a.activation_score,
|
||
e.start_date AS event_date,
|
||
e.name AS event_name,
|
||
t.name AS template_name,
|
||
t.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
|
||
WHERE a.instrument = ?
|
||
AND e.start_date >= ?
|
||
AND e.start_date <= ?
|
||
ORDER BY e.start_date
|
||
""", (inst_upper, str(extended_from), str(date_to))).fetchall()
|
||
finally:
|
||
conn.close()
|
||
|
||
# ── Build calendar-day series ─────────────────────────────────────────────
|
||
all_dates: List[str] = []
|
||
cur = date_from
|
||
while cur <= date_to:
|
||
all_dates.append(str(cur))
|
||
cur += timedelta(days=1)
|
||
|
||
curve: Dict[str, Dict] = {
|
||
d: {"cumulative_pips": 0.0, "contributions": []} for d in all_dates
|
||
}
|
||
|
||
for row in rows:
|
||
r = dict(row)
|
||
try:
|
||
predictions = json.loads(r["prediction_json"] or "{}")
|
||
calib = json.loads(r["calibration_json"] or "{}")
|
||
except Exception:
|
||
continue
|
||
|
||
# Extract predicted pips for this instrument from node_values dict
|
||
inst_lower = inst_upper.lower()
|
||
predicted_pips: Optional[float] = None
|
||
if inst_lower in predictions:
|
||
predicted_pips = float(predictions[inst_lower])
|
||
else:
|
||
for k, v in predictions.items():
|
||
if inst_lower in k.lower():
|
||
try:
|
||
predicted_pips = float(v)
|
||
break
|
||
except (TypeError, ValueError):
|
||
pass
|
||
|
||
if predicted_pips is None or predicted_pips == 0:
|
||
continue
|
||
|
||
absorption_days: int = max(1, int(calib.get("absorption_days", 7)))
|
||
dtype: str = str(calib.get("decay_type", "exp"))
|
||
event_date_str: str = r["event_date"][:10]
|
||
|
||
try:
|
||
event_date = datetime.strptime(event_date_str, "%Y-%m-%d").date()
|
||
except ValueError:
|
||
continue
|
||
|
||
for d in all_dates:
|
||
cal_date = datetime.strptime(d, "%Y-%m-%d").date()
|
||
days_after = (cal_date - event_date).days
|
||
df = _decay(days_after, absorption_days, dtype)
|
||
if df < 0.01:
|
||
continue
|
||
contribution = round(predicted_pips * df, 2)
|
||
curve[d]["cumulative_pips"] += contribution
|
||
curve[d]["contributions"].append({
|
||
"template_name": r["template_name"],
|
||
"event_name": r["event_name"],
|
||
"event_date": event_date_str,
|
||
"pips": contribution,
|
||
"decay_factor": round(df, 3),
|
||
})
|
||
|
||
# Round totals and strip empty-contribution days at the edges
|
||
result = []
|
||
for d in all_dates:
|
||
entry = curve[d]
|
||
entry["cumulative_pips"] = round(entry["cumulative_pips"], 1)
|
||
result.append({"date": d, **entry})
|
||
|
||
return result
|
||
|
||
|
||
# ── Libellés lisibles par catégorie ───────────────────────────────────────────
|
||
_CAT_LABELS: Dict[str, str] = {
|
||
"central_bank": "Banque Centrale",
|
||
"monetary_shock": "Surprise Macro",
|
||
"geopolitical": "Géopolitique",
|
||
"commodity": "Commodités",
|
||
"growth_shock": "Croissance",
|
||
"trade_policy": "Commerce / Tarifs",
|
||
"credit_stress": "Stress Crédit",
|
||
"sentiment": "Sentiment & Position.",
|
||
"technical": "Technique",
|
||
"positioning": "Flux Institutionnels",
|
||
"unclassified": "Non Classifié",
|
||
}
|
||
|
||
|
||
@router.get("/{instrument_id}/factor-state")
|
||
def get_factor_state(
|
||
instrument_id: str,
|
||
at_date: Optional[str] = Query(None, description="YYYY-MM-DD (défaut: aujourd'hui)"),
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
Pression nette actuelle sur l'instrument : somme de toutes les contributions
|
||
d'events actifs pondérées par leur courbe de dissipation.
|
||
|
||
Retourne une décomposition par catégorie causale (Banque Centrale, Surprise Macro…)
|
||
avec détail par event, ainsi que le NET en pips et la direction.
|
||
"""
|
||
from services.database import get_conn
|
||
|
||
try:
|
||
ref_date = date_type.fromisoformat(at_date) if at_date else datetime.utcnow().date()
|
||
except ValueError:
|
||
ref_date = datetime.utcnow().date()
|
||
|
||
# Cherche les analyses pour cet instrument dans les 180 jours précédents
|
||
extended_from = ref_date - timedelta(days=180)
|
||
inst_upper = instrument_id.upper()
|
||
|
||
conn = get_conn()
|
||
try:
|
||
rows = conn.execute("""
|
||
SELECT a.prediction_json,
|
||
e.start_date AS event_date,
|
||
e.name AS event_name,
|
||
e.sub_type AS event_sub_type,
|
||
e.end_date AS event_end_date,
|
||
t.name AS template_name,
|
||
t.category AS category,
|
||
t.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
|
||
WHERE a.instrument = ?
|
||
AND e.start_date >= ?
|
||
AND e.start_date <= ?
|
||
ORDER BY e.start_date DESC
|
||
""", (inst_upper, str(extended_from), str(ref_date))).fetchall()
|
||
finally:
|
||
conn.close()
|
||
|
||
inst_lower = inst_upper.lower()
|
||
by_category: Dict[str, Dict] = {}
|
||
seen_events: set = set() # évite les doublons (même event × multi-analyse)
|
||
|
||
for row in rows:
|
||
r = dict(row)
|
||
event_key = (r["event_name"], r["event_date"])
|
||
if event_key in seen_events:
|
||
continue
|
||
seen_events.add(event_key)
|
||
|
||
try:
|
||
predictions = json.loads(r["prediction_json"] or "{}")
|
||
calib = json.loads(r["calibration_json"] or "{}")
|
||
except Exception:
|
||
continue
|
||
|
||
# Pips prédits pour cet instrument (cherche node_id == inst_lower ou contenant)
|
||
pips_full: Optional[float] = None
|
||
if inst_lower in predictions:
|
||
pips_full = float(predictions[inst_lower])
|
||
else:
|
||
for k, v in predictions.items():
|
||
if inst_lower in k.lower():
|
||
try:
|
||
pips_full = float(v)
|
||
break
|
||
except (TypeError, ValueError):
|
||
pass
|
||
|
||
if pips_full is None or pips_full == 0:
|
||
continue
|
||
|
||
absorption_days = max(1, int(calib.get("absorption_days", 7)))
|
||
decay_type = str(calib.get("decay_type", "exp"))
|
||
|
||
try:
|
||
ev_date = date_type.fromisoformat(r["event_date"][:10])
|
||
except ValueError:
|
||
continue
|
||
|
||
# Pour les guidance events : end_date = meeting date → absorption dynamique
|
||
ev_end = r.get("event_end_date")
|
||
if ev_end and r.get("event_sub_type", "").startswith("rate_guidance"):
|
||
try:
|
||
meeting = date_type.fromisoformat(ev_end[:10])
|
||
absorption_days = max(1, (meeting - ev_date).days)
|
||
decay_type = "linear" # anticipation linéaire jusqu'à la réunion
|
||
except ValueError:
|
||
pass
|
||
|
||
days_elapsed = (ref_date - ev_date).days
|
||
df = _decay(days_elapsed, absorption_days, decay_type)
|
||
if df < 0.01:
|
||
continue
|
||
|
||
current_pips = round(pips_full * df, 1)
|
||
cat = r["category"]
|
||
|
||
if cat not in by_category:
|
||
by_category[cat] = {
|
||
"label": _CAT_LABELS.get(cat, cat),
|
||
"pips": 0.0,
|
||
"contributions": [],
|
||
}
|
||
|
||
by_category[cat]["pips"] += current_pips
|
||
by_category[cat]["contributions"].append({
|
||
"event_name": r["event_name"],
|
||
"event_date": r["event_date"][:10],
|
||
"template_name": r["template_name"],
|
||
"pips_full": round(pips_full, 1),
|
||
"days_elapsed": days_elapsed,
|
||
"absorption_days": absorption_days,
|
||
"decay_pct": round(df * 100),
|
||
"pips_current": current_pips,
|
||
})
|
||
|
||
# Arrondi + tri par |pips| décroissant
|
||
for v in by_category.values():
|
||
v["pips"] = round(v["pips"], 1)
|
||
v["contributions"].sort(key=lambda c: abs(c["pips_current"]), reverse=True)
|
||
|
||
categories = sorted(by_category.values(), key=lambda x: abs(x["pips"]), reverse=True)
|
||
net_pips = round(sum(v["pips"] for v in by_category.values()), 1)
|
||
|
||
direction = "neutral"
|
||
if net_pips > 5:
|
||
direction = "bullish"
|
||
elif net_pips < -5:
|
||
direction = "bearish"
|
||
|
||
return {
|
||
"instrument": inst_upper,
|
||
"at_date": str(ref_date),
|
||
"net_pips": net_pips,
|
||
"direction": direction,
|
||
"categories": categories,
|
||
"n_events": len(seen_events),
|
||
}
|