From a68896cf675189e4e9b433b5bd3aec02dabf6f08 Mon Sep 17 00:00:00 2001 From: OpenSquared Date: Thu, 25 Jun 2026 16:26:13 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Impact=20Monitor=20=E2=80=94=20AI=20imp?= =?UTF-8?q?act=20evaluation=20for=20eco=20events=20&=20geopolitical=20news?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New DB tables: event_categories (18 bootstrap categories) + instrument_impacts - impact_categories_bootstrap.py: FOMC/NFP/CPI/GDP/PCE/ISM/BOJ/ECB/BOE/OPEC+ + 7 géopolitical categories with per-instrument sensitivity & direction defaults - impact_service.py: GPT-4o-mini evaluation (0-1 score, direction, rationale) + monitor data aggregation - routers/impact.py: GET/POST endpoints for evaluate/bulk/monitor/adjust/categories - ImpactMonitor.tsx: two-tab page (Évalués / À évaluer), top instruments bar, inline score override modal, category browser - Startup auto-bootstrap for impact categories - Route /impact + sidebar nav entry Co-Authored-By: Claude Sonnet 4.6 --- backend/main.py | 8 +- backend/routers/impact.py | 155 +++++ backend/services/database.py | 176 +++++ .../services/impact_categories_bootstrap.py | 350 ++++++++++ backend/services/impact_service.py | 256 +++++++ frontend/src/App.tsx | 2 + frontend/src/components/layout/Sidebar.tsx | 3 +- frontend/src/pages/ImpactMonitor.tsx | 643 ++++++++++++++++++ 8 files changed, 1590 insertions(+), 3 deletions(-) create mode 100644 backend/routers/impact.py create mode 100644 backend/services/impact_categories_bootstrap.py create mode 100644 backend/services/impact_service.py create mode 100644 frontend/src/pages/ImpactMonitor.tsx diff --git a/backend/main.py b/backend/main.py index a226537..6e39f9e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -5,6 +5,7 @@ from routers import pattern_lab as pattern_lab_router from routers import specialist_desks as specialist_desks_router from routers import timeline as timeline_router from routers import instruments as instruments_router +from routers import impact as impact_router from routers import logs as logs_router from routers import var as var_router from routers import reports as reports_router @@ -82,10 +83,12 @@ def startup(): try: from services.macro_events_bootstrap import bootstrap_macro_events from services.eco_calendar_bootstrap import bootstrap_eco_events + from services.impact_categories_bootstrap import bootstrap_impact_categories r1 = bootstrap_macro_events() r2 = bootstrap_eco_events() - if r1.get("inserted", 0) or r2.get("inserted", 0): - _log.info(f"[Startup] Events bootstrapped — macro: +{r1.get('inserted',0)}, eco: +{r2.get('inserted',0)}") + r3 = bootstrap_impact_categories() + if r1.get("inserted", 0) or r2.get("inserted", 0) or r3.get("inserted", 0): + _log.info(f"[Startup] Bootstrapped — macro: +{r1.get('inserted',0)}, eco: +{r2.get('inserted',0)}, categories: +{r3.get('inserted',0)}") except Exception as _e: _log.warning(f"[Startup] Event bootstrap failed: {_e}") # Start auto-cycle scheduler if enabled @@ -135,6 +138,7 @@ app.include_router(pattern_lab_router.router) app.include_router(specialist_desks_router.router) app.include_router(timeline_router.router) app.include_router(instruments_router.router) +app.include_router(impact_router.router) @app.get("/") diff --git a/backend/routers/impact.py b/backend/routers/impact.py new file mode 100644 index 0000000..ecf2fff --- /dev/null +++ b/backend/routers/impact.py @@ -0,0 +1,155 @@ +""" +Impact Monitor — instrument impact evaluation for market events and news. +Prefix: /api/impact +""" +import logging +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, HTTPException, Query +from pydantic import BaseModel + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/api/impact", tags=["impact"]) + + +# ── Schemas ─────────────────────────────────────────────────────────────────── + +class ImpactAdjustment(BaseModel): + adjusted_score: float + adjusted_direction: str + override_rationale: str = "" + + +class CategoryUpdate(BaseModel): + type: str + sub_type: str = "" + description: str = "" + default_impacts: List[Dict[str, Any]] = [] + + +# ── Categories ──────────────────────────────────────────────────────────────── + +@router.get("/categories") +def list_categories() -> List[Dict[str, Any]]: + from services.database import get_all_event_categories + import json + cats = get_all_event_categories() + for c in cats: + try: + c["default_impacts"] = json.loads(c.get("default_impacts") or "[]") + except Exception: + c["default_impacts"] = [] + return cats + + +@router.put("/categories/{name}") +def update_category(name: str, body: CategoryUpdate) -> Dict[str, Any]: + from services.database import upsert_event_category + import json + cat_id = upsert_event_category({ + "name": name, + "type": body.type, + "sub_type": body.sub_type, + "description": body.description, + "default_impacts": json.dumps(body.default_impacts), + }) + return {"id": cat_id, "name": name, "status": "updated"} + + +# ── Evaluate ────────────────────────────────────────────────────────────────── + +@router.post("/evaluate/event/{event_id}") +def evaluate_event( + event_id: int, + force: bool = Query(False, description="Re-evaluate even if already done"), +) -> Dict[str, Any]: + """Trigger AI impact evaluation for a market event.""" + from services.impact_service import evaluate_event_impacts + try: + result = evaluate_event_impacts(event_id, force=force) + if "error" in result: + raise HTTPException(400, result["error"]) + return result + except HTTPException: + raise + except Exception as e: + logger.error(f"[impact] evaluate_event {event_id} failed: {e}") + raise HTTPException(500, str(e)) + + +@router.post("/evaluate/bulk") +def evaluate_bulk( + event_ids: List[int], + force: bool = Query(False), +) -> Dict[str, Any]: + """Evaluate multiple events sequentially.""" + from services.impact_service import evaluate_event_impacts + results = [] + errors = [] + for eid in event_ids[:20]: # cap at 20 per call + try: + r = evaluate_event_impacts(eid, force=force) + if "error" not in r: + results.append({"event_id": eid, "n_instruments": r.get("n_instruments", 0)}) + else: + errors.append({"event_id": eid, "error": r["error"]}) + except Exception as e: + errors.append({"event_id": eid, "error": str(e)}) + return {"evaluated": len(results), "errors": len(errors), "results": results, "error_details": errors} + + +# ── Monitor ─────────────────────────────────────────────────────────────────── + +@router.get("/monitor") +def get_monitor( + days: int = Query(7, ge=1, le=90, description="Fenêtre temporelle en jours"), + min_score: float = Query(0.3, ge=0.0, le=1.0, description="Score minimum d'impact"), +) -> Dict[str, Any]: + """Weekly impact monitor — evaluated events with instrument scores.""" + from services.impact_service import get_impact_monitor_data + try: + return get_impact_monitor_data(days=days, min_score=min_score) + except Exception as e: + logger.error(f"[impact] monitor failed: {e}") + raise HTTPException(500, str(e)) + + +@router.get("/event/{event_id}") +def get_event_impacts(event_id: int) -> Dict[str, Any]: + """Get stored impacts for a specific event.""" + from services.database import get_impacts_for_source, get_all_market_events + impacts = get_impacts_for_source("event", event_id) + events = get_all_market_events() + event = next((e for e in events if e["id"] == event_id), None) + return { + "event_id": event_id, + "event": event, + "impacts": impacts, + "evaluated": len(impacts) > 0, + } + + +# ── Adjust ──────────────────────────────────────────────────────────────────── + +@router.put("/adjust/{impact_id}") +def adjust_impact(impact_id: int, body: ImpactAdjustment) -> Dict[str, Any]: + """Manually override an AI-estimated impact score/direction.""" + from services.database import update_impact_adjustment + ok = update_impact_adjustment( + impact_id, + body.adjusted_score, + body.adjusted_direction, + body.override_rationale, + ) + if not ok: + raise HTTPException(404, f"Impact {impact_id} not found") + return {"id": impact_id, "status": "adjusted"} + + +# ── Bootstrap ───────────────────────────────────────────────────────────────── + +@router.post("/bootstrap-categories") +def bootstrap_categories(force: bool = False) -> Dict[str, Any]: + """Seed default impact categories.""" + from services.impact_categories_bootstrap import bootstrap_impact_categories + return bootstrap_impact_categories(force=force) diff --git a/backend/services/database.py b/backend/services/database.py index 6b10c4f..9cbcbbf 100644 --- a/backend/services/database.py +++ b/backend/services/database.py @@ -766,6 +766,42 @@ def init_db(): except Exception: pass + # ── Impact categories (event_calendar + geopolitical default impacts) ──────── + c.execute("""CREATE TABLE IF NOT EXISTS event_categories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT UNIQUE NOT NULL, + type TEXT NOT NULL, + sub_type TEXT DEFAULT '', + description TEXT DEFAULT '', + default_impacts TEXT DEFAULT '[]', + is_ai_generated INTEGER DEFAULT 0, + updated_at TEXT DEFAULT (datetime('now')) + )""") + + # ── Per-event/news instrument impact assessments ────────────────────────── + c.execute("""CREATE TABLE IF NOT EXISTS instrument_impacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_type TEXT NOT NULL, + source_id INTEGER, + source_name TEXT NOT NULL, + source_date TEXT NOT NULL, + category_name TEXT DEFAULT '', + instrument_id TEXT NOT NULL, + impact_score REAL DEFAULT 0.5, + direction TEXT DEFAULT 'neutral', + rationale TEXT DEFAULT '', + confidence REAL DEFAULT 0.7, + ai_generated INTEGER DEFAULT 0, + manually_adjusted INTEGER DEFAULT 0, + adjusted_score REAL, + adjusted_direction TEXT, + override_rationale TEXT DEFAULT '', + created_at TEXT DEFAULT (datetime('now')) + )""") + c.execute("CREATE INDEX IF NOT EXISTS idx_ii_source ON instrument_impacts(source_type, source_id)") + c.execute("CREATE INDEX IF NOT EXISTS idx_ii_date ON instrument_impacts(source_date)") + c.execute("CREATE INDEX IF NOT EXISTS idx_ii_instrument ON instrument_impacts(instrument_id)") + c.execute("""CREATE TABLE IF NOT EXISTS timeline_context ( ref_date TEXT PRIMARY KEY, long_event_id INTEGER REFERENCES market_events(id), @@ -4610,3 +4646,143 @@ def count_market_events() -> int: return conn.execute("SELECT COUNT(*) FROM market_events").fetchone()[0] finally: conn.close() + + +# ── Event categories ────────────────────────────────────────────────────────── + +def get_all_event_categories() -> List[Dict[str, Any]]: + conn = get_conn() + try: + rows = conn.execute("SELECT * FROM event_categories ORDER BY type, name").fetchall() + return [dict(r) for r in rows] + finally: + conn.close() + + +def get_event_category(name: str) -> Optional[Dict[str, Any]]: + conn = get_conn() + try: + row = conn.execute("SELECT * FROM event_categories WHERE name=?", (name,)).fetchone() + return dict(row) if row else None + finally: + conn.close() + + +def upsert_event_category(cat: Dict[str, Any]) -> int: + import json as _json + conn = get_conn() + try: + defaults = cat.get("default_impacts", []) + if isinstance(defaults, list): + defaults = _json.dumps(defaults) + row = conn.execute("SELECT id FROM event_categories WHERE name=?", (cat["name"],)).fetchone() + if row: + conn.execute("""UPDATE event_categories SET + type=?, sub_type=?, description=?, default_impacts=?, + is_ai_generated=?, updated_at=datetime('now') + WHERE name=?""", + (cat["type"], cat.get("sub_type",""), cat.get("description",""), + defaults, int(cat.get("is_ai_generated", 0)), cat["name"])) + conn.commit() + return row[0] + else: + cur = conn.execute("""INSERT INTO event_categories + (name, type, sub_type, description, default_impacts, is_ai_generated) + VALUES (?,?,?,?,?,?)""", + (cat["name"], cat["type"], cat.get("sub_type",""), + cat.get("description",""), defaults, int(cat.get("is_ai_generated",0)))) + conn.commit() + return cur.lastrowid + finally: + conn.close() + + +# ── Instrument impacts ──────────────────────────────────────────────────────── + +def save_instrument_impacts(impacts: List[Dict[str, Any]]) -> int: + import json as _json + if not impacts: + return 0 + conn = get_conn() + try: + inserted = 0 + for imp in impacts: + conn.execute("""INSERT INTO instrument_impacts + (source_type, source_id, source_name, source_date, category_name, + instrument_id, impact_score, direction, rationale, confidence, ai_generated) + VALUES (?,?,?,?,?,?,?,?,?,?,?)""", + (imp["source_type"], imp.get("source_id"), imp["source_name"], + imp["source_date"], imp.get("category_name",""), + imp["instrument_id"], imp.get("impact_score", 0.5), + imp.get("direction","neutral"), imp.get("rationale",""), + imp.get("confidence", 0.7), int(imp.get("ai_generated", 0)))) + inserted += 1 + conn.commit() + return inserted + finally: + conn.close() + + +def get_impacts_for_source(source_type: str, source_id: int) -> List[Dict[str, Any]]: + conn = get_conn() + try: + rows = conn.execute( + "SELECT * FROM instrument_impacts WHERE source_type=? AND source_id=? ORDER BY impact_score DESC", + (source_type, source_id)).fetchall() + return [dict(r) for r in rows] + finally: + conn.close() + + +def delete_impacts_for_source(source_type: str, source_id: int) -> int: + conn = get_conn() + try: + cur = conn.execute( + "DELETE FROM instrument_impacts WHERE source_type=? AND source_id=?", + (source_type, source_id)) + conn.commit() + return cur.rowcount + finally: + conn.close() + + +def update_impact_adjustment(impact_id: int, adjusted_score: float, + adjusted_direction: str, override_rationale: str = "") -> bool: + conn = get_conn() + try: + cur = conn.execute("""UPDATE instrument_impacts SET + manually_adjusted=1, adjusted_score=?, adjusted_direction=?, override_rationale=? + WHERE id=?""", + (adjusted_score, adjusted_direction, override_rationale, impact_id)) + conn.commit() + return cur.rowcount > 0 + finally: + conn.close() + + +def get_weekly_impact_sources(days: int = 7, min_score: float = 0.3) -> List[Dict[str, Any]]: + """Return distinct sources (events/news) with their max impact score from last N days.""" + conn = get_conn() + try: + cutoff = ( + __import__('datetime').datetime.utcnow() - + __import__('datetime').timedelta(days=days) + ).strftime("%Y-%m-%d") + rows = conn.execute(""" + SELECT source_type, source_id, source_name, source_date, category_name, + MAX(COALESCE(adjusted_score, impact_score)) as max_score, + COUNT(*) as n_instruments, + MAX(created_at) as evaluated_at + FROM instrument_impacts + WHERE source_date >= ? AND COALESCE(adjusted_score, impact_score) >= ? + GROUP BY source_type, source_id + ORDER BY source_date DESC, max_score DESC + """, (cutoff, min_score)).fetchall() + result = [] + for r in rows: + d = dict(r) + d["impacts"] = get_impacts_for_source(d["source_type"], d.get("source_id")) + result.append(d) + return result + finally: + conn.close() diff --git a/backend/services/impact_categories_bootstrap.py b/backend/services/impact_categories_bootstrap.py new file mode 100644 index 0000000..5e51bb3 --- /dev/null +++ b/backend/services/impact_categories_bootstrap.py @@ -0,0 +1,350 @@ +""" +Bootstrap default event categories with instrument impact sensitivities. +Each category has default_impacts: list of {instrument_id, sensitivity (0-1), +typical_direction, notes}. +sensitivity = how strongly this instrument type is typically affected. +typical_direction = usual bias (bullish/bearish/neutral/depends_on_outcome). +""" +from typing import List, Dict, Any + +ALL_INSTRUMENTS = [ + "SPY","QQQ","IWM","EEM","EFA", + "GLD","SLV","USO","UNG","TLT", + "HYG","EURUSD=X","USDJPY=X","GBPUSD=X", + "VXX","AAPL","NVDA","GS","XOM","BTC-USD", +] + +CATEGORIES: List[Dict[str, Any]] = [ + + # ═══════════════════════════════════════════════════════ + # EVENT CALENDAR + # ═══════════════════════════════════════════════════════ + + { + "name": "FOMC — Décision de taux", + "type": "event_calendar", + "sub_type": "FOMC", + "description": "Décision du Federal Open Market Committee sur les taux directeurs US.", + "default_impacts": [ + {"instrument_id": "SPY", "sensitivity": 0.88, "typical_direction": "depends_on_outcome", "notes": "Hausse = baisse. Baisse = hausse. Surprise domine."}, + {"instrument_id": "QQQ", "sensitivity": 0.90, "typical_direction": "depends_on_outcome", "notes": "Très sensible aux taux réels — plus volatile que SPY sur les FOMC."}, + {"instrument_id": "IWM", "sensitivity": 0.80, "typical_direction": "depends_on_outcome", "notes": "Small caps très sensibles au crédit et aux taux courts."}, + {"instrument_id": "TLT", "sensitivity": 0.95, "typical_direction": "depends_on_outcome", "notes": "Relation directe : hausse = TLT baisse, pivot = TLT monte."}, + {"instrument_id": "HYG", "sensitivity": 0.82, "typical_direction": "depends_on_outcome", "notes": "Spreads HY se compriment (dovish) ou s'écartent (hawkish)."}, + {"instrument_id": "GLD", "sensitivity": 0.78, "typical_direction": "depends_on_outcome", "notes": "Or inverse aux taux réels. Dovish = gold up. Hawkish = gold down."}, + {"instrument_id": "EURUSD=X", "sensitivity": 0.85, "typical_direction": "depends_on_outcome", "notes": "Différentiel Fed/BCE. Hawkish Fed = USD fort = EUR baisse."}, + {"instrument_id": "USDJPY=X", "sensitivity": 0.88, "typical_direction": "depends_on_outcome", "notes": "Différentiel US-JP. Hawkish Fed = USD/JPY monte."}, + {"instrument_id": "GBPUSD=X", "sensitivity": 0.72, "typical_direction": "depends_on_outcome", "notes": "Impact via USD strength/weakness."}, + {"instrument_id": "VXX", "sensitivity": 0.80, "typical_direction": "depends_on_outcome", "notes": "Vol spike si surprise. Compression si résultat attendu."}, + {"instrument_id": "BTC-USD", "sensitivity": 0.72, "typical_direction": "depends_on_outcome", "notes": "Actif risqué/liquidité. Dovish Fed = BTC monte."}, + {"instrument_id": "GS", "sensitivity": 0.78, "typical_direction": "depends_on_outcome", "notes": "Banque sensible à la courbe des taux et aux conditions crédit."}, + {"instrument_id": "EEM", "sensitivity": 0.70, "typical_direction": "depends_on_outcome", "notes": "USD fort = pression sur EM (dette USD). Dovish = flux EM."}, + ] + }, + + { + "name": "NFP — Non-Farm Payrolls", + "type": "event_calendar", + "sub_type": "NFP", + "description": "Publication mensuelle de l'emploi non-agricole US.", + "default_impacts": [ + {"instrument_id": "SPY", "sensitivity": 0.78, "typical_direction": "depends_on_outcome", "notes": "Beat = bon pour actions si pas de peur de la Fed. Miss = récession fear."}, + {"instrument_id": "QQQ", "sensitivity": 0.75, "typical_direction": "depends_on_outcome", "notes": "Moins sensible que SPY sauf si choc majeur."}, + {"instrument_id": "IWM", "sensitivity": 0.80, "typical_direction": "bullish_if_beat", "notes": "Small caps très sensibles à la croissance domestique."}, + {"instrument_id": "TLT", "sensitivity": 0.82, "typical_direction": "depends_on_outcome", "notes": "Beat = TLT baisse (Fed plus hawkish). Miss = TLT monte (récession trade)."}, + {"instrument_id": "HYG", "sensitivity": 0.75, "typical_direction": "bullish_if_beat", "notes": "Bon emploi = défauts faibles = HY spreads se compriment."}, + {"instrument_id": "EURUSD=X", "sensitivity": 0.72, "typical_direction": "depends_on_outcome", "notes": "Beat = USD fort. Miss = USD faible."}, + {"instrument_id": "USDJPY=X", "sensitivity": 0.75, "typical_direction": "depends_on_outcome", "notes": "Beat = USD/JPY monte."}, + {"instrument_id": "GLD", "sensitivity": 0.65, "typical_direction": "depends_on_outcome", "notes": "Beat = or baisse (taux montent). Miss = or monte (récession hedge)."}, + {"instrument_id": "VXX", "sensitivity": 0.70, "typical_direction": "depends_on_outcome", "notes": "Miss majeur = vol spike. Beat en ligne = vol compression."}, + ] + }, + + { + "name": "CPI — Inflation US", + "type": "event_calendar", + "sub_type": "CPI", + "description": "Publication mensuelle de l'indice des prix à la consommation US.", + "default_impacts": [ + {"instrument_id": "SPY", "sensitivity": 0.82, "typical_direction": "depends_on_outcome", "notes": "CPI > attendu = hawkish Fed = actions baissent."}, + {"instrument_id": "QQQ", "sensitivity": 0.85, "typical_direction": "depends_on_outcome", "notes": "QQQ plus sensible que SPY via taux réels."}, + {"instrument_id": "TLT", "sensitivity": 0.90, "typical_direction": "depends_on_outcome", "notes": "CPI > attendu = TLT vend massivement. En-dessous = TLT rally."}, + {"instrument_id": "GLD", "sensitivity": 0.80, "typical_direction": "depends_on_outcome", "notes": "CPI > attendu = d'abord bearish or (taux réels montent). Mais LT = hedge."}, + {"instrument_id": "EURUSD=X", "sensitivity": 0.75, "typical_direction": "depends_on_outcome", "notes": "CPI hot = USD fort = EUR baisse."}, + {"instrument_id": "USDJPY=X", "sensitivity": 0.78, "typical_direction": "depends_on_outcome", "notes": "CPI hot = Fed hawkish = USD/JPY monte."}, + {"instrument_id": "HYG", "sensitivity": 0.72, "typical_direction": "depends_on_outcome", "notes": "CPI hot = risque de hausse des taux = spreads s'écartent."}, + {"instrument_id": "VXX", "sensitivity": 0.75, "typical_direction": "depends_on_outcome", "notes": "Surprise majeure dans les 2 sens = vol spike."}, + {"instrument_id": "BTC-USD", "sensitivity": 0.65, "typical_direction": "depends_on_outcome", "notes": "CPI hot = taux montent = liquidité se réduit = BTC baisse."}, + ] + }, + + { + "name": "GDP — Croissance US", + "type": "event_calendar", + "sub_type": "GDP", + "description": "Publication trimestrielle du PIB américain (estimé, révisé, final).", + "default_impacts": [ + {"instrument_id": "SPY", "sensitivity": 0.78, "typical_direction": "bullish_if_beat", "notes": "Croissance forte = bénéfices. Récession = bear market."}, + {"instrument_id": "QQQ", "sensitivity": 0.75, "typical_direction": "bullish_if_beat", "notes": "Corrélé mais moins direct que SPY."}, + {"instrument_id": "IWM", "sensitivity": 0.85, "typical_direction": "bullish_if_beat", "notes": "Small caps très corrélées au cycle domestique."}, + {"instrument_id": "TLT", "sensitivity": 0.78, "typical_direction": "bearish_if_beat", "notes": "Croissance forte = taux montent = TLT baisse."}, + {"instrument_id": "HYG", "sensitivity": 0.80, "typical_direction": "bullish_if_beat", "notes": "Croissance forte = défauts faibles = HY se comprime."}, + {"instrument_id": "GLD", "sensitivity": 0.60, "typical_direction": "depends_on_outcome", "notes": "Impact indirect via taux réels."}, + {"instrument_id": "EURUSD=X", "sensitivity": 0.65, "typical_direction": "depends_on_outcome", "notes": "PIB fort = USD fort si surprise."}, + {"instrument_id": "VXX", "sensitivity": 0.72, "typical_direction": "depends_on_outcome", "notes": "Récession technique = vol spike. Beat = vol compression."}, + {"instrument_id": "GS", "sensitivity": 0.75, "typical_direction": "bullish_if_beat", "notes": "Croissance forte = M&A, trading, activité bancaire."}, + {"instrument_id": "XOM", "sensitivity": 0.68, "typical_direction": "bullish_if_beat", "notes": "Croissance = demande énergétique plus forte."}, + ] + }, + + { + "name": "PCE — Core PCE (indicateur Fed)", + "type": "event_calendar", + "sub_type": "PCE", + "description": "Core PCE Price Index — indicateur d'inflation de référence de la Fed.", + "default_impacts": [ + {"instrument_id": "SPY", "sensitivity": 0.75, "typical_direction": "depends_on_outcome", "notes": "Similaire au CPI mais moins volatile. Impact si surprise notable."}, + {"instrument_id": "TLT", "sensitivity": 0.82, "typical_direction": "depends_on_outcome", "notes": "Hot PCE = taux montent = TLT vend."}, + {"instrument_id": "GLD", "sensitivity": 0.72, "typical_direction": "depends_on_outcome", "notes": "Impact via taux réels."}, + {"instrument_id": "EURUSD=X", "sensitivity": 0.68, "typical_direction": "depends_on_outcome", "notes": "Hot PCE = Fed hawkish = USD fort."}, + {"instrument_id": "QQQ", "sensitivity": 0.78, "typical_direction": "depends_on_outcome", "notes": "QQQ très sensible aux taux réels."}, + ] + }, + + { + "name": "ISM Manufacturing", + "type": "event_calendar", + "sub_type": "ISM", + "description": "ISM Manufacturing PMI — activité industrielle US (>50 expansion, <50 contraction).", + "default_impacts": [ + {"instrument_id": "IWM", "sensitivity": 0.80, "typical_direction": "bullish_if_beat", "notes": "Très corrélé à l'industrie domestique."}, + {"instrument_id": "SPY", "sensitivity": 0.68, "typical_direction": "bullish_if_beat", "notes": "Indicateur de cycle économique."}, + {"instrument_id": "USO", "sensitivity": 0.65, "typical_direction": "bullish_if_beat", "notes": "Production industrielle forte = demande énergie."}, + {"instrument_id": "XOM", "sensitivity": 0.65, "typical_direction": "bullish_if_beat", "notes": "Similaire à USO."}, + {"instrument_id": "TLT", "sensitivity": 0.65, "typical_direction": "bearish_if_beat", "notes": "Expansion = risque de hausse taux."}, + {"instrument_id": "VXX", "sensitivity": 0.62, "typical_direction": "depends_on_outcome", "notes": "Miss sévère déclenche vol."}, + ] + }, + + { + "name": "BOJ — Décision de taux", + "type": "event_calendar", + "sub_type": "BOJ", + "description": "Décision de politique monétaire de la Banque du Japon — YCC, NIRP, normalisation.", + "default_impacts": [ + {"instrument_id": "USDJPY=X", "sensitivity": 0.97, "typical_direction": "depends_on_outcome", "notes": "DRIVER PRINCIPAL. Hausse surprise = JPY fort = USD/JPY chute."}, + {"instrument_id": "EFA", "sensitivity": 0.82, "typical_direction": "depends_on_outcome", "notes": "Japon = composante majeure de l'EAFE."}, + {"instrument_id": "GLD", "sensitivity": 0.68, "typical_direction": "depends_on_outcome", "notes": "Normalisation BOJ = or peut monter (diversification réserves)."}, + {"instrument_id": "TLT", "sensitivity": 0.72, "typical_direction": "depends_on_outcome", "notes": "BOJ hawkish = hausse taux Japon = JGB concurrence UST."}, + {"instrument_id": "VXX", "sensitivity": 0.80, "typical_direction": "bullish_if_surprise","notes": "BOJ surprise = carry trade unwind = vol spike mondial."}, + {"instrument_id": "SPY", "sensitivity": 0.65, "typical_direction": "depends_on_outcome", "notes": "Via carry trade unwind (BOJ hawkish → vente actifs risqués)."}, + {"instrument_id": "BTC-USD", "sensitivity": 0.58, "typical_direction": "bearish_if_hawkish", "notes": "Risk-off si carry unwind."}, + ] + }, + + { + "name": "ECB — Décision de taux", + "type": "event_calendar", + "sub_type": "ECB", + "description": "Décision de politique monétaire de la Banque Centrale Européenne.", + "default_impacts": [ + {"instrument_id": "EURUSD=X", "sensitivity": 0.95, "typical_direction": "depends_on_outcome", "notes": "DRIVER PRINCIPAL. Hawkish ECB = EUR fort. Dovish = EUR faible."}, + {"instrument_id": "GBPUSD=X", "sensitivity": 0.72, "typical_direction": "depends_on_outcome", "notes": "Impact via USD et sentiment zone euro."}, + {"instrument_id": "EFA", "sensitivity": 0.85, "typical_direction": "depends_on_outcome", "notes": "Zone euro = composante majeure EAFE."}, + {"instrument_id": "TLT", "sensitivity": 0.65, "typical_direction": "depends_on_outcome", "notes": "Politique BCE influence flux globaux vers Treasuries."}, + {"instrument_id": "GLD", "sensitivity": 0.60, "typical_direction": "depends_on_outcome", "notes": "BCE hawkish = EUR fort = or en EUR baisse."}, + {"instrument_id": "VXX", "sensitivity": 0.62, "typical_direction": "depends_on_outcome", "notes": "Surprise ECB peut déclencher vol."}, + ] + }, + + { + "name": "BOE — Décision de taux", + "type": "event_calendar", + "sub_type": "BOE", + "description": "Décision de politique monétaire de la Bank of England.", + "default_impacts": [ + {"instrument_id": "GBPUSD=X", "sensitivity": 0.95, "typical_direction": "depends_on_outcome", "notes": "DRIVER PRINCIPAL. Hawkish BOE = GBP fort. Dovish = GBP faible."}, + {"instrument_id": "EURUSD=X", "sensitivity": 0.55, "typical_direction": "depends_on_outcome", "notes": "Impact indirect via sentiment EUR/GBP."}, + {"instrument_id": "TLT", "sensitivity": 0.52, "typical_direction": "depends_on_outcome", "notes": "Impact global limité sauf si crise gilts (2022)."}, + {"instrument_id": "VXX", "sensitivity": 0.55, "typical_direction": "depends_on_outcome", "notes": "Crise gilt-style = vol spike."}, + ] + }, + + { + "name": "OPEC+ — Décision de production", + "type": "event_calendar", + "sub_type": "OPEC", + "description": "Réunion OPEC+ : décision de quotas de production pétrolière.", + "default_impacts": [ + {"instrument_id": "USO", "sensitivity": 0.95, "typical_direction": "depends_on_outcome", "notes": "DRIVER PRINCIPAL. Coupes = oil monte. Augmentation = oil baisse."}, + {"instrument_id": "XOM", "sensitivity": 0.88, "typical_direction": "depends_on_outcome", "notes": "Prix pétrole = cash-flow XOM."}, + {"instrument_id": "EEM", "sensitivity": 0.68, "typical_direction": "depends_on_outcome", "notes": "Exportateurs EM bénéficient des coupes."}, + {"instrument_id": "GLD", "sensitivity": 0.55, "typical_direction": "depends_on_outcome", "notes": "Oil choc = inflation = gold monte parfois."}, + {"instrument_id": "EURUSD=X", "sensitivity": 0.58, "typical_direction": "depends_on_outcome", "notes": "Pétrole cher = pression sur l'Europe importatrice = EUR faible."}, + {"instrument_id": "VXX", "sensitivity": 0.65, "typical_direction": "depends_on_outcome", "notes": "Surprise coupe majeure = spike vol court terme."}, + {"instrument_id": "SPY", "sensitivity": 0.60, "typical_direction": "depends_on_outcome", "notes": "Oil choc = inflation = stagflation fear."}, + {"instrument_id": "TLT", "sensitivity": 0.58, "typical_direction": "depends_on_outcome", "notes": "Oil choc = inflation = taux montent = TLT baisse."}, + ] + }, + + { + "name": "EIA — Stocks pétroliers", + "type": "event_calendar", + "sub_type": "EIA", + "description": "Rapport hebdomadaire EIA sur les stocks de pétrole brut US.", + "default_impacts": [ + {"instrument_id": "USO", "sensitivity": 0.82, "typical_direction": "depends_on_outcome", "notes": "Build (hausse stocks) = baissier. Draw (baisse stocks) = haussier."}, + {"instrument_id": "XOM", "sensitivity": 0.72, "typical_direction": "depends_on_outcome", "notes": "Corrélé au prix pétrole."}, + {"instrument_id": "UNG", "sensitivity": 0.45, "typical_direction": "neutral", "notes": "Impact indirect via sentiment énergie."}, + ] + }, + + # ═══════════════════════════════════════════════════════ + # GEOPOLITICAL + # ═══════════════════════════════════════════════════════ + + { + "name": "Guerre — Moyen-Orient", + "type": "geopolitical", + "sub_type": "War", + "description": "Conflit militaire ou escalade au Moyen-Orient (Iran, Israël, Yemen, Golfe Persique).", + "default_impacts": [ + {"instrument_id": "USO", "sensitivity": 0.92, "typical_direction": "bullish", "notes": "DRIVER PRINCIPAL. Menace sur les livraisons du Golfe Persique."}, + {"instrument_id": "GLD", "sensitivity": 0.85, "typical_direction": "bullish", "notes": "Safe haven classique en crise géopolitique."}, + {"instrument_id": "VXX", "sensitivity": 0.88, "typical_direction": "bullish", "notes": "Choc géopolitique = vol spike immédiat."}, + {"instrument_id": "XOM", "sensitivity": 0.80, "typical_direction": "bullish", "notes": "Prime géopolitique sur le pétrole."}, + {"instrument_id": "SPY", "sensitivity": 0.75, "typical_direction": "bearish", "notes": "Risk-off. Ampleur dépend de la durée et de l'escalade."}, + {"instrument_id": "QQQ", "sensitivity": 0.72, "typical_direction": "bearish", "notes": "Tech vend en risk-off."}, + {"instrument_id": "TLT", "sensitivity": 0.72, "typical_direction": "bullish", "notes": "Fuite vers la qualité."}, + {"instrument_id": "EURUSD=X", "sensitivity": 0.68, "typical_direction": "bearish", "notes": "USD safe haven. EUR exposé via énergie."}, + {"instrument_id": "EEM", "sensitivity": 0.72, "typical_direction": "bearish", "notes": "Risk-off pèse sur EM. Pays importateurs pétrole souffrent."}, + {"instrument_id": "USDJPY=X", "sensitivity": 0.65, "typical_direction": "bearish", "notes": "JPY safe haven = USD/JPY baisse."}, + {"instrument_id": "BTC-USD", "sensitivity": 0.60, "typical_direction": "bearish", "notes": "Risk-off en phase initiale. Puis possible refuge si dévaluation USD."}, + ] + }, + + { + "name": "Guerre — Russie / Ukraine", + "type": "geopolitical", + "sub_type": "War", + "description": "Escalade ou désescalade du conflit russo-ukrainien. Impact sur énergie, céréales, EM.", + "default_impacts": [ + {"instrument_id": "USO", "sensitivity": 0.85, "typical_direction": "bullish", "notes": "Russie = grand exportateur. Sanctions = offre réduite."}, + {"instrument_id": "UNG", "sensitivity": 0.80, "typical_direction": "bullish", "notes": "Gaz russe = composante clé approvisionnement Europe."}, + {"instrument_id": "GLD", "sensitivity": 0.82, "typical_direction": "bullish", "notes": "Safe haven + incertitude systémique."}, + {"instrument_id": "EFA", "sensitivity": 0.78, "typical_direction": "bearish", "notes": "Europe exposée énergétiquement et économiquement."}, + {"instrument_id": "EURUSD=X", "sensitivity": 0.80, "typical_direction": "bearish", "notes": "EUR exposé au choc énergétique et à la croissance européenne."}, + {"instrument_id": "VXX", "sensitivity": 0.85, "typical_direction": "bullish", "notes": "Escalade = spike vol immédiat."}, + {"instrument_id": "SPY", "sensitivity": 0.68, "typical_direction": "bearish", "notes": "Risk-off, plus modéré car US moins exposé."}, + {"instrument_id": "EEM", "sensitivity": 0.72, "typical_direction": "bearish", "notes": "EM exportateurs céréales et énergie impactés."}, + {"instrument_id": "TLT", "sensitivity": 0.68, "typical_direction": "bullish", "notes": "Fuite vers qualité en escalade."}, + {"instrument_id": "XOM", "sensitivity": 0.72, "typical_direction": "bullish", "notes": "Prix pétrole/gaz monte = XOM bénéficie."}, + ] + }, + + { + "name": "Sanctions — Pétrole / Énergie", + "type": "geopolitical", + "sub_type": "Sanctions", + "description": "Sanctions énergétiques (Russie, Iran, Venezuela) ou perturbation supply pétrolier.", + "default_impacts": [ + {"instrument_id": "USO", "sensitivity": 0.90, "typical_direction": "bullish", "notes": "Offre réduite = prix monte."}, + {"instrument_id": "XOM", "sensitivity": 0.82, "typical_direction": "bullish", "notes": "Bénéfice du prix élevé et du remplacement de supply."}, + {"instrument_id": "GLD", "sensitivity": 0.68, "typical_direction": "bullish", "notes": "Inflation énergie = or monte."}, + {"instrument_id": "TLT", "sensitivity": 0.62, "typical_direction": "bearish", "notes": "Inflation énergie = taux montent."}, + {"instrument_id": "SPY", "sensitivity": 0.65, "typical_direction": "bearish", "notes": "Stagflation si choc sévère."}, + {"instrument_id": "EURUSD=X", "sensitivity": 0.68, "typical_direction": "bearish", "notes": "Europe plus exposée que US."}, + ] + }, + + { + "name": "Chine — Taiwan / Tensions géopolitiques", + "type": "geopolitical", + "sub_type": "China", + "description": "Escalade militaire ou diplomatique Chine-Taiwan, mers de Chine.", + "default_impacts": [ + {"instrument_id": "VXX", "sensitivity": 0.95, "typical_direction": "bullish", "notes": "Choc systémique maximal si escalade."}, + {"instrument_id": "NVDA", "sensitivity": 0.92, "typical_direction": "bearish", "notes": "Taiwan = TSMC. Supply chain semi-conducteurs bloquée."}, + {"instrument_id": "QQQ", "sensitivity": 0.85, "typical_direction": "bearish", "notes": "Tech exposé via supply chain Asie."}, + {"instrument_id": "SPY", "sensitivity": 0.80, "typical_direction": "bearish", "notes": "Risk-off systémique."}, + {"instrument_id": "GLD", "sensitivity": 0.88, "typical_direction": "bullish", "notes": "Safe haven ultime."}, + {"instrument_id": "TLT", "sensitivity": 0.75, "typical_direction": "bullish", "notes": "Fuite vers qualité."}, + {"instrument_id": "EEM", "sensitivity": 0.90, "typical_direction": "bearish", "notes": "Chine = composante majeure EEM."}, + {"instrument_id": "AAPL", "sensitivity": 0.85, "typical_direction": "bearish", "notes": "Apple : fabrication en Chine + ventes chinoises menacées."}, + {"instrument_id": "USDJPY=X", "sensitivity": 0.70, "typical_direction": "bearish", "notes": "JPY safe haven."}, + {"instrument_id": "BTC-USD", "sensitivity": 0.65, "typical_direction": "bearish", "notes": "Risk-off initial."}, + ] + }, + + { + "name": "Guerre commerciale / Tarifs douaniers", + "type": "geopolitical", + "sub_type": "TradeWar", + "description": "Annonce de tarifs douaniers, escalade guerre commerciale US-Chine ou US-UE.", + "default_impacts": [ + {"instrument_id": "EEM", "sensitivity": 0.85, "typical_direction": "bearish", "notes": "Exportateurs EM très exposés."}, + {"instrument_id": "QQQ", "sensitivity": 0.80, "typical_direction": "bearish", "notes": "Tech exposé via supply chain et marchés chinois."}, + {"instrument_id": "AAPL", "sensitivity": 0.88, "typical_direction": "bearish", "notes": "Apple : tarifs sur produits fabriqués en Chine."}, + {"instrument_id": "NVDA", "sensitivity": 0.82, "typical_direction": "bearish", "notes": "Export controls sur chips."}, + {"instrument_id": "SPY", "sensitivity": 0.75, "typical_direction": "bearish", "notes": "Stagflation domestique + récession mondiale."}, + {"instrument_id": "GLD", "sensitivity": 0.72, "typical_direction": "bullish", "notes": "Hedge inflation + incertitude."}, + {"instrument_id": "TLT", "sensitivity": 0.65, "typical_direction": "depends_on_outcome", "notes": "Récession = haussier TLT. Inflation = baissier."}, + {"instrument_id": "EURUSD=X", "sensitivity": 0.68, "typical_direction": "bearish", "notes": "USD peut se renforcer en position de force."}, + {"instrument_id": "VXX", "sensitivity": 0.80, "typical_direction": "bullish", "notes": "Incertitude = vol monte."}, + {"instrument_id": "USO", "sensitivity": 0.60, "typical_direction": "bearish", "notes": "Récession mondiale = demande pétrole baisse."}, + {"instrument_id": "XOM", "sensitivity": 0.58, "typical_direction": "bearish", "notes": "Même logique que USO."}, + ] + }, + + { + "name": "Crise bancaire / Stress systémique", + "type": "geopolitical", + "sub_type": "Banking", + "description": "Faillite bancaire, crise de liquidité, risque systémique (type SVB, Lehman, gilts UK).", + "default_impacts": [ + {"instrument_id": "VXX", "sensitivity": 0.97, "typical_direction": "bullish", "notes": "Crise systémique = vol spike maximal."}, + {"instrument_id": "HYG", "sensitivity": 0.92, "typical_direction": "bearish", "notes": "Spreads HY explosent en crise de crédit."}, + {"instrument_id": "GS", "sensitivity": 0.90, "typical_direction": "bearish", "notes": "Banques directement exposées."}, + {"instrument_id": "GLD", "sensitivity": 0.85, "typical_direction": "bullish", "notes": "Safe haven ultime."}, + {"instrument_id": "TLT", "sensitivity": 0.80, "typical_direction": "bullish", "notes": "Fuite vers la qualité + attente pivot Fed."}, + {"instrument_id": "SPY", "sensitivity": 0.82, "typical_direction": "bearish", "notes": "Contagion systémique."}, + {"instrument_id": "IWM", "sensitivity": 0.88, "typical_direction": "bearish", "notes": "Small caps très dépendantes du crédit bancaire régional."}, + {"instrument_id": "BTC-USD", "sensitivity": 0.72, "typical_direction": "depends_on_outcome", "notes": "Initial sell-off, puis possible rally si pivot Fed."}, + {"instrument_id": "EURUSD=X", "sensitivity": 0.72, "typical_direction": "depends_on_outcome", "notes": "USD safe haven. EUR exposé si crise zone euro."}, + {"instrument_id": "USDJPY=X", "sensitivity": 0.70, "typical_direction": "bearish", "notes": "Risk-off = JPY monte."}, + ] + }, + + { + "name": "Stimulus Chine / PBOC", + "type": "geopolitical", + "sub_type": "China", + "description": "Annonce de stimulus économique par Pékin ou décision d'assouplissement de la PBOC.", + "default_impacts": [ + {"instrument_id": "EEM", "sensitivity": 0.92, "typical_direction": "bullish", "notes": "Chine = moteur principal des émergents."}, + {"instrument_id": "GLD", "sensitivity": 0.75, "typical_direction": "bullish", "notes": "Stimulus = inflation + demande bijouterie/physique."}, + {"instrument_id": "USO", "sensitivity": 0.80, "typical_direction": "bullish", "notes": "Chine = 1er importateur pétrole. Stimulus = demande monte."}, + {"instrument_id": "SLV", "sensitivity": 0.78, "typical_direction": "bullish", "notes": "Chine = 1er consommateur industriel argent."}, + {"instrument_id": "XOM", "sensitivity": 0.70, "typical_direction": "bullish", "notes": "Demande pétrole Chine monte."}, + {"instrument_id": "EFA", "sensitivity": 0.70, "typical_direction": "bullish", "notes": "Europe/Japon exportent vers Chine."}, + {"instrument_id": "SPY", "sensitivity": 0.60, "typical_direction": "bullish", "notes": "Sentiment global améliore."}, + {"instrument_id": "EURUSD=X", "sensitivity": 0.58, "typical_direction": "bullish", "notes": "Risk-on = USD faible."}, + ] + }, +] + + +def bootstrap_impact_categories(force: bool = False) -> Dict[str, Any]: + """Insert default impact categories. Idempotent if force=False.""" + from services.database import upsert_event_category, get_all_event_categories + + existing = {c["name"] for c in get_all_event_categories()} + inserted = skipped = 0 + for cat in CATEGORIES: + if not force and cat["name"] in existing: + skipped += 1 + continue + upsert_event_category(cat) + inserted += 1 + + return {"inserted": inserted, "skipped": skipped, "total": len(CATEGORIES)} diff --git a/backend/services/impact_service.py b/backend/services/impact_service.py new file mode 100644 index 0000000..f27e134 --- /dev/null +++ b/backend/services/impact_service.py @@ -0,0 +1,256 @@ +""" +Impact evaluation service. +Given a market_event (calendar or geopolitical), calls the AI to estimate +instrument-level impacts (score 0-1, direction, rationale, confidence). +Results are persisted in instrument_impacts table. +""" +import json +import logging +import os +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +ALL_INSTRUMENTS = [ + "SPY","QQQ","IWM","EEM","EFA", + "GLD","SLV","USO","UNG","TLT", + "HYG","EURUSD=X","USDJPY=X","GBPUSD=X", + "VXX","AAPL","NVDA","GS","XOM","BTC-USD", +] + +INSTRUMENT_NAMES = { + "SPY": "S&P 500 ETF", "QQQ": "Nasdaq 100 ETF", "IWM": "Russell 2000 ETF", + "EEM": "Emerging Markets ETF", "EFA": "MSCI EAFE ETF", + "GLD": "Gold ETF", "SLV": "Silver ETF", "USO": "WTI Oil ETF", + "UNG": "Natural Gas ETF", "TLT": "US 20Y Treasury ETF", + "HYG": "High Yield Credit ETF", "EURUSD=X": "EUR/USD", + "USDJPY=X": "USD/JPY", "GBPUSD=X": "GBP/USD", + "VXX": "VIX Tracker", "AAPL": "Apple Inc", + "NVDA": "NVIDIA Corp", "GS": "Goldman Sachs", + "XOM": "ExxonMobil", "BTC-USD": "Bitcoin", +} + +_INST_LIST_STR = ", ".join(f"{k} ({v})" for k, v in INSTRUMENT_NAMES.items()) + + +def _get_api_key() -> str: + key = os.environ.get("OPENAI_API_KEY", "") + if not key: + from services.database import get_config + key = get_config("openai_api_key") or "" + return key + + +def _build_prompt(event: Dict[str, Any], category_defaults: Optional[List[Dict]] = None) -> str: + cat_context = "" + if category_defaults: + top = sorted(category_defaults, key=lambda x: x.get("sensitivity", 0), reverse=True)[:8] + lines = [f" • {d['instrument_id']}: sensibilité {d.get('sensitivity',0):.0%}, direction usuelle = {d.get('typical_direction','?')}" for d in top] + cat_context = "\nImpacts par défaut de cette catégorie (ajuste si l'événement est atypique) :\n" + "\n".join(lines) + + return f"""Tu es un analyste macro senior spécialisé en options et trading multi-actifs. + +ÉVÉNEMENT À ANALYSER : +- Nom : {event.get('name', '')} +- Date : {event.get('start_date', '')} +- Type : {event.get('category', '')} / {event.get('sub_type', '')} +- Description : {event.get('description', '')} +- Impact attendu (résumé) : {event.get('market_impact', '')} +{cat_context} + +MISSION : +Évalue l'impact potentiel de cet événement sur chacun des 20 instruments suivants. +Instruments disponibles : {_INST_LIST_STR} + +Pour chaque instrument IMPACTÉ (score >= 0.15), retourne : +- instrument_id : identifiant exact (ex: "SPY", "EURUSD=X", "BTC-USD") +- impact_score : 0.0 (aucun) → 1.0 (impact majeur, mouvement > 2%) +- direction : "bullish" | "bearish" | "neutral" | "depends_on_outcome" +- rationale : 1 phrase MAX expliquant le mécanisme +- confidence : 0.0-1.0 (ta certitude sur l'estimation) + +Critères de scoring : +- 0.0-0.2 : négligeable (bruit) +- 0.2-0.4 : faible (< 0.5% mouvement) +- 0.4-0.6 : modéré (0.5-1% mouvement) +- 0.6-0.8 : fort (1-2% mouvement) +- 0.8-1.0 : majeur (> 2%, event driver primaire) + +FORMAT JSON STRICT (pas de texte hors du JSON) : +{{ + "impacts": [ + {{"instrument_id": "SPY", "impact_score": 0.85, "direction": "bearish", "rationale": "Hausse taux comprime les multiples P/E", "confidence": 0.90}} + ], + "summary": "Contexte macro de cet événement en 1 phrase", + "key_driver": "Instrument le plus impacté et pourquoi" +}}""" + + +def evaluate_event_impacts( + event_id: int, + force: bool = False, +) -> Dict[str, Any]: + """ + Evaluate instrument impacts for a market_event via AI. + Saves results to instrument_impacts table. + Returns the list of impacts + AI summary. + """ + from services.database import ( + get_all_market_events, get_event_category, + get_impacts_for_source, delete_impacts_for_source, + save_instrument_impacts, + ) + + # Fetch event + events = get_all_market_events() + event = next((e for e in events if e["id"] == event_id), None) + if not event: + return {"error": f"Event {event_id} not found"} + + # Check if already evaluated + existing = get_impacts_for_source("event", event_id) + if existing and not force: + return { + "event_id": event_id, + "impacts": existing, + "summary": "", + "from_cache": True, + "n_instruments": len(existing), + } + + if force and existing: + delete_impacts_for_source("event", event_id) + + # Get category defaults for context + category_defaults = None + cat_name = event.get("sub_type") or event.get("category", "") + if cat_name: + for possible in [ + cat_name, + f"FOMC — Décision de taux" if "FOMC" in cat_name.upper() else None, + f"NFP — Non-Farm Payrolls" if "NFP" in cat_name.upper() else None, + ]: + if possible: + cat = get_event_category(possible) + if cat: + try: + category_defaults = json.loads(cat.get("default_impacts") or "[]") + except Exception: + pass + break + + api_key = _get_api_key() + if not api_key: + return {"error": "No OpenAI API key configured"} + + prompt = _build_prompt(event, category_defaults) + + try: + from openai import OpenAI + client = OpenAI(api_key=api_key) + response = client.chat.completions.create( + model="gpt-4o-mini", + messages=[{"role": "user", "content": prompt}], + response_format={"type": "json_object"}, + temperature=0.25, + max_tokens=1200, + ) + raw = json.loads(response.choices[0].message.content) + except Exception as e: + logger.error(f"[impact_service] AI call failed for event {event_id}: {e}") + return {"error": str(e)} + + ai_impacts = raw.get("impacts", []) + summary = raw.get("summary", "") + key_driver = raw.get("key_driver", "") + + # Persist + to_save = [] + for imp in ai_impacts: + if imp.get("instrument_id") not in ALL_INSTRUMENTS: + continue + to_save.append({ + "source_type": "event", + "source_id": event_id, + "source_name": event["name"], + "source_date": event["start_date"], + "category_name": cat_name, + "instrument_id": imp["instrument_id"], + "impact_score": float(imp.get("impact_score", 0.5)), + "direction": imp.get("direction", "neutral"), + "rationale": imp.get("rationale", ""), + "confidence": float(imp.get("confidence", 0.7)), + "ai_generated": 1, + }) + + save_instrument_impacts(to_save) + saved = get_impacts_for_source("event", event_id) + + return { + "event_id": event_id, + "event_name": event["name"], + "impacts": saved, + "summary": summary, + "key_driver": key_driver, + "n_instruments": len(saved), + "from_cache": False, + } + + +def get_impact_monitor_data(days: int = 7, min_score: float = 0.3) -> Dict[str, Any]: + """ + Return all evaluated sources (events + news) from the last N days, + filtered by min_score, with their instrument impacts. + Also includes unevaluated events for quick launch. + """ + from services.database import ( + get_weekly_impact_sources, get_all_market_events, + get_impacts_for_source, + ) + from datetime import datetime, timedelta + + cutoff = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d") + + # Evaluated sources + evaluated = get_weekly_impact_sources(days=days, min_score=min_score) + + # All recent events not yet evaluated + all_events = get_all_market_events() + evaluated_ids = {(s["source_type"], s.get("source_id")) for s in evaluated} + + unevaluated = [] + for ev in all_events: + if ev.get("start_date", "") < cutoff: + continue + if ("event", ev["id"]) not in evaluated_ids: + unevaluated.append({ + "id": ev["id"], + "name": ev["name"], + "start_date": ev["start_date"], + "category": ev.get("category",""), + "sub_type": ev.get("sub_type",""), + "impact_score": ev.get("impact_score", 0.5), + }) + + # Top impacted instruments this period + from collections import defaultdict + inst_scores: Dict[str, List[float]] = defaultdict(list) + for source in evaluated: + for imp in source.get("impacts", []): + score = imp.get("adjusted_score") or imp.get("impact_score", 0) + if score >= min_score: + inst_scores[imp["instrument_id"]].append(score) + + top_instruments = sorted( + [{"instrument_id": k, "avg_score": sum(v)/len(v), "n_events": len(v)} + for k, v in inst_scores.items()], + key=lambda x: x["avg_score"] * x["n_events"], reverse=True + )[:10] + + return { + "evaluated": evaluated, + "unevaluated": sorted(unevaluated, key=lambda x: x["start_date"], reverse=True), + "top_instruments": top_instruments, + "period_days": days, + "min_score": min_score, + } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bb114a2..42af2e8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,6 +26,7 @@ import SpecialistDesks from './pages/SpecialistDesks' import Timeline from './pages/Timeline' import ExternalSnapshot from './pages/ExternalSnapshot' import InstrumentDashboard from './pages/InstrumentDashboard' +import ImpactMonitor from './pages/ImpactMonitor' import { Navigate } from 'react-router-dom' import { useCycleWatcher } from './hooks/useApi' @@ -69,6 +70,7 @@ export default function App() { } /> } /> } /> + } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 66418cb..fa29677 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -1,7 +1,7 @@ import { NavLink } from 'react-router-dom' import { LayoutDashboard, Globe, BarChart2, FlaskConical, - History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, Layers, ScanEye, CandlestickChart + History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, Layers, ScanEye, CandlestickChart, Crosshair } from 'lucide-react' import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi' import clsx from 'clsx' @@ -28,6 +28,7 @@ const nav = [ { to: '/institutional', icon: Building2, label: 'Inst. Reports' }, { to: '/specialist-desks', icon: Users, label: 'Specialist Desks' }, { to: '/instruments', icon: CandlestickChart, label: 'Instrument Snap.' }, + { to: '/impact', icon: Crosshair, label: 'Impact Monitor' }, { to: '/snapshot', icon: ScanEye, label: 'Snapshot Externe' }, { to: '/timeline', icon: Layers, label: 'Timeline' }, { to: '/logs', icon: ScrollText, label: 'System Logs' }, diff --git a/frontend/src/pages/ImpactMonitor.tsx b/frontend/src/pages/ImpactMonitor.tsx new file mode 100644 index 0000000..66a723b --- /dev/null +++ b/frontend/src/pages/ImpactMonitor.tsx @@ -0,0 +1,643 @@ +import { useState, useEffect, useCallback } from 'react' +import { + Zap, RefreshCw, ChevronDown, ChevronRight, AlertCircle, + TrendingUp, TrendingDown, Minus, Settings, Play, Check, Sliders, +} from 'lucide-react' +import axios from 'axios' +import clsx from 'clsx' + +const api = axios.create({ baseURL: '/api' }) + +// ── Types ───────────────────────────────────────────────────────────────────── + +interface Impact { + id: number + instrument_id: string + impact_score: number + direction: string + rationale: string + confidence: number + ai_generated: number + manually_adjusted: number + adjusted_score?: number + adjusted_direction?: string + override_rationale?: string +} + +interface EvaluatedSource { + source_type: string + source_id: number + source_name: string + source_date: string + category_name: string + max_score: number + n_instruments: number + evaluated_at: string + impacts: Impact[] +} + +interface UnevaluatedEvent { + id: number + name: string + start_date: string + category: string + sub_type: string + impact_score: number +} + +interface TopInstrument { + instrument_id: string + avg_score: number + n_events: number +} + +interface MonitorData { + evaluated: EvaluatedSource[] + unevaluated: UnevaluatedEvent[] + top_instruments: TopInstrument[] + period_days: number + min_score: number +} + +interface DefaultImpact { + instrument_id: string + sensitivity: number + typical_direction: string + notes: string +} + +interface Category { + id: number + name: string + type: string + sub_type: string + description: string + default_impacts: DefaultImpact[] +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const DIR_CFG: Record = { + bullish: { icon: , color: 'text-emerald-400', short: '↑' }, + bearish: { icon: , color: 'text-red-400', short: '↓' }, + neutral: { icon: , color: 'text-slate-400', short: '—' }, + depends_on_outcome: { icon: , color: 'text-amber-400', short: '?' }, + bullish_if_beat: { icon: , color: 'text-emerald-300', short: '↑?' }, + bearish_if_hawkish: { icon: , color: 'text-red-300', short: '↓H' }, + bullish_if_surprise: { icon: , color: 'text-violet-400', short: '↑!' }, +} + +const TYPE_CFG: Record = { + event_calendar: { color: 'text-amber-400 bg-amber-900/30 border-amber-700/30', label: 'Calendrier' }, + geopolitical: { color: 'text-red-400 bg-red-900/30 border-red-700/30', label: 'Géopolitique' }, +} + +function scoreBar(score: number, color = 'bg-blue-500') { + return ( +
+
+
+
+ {(score * 100).toFixed(0)} +
+ ) +} + +function scoreColor(score: number) { + if (score >= 0.7) return 'bg-red-500' + if (score >= 0.5) return 'bg-orange-500' + if (score >= 0.3) return 'bg-amber-500' + return 'bg-slate-500' +} + +function dirColor(dir: string) { + return DIR_CFG[dir]?.color ?? 'text-slate-400' +} + +// ── AdjustModal ─────────────────────────────────────────────────────────────── + +function AdjustModal({ + impact, onClose, onSave, +}: { + impact: Impact + onClose: () => void + onSave: (id: number, score: number, dir: string, rationale: string) => void +}) { + const [score, setScore] = useState(impact.adjusted_score ?? impact.impact_score) + const [dir, setDir] = useState(impact.adjusted_direction ?? impact.direction) + const [rationale, setRationale] = useState(impact.override_rationale ?? '') + + const DIRS = ['bullish','bearish','neutral','depends_on_outcome'] + + return ( +
+
+
+ Ajuster l'impact — {impact.instrument_id} + +
+
+
Score IA : {(impact.impact_score * 100).toFixed(0)}
+ + setScore(parseFloat(e.target.value))} + className="w-full accent-blue-500" /> +
+
+ +
+ {DIRS.map(d => ( + + ))} +
+
+
+ +