feat: pressure

This commit is contained in:
OpenSquared
2026-07-02 18:07:21 +02:00
parent 01709e5edf
commit ae5865a156
3 changed files with 303 additions and 6 deletions

View File

@@ -4,7 +4,7 @@ Exposes per-instrument snapshot (price, indicators, regime, trend, events) and A
"""
import json
import math
from datetime import datetime, timedelta
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
@@ -227,3 +227,166 @@ def get_theoretical_curve(
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),
}