diff --git a/backend/routers/causal_lab.py b/backend/routers/causal_lab.py index a3f1280..548da91 100644 --- a/backend/routers/causal_lab.py +++ b/backend/routers/causal_lab.py @@ -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 diff --git a/frontend/src/pages/CausalLab.tsx b/frontend/src/pages/CausalLab.tsx index 21a6f15..69062d0 100644 --- a/frontend/src/pages/CausalLab.tsx +++ b/frontend/src/pages/CausalLab.tsx @@ -23,11 +23,23 @@ export interface CausalEdge { label?: string } interface Coefficient { value: number; calibrated: number | null; description: string } +interface InputMapping { + source: 'user_input' | 'market_watchlist' | 'economic_events' | 'ff_calendar' | 'surprise' | 'actual_value' | 'impact_score_scaled' | 'impact_score_pct' + key?: string + field?: string + unit?: string + range?: number[] +} +interface DataSources { + prices: { key: string; label: string }[] + macro_series: { key: string; label: string }[] + ff_events: { key: string; label: string }[] +} export interface GraphJson { nodes: CausalNode[]; edges: CausalEdge[] coefficients: Record instruments: string[] - input_mapping: Record + input_mapping: Record } interface Template { id: number; name: string; category: string; sub_type: string @@ -324,6 +336,7 @@ function TabLibrary() { const [selected, setSelected] = useState