From e4e17330b46368f7405ff12be84fdfbbe8b633b3 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Sun, 28 Jun 2026 16:36:28 +0200 Subject: [PATCH] feat: instrument analysis --- backend/routers/ai_desks.py | 16 +- backend/routers/causal_lab.py | 228 ++++++- backend/services/database.py | 22 + frontend/src/pages/CycleActions.tsx | 78 ++- frontend/src/pages/InstrumentDashboard.tsx | 294 ++++++--- frontend/src/pages/MarketEvents.tsx | 726 ++++++--------------- 6 files changed, 715 insertions(+), 649 deletions(-) diff --git a/backend/routers/ai_desks.py b/backend/routers/ai_desks.py index b6bec93..350bbee 100644 --- a/backend/routers/ai_desks.py +++ b/backend/routers/ai_desks.py @@ -185,15 +185,27 @@ def create_desk(body: AIDeskUpsert) -> Dict[str, Any]: @router.put("/{desk_id}") def update_desk(desk_id: int, body: AIDeskUpsert) -> Dict[str, Any]: - from services.database import get_all_ai_desks, upsert_ai_desk + from services.database import get_all_ai_desks, update_ai_desk_by_id desks = get_all_ai_desks() desk = next((d for d in desks if d["id"] == desk_id), None) if not desk: raise HTTPException(404, f"Desk {desk_id} not found") - upsert_ai_desk({**body.dict(), "name": desk["name"]}) + update_ai_desk_by_id(desk_id, body.dict()) return {"status": "updated"} +@router.patch("/{desk_id}/toggle") +def toggle_desk_active(desk_id: int) -> Dict[str, Any]: + from services.database import get_all_ai_desks, update_ai_desk_by_id + desks = get_all_ai_desks() + desk = next((d for d in desks if d["id"] == desk_id), None) + if not desk: + raise HTTPException(404, f"Desk {desk_id} not found") + desk["active"] = not desk["active"] + update_ai_desk_by_id(desk_id, desk) + return {"status": "toggled", "active": desk["active"]} + + @router.delete("/{desk_id}") def delete_desk(desk_id: int) -> Dict[str, Any]: from services.database import get_all_ai_desks, delete_ai_desk diff --git a/backend/routers/causal_lab.py b/backend/routers/causal_lab.py index c8e68b7..5781c8c 100644 --- a/backend/routers/causal_lab.py +++ b/backend/routers/causal_lab.py @@ -329,11 +329,15 @@ class InstantiateRequest(BaseModel): class AnalyzeRequest(BaseModel): market_event_id: int template_id: int - instrument: str = "EURUSD" + instrument: str = "" # vide = analyser tous les instruments du template inputs: dict = {} coef_overrides: dict = {} +class CreateFromEventRequest(BaseModel): + market_event_id: int + + # ── Endpoints ───────────────────────────────────────────────────────────────── @router.get("/api/causal-lab/debug") @@ -697,7 +701,12 @@ Règles : Retourne UNIQUEMENT un JSON : {{ "node_id": valeur_numerique, ... }} Inclure uniquement les nœuds listés ci-dessus.""" - client = _openai_client() + from services.database import get_config as _get_cfg + import openai as _openai + _key = _get_cfg("openai_api_key") or "" + if not _key: + raise HTTPException(400, "Clé OpenAI manquante dans la configuration") + client = _openai.OpenAI(api_key=_key) resp = client.chat.completions.create( model="gpt-4o-mini", messages=[{"role": "user", "content": prompt}], @@ -864,8 +873,13 @@ def analyze_event(body: AnalyzeRequest): default=0, ) - # Prix réels - instruments = list({body.instrument} | set(tmpl.get("instruments", [body.instrument]))) + # Instruments : utiliser tous ceux du template si aucun spécifié + if body.instrument: + instruments = list({body.instrument} | set(tmpl.get("instruments", [body.instrument]))) + else: + instruments = list(set(tmpl.get("instruments", []))) or ["EURUSD"] + primary_inst = body.instrument or (instruments[0] if instruments else "EURUSD") + prices = _fetch_prices(event["start_date"], instruments) edate = event["start_date"][:10] @@ -893,16 +907,18 @@ def analyze_event(body: AnalyzeRequest): "event": event, "template_id": body.template_id, "template_name": tmpl["name"], - "instrument": body.instrument, + "instrument": primary_inst, + "instruments": instruments, "inputs": inputs, "node_values": node_values, "actual_moves": actual_moves, "yields": yields, "activation": activation, - "drift": drift_by_inst.get(body.instrument, {}), + "drift": drift_by_inst.get(primary_inst, {}), "prices_mode": prices.get("mode", "none"), "effective_lag_min": effective_lag, "analyzed_at": analyzed_at, + "graph_json": graph, # structure complète pour visualisation frontend } # Persistance @@ -915,13 +931,13 @@ def analyze_event(body: AnalyzeRequest): """, ( body.market_event_id, body.template_id, - body.instrument, + primary_inst, 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, {})), + json.dumps(drift_by_inst.get(primary_inst, {})), analyzed_at, )) conn.commit() @@ -929,11 +945,11 @@ def analyze_event(body: AnalyzeRequest): # 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 + "instrument": primary_inst, + "pred_pips": node_values.get(primary_inst.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), + if primary_inst.lower() in k.lower()), 0), + "actual_pips": actual_moves.get(primary_inst), "analyzed_at": analyzed_at, }) conn.close() @@ -947,6 +963,194 @@ def analyze_event(body: AnalyzeRequest): raise HTTPException(500, str(e)) +def _build_graph_json_from_spec(spec: dict) -> dict: + """Construit un graph_json complet depuis le spec simplifié généré par GPT-4o.""" + input_node = spec.get("input_node", {}) + intermediates = spec.get("intermediate_nodes", []) + outputs = spec.get("output_nodes", []) + + nodes: list = [] + edges: list = [] + coefficients: dict = {} + input_mapping: dict = {} + + # Nœud d'entrée — centré en haut + inp_id = input_node.get("id", "surprise") + nodes.append({ + "id": inp_id, "label": input_node.get("label", "Surprise"), + "type": "input", "x": 200, "y": 50, + "unit": input_node.get("unit", "% vs consensus"), + }) + input_mapping[inp_id] = { + "source": "surprise", + "unit": input_node.get("unit", "% vs consensus"), + "description": input_node.get("label", "Surprise"), + } + + # Nœuds intermédiaires — étalés horizontalement au milieu + n_inter = max(len(intermediates), 1) + x_step_i = max(160, 340 // n_inter) + for i, inter in enumerate(intermediates): + x = 200 - (len(intermediates) - 1) * x_step_i // 2 + i * x_step_i + inter_id = inter["id"] + coef_name = inter.get("coef_name", f"coef_{inter_id}") + coef_val = float(inter.get("coef_value", 2.0)) + coefficients[coef_name] = { + "value": coef_val, "calibrated": False, + "description": inter.get("coef_desc", coef_name), + } + nodes.append({ + "id": inter_id, "label": inter.get("label", inter_id), + "type": "intermediate", "x": x, "y": 175, + "formula": f"{inp_id} * {coef_name}", + }) + edges.append({"from": inp_id, "to": inter_id, + "sign": "positive" if coef_val >= 0 else "negative", "style": "solid"}) + + # Nœuds de sortie — étalés horizontalement en bas + inter_ids = [n["id"] for n in intermediates] + n_out = max(len(outputs), 1) + x_step_o = max(140, 320 // n_out) + for i, out in enumerate(outputs): + x = 200 - (len(outputs) - 1) * x_step_o // 2 + i * x_step_o + out_id = out["id"] + coef_name = out.get("coef_name", f"coef_{out_id}") + coef_val = float(out.get("coef_value", -100.0)) + coefficients[coef_name] = { + "value": coef_val, "calibrated": False, + "description": out.get("coef_desc", coef_name), + } + src = out.get("source_intermediate", "") + from_id = src if src in inter_ids else (intermediates[0]["id"] if intermediates else inp_id) + nodes.append({ + "id": out_id, "label": out.get("label", out_id), + "type": "market_asset", "x": x, "y": 310, + "unit": "pips", "instrument": out.get("instrument", "EURUSD"), + "formula": f"{from_id} * {coef_name}", + }) + edges.append({"from": from_id, "to": out_id, + "sign": "positive" if coef_val >= 0 else "negative", "style": "solid"}) + + instruments = list({out.get("instrument", "EURUSD") for out in outputs}) + return { + "nodes": nodes, "edges": edges, + "coefficients": coefficients, "input_mapping": input_mapping, + "instruments": instruments, + } + + +@router.post("/api/causal-lab/create-from-event") +def create_template_from_event(body: CreateFromEventRequest): + """GPT-4o génère et enregistre un template causal adapté à l'événement.""" + try: + from services.database import get_conn, get_config + from services.causal_graphs import init_tables + import openai + + key = get_config("openai_api_key") or "" + if not key: + raise HTTPException(400, "Clé OpenAI manquante dans la configuration") + + conn = get_conn() + init_tables(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, "Événement introuvable") + event = dict(ev_row) + + prompt = f"""Tu es un analyste financier spécialisé en graphes causaux macro. +Crée un graphe causal pour l'événement suivant. + +Événement : +- Nom : {event.get('name')} +- Catégorie : {event.get('category')} / {event.get('sub_type', '')} +- Description : {(event.get('description') or '')[:400]} +- Réel : {event.get('actual_value')} | Attendu : {event.get('expected_value')} | Surprise % : {event.get('surprise_pct')} + +Retourne UNIQUEMENT ce JSON (sans commentaire ni markdown) : +{{ + "name": "", + "category": "macro_us", + "description": "<1 phrase décrivant le mécanisme>", + "input_node": {{ + "id": "", + "label": "