feat: Impact Monitor — AI impact evaluation for eco events & geopolitical news

- 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 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-25 16:26:13 +02:00
parent 9ed59c5ca2
commit a68896cf67
8 changed files with 1590 additions and 3 deletions

View File

@@ -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("/")

155
backend/routers/impact.py Normal file
View File

@@ -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)

View File

@@ -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()

View File

@@ -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)}

View File

@@ -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,
}