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

View File

@@ -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() {
<Route path="/snapshot" element={<ExternalSnapshot />} />
<Route path="/instruments" element={<Navigate to="/instruments/SPY" replace />} />
<Route path="/instruments/:id" element={<InstrumentDashboard />} />
<Route path="/impact" element={<ImpactMonitor />} />
</Routes>
</main>
</div>

View File

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

View File

@@ -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<string, { icon: React.ReactNode; color: string; short: string }> = {
bullish: { icon: <TrendingUp className="w-3 h-3" />, color: 'text-emerald-400', short: '↑' },
bearish: { icon: <TrendingDown className="w-3 h-3" />, color: 'text-red-400', short: '↓' },
neutral: { icon: <Minus className="w-3 h-3" />, color: 'text-slate-400', short: '—' },
depends_on_outcome: { icon: <Minus className="w-3 h-3" />, color: 'text-amber-400', short: '?' },
bullish_if_beat: { icon: <TrendingUp className="w-3 h-3" />, color: 'text-emerald-300', short: '↑?' },
bearish_if_hawkish: { icon: <TrendingDown className="w-3 h-3" />, color: 'text-red-300', short: '↓H' },
bullish_if_surprise: { icon: <TrendingUp className="w-3 h-3" />, color: 'text-violet-400', short: '↑!' },
}
const TYPE_CFG: Record<string, { color: string; label: string }> = {
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 (
<div className="flex items-center gap-1.5">
<div className="w-16 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full', color)} style={{ width: `${score * 100}%` }} />
</div>
<span className="text-xs tabular-nums text-slate-400">{(score * 100).toFixed(0)}</span>
</div>
)
}
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 (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70">
<div className="bg-dark-800 border border-slate-700/40 rounded-xl p-5 w-[400px] space-y-4 shadow-2xl">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-white">Ajuster l'impact — {impact.instrument_id}</span>
<button onClick={onClose} className="text-slate-500 hover:text-white"><span className="text-lg">×</span></button>
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Score IA : {(impact.impact_score * 100).toFixed(0)}</div>
<label className="text-xs text-slate-400 mb-1 block">Score ajusté : {(score * 100).toFixed(0)}</label>
<input type="range" min={0} max={1} step={0.05} value={score}
onChange={e => setScore(parseFloat(e.target.value))}
className="w-full accent-blue-500" />
</div>
<div>
<label className="text-xs text-slate-400 mb-1 block">Direction</label>
<div className="flex gap-1.5">
{DIRS.map(d => (
<button key={d} onClick={() => setDir(d)}
className={clsx('flex-1 text-xs py-1 rounded border transition-colors',
dir === d
? 'border-blue-500 bg-blue-900/30 text-blue-300'
: 'border-slate-700/40 text-slate-500 hover:text-white'
)}>
{d === 'depends_on_outcome' ? '?' : d.slice(0,4)}
</button>
))}
</div>
</div>
<div>
<label className="text-xs text-slate-400 mb-1 block">Commentaire</label>
<textarea
className="w-full text-xs bg-dark-700 border border-slate-700/40 rounded px-2 py-1.5 text-slate-300 placeholder-slate-600 resize-none"
rows={2} placeholder="Pourquoi ajuster..."
value={rationale} onChange={e => setRationale(e.target.value)}
/>
</div>
<div className="flex gap-2">
<button onClick={onClose}
className="flex-1 text-xs py-1.5 border border-slate-700/40 rounded-lg text-slate-400 hover:text-white">
Annuler
</button>
<button onClick={() => { onSave(impact.id, score, dir, rationale); onClose() }}
className="flex-1 text-xs py-1.5 bg-blue-700/40 hover:bg-blue-700/60 text-blue-300 border border-blue-600/40 rounded-lg">
Sauvegarder
</button>
</div>
</div>
</div>
)
}
// ── ImpactRow ─────────────────────────────────────────────────────────────────
function ImpactRow({
impact, onAdjust,
}: {
impact: Impact
onAdjust: (imp: Impact) => void
}) {
const score = impact.adjusted_score ?? impact.impact_score
const dir = impact.adjusted_direction ?? impact.direction
const dc = DIR_CFG[dir] ?? DIR_CFG['neutral']
return (
<div className="flex items-center gap-2 py-0.5 group">
<span className="text-xs font-mono text-slate-300 w-20 shrink-0">{impact.instrument_id}</span>
{scoreBar(score, scoreColor(score))}
<span className={clsx('text-xs flex items-center gap-0.5 w-16 shrink-0', dc.color)}>
{dc.icon}{dc.short}
</span>
<span className="text-xs text-slate-600 truncate flex-1">{impact.rationale}</span>
{impact.manually_adjusted === 1 && (
<span className="text-xs text-violet-500 shrink-0">✎</span>
)}
<button
onClick={() => onAdjust(impact)}
className="opacity-0 group-hover:opacity-100 p-0.5 text-slate-600 hover:text-white transition-opacity"
title="Ajuster">
<Sliders className="w-3 h-3" />
</button>
</div>
)
}
// ── EvaluatedCard ─────────────────────────────────────────────────────────────
function EvaluatedCard({
source, onAdjust, onReEval,
}: {
source: EvaluatedSource
onAdjust: (imp: Impact) => void
onReEval: (id: number) => void
}) {
const [open, setOpen] = useState(false)
const catType = source.impacts[0] ? 'event_calendar' : 'geopolitical'
const tcfg = TYPE_CFG[source.source_type === 'news' ? 'geopolitical' : 'event_calendar']
const sorted = [...source.impacts].sort((a, b) =>
(b.adjusted_score ?? b.impact_score) - (a.adjusted_score ?? a.impact_score)
)
return (
<div className="rounded-xl border border-slate-700/30 bg-dark-800/60 overflow-hidden">
<div
className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-dark-700/40 transition-colors"
onClick={() => setOpen(o => !o)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-sm font-medium text-white truncate">{source.source_name}</span>
</div>
<div className="flex items-center gap-2 text-xs text-slate-500">
<span>{source.source_date}</span>
{source.category_name && (
<span className={clsx('px-1.5 py-0 rounded border', tcfg?.color ?? 'text-slate-400 border-slate-700/30')}
style={{ fontSize: 9, paddingTop: 1, paddingBottom: 1 }}>
{source.category_name}
</span>
)}
<span>{source.n_instruments} instruments</span>
</div>
</div>
<div className="flex items-center gap-3 shrink-0">
{/* Top 3 instruments mini-badges */}
<div className="flex gap-1">
{sorted.slice(0, 3).map(imp => (
<span key={imp.instrument_id}
className={clsx('text-xs px-1.5 py-0.5 rounded font-mono',
(imp.adjusted_score ?? imp.impact_score) >= 0.7
? 'bg-red-900/30 text-red-300'
: (imp.adjusted_score ?? imp.impact_score) >= 0.5
? 'bg-orange-900/30 text-orange-300'
: 'bg-slate-700/40 text-slate-400'
)}>
{imp.instrument_id}
</span>
))}
</div>
<div className="flex items-center gap-1">
<div className="w-8 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full', scoreColor(source.max_score))}
style={{ width: `${source.max_score * 100}%` }} />
</div>
<span className="text-xs text-slate-400">{(source.max_score * 100).toFixed(0)}</span>
</div>
<button
onClick={e => { e.stopPropagation(); onReEval(source.source_id) }}
className="p-1 text-slate-600 hover:text-amber-400 transition-colors"
title="Re-évaluer">
<RefreshCw className="w-3 h-3" />
</button>
{open ? <ChevronDown className="w-4 h-4 text-slate-500" /> : <ChevronRight className="w-4 h-4 text-slate-500" />}
</div>
</div>
{open && (
<div className="px-4 pb-3 border-t border-slate-700/20 pt-2 space-y-0.5">
{sorted.map(imp => (
<ImpactRow key={imp.id} impact={imp} onAdjust={onAdjust} />
))}
</div>
)}
</div>
)
}
// ── CategoryPanel ─────────────────────────────────────────────────────────────
function CategoryPanel({ onClose }: { onClose: () => void }) {
const [categories, setCategories] = useState<Category[]>([])
const [selected, setSelected] = useState<Category | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
api.get('/impact/categories').then(r => {
setCategories(r.data)
setLoading(false)
}).catch(() => setLoading(false))
}, [])
return (
<div className="rounded-xl border border-slate-700/40 bg-dark-800/80 p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold text-white">Catégories & Impacts par défaut</span>
<button onClick={onClose} className="text-slate-500 hover:text-white text-lg">×</button>
</div>
{loading ? (
<div className="text-xs text-slate-500">Chargement...</div>
) : (
<div className="grid grid-cols-2 gap-3">
{/* Category list */}
<div className="space-y-1 max-h-[500px] overflow-y-auto pr-1">
{categories.map(cat => (
<div key={cat.name}
className={clsx('rounded-lg border px-3 py-2 cursor-pointer transition-colors',
selected?.name === cat.name
? 'border-blue-600/50 bg-blue-900/20'
: 'border-slate-700/30 bg-dark-700/40 hover:bg-dark-700/70'
)}
onClick={() => setSelected(cat)}
>
<div className="flex items-center gap-2">
<span className={clsx('text-xs px-1.5 py-0 rounded border',
TYPE_CFG[cat.type]?.color ?? 'text-slate-400 border-slate-700/30'
)} style={{ fontSize: 8, paddingTop: 1, paddingBottom: 1 }}>
{TYPE_CFG[cat.type]?.label ?? cat.type}
</span>
<span className="text-xs text-slate-300 truncate">{cat.name}</span>
</div>
<div className="text-xs text-slate-600 mt-0.5">{cat.default_impacts.length} instruments</div>
</div>
))}
</div>
{/* Default impacts detail */}
<div className="border border-slate-700/30 rounded-lg p-3 max-h-[500px] overflow-y-auto">
{selected ? (
<div className="space-y-2">
<div className="text-xs font-semibold text-white">{selected.name}</div>
<div className="text-xs text-slate-500">{selected.description}</div>
<div className="space-y-1 mt-2">
{[...selected.default_impacts]
.sort((a, b) => b.sensitivity - a.sensitivity)
.map(di => {
const dc = DIR_CFG[di.typical_direction] ?? DIR_CFG['neutral']
return (
<div key={di.instrument_id} className="flex items-center gap-2 py-0.5">
<span className="text-xs font-mono text-slate-300 w-20 shrink-0">{di.instrument_id}</span>
{scoreBar(di.sensitivity, scoreColor(di.sensitivity))}
<span className={clsx('text-xs shrink-0', dc.color)}>{dc.short}</span>
<span className="text-xs text-slate-600 truncate">{di.notes}</span>
</div>
)
})}
</div>
</div>
) : (
<div className="text-xs text-slate-600 italic">Sélectionne une catégorie</div>
)}
</div>
</div>
)}
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function ImpactMonitor() {
const [data, setData] = useState<MonitorData | null>(null)
const [loading, setLoading] = useState(false)
const [evaluating, setEvaluating] = useState<Set<number>>(new Set())
const [days, setDays] = useState(14)
const [minScore, setMinScore] = useState(0.3)
const [adjusting, setAdjusting] = useState<Impact | null>(null)
const [showCategories, setShowCategories] = useState(false)
const [tab, setTab] = useState<'evaluated' | 'pending'>('evaluated')
const load = useCallback(() => {
setLoading(true)
api.get(`/impact/monitor?days=${days}&min_score=${minScore}`)
.then(r => setData(r.data))
.catch(() => {})
.finally(() => setLoading(false))
}, [days, minScore])
useEffect(() => { load() }, [load])
async function evaluateEvent(eventId: number, force = false) {
setEvaluating(s => new Set(s).add(eventId))
try {
await api.post(`/impact/evaluate/event/${eventId}?force=${force}`)
load()
} catch (e) {
console.error(e)
} finally {
setEvaluating(s => { const n = new Set(s); n.delete(eventId); return n })
}
}
async function evaluateAllPending() {
if (!data?.unevaluated.length) return
const ids = data.unevaluated.slice(0, 10).map(e => e.id)
setEvaluating(new Set(ids))
try {
await api.post('/impact/evaluate/bulk', ids)
load()
} finally {
setEvaluating(new Set())
}
}
async function saveAdjustment(id: number, score: number, dir: string, rationale: string) {
await api.put(`/impact/adjust/${id}`, {
adjusted_score: score,
adjusted_direction: dir,
override_rationale: rationale,
})
load()
}
const evaluated = data?.evaluated ?? []
const unevaluated = data?.unevaluated ?? []
const topInstr = data?.top_instruments ?? []
return (
<div className="p-6 max-w-screen-xl mx-auto space-y-5">
{/* Header */}
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<Zap className="w-5 h-5 text-amber-400" />
<h1 className="text-xl font-bold text-white">Impact Monitor</h1>
<span className="text-xs text-slate-500 bg-dark-700 border border-slate-700/40 px-2 py-0.5 rounded-full">
Événements Éco + Géopolitique
</span>
</div>
<div className="ml-auto flex items-center gap-2">
{/* Days selector */}
<div className="flex items-center gap-1 bg-dark-800 border border-slate-700/40 rounded-xl p-1">
{[7, 14, 30, 90].map(d => (
<button key={d} onClick={() => setDays(d)}
className={clsx('px-3 py-1 text-xs rounded-lg transition-colors',
days === d ? 'bg-blue-700/50 text-blue-300' : 'text-slate-400 hover:text-white hover:bg-dark-700'
)}>
{d}j
</button>
))}
</div>
{/* Min score */}
<div className="flex items-center gap-1.5 text-xs text-slate-400">
<span>Seuil</span>
<select
className="bg-dark-700 border border-slate-700/40 rounded px-1.5 py-1 text-xs text-slate-300"
value={minScore}
onChange={e => setMinScore(parseFloat(e.target.value))}
>
{[0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7].map(v => (
<option key={v} value={v}>{(v * 100).toFixed(0)}</option>
))}
</select>
</div>
<button onClick={load} disabled={loading}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-dark-800 border border-slate-700/40 rounded-lg text-slate-400 hover:text-white transition-colors disabled:opacity-40">
<RefreshCw className={clsx('w-3 h-3', loading && 'animate-spin')} />
Actualiser
</button>
<button onClick={() => setShowCategories(s => !s)}
className={clsx('flex items-center gap-1.5 text-xs px-3 py-1.5 border rounded-lg transition-colors',
showCategories
? 'bg-slate-700/50 text-white border-slate-600/50'
: 'bg-dark-800 border-slate-700/40 text-slate-400 hover:text-white'
)}>
<Settings className="w-3 h-3" />
Catégories
</button>
</div>
</div>
{/* Categories panel */}
{showCategories && <CategoryPanel onClose={() => setShowCategories(false)} />}
{/* Top instruments bar */}
{topInstr.length > 0 && (
<div className="rounded-xl border border-slate-700/30 bg-dark-800/60 px-4 py-3">
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">
Instruments les plus impactés — {days}j
</div>
<div className="flex flex-wrap gap-2">
{topInstr.map(ti => (
<div key={ti.instrument_id}
className="flex items-center gap-1.5 bg-dark-700/60 border border-slate-700/30 rounded-lg px-2.5 py-1">
<span className="text-xs font-mono font-semibold text-white">{ti.instrument_id}</span>
<div className="w-12 h-1 bg-slate-800 rounded-full overflow-hidden">
<div className={clsx('h-full rounded-full', scoreColor(ti.avg_score))}
style={{ width: `${ti.avg_score * 100}%` }} />
</div>
<span className="text-xs text-slate-500">{ti.n_events}×</span>
</div>
))}
</div>
</div>
)}
{/* Tabs */}
<div className="flex items-center gap-1 border-b border-slate-700/30">
<button onClick={() => setTab('evaluated')}
className={clsx('px-4 py-2 text-sm transition-colors border-b-2',
tab === 'evaluated'
? 'text-white border-blue-500'
: 'text-slate-500 border-transparent hover:text-white'
)}>
Évalués <span className="ml-1.5 text-xs text-slate-600">({evaluated.length})</span>
</button>
<button onClick={() => setTab('pending')}
className={clsx('px-4 py-2 text-sm transition-colors border-b-2',
tab === 'pending'
? 'text-white border-amber-500'
: 'text-slate-500 border-transparent hover:text-white'
)}>
À évaluer
{unevaluated.length > 0 && (
<span className="ml-1.5 text-xs px-1.5 py-0.5 bg-amber-900/40 text-amber-400 rounded-full border border-amber-700/30">
{unevaluated.length}
</span>
)}
</button>
</div>
{/* Loading */}
{loading && (
<div className="space-y-3">
{[1,2,3].map(i => (
<div key={i} className="h-16 rounded-xl bg-dark-800/60 border border-slate-700/30 animate-pulse" />
))}
</div>
)}
{/* Evaluated tab */}
{!loading && tab === 'evaluated' && (
<div className="space-y-2">
{evaluated.length === 0 ? (
<div className="text-center py-16 text-slate-500">
<AlertCircle className="w-8 h-8 mx-auto mb-3 opacity-30" />
<p>Aucun événement évalué sur les {days} derniers jours</p>
<p className="text-xs mt-1">Passe à l'onglet "À évaluer" pour lancer les évaluations IA</p>
</div>
) : (
evaluated.map(src => (
<EvaluatedCard
key={`${src.source_type}-${src.source_id}`}
source={src}
onAdjust={imp => setAdjusting(imp)}
onReEval={id => evaluateEvent(id, true)}
/>
))
)}
</div>
)}
{/* Pending tab */}
{!loading && tab === 'pending' && (
<div className="space-y-3">
{unevaluated.length > 0 && (
<div className="flex items-center justify-between">
<span className="text-xs text-slate-500">
{unevaluated.length} événement(s) récents sans évaluation IA
</span>
<button
onClick={evaluateAllPending}
disabled={evaluating.size > 0}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-amber-800/30 hover:bg-amber-800/50 text-amber-300 border border-amber-700/40 rounded-lg transition-colors disabled:opacity-40">
<Play className="w-3 h-3" />
Évaluer les 10 premiers (IA)
</button>
</div>
)}
{unevaluated.length === 0 ? (
<div className="text-center py-16 text-slate-500">
<Check className="w-8 h-8 mx-auto mb-3 opacity-30 text-emerald-400" />
<p>Tous les événements récents ont é évalués</p>
</div>
) : (
<div className="space-y-2">
{unevaluated.map(ev => (
<div key={ev.id}
className="flex items-center gap-3 rounded-xl border border-slate-700/30 bg-dark-800/60 px-4 py-3">
<div className="flex-1 min-w-0">
<div className="text-sm text-slate-300 truncate">{ev.name}</div>
<div className="flex items-center gap-2 mt-0.5 text-xs text-slate-500">
<span>{ev.start_date}</span>
{ev.sub_type && (
<span className="px-1.5 py-0 rounded bg-slate-700/40 border border-slate-600/30"
style={{ fontSize: 9, paddingTop: 1, paddingBottom: 1 }}>
{ev.sub_type}
</span>
)}
</div>
</div>
<button
onClick={() => evaluateEvent(ev.id)}
disabled={evaluating.has(ev.id)}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 bg-blue-800/30 hover:bg-blue-800/50 text-blue-300 border border-blue-700/40 rounded-lg transition-colors disabled:opacity-40">
{evaluating.has(ev.id)
? <><RefreshCw className="w-3 h-3 animate-spin" /> Analyse IA...</>
: <><Zap className="w-3 h-3" /> Évaluer IA</>
}
</button>
</div>
))}
</div>
)}
</div>
)}
{/* Adjust modal */}
{adjusting && (
<AdjustModal
impact={adjusting}
onClose={() => setAdjusting(null)}
onSave={saveAdjustment}
/>
)}
</div>
)
}