feat: causal lab — éditeur visuel, types nœuds, force/signe arêtes, NFP v2

This commit is contained in:
OpenSquared
2026-06-28 09:28:43 +02:00
parent 83a8e8c5be
commit e8fb7c13aa
3 changed files with 838 additions and 199 deletions

View File

@@ -306,6 +306,16 @@ 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
@@ -314,8 +324,8 @@ class AnalyzeRequest(BaseModel):
market_event_id: int
template_id: int
instrument: str = "EURUSD"
inputs: dict = {} # Inputs manuels (ex: tone_score)
coef_overrides: dict = {} # Coefficients overrides pour cet run
inputs: dict = {}
coef_overrides: dict = {}
# ── Endpoints ─────────────────────────────────────────────────────────────────
@@ -359,6 +369,62 @@ def list_templates(category: str = Query("")):
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:

View File

@@ -30,8 +30,13 @@ def _n(id_, label, type_, x, y, formula=None, unit="", instrument=None, descript
if description: n["description"] = description
return n
def _e(from_, to_, style="solid", type_="causal", label=""):
e = {"from": from_, "to": to_, "style": style, "type": type_}
def _e(from_, to_, style="solid", type_="causal", strength=2, sign="neutral", label=""):
"""
strength : 1=fin, 2=normal, 3=épais
sign : "positive" (teal), "negative" (orange), "neutral" (gris)
"""
e = {"from": from_, "to": to_, "style": style, "type": type_,
"strength": strength, "sign": sign}
if label: e["label"] = label
return e
@@ -82,42 +87,65 @@ BUILT_IN_TEMPLATES = [
},
},
# ── 2. NFP surprise ─────────────────────────────────────────────────────────
# ── 2. NFP surprise v2 (dual-channel, spread US-DE, OIS) ────────────────────
{
"name": "NFP — surprise emploi non-agricole",
"category": "macro_us",
"sub_type": "NFP",
"heuristic_ver": 2,
"instruments": ["EURUSD", "SP500"],
"description": "Surprise créations d'emploi → signal FED → 2Y + 10Y → EUR/USD & S&P",
"description": (
"NFP surprise → OIS repricing → US 2Y (fort) + US 10Y (faible) → "
"spread US-DE 2Y → EUR/USD | NFP → croissance attendue → S&P500 (2 canaux)"
),
"ai_rationale": (
"Un NFP supérieur aux attentes signale une économie robuste et renforce "
"les anticipations restrictives FED. Le dollar s'apprécie via le canal des taux courts. "
"L'impact sur le S&P500 est négatif net en contexte hawkish (taux > croissance)."
"Suite à la critique : (1) le nœud 'Signal FED anticipations' est remplacé par un "
"proxy OIS observable (Fed Funds Futures / OIS pricing) ; "
"(2) EUR/USD dépend du spread US-DE 2Y plutôt du seul 2Y US ; "
"(3) S&P500 reçoit deux effets opposés — croissance (positif) via canal bénéfices "
"et taux 10Y (négatif) via canal valorisation. Le lien NFP→10Y est plus faible "
"que NFP→2Y (prime de terme + croissance modèrent l'effet)."
),
"graph_json": {
"nodes": [
_n("nfp_surprise", "NFP surprise", "input", 200, 45, unit="k"),
_n("fed_fwd", "Signal FED anticipations", "intermediate", 200, 155, formula="nfp_surprise / 100 * {{coef_nfp_fwd}}"),
_n("us_2y", "Δ US 2Y", "intermediate", 110, 265, formula="fed_fwd * {{coef_fwd_2y}}", unit="%"),
_n("us_10y", "Δ US 10Y", "intermediate", 290, 265, formula="fed_fwd * {{coef_fwd_10y}}", unit="%"),
_n("eurusd", "EUR/USD", "output", 110, 380, formula="-(us_2y * {{coef_2y_fx}} + us_10y * {{coef_10y_fx}})", unit="pips", instrument="EURUSD"),
_n("sp500", "S&P 500", "output", 290, 380, formula="-fed_fwd * {{coef_fwd_sp}}", unit="pts", instrument="SP500"),
_n("nfp_surprise", "NFP surprise", "macro_event", 200, 50, unit="k"),
_n("ois_pricing", "Fed OIS / probabilités", "observable", 200, 165,
formula="nfp_surprise / 100 * {{coef_nfp_ois}}",
description="OIS ou Fed Funds Futures — probabilité implicite décision FED"),
_n("us_2y", "Δ US 2Y", "observable", 90, 295,
formula="ois_pricing * {{coef_ois_2y}}", unit="%"),
_n("us_10y", "Δ US 10Y", "observable", 310, 295,
formula="ois_pricing * {{coef_ois_10y}}", unit="%"),
_n("rate_diff", "Spread US-DE 2Y", "observable", 90, 405,
formula="us_2y * {{coef_diff_factor}}",
description="proxy: US 2Y seul, idéalement US2Y DE2Y"),
_n("growth_exp", "Croissance attendue", "latent", 310, 405,
formula="nfp_surprise * {{coef_nfp_growth}}"),
_n("eurusd", "EUR/USD", "market_asset", 90, 515,
formula="-rate_diff * {{coef_diff_eurusd}}", unit="pips", instrument="EURUSD"),
_n("sp500", "S&P 500", "market_asset", 310, 515,
formula="growth_exp * {{coef_growth_sp}} - us_10y * {{coef_10y_sp}}",
unit="pts", instrument="SP500"),
],
"edges": [
_e("nfp_surprise", "fed_fwd", "dashed", "fwd_guidance"),
_e("fed_fwd", "us_2y", "solid", "rate_channel"),
_e("fed_fwd", "us_10y", "dashed", "expectations"),
_e("us_2y", "eurusd", "solid", "rate_diff"),
_e("us_10y", "eurusd", "dashed", "rate_diff"),
_e("fed_fwd", "sp500", "dashed", "risk_asset"),
_e("nfp_surprise", "ois_pricing", "solid", "repricing", strength=3, sign="positive", label="repricing"),
_e("ois_pricing", "us_2y", "solid", "rate_anchor", strength=3, sign="positive", label="canal court"),
_e("ois_pricing", "us_10y", "dashed", "expectations", strength=1, sign="positive", label="canal terme"),
_e("us_2y", "rate_diff", "solid", "spread", strength=2, sign="positive"),
_e("nfp_surprise", "growth_exp", "dashed", "growth", strength=2, sign="positive", label="bénéfices"),
_e("rate_diff", "eurusd", "solid", "fx_channel", strength=3, sign="negative", label="USD s'apprécie"),
_e("growth_exp", "sp500", "solid", "growth_sp", strength=2, sign="positive", label="bénéfices +"),
_e("us_10y", "sp500", "dashed", "discount", strength=2, sign="negative", label="discount rate"),
],
"coefficients": {
"coef_nfp_fwd": _c(0.35, "100k NFP → signal FED"),
"coef_fwd_2y": _c(0.030, "Signal FED → Δ US 2Y (%)"),
"coef_fwd_10y": _c(0.070, "Signal FED → Δ US 10Y (%)"),
"coef_2y_fx": _c(500, "1% spread 2Y → EUR/USD (pips)"),
"coef_10y_fx": _c(200, "1% spread 10Y → EUR/USD (pips)"),
"coef_fwd_sp": _c(20.0, "Unité signal FED hawkish → S&P500 (pts, négatif)"),
"coef_nfp_ois": _c(0.35, "100k NFP → signal OIS (probabilité remonté taux)"),
"coef_ois_2y": _c(0.030, "Signal OIS → Δ US 2Y (%) — canal court fort"),
"coef_ois_10y": _c(0.012, "Signal OIS → Δ US 10Y (%) — plus faible que 2Y"),
"coef_diff_factor":_c(1.0, "US 2Y → spread US-DE (proxy sans données EU)"),
"coef_nfp_growth": _c(0.003, "100k NFP → signal croissance attendue"),
"coef_diff_eurusd":_c(500, "1% spread → EUR/USD pips (négatif pour EUR)"),
"coef_growth_sp": _c(80, "Signal croissance → S&P500 pts (positif)"),
"coef_10y_sp": _c(150, "1% hausse 10Y → S&P500 pts (négatif, valorisation)"),
},
"instruments": ["EURUSD", "SP500"],
"input_mapping": {
@@ -518,25 +546,39 @@ def init_tables(conn):
def seed_templates(conn):
"""Insère les templates built-in s'ils n'existent pas encore."""
"""Insère les templates built-in ; met à jour si heuristic_ver a été augmentée."""
for t in BUILT_IN_TEMPLATES:
ver = t.get("heuristic_ver", 1)
existing = conn.execute(
"SELECT id FROM causal_graph_templates WHERE name = ?", (t["name"],)
"SELECT id, heuristic_ver FROM causal_graph_templates WHERE name = ?", (t["name"],)
).fetchone()
if existing:
if (existing["heuristic_ver"] or 1) < ver:
conn.execute("""
UPDATE causal_graph_templates
SET graph_json=?, description=?, ai_rationale=?, instruments=?,
heuristic_ver=?, updated_at=datetime('now')
WHERE name=?
""", (
json.dumps(t["graph_json"]),
t.get("description", ""),
t.get("ai_rationale", ""),
json.dumps(t.get("instruments", [])),
ver, t["name"],
))
continue
conn.execute("""
INSERT INTO causal_graph_templates
(name, category, sub_type, instruments, description, graph_json, ai_rationale, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, 'system')
(name, category, sub_type, instruments, description, graph_json,
ai_rationale, heuristic_ver, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'system')
""", (
t["name"],
t["category"],
t.get("sub_type", ""),
t["name"], t["category"], t.get("sub_type", ""),
json.dumps(t.get("instruments", [])),
t.get("description", ""),
json.dumps(t["graph_json"]),
t.get("ai_rationale", ""),
ver,
))
conn.commit()

File diff suppressed because it is too large Load Diff