feat: causal lab - delete template + observable mapping + edge editing

- TabLibrary: bouton Supprimer (templates user uniquement)
- TabEditor: inputMapping dans EditorState, panneau mapping visible quand noeud observable selectionne
- TabEditor: edition d aretes (cliquer sur arête pour modifier style/force/signe/label)
- Backend: GET /api/causal-lab/data-sources (prices/macro_series/ff_events)
- analyze: auto-fetch market_watchlist+economic_events+ff_calendar depuis input_mapping
- TabAnalyze: affiche inputs auto-recuperes vs manuels dans les resultats

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-28 10:53:50 +02:00
parent dd31f92aaf
commit 1117e9ee8a
2 changed files with 389 additions and 31 deletions

View File

@@ -500,6 +500,69 @@ def update_template(template_id: int, body: UpdateCoefRequest):
raise HTTPException(500, str(e))
@router.get("/api/causal-lab/data-sources")
def get_data_sources():
"""
Retourne toutes les sources de données disponibles pour le mapping des nœuds observables :
- prices : tickers yfinance (market_watchlist + defaults)
- macro_series : series_id distincts depuis economic_events
- ff_events : événements récurrents depuis ff_calendar
"""
PRICE_DEFAULTS = [
{"key": "EURUSD", "label": "EUR/USD"},
{"key": "XAUUSD", "label": "Or (Gold)"},
{"key": "SP500", "label": "S&P 500"},
{"key": "BRENT", "label": "Brent Crude"},
{"key": "US2Y", "label": "US 2Y Yield"},
{"key": "US10Y", "label": "US 10Y Yield"},
{"key": "EU10Y", "label": "EU 10Y Yield"},
]
try:
from services.database import get_conn
conn = get_conn()
wl = conn.execute(
"SELECT ticker, name FROM market_watchlist ORDER BY ticker"
).fetchall()
known = {x["key"] for x in PRICE_DEFAULTS}
extra = [
{"key": r["ticker"], "label": r["name"] or r["ticker"]}
for r in wl if r["ticker"] not in known
]
macro = conn.execute(
"""SELECT DISTINCT series_id, event_name
FROM economic_events
WHERE series_id IS NOT NULL AND series_id != ''
ORDER BY series_id LIMIT 200"""
).fetchall()
ff = conn.execute(
"""SELECT event_name, currency, COUNT(*) as cnt
FROM ff_calendar
WHERE event_name IS NOT NULL AND event_name != ''
GROUP BY event_name, currency
HAVING cnt >= 2
ORDER BY cnt DESC LIMIT 100"""
).fetchall()
conn.close()
return {
"prices": PRICE_DEFAULTS + extra,
"macro_series": [
{"key": r["series_id"], "label": r["event_name"] or r["series_id"]}
for r in macro
],
"ff_events": [
{"key": r["event_name"], "label": f"{r['event_name']} ({r['currency']})"}
for r in ff
],
}
except Exception as e:
logger.error(f"[causal_lab] data_sources: {e}")
raise HTTPException(500, str(e))
@router.get("/api/causal-lab/market-events")
def list_market_events(
limit: int = Query(200, le=500),
@@ -604,21 +667,94 @@ def analyze_event(body: AnalyzeRequest):
graph = tmpl["graph_json"]
# Inputs de base depuis l'événement
# Inputs de base depuis l'événement + mappings DB/yfinance
inputs = {}
mapping = graph.get("input_mapping", {})
YFINANCE_MAP = {
"EURUSD": "EURUSD=X", "XAUUSD": "GC=F", "SP500": "^GSPC",
"BRENT": "BZ=F", "US2Y": "US2YT=RR", "US10Y": "^TNX", "EU10Y": "GE10YT=RR",
}
edate_str = event["start_date"][:10]
for input_id, cfg in mapping.items():
src = cfg.get("source", "")
if src == "surprise" and event.get("surprise_pct") is not None:
src = cfg.get("source", "")
key = cfg.get("key", "")
field = cfg.get("field", "close")
if src == "market_watchlist" and key:
try:
import yfinance as yf
from datetime import datetime as _dt, timedelta as _td
event_dt = _dt.strptime(edate_str, "%Y-%m-%d")
start = (event_dt - _td(days=5)).strftime("%Y-%m-%d")
end = (event_dt + _td(days=2)).strftime("%Y-%m-%d")
sym = YFINANCE_MAP.get(key, key)
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)
rows_yf = [
(str(idx.date()), float(row["Close"]))
for idx, row in df.iterrows()
if float(row["Close"]) == float(row["Close"])
]
if field == "change_pct" and len(rows_yf) >= 2:
pre = next((c for d, c in rows_yf if d < edate_str), None)
cur = next((c for d, c in rows_yf if d == edate_str), None)
if pre and cur and pre != 0:
inputs[input_id] = round((cur - pre) / pre * 100, 4)
else:
val = next((c for d, c in rows_yf if d == edate_str), None)
if val is None:
val = next((c for d, c in reversed(rows_yf) if d <= edate_str), None)
if val is not None:
inputs[input_id] = round(val, 5)
except Exception as _e:
logger.debug(f"[causal_lab] market_watchlist fetch {key}: {_e}")
elif src == "economic_events" and key:
row_e = conn.execute(
"""SELECT actual_value, forecast_value FROM economic_events
WHERE series_id = ? AND event_date <= ? AND actual_value IS NOT NULL
ORDER BY event_date DESC LIMIT 1""",
(key, edate_str)
).fetchone()
if row_e:
if field == "surprise" and row_e["forecast_value"] is not None:
inputs[input_id] = round(
float(row_e["actual_value"]) - float(row_e["forecast_value"]), 4
)
else:
inputs[input_id] = float(row_e["actual_value"])
elif src == "ff_calendar" and key:
row_f = conn.execute(
"""SELECT actual_value, forecast_value FROM ff_calendar
WHERE event_name = ? AND event_date <= ? AND actual_value IS NOT NULL
ORDER BY event_date DESC LIMIT 1""",
(key, edate_str)
).fetchone()
if row_f and row_f["actual_value"]:
try:
actual = float(row_f["actual_value"])
if field == "surprise" and row_f["forecast_value"]:
inputs[input_id] = round(actual - float(row_f["forecast_value"]), 4)
else:
inputs[input_id] = actual
except (ValueError, TypeError):
pass
# Sources legacy
elif 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:
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 manuels (saisie frontend — priorité sur auto-fetch)
inputs.update(body.inputs)
# Évaluation du graphe