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:
@@ -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
155
backend/routers/impact.py
Normal 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)
|
||||
@@ -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()
|
||||
|
||||
350
backend/services/impact_categories_bootstrap.py
Normal file
350
backend/services/impact_categories_bootstrap.py
Normal 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)}
|
||||
256
backend/services/impact_service.py
Normal file
256
backend/services/impact_service.py
Normal 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,
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
643
frontend/src/pages/ImpactMonitor.tsx
Normal file
643
frontend/src/pages/ImpactMonitor.tsx
Normal 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 été é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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user