Files
OpenFin/backend/routers/causal_lab.py

755 lines
27 KiB
Python
Raw 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.
"""
Causal Lab v2 — Bibliothèque de graphes causaux + analyse empirique.
Sources d'événements : market_events (toutes catégories).
Templates de graphes : causal_graph_templates (seed automatique au démarrage).
IA : GPT-4o pour recommandation de template + coefficients.
Endpoints :
GET /api/causal-lab/templates — liste des templates
GET /api/causal-lab/template/{id} — détail d'un template
PUT /api/causal-lab/template/{id} — mettre à jour les coefficients
GET /api/causal-lab/market-events — événements analysables
POST /api/causal-lab/recommend — GPT-4o recommande un template
POST /api/causal-lab/analyze — analyse (prédiction + réel)
GET /api/causal-lab/analyses — historique des analyses
GET /api/causal-lab/calibration — stats de calibration par template
"""
import json
import logging
import math
from datetime import datetime, timedelta
from typing import Optional
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
router = APIRouter()
logger = logging.getLogger(__name__)
# ── Initialisation DB (appelée depuis startup) ────────────────────────────────
def _init(conn):
from services.causal_graphs import init_tables, seed_templates
init_tables(conn)
seed_templates(conn)
# ── Récupération des prix autour d'un événement ───────────────────────────────
def _fetch_prices(event_date_str: str, instruments: list[str]) -> dict:
"""
Télécharge les prix autour de l'événement pour chaque instrument demandé.
Retourne dict { instrument: [{"t": iso, "c": float}, ...] }
"""
YFINANCE_MAP = {
"EURUSD": "EURUSD=X",
"XAUUSD": "GC=F",
"SP500": "^GSPC",
"BRENT": "BZ=F",
"US2Y": "US2YT=RR",
"US10Y": "^TNX",
"EU10Y": "GE10YT=RR",
}
out: dict = {"mode": "none"}
for inst in instruments + ["US2Y", "US10Y", "EU10Y"]:
out[inst] = []
try:
import yfinance as yf
event_dt = datetime.strptime(event_date_str[:10], "%Y-%m-%d")
days_ago = (datetime.utcnow() - event_dt).days
# Intraday 5min (< 55 jours) pour les FX / actifs principaux
use_intraday = days_ago < 55
for inst in instruments:
sym = YFINANCE_MAP.get(inst)
if not sym:
continue
try:
if use_intraday:
start = (event_dt - timedelta(days=1)).strftime("%Y-%m-%d")
end = (event_dt + timedelta(days=2)).strftime("%Y-%m-%d")
df = yf.download(sym, start=start, end=end,
interval="5m", progress=False, auto_adjust=True)
if df is not None and len(df) > 0:
if hasattr(df.columns, "levels"):
df.columns = df.columns.get_level_values(0)
day_df = df[df.index.date == event_dt.date()]
rows = [
{"t": idx.isoformat(), "c": round(float(row["Close"]), 5)}
for idx, row in day_df.iterrows()
if not math.isnan(float(row["Close"]))
]
if len(rows) >= 6:
out[inst] = rows
out["mode"] = "intraday_5m"
continue
# Fallback journalier
start = (event_dt - timedelta(days=5)).strftime("%Y-%m-%d")
end = (event_dt + timedelta(days=5)).strftime("%Y-%m-%d")
df = yf.download(sym, start=start, end=end,
interval="1d", progress=False, auto_adjust=True)
if df is not None and len(df) > 0:
if hasattr(df.columns, "levels"):
df.columns = df.columns.get_level_values(0)
out[inst] = [
{"t": str(idx.date()), "c": round(float(row["Close"]), 5)}
for idx, row in df.iterrows()
if float(row["Close"]) == float(row["Close"])
]
if out["mode"] == "none":
out["mode"] = "daily"
except Exception as e:
logger.debug(f"[causal_lab] price {sym}: {e}")
# Taux (toujours en journalier)
start = (event_dt - timedelta(days=4)).strftime("%Y-%m-%d")
end = (event_dt + timedelta(days=4)).strftime("%Y-%m-%d")
for key in ["US2Y", "US10Y", "EU10Y"]:
sym = YFINANCE_MAP.get(key)
if not sym:
continue
try:
df = yf.download(sym, start=start, end=end,
interval="1d", progress=False, auto_adjust=True)
if df is None or len(df) == 0:
continue
if hasattr(df.columns, "levels"):
df.columns = df.columns.get_level_values(0)
out[key] = [
{"t": str(idx.date()), "c": round(float(row["Close"]), 3)}
for idx, row in df.iterrows()
if float(row["Close"]) == float(row["Close"])
]
except Exception as e:
logger.debug(f"[causal_lab] yield {sym}: {e}")
except Exception as e:
logger.error(f"[causal_lab] _fetch_prices: {e}")
return out
def _drift_metrics(prices: dict, event_date_str: str, inst: str) -> dict:
series = prices.get(inst, [])
mode = prices.get("mode", "none")
edate = event_date_str[:10]
empty = {"pre_pips": None, "post_pips": None, "drift_ratio": None, "leak": "unknown"}
if not series:
return empty
if mode == "intraday_5m":
n = len(series)
if n < 8:
return empty
mid = n // 2
# Convertir en pips (×10000 pour FX, ×10 pour or/pétrole/SP500)
mult = 10000 if inst in ("EURUSD",) else 10
pre_pips = round((series[mid - 1]["c"] - series[0]["c"]) * mult)
post_pips = round((series[-1]["c"] - series[mid]["c"]) * mult)
else:
pre = [b for b in series if b["t"] < edate]
same = [b for b in series if b["t"] == edate]
if not pre or not same:
return empty
mult = 10000 if inst in ("EURUSD",) else 10
pre_pips = None
post_pips = round((same[0]["c"] - pre[-1]["c"]) * mult)
ratio: Optional[float] = None
if pre_pips is not None and post_pips and post_pips != 0:
ratio = round(pre_pips / post_pips, 3)
leak = "unknown"
if ratio is not None:
ar = abs(ratio)
leak = "high" if ar > 0.5 else "medium" if ar > 0.25 else "low"
return {"pre_pips": pre_pips, "post_pips": post_pips, "drift_ratio": ratio, "leak": leak}
def _yield_delta(series: list, event_date: str) -> Optional[float]:
if not series:
return None
pre = [b for b in series if b["t"] < event_date]
post = [b for b in series if b["t"] >= event_date]
if not pre or not post:
return None
return round(post[0]["c"] - pre[-1]["c"], 3)
def _activation_score(node_values: dict, actual_moves: dict, template_graph: dict) -> dict:
"""
Compare les sorties du graphe (outputs) aux mouvements réels.
Retourne {score, nodes: {node_id: {pred, act, status}}}
"""
output_nodes = [n for n in template_graph.get("nodes", []) if n.get("type") == "output"]
nodes_detail: dict = {}
correct = 0
evaluated = 0
for node in output_nodes:
nid = node["id"]
inst = node.get("instrument", "")
pred = node_values.get(nid)
act = actual_moves.get(inst)
if pred is None or act is None:
status = "unknown"
elif abs(pred) < 2 and abs(act) < 2:
status = "neutral"
elif (pred > 0) == (act > 0):
status = "correct"
correct += 1
evaluated += 1
else:
status = "wrong"
evaluated += 1
nodes_detail[nid] = {
"pred": round(pred, 2) if pred is not None else None,
"act": round(act, 2) if act is not None else None,
"status": status,
"label": node.get("label", nid),
}
score = round(correct / evaluated, 2) if evaluated else None
return {"score": score, "nodes": nodes_detail, "correct": correct, "total": evaluated}
# ── Helpers GPT-4o ─────────────────────────────────────────────────────────────
def _gpt4o_recommend(event: dict, templates: list) -> dict:
"""Appelle GPT-4o pour recommander un template + coefficients."""
try:
from services.database import get_config
import openai
key = get_config("openai_api_key") or ""
if not key:
return {"error": "GPT-4o non configuré (clé OpenAI manquante)"}
client = openai.OpenAI(api_key=key)
template_summary = [
{
"id": t["id"],
"name": t["name"],
"category": t["category"],
"sub_type": t.get("sub_type", ""),
"instruments": t.get("instruments", []),
"description": t.get("description", ""),
}
for t in templates
]
system_prompt = (
"You are a macro market analyst and causal graph specialist for the GeoOptions platform. "
"Given a market event and a library of causal graph templates, recommend the most appropriate template "
"and suggest any coefficient adjustments. Respond with a JSON object only."
)
user_prompt = f"""Market event to analyze:
- Name: {event.get('name')}
- Category: {event.get('category')}
- Sub-type: {event.get('sub_type', '')}
- Description: {event.get('description', '')}
- Market impact: {event.get('market_impact', '')}
- Affected assets: {event.get('affected_assets', '')}
- Impact score: {event.get('impact_score', '')}
- Surprise (%): {event.get('surprise_pct', 'N/A')}
- Expected: {event.get('expected_value', 'N/A')}
- Actual: {event.get('actual_value', 'N/A')}
Available templates:
{json.dumps(template_summary, ensure_ascii=False, indent=2)}
Respond with this exact JSON structure:
{{
"template_id": <int or null>,
"template_name": "<name>",
"confidence": <0.0-1.0>,
"rationale": "<2-3 sentences explaining why this template fits>",
"coefficient_suggestions": {{
"<coef_key>": <suggested_value>
}},
"instrument_focus": ["<primary instrument>"],
"alternative_template_id": <int or null>,
"notes": "<any additional context>"
}}"""
resp = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
response_format={"type": "json_object"},
temperature=0.3,
max_tokens=600,
)
raw = resp.choices[0].message.content or "{}"
return json.loads(raw)
except Exception as e:
logger.error(f"[causal_lab] GPT-4o recommend: {e}")
return {"error": str(e)}
# ── Schémas Pydantic ──────────────────────────────────────────────────────────
class UpdateCoefRequest(BaseModel):
coefficients: dict # {coef_key: float}
class CreateTemplateRequest(BaseModel):
name: str
category: str
sub_type: str = ""
description: str = ""
instruments: list = ["EURUSD"]
graph_json: dict
ai_rationale: str = ""
class RecommendRequest(BaseModel):
market_event_id: int
class AnalyzeRequest(BaseModel):
market_event_id: int
template_id: int
instrument: str = "EURUSD"
inputs: dict = {}
coef_overrides: dict = {}
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/api/causal-lab/debug")
def debug_causal():
"""Endpoint de diagnostic — vérifie que causal_graphs est bien chargé."""
try:
from services.database import get_conn
from services.causal_graphs import BUILT_IN_TEMPLATES, init_tables, seed_templates
conn = get_conn()
init_tables(conn)
seed_templates(conn)
count = conn.execute("SELECT COUNT(*) FROM causal_graph_templates").fetchone()[0]
conn.close()
return {
"built_in_templates": len(BUILT_IN_TEMPLATES),
"db_templates": count,
"status": "ok",
}
except Exception as e:
import traceback
return {"status": "error", "error": str(e), "trace": traceback.format_exc()}
@router.get("/api/causal-lab/templates")
def list_templates(category: str = Query("")):
try:
from services.database import get_conn
from services.causal_graphs import get_templates
conn = get_conn()
_init(conn)
data = get_templates(conn, category)
conn.close()
print(f"[causal_lab] list_templates → {len(data)} templates (category={category!r})", flush=True)
return data
except Exception as e:
import traceback
print(f"[causal_lab] list_templates ERROR: {e}\n{traceback.format_exc()}", flush=True)
logger.error(f"[causal_lab] list_templates: {e}")
raise HTTPException(500, str(e))
@router.post("/api/causal-lab/template")
def create_template(body: CreateTemplateRequest):
"""Crée un nouveau template utilisateur."""
try:
from services.database import get_conn
from services.causal_graphs import init_tables, seed_templates
conn = get_conn()
init_tables(conn); seed_templates(conn)
existing = conn.execute(
"SELECT id FROM causal_graph_templates WHERE name = ?", (body.name,)
).fetchone()
if existing:
raise HTTPException(409, f"Un template nommé '{body.name}' existe déjà")
cur = conn.execute("""
INSERT INTO causal_graph_templates
(name, category, sub_type, instruments, description, graph_json, ai_rationale, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, 'user')
""", (
body.name, body.category, body.sub_type,
json.dumps(body.instruments), body.description,
json.dumps(body.graph_json), body.ai_rationale,
))
conn.commit()
new_id = cur.lastrowid
conn.close()
return {"id": new_id, "name": body.name}
except HTTPException:
raise
except Exception as e:
logger.error(f"[causal_lab] create_template: {e}")
raise HTTPException(500, str(e))
@router.delete("/api/causal-lab/template/{template_id}")
def delete_template(template_id: int):
"""Supprime un template utilisateur (les templates system sont protégés)."""
try:
from services.database import get_conn
conn = get_conn()
row = conn.execute(
"SELECT created_by FROM causal_graph_templates WHERE id = ?", (template_id,)
).fetchone()
if not row:
conn.close(); raise HTTPException(404, "Template introuvable")
if row["created_by"] == "system":
conn.close(); raise HTTPException(403, "Les templates système ne peuvent pas être supprimés")
conn.execute("DELETE FROM causal_graph_templates WHERE id = ?", (template_id,))
conn.commit(); conn.close()
return {"ok": True}
except HTTPException:
raise
except Exception as e:
logger.error(f"[causal_lab] delete_template {template_id}: {e}")
raise HTTPException(500, str(e))
@router.get("/api/causal-lab/template/{template_id}")
def get_template_detail(template_id: int):
try:
from services.database import get_conn
from services.causal_graphs import get_template, init_tables, seed_templates
conn = get_conn()
init_tables(conn)
seed_templates(conn)
tmpl = get_template(conn, template_id)
conn.close()
if not tmpl:
raise HTTPException(404, "Template introuvable")
return tmpl
except HTTPException:
raise
except Exception as e:
logger.error(f"[causal_lab] get_template {template_id}: {e}")
raise HTTPException(500, str(e))
@router.put("/api/causal-lab/template/{template_id}")
def update_template(template_id: int, body: UpdateCoefRequest):
try:
from services.database import get_conn
from services.causal_graphs import update_coefficients, init_tables, seed_templates
conn = get_conn()
init_tables(conn)
seed_templates(conn)
ok = update_coefficients(conn, template_id, body.coefficients)
conn.close()
if not ok:
raise HTTPException(404, "Template introuvable")
return {"ok": True}
except HTTPException:
raise
except Exception as e:
logger.error(f"[causal_lab] update_template {template_id}: {e}")
raise HTTPException(500, str(e))
@router.get("/api/causal-lab/market-events")
def list_market_events(
limit: int = Query(200, le=500),
category: str = Query(""),
q: str = Query(""),
):
"""Tous les market_events analysables (avec ou sans analyse existante)."""
try:
from services.database import get_conn
conn = get_conn()
_init(conn)
conditions = ["1=1"]
if category:
conditions.append(f"e.category = '{category}'")
if q:
safe_q = q.replace("'", "''")
conditions.append(f"(e.name LIKE '%{safe_q}%' OR e.description LIKE '%{safe_q}%')")
where = " AND ".join(conditions)
rows = conn.execute(f"""
SELECT e.id, e.name, e.category, e.sub_type,
e.start_date, e.end_date, e.level,
e.impact_score, e.market_impact, e.affected_assets,
e.actual_value, e.expected_value, e.surprise_pct,
a.id as analysis_id, a.template_id, a.activation_score,
a.instrument, a.analyzed_at
FROM market_events e
LEFT JOIN causal_event_analyses a ON a.market_event_id = e.id
WHERE {where}
ORDER BY e.start_date DESC
LIMIT {limit}
""").fetchall()
conn.close()
return [dict(r) for r in rows]
except Exception as e:
logger.error(f"[causal_lab] list_market_events: {e}")
raise HTTPException(500, str(e))
@router.post("/api/causal-lab/recommend")
def recommend_template(body: RecommendRequest):
"""GPT-4o recommande le template le plus adapté à l'événement."""
try:
from services.database import get_conn
from services.causal_graphs import get_templates, init_tables, seed_templates
conn = get_conn()
init_tables(conn)
seed_templates(conn)
ev_row = conn.execute(
"SELECT * FROM market_events WHERE id = ?", (body.market_event_id,)
).fetchone()
if not ev_row:
conn.close()
raise HTTPException(404, "market_event introuvable")
event = dict(ev_row)
templates = get_templates(conn)
conn.close()
recommendation = _gpt4o_recommend(event, templates)
return {"event": event, "recommendation": recommendation}
except HTTPException:
raise
except Exception as e:
logger.error(f"[causal_lab] recommend: {e}")
raise HTTPException(500, str(e))
@router.post("/api/causal-lab/analyze")
def analyze_event(body: AnalyzeRequest):
"""
Analyse complète : évalue le template sur l'événement,
compare aux mouvements réels, stocke le résultat.
"""
try:
from services.database import get_conn
from services.causal_graphs import (
get_template, evaluate_graph, update_calibration,
init_tables, seed_templates,
)
conn = get_conn()
init_tables(conn)
seed_templates(conn)
ev_row = conn.execute(
"SELECT * FROM market_events WHERE id = ?", (body.market_event_id,)
).fetchone()
if not ev_row:
conn.close()
raise HTTPException(404, "market_event introuvable")
event = dict(ev_row)
tmpl = get_template(conn, body.template_id)
if not tmpl:
conn.close()
raise HTTPException(404, "Template introuvable")
graph = tmpl["graph_json"]
# Inputs de base depuis l'événement
inputs = {}
mapping = graph.get("input_mapping", {})
for input_id, cfg in mapping.items():
src = cfg.get("source", "")
if src == "surprise" and event.get("surprise_pct") is not None:
inputs[input_id] = float(event["surprise_pct"])
elif src == "impact_score_scaled" and event.get("impact_score") is not None:
inputs[input_id] = float(event["impact_score"]) / 10.0
elif src == "impact_score_pct" and event.get("impact_score") is not None:
inputs[input_id] = float(event["impact_score"])
elif src == "actual_value" and event.get("actual_value") is not None:
inputs[input_id] = float(event["actual_value"])
# Inputs manuels (ex: tone_score depuis le frontend)
inputs.update(body.inputs)
# Évaluation du graphe
node_values = evaluate_graph(graph, inputs, body.coef_overrides or {})
# Prix réels
instruments = list({body.instrument} | set(tmpl.get("instruments", [body.instrument])))
prices = _fetch_prices(event["start_date"], instruments)
edate = event["start_date"][:10]
actual_moves: dict = {}
drift_by_inst: dict = {}
for inst in instruments:
drift = _drift_metrics(prices, event["start_date"], inst)
drift_by_inst[inst] = drift
if drift.get("post_pips") is not None:
actual_moves[inst] = drift["post_pips"]
# Activation
activation = _activation_score(node_values, actual_moves, graph)
# Taux
yields = {
"us10y": _yield_delta(prices.get("US10Y", []), edate),
"eu10y": _yield_delta(prices.get("EU10Y", []), edate),
"us2y": _yield_delta(prices.get("US2Y", []), edate),
}
analyzed_at = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
result = {
"event": event,
"template_id": body.template_id,
"template_name": tmpl["name"],
"instrument": body.instrument,
"inputs": inputs,
"node_values": node_values,
"actual_moves": actual_moves,
"yields": yields,
"activation": activation,
"drift": drift_by_inst.get(body.instrument, {}),
"prices_mode": prices.get("mode", "none"),
"analyzed_at": analyzed_at,
}
# Persistance
conn.execute("""
INSERT INTO causal_event_analyses
(market_event_id, template_id, instrument, inputs_json,
override_params, prediction_json, actual_json,
activation_score, drift_json, analyzed_at)
VALUES (?,?,?,?,?,?,?,?,?,?)
""", (
body.market_event_id,
body.template_id,
body.instrument,
json.dumps(inputs),
json.dumps(body.coef_overrides),
json.dumps(node_values),
json.dumps(actual_moves),
activation.get("score"),
json.dumps(drift_by_inst.get(body.instrument, {})),
analyzed_at,
))
conn.commit()
# Calibration
update_calibration(conn, body.template_id, {
"activation_score": activation.get("score"),
"instrument": body.instrument,
"pred_pips": node_values.get(body.instrument.lower(), 0) or
next((v for k, v in node_values.items()
if body.instrument.lower() in k.lower()), 0),
"actual_pips": actual_moves.get(body.instrument),
"analyzed_at": analyzed_at,
})
conn.close()
return result
except HTTPException:
raise
except Exception as e:
logger.error(f"[causal_lab] analyze: {e}")
raise HTTPException(500, str(e))
@router.get("/api/causal-lab/analyses")
def list_analyses(
template_id: int = Query(0),
instrument: str = Query(""),
limit: int = Query(100, le=500),
):
try:
from services.database import get_conn
conn = get_conn()
_init(conn)
conditions = ["1=1"]
if template_id:
conditions.append(f"a.template_id = {template_id}")
if instrument:
conditions.append(f"a.instrument = '{instrument}'")
where = " AND ".join(conditions)
rows = conn.execute(f"""
SELECT a.*, e.name as event_name, e.category, e.start_date,
t.name as template_name
FROM causal_event_analyses a
LEFT JOIN market_events e ON e.id = a.market_event_id
LEFT JOIN causal_graph_templates t ON t.id = a.template_id
WHERE {where}
ORDER BY a.analyzed_at DESC
LIMIT {limit}
""").fetchall()
conn.close()
result = []
for r in rows:
d = dict(r)
for f in ("inputs_json", "override_params", "prediction_json", "actual_json", "drift_json"):
try:
d[f] = json.loads(d[f] or "{}")
except Exception:
d[f] = {}
result.append(d)
return result
except Exception as e:
logger.error(f"[causal_lab] list_analyses: {e}")
raise HTTPException(500, str(e))
@router.get("/api/causal-lab/calibration")
def get_calibration():
"""Statistiques de calibration par template (avg_activation, avg_pred vs avg_actual)."""
try:
from services.database import get_conn
conn = get_conn()
_init(conn)
rows = conn.execute("""
SELECT t.id, t.name, t.category, t.sub_type,
t.calibration_json, t.heuristic_ver,
COUNT(a.id) as n_analyses,
AVG(a.activation_score) as avg_activation
FROM causal_graph_templates t
LEFT JOIN causal_event_analyses a ON a.template_id = t.id
GROUP BY t.id
ORDER BY t.category, t.name
""").fetchall()
conn.close()
result = []
for r in rows:
d = dict(r)
try:
d["calibration"] = json.loads(d.pop("calibration_json") or "{}")
except Exception:
d["calibration"] = {}
if d.get("avg_activation") is not None:
d["avg_activation"] = round(d["avg_activation"], 3)
result.append(d)
return result
except Exception as e:
logger.error(f"[causal_lab] calibration: {e}")
raise HTTPException(500, str(e))