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 specialist_desks as specialist_desks_router
|
||||||
from routers import timeline as timeline_router
|
from routers import timeline as timeline_router
|
||||||
from routers import instruments as instruments_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 logs as logs_router
|
||||||
from routers import var as var_router
|
from routers import var as var_router
|
||||||
from routers import reports as reports_router
|
from routers import reports as reports_router
|
||||||
@@ -82,10 +83,12 @@ def startup():
|
|||||||
try:
|
try:
|
||||||
from services.macro_events_bootstrap import bootstrap_macro_events
|
from services.macro_events_bootstrap import bootstrap_macro_events
|
||||||
from services.eco_calendar_bootstrap import bootstrap_eco_events
|
from services.eco_calendar_bootstrap import bootstrap_eco_events
|
||||||
|
from services.impact_categories_bootstrap import bootstrap_impact_categories
|
||||||
r1 = bootstrap_macro_events()
|
r1 = bootstrap_macro_events()
|
||||||
r2 = bootstrap_eco_events()
|
r2 = bootstrap_eco_events()
|
||||||
if r1.get("inserted", 0) or r2.get("inserted", 0):
|
r3 = bootstrap_impact_categories()
|
||||||
_log.info(f"[Startup] Events bootstrapped — macro: +{r1.get('inserted',0)}, eco: +{r2.get('inserted',0)}")
|
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:
|
except Exception as _e:
|
||||||
_log.warning(f"[Startup] Event bootstrap failed: {_e}")
|
_log.warning(f"[Startup] Event bootstrap failed: {_e}")
|
||||||
# Start auto-cycle scheduler if enabled
|
# 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(specialist_desks_router.router)
|
||||||
app.include_router(timeline_router.router)
|
app.include_router(timeline_router.router)
|
||||||
app.include_router(instruments_router.router)
|
app.include_router(instruments_router.router)
|
||||||
|
app.include_router(impact_router.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@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:
|
except Exception:
|
||||||
pass
|
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 (
|
c.execute("""CREATE TABLE IF NOT EXISTS timeline_context (
|
||||||
ref_date TEXT PRIMARY KEY,
|
ref_date TEXT PRIMARY KEY,
|
||||||
long_event_id INTEGER REFERENCES market_events(id),
|
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]
|
return conn.execute("SELECT COUNT(*) FROM market_events").fetchone()[0]
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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 Timeline from './pages/Timeline'
|
||||||
import ExternalSnapshot from './pages/ExternalSnapshot'
|
import ExternalSnapshot from './pages/ExternalSnapshot'
|
||||||
import InstrumentDashboard from './pages/InstrumentDashboard'
|
import InstrumentDashboard from './pages/InstrumentDashboard'
|
||||||
|
import ImpactMonitor from './pages/ImpactMonitor'
|
||||||
import { Navigate } from 'react-router-dom'
|
import { Navigate } from 'react-router-dom'
|
||||||
import { useCycleWatcher } from './hooks/useApi'
|
import { useCycleWatcher } from './hooks/useApi'
|
||||||
|
|
||||||
@@ -69,6 +70,7 @@ export default function App() {
|
|||||||
<Route path="/snapshot" element={<ExternalSnapshot />} />
|
<Route path="/snapshot" element={<ExternalSnapshot />} />
|
||||||
<Route path="/instruments" element={<Navigate to="/instruments/SPY" replace />} />
|
<Route path="/instruments" element={<Navigate to="/instruments/SPY" replace />} />
|
||||||
<Route path="/instruments/:id" element={<InstrumentDashboard />} />
|
<Route path="/instruments/:id" element={<InstrumentDashboard />} />
|
||||||
|
<Route path="/impact" element={<ImpactMonitor />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Globe, BarChart2, FlaskConical,
|
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'
|
} from 'lucide-react'
|
||||||
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
|
import { useGeoRiskScore, useAiStatus, usePortfolioSummary } from '../../hooks/useApi'
|
||||||
import clsx from 'clsx'
|
import clsx from 'clsx'
|
||||||
@@ -28,6 +28,7 @@ const nav = [
|
|||||||
{ to: '/institutional', icon: Building2, label: 'Inst. Reports' },
|
{ to: '/institutional', icon: Building2, label: 'Inst. Reports' },
|
||||||
{ to: '/specialist-desks', icon: Users, label: 'Specialist Desks' },
|
{ to: '/specialist-desks', icon: Users, label: 'Specialist Desks' },
|
||||||
{ to: '/instruments', icon: CandlestickChart, label: 'Instrument Snap.' },
|
{ to: '/instruments', icon: CandlestickChart, label: 'Instrument Snap.' },
|
||||||
|
{ to: '/impact', icon: Crosshair, label: 'Impact Monitor' },
|
||||||
{ to: '/snapshot', icon: ScanEye, label: 'Snapshot Externe' },
|
{ to: '/snapshot', icon: ScanEye, label: 'Snapshot Externe' },
|
||||||
{ to: '/timeline', icon: Layers, label: 'Timeline' },
|
{ to: '/timeline', icon: Layers, label: 'Timeline' },
|
||||||
{ to: '/logs', icon: ScrollText, label: 'System Logs' },
|
{ 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