From e1681edffc6e2a92ae59abf8318ecf9ccb3a5b81 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Wed, 1 Jul 2026 21:01:00 +0200 Subject: [PATCH] feat: causal lab --- backend/routers/causal_lab.py | 203 ++++-- backend/services/causal_graphs.py | 946 +++++++++++++++---------- backend/services/database.py | 10 + backend/services/macro_series_log.py | 23 +- frontend/src/pages/MacroSeriesPage.tsx | 37 +- 5 files changed, 774 insertions(+), 445 deletions(-) diff --git a/backend/routers/causal_lab.py b/backend/routers/causal_lab.py index 1a182a8..7f0906f 100644 --- a/backend/routers/causal_lab.py +++ b/backend/routers/causal_lab.py @@ -1324,20 +1324,99 @@ def _run_auto_analysis(event: dict, template_id: int) -> dict: return {"ok": False, "error": str(e)} -def auto_assign_template(event_id: int) -> dict: +def _classify_to_regime(event: dict) -> dict: """ - Auto-assign or auto-create a causal template for a market event. - Called by the eco detector when auto_template=True in desk config. - Returns {"template_id": int, "action": "assigned"|"created", "name": str} or {"error": ...} + Classify a market event to one of the 12 closed-list regime slugs using GPT-4o. + Never creates new templates. Falls back to UNCLASSIFIED_IMPACT if confidence < 0.4. + Returns {"slug": str, "confidence": float, "rationale": str} """ + from services.causal_graphs import REGIME_SLUGS try: - from services.database import get_conn, get_config - from services.causal_graphs import get_templates, init_tables, seed_templates + from services.database import get_config import openai - key = get_config("openai_api_key") or "" if not key: - return {"error": "Clé OpenAI manquante"} + return {"slug": "UNCLASSIFIED_IMPACT", "confidence": 0.0, "rationale": "OpenAI key not configured"} + + client = openai.OpenAI(api_key=key) + + regime_descriptions = { + "MACRO_DATA_SURPRISE": "Scheduled economic release vs consensus (CPI, NFP, GDP, PMI, retail sales, jobless claims, etc.)", + "CENTRAL_BANK_DECISION": "Any central bank rate decision, FOMC/ECB/BoJ/BoE statement, QE/QT announcement", + "GROWTH_CORPORATE_SIGNAL": "Corporate earnings, guidance, M&A, layoffs, analyst revisions, sector-wide activity signals", + "GEOPOLITICAL_RISK_OFF": "Military conflict, sanctions, coups, political crises, election shocks generating risk-off", + "COMMODITY_SUPPLY_SHOCK": "OPEC decision, pipeline disruption, sanctions on producer, WASDE crop report changing supply", + "TRADE_POLICY_SHOCK": "Tariff announcement, trade restriction, protectionist measure, export ban", + "CREDIT_SYSTEMIC_EVENT": "Bank failure, sovereign debt crisis, HY spread blowout, counterparty risk / financial contagion", + "TECHNICAL_MOMENTUM_BREAKOUT": "MA cross, key level break, RSI/Bollinger extreme, 52-week high/low — price-driven with no macro trigger", + "SENTIMENT_POSITIONING_EXTREME": "VIX spike extreme, COT crowded positioning, extreme put/call skew — contrarian setup", + "COMMODITY_INVENTORY_REPORT": "EIA crude / product weekly, API crude, DOE report — inventory vs consensus surprise", + "INSTITUTIONAL_FLOW": "COT repositioning, FX intervention, central bank buying/selling, end-of-month rebalancing", + "UNCLASSIFIED_IMPACT": "Use ONLY if none of the above clearly fits (low confidence fallback)", + } + + regimes_text = "\n".join( + f"- {slug}: {desc}" for slug, desc in regime_descriptions.items() + ) + + system_prompt = ( + "You are a macro market analyst. Classify the given market event into exactly one of the 12 regime slugs. " + "Choose the slug that best matches the PRIMARY causal mechanism — not just the asset class. " + "If genuinely uncertain, use UNCLASSIFIED_IMPACT. " + "Respond with a JSON object only." + ) + + user_prompt = f"""Market event: +- Name: {event.get('name')} +- Category: {event.get('category')} +- Sub-type: {event.get('sub_type', '')} +- Description: {(event.get('description') or '')[:300]} +- Market impact: {event.get('market_impact', '')} +- Affected assets: {event.get('affected_assets', '')} + +Regime slugs to choose from: +{regimes_text} + +Respond with this exact JSON: +{{ + "slug": "", + "confidence": <0.0-1.0>, + "rationale": "<1-2 sentences explaining why this regime fits>" +}}""" + + resp = client.chat.completions.create( + model="gpt-4o", + messages=[ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + response_format={"type": "json_object"}, + temperature=0.2, + max_tokens=200, + ) + result = json.loads(resp.choices[0].message.content or "{}") + slug = result.get("slug", "UNCLASSIFIED_IMPACT") + if slug not in REGIME_SLUGS: + slug = "UNCLASSIFIED_IMPACT" + confidence = float(result.get("confidence") or 0.0) + if confidence < 0.4: + slug = "UNCLASSIFIED_IMPACT" + return {"slug": slug, "confidence": confidence, "rationale": result.get("rationale", "")} + + except Exception as e: + logger.error(f"[classify_regime] {e}") + return {"slug": "UNCLASSIFIED_IMPACT", "confidence": 0.0, "rationale": f"Error: {e}"} + + +def auto_assign_template(event_id: int) -> dict: + """ + Classify a market event to one of 12 closed-list regime templates and assign it. + Never creates new templates — falls back to UNCLASSIFIED_IMPACT if confidence < 0.4. + Returns {"template_id": int, "action": "assigned", "name": str} or {"error": ...} + """ + try: + from services.database import get_conn + from services.causal_graphs import get_templates, init_tables, seed_templates conn = get_conn() init_tables(conn) @@ -1348,86 +1427,60 @@ def auto_assign_template(event_id: int) -> dict: conn.close() return {"error": f"Event {event_id} introuvable"} - event = dict(ev_row) + event = dict(ev_row) + + # Classify to one of the 12 regime slugs (never creates a new template) + classification = _classify_to_regime(event) + slug = classification["slug"] + confidence = classification["confidence"] + rationale = classification.get("rationale", "") + + # Look up the template by slug field (name matches slug label) templates = get_templates(conn) + tmpl = next((t for t in templates if t.get("name") == _slug_to_name(slug)), None) - # Step 1 — recommend an existing template - recommendation = _gpt4o_recommend(event, templates) - tmpl_id = recommendation.get("template_id") - confidence = float(recommendation.get("confidence") or 0.0) + # Fallback: try matching by category name if slug lookup fails + if not tmpl: + tmpl = next((t for t in templates if t.get("name") == "Unclassified Market Impact"), None) - if tmpl_id and confidence >= 0.6: - conn.execute("UPDATE market_events SET auto_template_id = ? WHERE id = ?", (tmpl_id, event_id)) - conn.commit() - row = conn.execute("SELECT name FROM causal_graph_templates WHERE id = ?", (tmpl_id,)).fetchone() + if not tmpl: conn.close() - _run_auto_analysis(event, tmpl_id) - return {"template_id": tmpl_id, "action": "assigned", "name": row["name"] if row else "", "confidence": confidence} + return {"error": f"Template for slug {slug} not found in DB — run seed_templates first"} - # Step 2 — no good match → generate a new template + tmpl_id = tmpl["id"] + conn.execute("UPDATE market_events SET auto_template_id = ? WHERE id = ?", (tmpl_id, event_id)) + conn.commit() conn.close() - client = openai.OpenAI(api_key=key) - 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": "