feat: causal lab — éditeur visuel, types nœuds, force/signe arêtes, NFP v2
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user