feat: Market Events admin page — source_refs + per-instrument impacts

- DB: colonne source_refs sur market_events (migration idempotente)
- save/update_market_event: persist source_refs (JSON array de {url,title,source})
- market_event_detector: stocke source_refs + évalue impacts instruments après chaque création
- Nouveau router /api/market-events: CRUD + evaluate + impacts CRUD
- Page MarketEvents.tsx: liste filtrée/triée + panneau détail (sources cliquables,
  tableau impacts par instrument avec score/direction/rationale inline-éditables,
  ajout manuel, bulk-evaluate)
- Sidebar: entrée Market Events (Radio icon)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OpenSquared
2026-06-25 18:29:09 +02:00
parent e66d3ddb46
commit d5da4737ef
7 changed files with 1283 additions and 95 deletions

View File

@@ -7,6 +7,7 @@ from routers import timeline as timeline_router
from routers import instruments as instruments_router
from routers import impact as impact_router
from routers import cycle_actions as cycle_actions_router
from routers import market_events as market_events_router
from routers import logs as logs_router
from routers import var as var_router
from routers import reports as reports_router
@@ -141,6 +142,7 @@ app.include_router(timeline_router.router)
app.include_router(instruments_router.router)
app.include_router(impact_router.router)
app.include_router(cycle_actions_router.router)
app.include_router(market_events_router.router)
@app.get("/")

View File

@@ -0,0 +1,269 @@
"""
Market Events — unified admin API for market_events + instrument impacts.
Prefix: /api/market-events
Merges what was split across /api/timeline and /api/impact.
"""
import json
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/market-events", tags=["Market Events"])
# ── Schemas ───────────────────────────────────────────────────────────────────
class SourceRef(BaseModel):
title: str
source: str = ""
url: str = ""
date: str = ""
original_score: float = 0.0
class EventCreate(BaseModel):
name: str
start_date: str
end_date: Optional[str] = None
level: str = "short"
category: str = "fundamental"
sub_type: str = ""
description: str = ""
market_impact: str = ""
affected_assets: List[str] = []
impact_score: float = 0.5
absorption_pct: Optional[float] = None
parent_event_id: Optional[int] = None
source_refs: List[SourceRef] = []
class EventUpdate(EventCreate):
pass
class InstrumentImpactCreate(BaseModel):
instrument_id: str
impact_score: float = 0.5
direction: str = "neutral"
rationale: str = ""
confidence: float = 0.7
class InstrumentImpactAdjust(BaseModel):
adjusted_score: float
adjusted_direction: str
override_rationale: str = ""
# ── Helpers ───────────────────────────────────────────────────────────────────
def _parse_event(ev: Dict) -> Dict:
"""Normalize JSON fields on a raw DB row."""
for field in ("affected_assets", "source_refs", "relevant_indicators"):
val = ev.get(field)
if isinstance(val, str):
try:
ev[field] = json.loads(val) if val else []
except Exception:
ev[field] = []
elif val is None:
ev[field] = []
return ev
# ── List / filter ─────────────────────────────────────────────────────────────
@router.get("")
def list_events(
category: Optional[str] = Query(None),
level: Optional[str] = Query(None),
search: Optional[str] = Query(None),
date_from: Optional[str] = Query(None),
date_to: Optional[str] = Query(None),
min_score: float = Query(0.0, ge=0.0, le=1.0),
evaluated: Optional[bool] = Query(None, description="True=only evaluated, False=only not evaluated"),
limit: int = Query(200, ge=1, le=1000),
offset: int = Query(0, ge=0),
) -> Dict[str, Any]:
from services.database import get_all_market_events, get_conn
events = get_all_market_events()
# Collect evaluated event IDs
evaluated_ids: set = set()
if evaluated is not None:
conn = get_conn()
rows = conn.execute(
"SELECT DISTINCT source_id FROM instrument_impacts WHERE source_type='event'"
).fetchall()
conn.close()
evaluated_ids = {r[0] for r in rows}
filtered = []
for ev in events:
if category and ev.get("category") != category:
continue
if level and ev.get("level") != level:
continue
if date_from and ev.get("start_date", "") < date_from:
continue
if date_to and ev.get("start_date", "") > date_to:
continue
if (ev.get("impact_score") or 0) < min_score:
continue
if search:
s = search.lower()
if s not in (ev.get("name") or "").lower() and \
s not in (ev.get("description") or "").lower() and \
s not in (ev.get("sub_type") or "").lower():
continue
if evaluated is True and ev["id"] not in evaluated_ids:
continue
if evaluated is False and ev["id"] in evaluated_ids:
continue
filtered.append(_parse_event(ev))
total = len(filtered)
page = filtered[offset: offset + limit]
return {"total": total, "offset": offset, "limit": limit, "events": page}
# ── CRUD ──────────────────────────────────────────────────────────────────────
@router.post("", status_code=201)
def create_event(body: EventCreate) -> Dict[str, Any]:
from services.database import save_market_event
data = body.dict()
data["source_refs"] = [r.dict() for r in body.source_refs]
new_id = save_market_event(data)
return {"id": new_id, "status": "created"}
@router.get("/{event_id}")
def get_event(event_id: int) -> Dict[str, Any]:
from services.database import get_all_market_events, get_impacts_for_source
events = get_all_market_events()
ev = next((e for e in events if e["id"] == event_id), None)
if not ev:
raise HTTPException(404, f"Event {event_id} not found")
ev = _parse_event(ev)
impacts = get_impacts_for_source("event", event_id)
ev["impacts"] = impacts
ev["evaluated"] = len(impacts) > 0
return ev
@router.put("/{event_id}")
def update_event(event_id: int, body: EventUpdate) -> Dict[str, Any]:
from services.database import update_market_event
data = body.dict()
data["source_refs"] = [r.dict() for r in body.source_refs]
ok = update_market_event(event_id, data)
if not ok:
raise HTTPException(404, "Event not found")
return {"status": "updated"}
@router.delete("/{event_id}")
def delete_event(event_id: int) -> Dict[str, Any]:
from services.database import delete_market_event, delete_impacts_for_source
delete_impacts_for_source("event", event_id)
delete_market_event(event_id)
return {"status": "deleted"}
# ── AI evaluation ─────────────────────────────────────────────────────────────
@router.post("/{event_id}/evaluate")
def evaluate_event(
event_id: int,
force: bool = Query(False),
) -> Dict[str, Any]:
"""Trigger AI impact evaluation — evaluates instrument-level impacts."""
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"[market_events] evaluate {event_id} failed: {e}")
raise HTTPException(500, str(e))
@router.post("/bulk-evaluate")
def bulk_evaluate(
event_ids: List[int],
force: bool = Query(False),
) -> Dict[str, Any]:
from services.impact_service import evaluate_event_impacts
results, errors = [], []
for eid in event_ids[:30]:
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}
# ── Instrument impacts ────────────────────────────────────────────────────────
@router.get("/{event_id}/impacts")
def get_impacts(event_id: int) -> List[Dict[str, Any]]:
from services.database import get_impacts_for_source
return get_impacts_for_source("event", event_id)
@router.post("/{event_id}/impacts", status_code=201)
def add_impact(event_id: int, body: InstrumentImpactCreate) -> Dict[str, Any]:
"""Manually add an instrument impact (not AI-generated)."""
from services.database import get_all_market_events, save_instrument_impacts
events = get_all_market_events()
ev = next((e for e in events if e["id"] == event_id), None)
if not ev:
raise HTTPException(404, "Event not found")
save_instrument_impacts([{
"source_type": "event",
"source_id": event_id,
"source_name": ev["name"],
"source_date": ev["start_date"],
"category_name": ev.get("sub_type") or ev.get("category", ""),
"instrument_id": body.instrument_id,
"impact_score": body.impact_score,
"direction": body.direction,
"rationale": body.rationale,
"confidence": body.confidence,
"ai_generated": 0,
}])
return {"status": "added"}
@router.put("/{event_id}/impacts/{impact_id}")
def adjust_impact(event_id: int, impact_id: int, body: InstrumentImpactAdjust) -> Dict[str, Any]:
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"}
@router.delete("/{event_id}/impacts/{impact_id}")
def delete_impact(event_id: int, impact_id: int) -> Dict[str, Any]:
from services.database import get_conn
conn = get_conn()
try:
conn.execute("DELETE FROM instrument_impacts WHERE id=? AND source_id=?", (impact_id, event_id))
conn.commit()
finally:
conn.close()
return {"status": "deleted"}

View File

@@ -942,6 +942,15 @@ def init_db():
except Exception:
pass
# Idempotent column additions
for _col_sql in [
"ALTER TABLE market_events ADD COLUMN source_refs TEXT DEFAULT '[]'",
]:
try:
c.execute(_col_sql)
except Exception:
pass
conn.commit()
conn.close()
@@ -4545,18 +4554,22 @@ def save_market_event(ev: Dict[str, Any]) -> int:
import json
conn = get_conn()
try:
src_refs = ev.get("source_refs", [])
if isinstance(src_refs, list):
src_refs = json.dumps(src_refs)
cur = conn.execute("""INSERT INTO market_events
(name, start_date, end_date, level, category, description, market_impact,
affected_assets, impact_score, parent_event_id,
expected_value, actual_value, surprise_pct, unit, sub_type, absorption_pct)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
expected_value, actual_value, surprise_pct, unit, sub_type, absorption_pct,
source_refs)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(ev["name"], ev["start_date"], ev.get("end_date"),
ev["level"], ev.get("category", "macro"), ev.get("description", ""),
ev.get("market_impact", ""), json.dumps(ev.get("affected_assets", [])),
ev.get("impact_score", 0.5), ev.get("parent_event_id"),
ev.get("expected_value"), ev.get("actual_value"),
ev.get("surprise_pct"), ev.get("unit"), ev.get("sub_type"),
ev.get("absorption_pct")))
ev.get("absorption_pct"), src_refs))
conn.commit()
return cur.lastrowid
finally:
@@ -4567,17 +4580,20 @@ def update_market_event(event_id: int, ev: Dict[str, Any]) -> bool:
import json
conn = get_conn()
try:
src_refs = ev.get("source_refs", [])
if isinstance(src_refs, list):
src_refs = json.dumps(src_refs)
conn.execute("""UPDATE market_events SET
name=?, start_date=?, end_date=?, level=?, category=?, description=?,
market_impact=?, affected_assets=?, impact_score=?,
absorption_pct=?, relevant_indicators=?
absorption_pct=?, relevant_indicators=?, source_refs=?
WHERE id=?""",
(ev["name"], ev["start_date"], ev.get("end_date"),
ev["level"], ev.get("category", "macro"), ev.get("description", ""),
ev.get("market_impact", ""), json.dumps(ev.get("affected_assets", [])),
ev.get("impact_score", 0.5),
ev.get("absorption_pct"), json.dumps(ev.get("relevant_indicators", [])),
event_id))
src_refs, event_id))
conn.commit()
return True
finally:

View File

@@ -6,6 +6,9 @@ Scans 4 sources and creates market_events for significant findings:
- eco : FRED economic releases with high surprise z-score
- technical: MA50/MA100/MA200 crossovers on key instruments
- reports : institutional reports (COT, EIA) with high importance
After each event is created, instrument impacts are evaluated immediately
via the AI (impact_service.evaluate_event_impacts).
"""
import json
import logging
@@ -14,7 +17,6 @@ from typing import Any, Dict, List, Optional
logger = logging.getLogger(__name__)
# Instruments monitored for technical crossovers
WATCH_INSTRUMENTS = [
"SPY", "QQQ", "IWM", "EEM", "EFA",
"GLD", "SLV", "USO", "TLT", "HYG",
@@ -43,7 +45,6 @@ def _get_api_key() -> str:
def _existing_event_keys() -> set:
"""Lowercase 50-char prefix of existing market_event names for fast dedup."""
from services.database import get_all_market_events
return {ev["name"].lower()[:50] for ev in get_all_market_events()}
@@ -53,15 +54,12 @@ def _is_dup(name: str, existing: set) -> bool:
def _parse_date(raw: str) -> str:
"""Return YYYY-MM-DD from any date string; fallback to today."""
if not raw:
return datetime.utcnow().strftime("%Y-%m-%d")
# ISO-like
try:
return datetime.fromisoformat(raw[:19]).strftime("%Y-%m-%d")
except Exception:
pass
# RFC 2822 (RSS)
try:
from email.utils import parsedate_to_datetime
return parsedate_to_datetime(raw).strftime("%Y-%m-%d")
@@ -70,6 +68,27 @@ def _parse_date(raw: str) -> str:
return raw[:10] if len(raw) >= 10 else datetime.utcnow().strftime("%Y-%m-%d")
def _save_and_evaluate(ev: Dict, existing: set) -> Optional[Dict]:
"""Save a market_event and immediately evaluate instrument impacts. Returns created dict or None."""
from services.database import save_market_event
try:
event_id = save_market_event(ev)
existing.add(ev["name"].lower()[:50])
logger.info(f"[check_events] ✓ saved event #{event_id}: {ev['name']}")
except Exception as e:
logger.error(f"[check_events] save failed for '{ev['name']}': {e}")
return None
# Evaluate instrument impacts immediately
try:
from services.impact_service import evaluate_event_impacts
evaluate_event_impacts(event_id, force=False)
except Exception as e:
logger.warning(f"[check_events] impact eval failed for #{event_id}: {e}")
return {"name": ev["name"], "category": ev.get("category", ""), "date": ev.get("start_date", ""), "event_id": event_id}
# ── Source 1: Geopolitical / macro news ──────────────────────────────────────
def _check_news(
@@ -77,12 +96,7 @@ def _check_news(
lookback_hours: int = 48,
max_to_evaluate: int = 15,
) -> List[Dict[str, Any]]:
"""
Fetch recent RSS news, keep those with impact_score >= threshold,
ask GPT-4o-mini which ones deserve a permanent market_event record.
"""
from services.data_fetcher import fetch_geo_news
from services.database import save_market_event
api_key = _get_api_key()
if not api_key:
@@ -170,6 +184,14 @@ FORMAT JSON STRICT:
if _is_dup(ev_name, existing):
continue
source_ref = {
"title": title,
"source": n.get("source", ""),
"url": n.get("url") or n.get("link", ""),
"date": _parse_date(n.get("date", "")),
"original_score": round(float(n.get("impact_score", 0)), 3),
}
ev = {
"name": ev_name,
"start_date": _parse_date(n.get("date", "")),
@@ -180,14 +202,12 @@ FORMAT JSON STRICT:
"market_impact": "",
"affected_assets": parsed.get("affected_assets", []),
"impact_score": float(parsed.get("impact_score", 0.6)),
"source_refs": [source_ref],
}
try:
save_market_event(ev)
existing.add(ev_name.lower()[:50])
created.append({"name": ev_name, "category": ev["category"], "date": ev["start_date"], "source": "news"})
logger.info(f"[check_events/news] ✓ {ev_name}")
except Exception as e:
logger.error(f"[check_events/news] save failed: {e}")
result = _save_and_evaluate(ev, existing)
if result:
result["source"] = "news"
created.append(result)
return created
@@ -195,11 +215,7 @@ FORMAT JSON STRICT:
# ── Source 2: Eco calendar — FRED surprises ───────────────────────────────────
def _check_eco(z_threshold: float = 1.5, days: int = 7) -> List[Dict[str, Any]]:
"""
Reads economic_events table (FRED releases already stored by fred_fetcher).
Creates market_events for releases with |z-score| >= threshold.
"""
from services.database import get_recent_economic_surprises, save_market_event
from services.database import get_recent_economic_surprises
try:
releases = get_recent_economic_surprises(days=days, min_zscore=z_threshold)
@@ -233,6 +249,14 @@ def _check_eco(z_threshold: float = 1.5, days: int = 7) -> List[Dict[str, Any]]:
except Exception:
assets = []
source_ref = {
"title": f"FRED release: {ev_name_base} ({ev_date})",
"source": "FRED",
"url": f"https://fred.stlouisfed.org/series/{s_id}" if s_id else "",
"date": ev_date,
"original_score": round(min(0.95, 0.35 + z * 0.15), 3),
}
ev = {
"name": ev_name,
"start_date": ev_date,
@@ -251,14 +275,12 @@ def _check_eco(z_threshold: float = 1.5, days: int = 7) -> List[Dict[str, Any]]:
"actual_value": str(rel.get("actual_value", "")),
"expected_value": str(rel.get("forecast_value", "")),
"surprise_pct": float(s_pct),
"source_refs": [source_ref],
}
try:
save_market_event(ev)
existing.add(ev_name.lower()[:50])
created.append({"name": ev_name, "category": "event_calendar", "date": ev_date, "source": "eco"})
logger.info(f"[check_events/eco] ✓ {ev_name}")
except Exception as e:
logger.error(f"[check_events/eco] save failed: {e}")
result = _save_and_evaluate(ev, existing)
if result:
result["source"] = "eco"
created.append(result)
return created
@@ -266,10 +288,6 @@ def _check_eco(z_threshold: float = 1.5, days: int = 7) -> List[Dict[str, Any]]:
# ── Source 3: MA crossovers (technical) ──────────────────────────────────────
def _check_technical(instruments: List[str] = None, lookback_days: int = 7) -> List[Dict[str, Any]]:
"""
Downloads recent OHLCV for each instrument, detects MA50/MA100/MA200 crossovers
in the last `lookback_days` days. Creates technical market_events.
"""
try:
import yfinance as yf
import pandas as pd
@@ -277,8 +295,6 @@ def _check_technical(instruments: List[str] = None, lookback_days: int = 7) -> L
logger.warning("[check_events/technical] yfinance/pandas not available")
return []
from services.database import save_market_event
if instruments is None:
instruments = WATCH_INSTRUMENTS
@@ -299,7 +315,6 @@ def _check_technical(instruments: List[str] = None, lookback_days: int = 7) -> L
recent = df.tail(lookback_days + 2)
# Check consecutive row pairs for crossovers
for i in range(1, len(recent)):
date_str = str(recent.index[i])[:10]
if date_str < cutoff:
@@ -308,12 +323,12 @@ def _check_technical(instruments: List[str] = None, lookback_days: int = 7) -> L
prev = recent.iloc[i - 1]
curr = recent.iloc[i]
def cross(fast_prev, fast_curr, slow_prev, slow_curr):
if any(pd.isna(v) for v in [fast_prev, fast_curr, slow_prev, slow_curr]):
def cross(fp, fc, sp, sc):
if any(pd.isna(v) for v in [fp, fc, sp, sc]):
return None
if fast_prev < slow_prev and fast_curr >= slow_curr:
if fp < sp and fc >= sc:
return "golden"
if fast_prev > slow_prev and fast_curr <= slow_curr:
if fp > sp and fc <= sc:
return "death"
return None
@@ -336,6 +351,14 @@ def _check_technical(instruments: List[str] = None, lookback_days: int = 7) -> L
direction = "bullish" if kind == "golden" else "bearish"
level = "medium" if slow_lbl == "MA200" else "short"
source_ref = {
"title": f"Technical signal: {ev_name}",
"source": "yfinance/computed",
"url": f"https://finance.yahoo.com/quote/{ticker}",
"date": date_str,
"original_score": 0.65 if slow_lbl == "MA200" else 0.45,
}
ev = {
"name": ev_name,
"start_date": date_str,
@@ -343,21 +366,21 @@ def _check_technical(instruments: List[str] = None, lookback_days: int = 7) -> L
"category": "technical",
"sub_type": f"{fast_lbl}/{slow_lbl} Cross",
"description": (
f"{cross_label} : {fast_lbl} passe {'au-dessus' if kind == 'golden' else 'en-dessous'} "
f"{cross_label} : {fast_lbl} passe "
f"{'au-dessus' if kind == 'golden' else 'en-dessous'} "
f"de la {slow_lbl} sur {ticker}. "
f"Signal {direction} de tendance {'long terme' if slow_lbl == 'MA200' else 'moyen terme'}."
f"Signal {direction} de tendance "
f"{'long terme' if slow_lbl == 'MA200' else 'moyen terme'}."
),
"market_impact": f"Signal {direction} sur {ticker}",
"affected_assets": [ticker],
"impact_score": 0.65 if slow_lbl == "MA200" else 0.45,
"source_refs": [source_ref],
}
try:
save_market_event(ev)
existing.add(ev_name.lower()[:50])
created.append({"name": ev_name, "category": "technical", "date": date_str, "source": "technical"})
logger.info(f"[check_events/technical] ✓ {ev_name}")
except Exception as e:
logger.error(f"[check_events/technical] save failed: {e}")
result = _save_and_evaluate(ev, existing)
if result:
result["source"] = "technical"
created.append(result)
except Exception as e:
logger.debug(f"[check_events/technical] {ticker} failed: {e}")
@@ -368,11 +391,7 @@ def _check_technical(instruments: List[str] = None, lookback_days: int = 7) -> L
# ── Source 4: Institutional reports ──────────────────────────────────────────
def _check_reports(days: int = 7, min_importance: int = 3) -> List[Dict[str, Any]]:
"""
Reads institutional_reports table for recent high-importance entries.
Creates report market_events.
"""
from services.database import get_conn, save_market_event
from services.database import get_conn
try:
cutoff = (datetime.utcnow() - timedelta(days=days)).strftime("%Y-%m-%d")
@@ -410,7 +429,6 @@ def _check_reports(days: int = 7, min_importance: int = 3) -> List[Dict[str, Any
except Exception:
pass
# Derive affected assets from signal columns
assets: List[str] = []
for sig_col, asset_list in [
("signal_energy", ["USO", "XOM"]),
@@ -421,6 +439,14 @@ def _check_reports(days: int = 7, min_importance: int = 3) -> List[Dict[str, Any
if rpt.get(sig_col, "neutral") not in ("neutral", "", None):
assets.extend(asset_list)
source_ref = {
"title": title,
"source": rpt.get("source", rpt_type),
"url": "",
"date": rpt_date,
"original_score": round(min(0.9, 0.3 + rpt.get("importance", 2) * 0.12), 3),
}
ev = {
"name": ev_name,
"start_date": rpt_date,
@@ -431,14 +457,12 @@ def _check_reports(days: int = 7, min_importance: int = 3) -> List[Dict[str, Any
"market_impact": rpt.get("trading_implications", ""),
"affected_assets": list(set(assets)),
"impact_score": min(0.9, 0.3 + rpt.get("importance", 2) * 0.12),
"source_refs": [source_ref],
}
try:
save_market_event(ev)
existing.add(ev_name.lower()[:50])
created.append({"name": ev_name, "category": "report", "date": rpt_date, "source": "reports"})
logger.info(f"[check_events/reports] ✓ {ev_name}")
except Exception as e:
logger.error(f"[check_events/reports] save failed: {e}")
result = _save_and_evaluate(ev, existing)
if result:
result["source"] = "reports"
created.append(result)
return created
@@ -456,62 +480,43 @@ def check_new_market_events(
report_min_importance: int = 3,
) -> Dict[str, Any]:
"""
Isolated cycle action — scans all (or selected) sources and creates
market_events for significant findings.
sources: subset of ['news', 'eco', 'technical', 'reports']
default = all four
Isolated cycle action — scans all (or selected) sources, creates
market_events with source_refs, and immediately evaluates instrument impacts.
"""
if sources is None:
sources = ["news", "eco", "technical", "reports"]
results: Dict[str, Any] = {
"news": [],
"eco": [],
"technical": [],
"reports": [],
"news": [], "eco": [], "technical": [], "reports": [],
"total_created": 0,
"ran_at": datetime.utcnow().isoformat(),
}
if "news" in sources:
try:
results["news"] = _check_news(
min_impact=news_impact_min,
lookback_hours=news_lookback_hours,
)
results["news"] = _check_news(min_impact=news_impact_min, lookback_hours=news_lookback_hours)
except Exception as e:
logger.error(f"[check_events] news source error: {e}")
if "eco" in sources:
try:
results["eco"] = _check_eco(
z_threshold=eco_z_threshold,
days=eco_days,
)
results["eco"] = _check_eco(z_threshold=eco_z_threshold, days=eco_days)
except Exception as e:
logger.error(f"[check_events] eco source error: {e}")
if "technical" in sources:
try:
results["technical"] = _check_technical(
lookback_days=technical_lookback_days,
)
results["technical"] = _check_technical(lookback_days=technical_lookback_days)
except Exception as e:
logger.error(f"[check_events] technical source error: {e}")
if "reports" in sources:
try:
results["reports"] = _check_reports(
days=report_days,
min_importance=report_min_importance,
)
results["reports"] = _check_reports(days=report_days, min_importance=report_min_importance)
except Exception as e:
logger.error(f"[check_events] reports source error: {e}")
results["total_created"] = sum(
len(results[s]) for s in ["news", "eco", "technical", "reports"]
)
results["total_created"] = sum(len(results[s]) for s in ["news", "eco", "technical", "reports"])
logger.info(
f"[check_events] Done — {results['total_created']} new events: "
f"news={len(results['news'])} eco={len(results['eco'])} "

View File

@@ -28,6 +28,7 @@ import ExternalSnapshot from './pages/ExternalSnapshot'
import InstrumentDashboard from './pages/InstrumentDashboard'
import ImpactMonitor from './pages/ImpactMonitor'
import CycleActions from './pages/CycleActions'
import MarketEvents from './pages/MarketEvents'
import { Navigate } from 'react-router-dom'
import { useCycleWatcher } from './hooks/useApi'
@@ -72,6 +73,7 @@ export default function App() {
<Route path="/instruments" element={<Navigate to="/instruments/SPY" replace />} />
<Route path="/instruments/:id" element={<InstrumentDashboard />} />
<Route path="/impact" element={<ImpactMonitor />} />
<Route path="/market-events" element={<MarketEvents />} />
<Route path="/cycle-actions" element={<CycleActions />} />
</Routes>
</main>

View File

@@ -1,7 +1,7 @@
import { NavLink } from 'react-router-dom'
import {
LayoutDashboard, Globe, BarChart2, FlaskConical,
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, Layers, ScanEye, CandlestickChart, Crosshair, PlayCircle
History, Calendar, TrendingUp, Zap, DollarSign, Settings, BrainCircuit, Activity, BookOpen, FileBarChart, Brain, ShieldAlert, Microscope, ScrollText, Gauge, GitCompare, Building2, Users, Layers, ScanEye, CandlestickChart, Crosshair, PlayCircle, Radio
} 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: '/market-events', icon: Radio, label: 'Market Events' },
{ to: '/impact', icon: Crosshair, label: 'Impact Monitor' },
{ to: '/cycle-actions', icon: PlayCircle, label: 'Cycle Actions' },
{ to: '/snapshot', icon: ScanEye, label: 'Snapshot Externe' },

View File

@@ -0,0 +1,893 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import {
Search, RefreshCw, Plus, Trash2, Edit3, Sparkles, ExternalLink,
ChevronDown, ChevronUp, Loader2, CheckCircle, AlertCircle, X,
SlidersHorizontal, BarChart2, Star, Users
} from 'lucide-react'
import clsx from 'clsx'
// ── Types ─────────────────────────────────────────────────────────────────────
interface SourceRef {
title: string
source: string
url: string
date: string
original_score: number
}
interface InstrumentImpact {
id: number
instrument_id: string
impact_score: number
direction: 'bullish' | 'bearish' | 'neutral' | 'depends_on_outcome'
rationale: string
confidence: number
ai_generated: number
manually_adjusted: number
adjusted_score: number | null
adjusted_direction: string | null
override_rationale: string
}
interface MarketEvent {
id: number
name: string
start_date: string
end_date: string | null
level: 'long' | 'medium' | 'short'
category: string
sub_type: string
description: string
market_impact: string
affected_assets: string[]
impact_score: number
absorption_pct: number | null
source_refs: SourceRef[]
impacts?: InstrumentImpact[]
evaluated?: boolean
}
// ── Constants ─────────────────────────────────────────────────────────────────
const CAT_COLORS: Record<string, string> = {
event_calendar: 'bg-amber-500/20 text-amber-300 border-amber-500/30',
geopolitical: 'bg-red-500/20 text-red-300 border-red-500/30',
fundamental: 'bg-emerald-500/20 text-emerald-300 border-emerald-500/30',
report: 'bg-blue-500/20 text-blue-300 border-blue-500/30',
sentiment: 'bg-purple-500/20 text-purple-300 border-purple-500/30',
technical: 'bg-cyan-500/20 text-cyan-300 border-cyan-500/30',
}
const CAT_DOT: Record<string, string> = {
event_calendar: 'bg-amber-400',
geopolitical: 'bg-red-400',
fundamental: 'bg-emerald-400',
report: 'bg-blue-400',
sentiment: 'bg-purple-400',
technical: 'bg-cyan-400',
}
const LEVEL_COLORS: Record<string, string> = {
long: 'text-purple-400',
medium: 'text-amber-400',
short: 'text-emerald-400',
}
const DIR_COLORS: Record<string, string> = {
bullish: 'text-emerald-400',
bearish: 'text-red-400',
neutral: 'text-slate-400',
depends_on_outcome: 'text-amber-400',
}
const DIR_ICONS: Record<string, string> = {
bullish: '▲', bearish: '▼', neutral: '●', depends_on_outcome: '◆',
}
const CATEGORIES = ['event_calendar', 'geopolitical', 'fundamental', 'report', 'sentiment', 'technical']
const LEVELS = ['long', 'medium', 'short']
const 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',
]
// ── Helpers ───────────────────────────────────────────────────────────────────
function ScoreBar({ score, max = 1 }: { score: number; max?: number }) {
const pct = Math.round((score / max) * 100)
const color = score >= 0.7 ? 'bg-red-500' : score >= 0.5 ? 'bg-amber-500' : 'bg-emerald-500'
return (
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div className={`h-full rounded-full ${color}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs text-slate-400 w-8 text-right">{score.toFixed(2)}</span>
</div>
)
}
function CatBadge({ cat }: { cat: string }) {
return (
<span className={clsx('inline-flex items-center gap-1 px-2 py-0.5 rounded border text-xs font-medium', CAT_COLORS[cat] ?? 'bg-slate-700/50 text-slate-400 border-slate-700')}>
<span className={clsx('w-1.5 h-1.5 rounded-full', CAT_DOT[cat] ?? 'bg-slate-400')} />
{cat.replace('_', ' ')}
</span>
)
}
// ── Instrument impact row (inline editable) ───────────────────────────────────
function ImpactRow({
imp, eventId, onUpdated, onDeleted,
}: {
imp: InstrumentImpact
eventId: number
onUpdated: () => void
onDeleted: () => void
}) {
const [editing, setEditing] = useState(false)
const [score, setScore] = useState(String(imp.adjusted_score ?? imp.impact_score))
const [dir, setDir] = useState(imp.adjusted_direction || imp.direction)
const [note, setNote] = useState(imp.override_rationale || '')
const [saving, setSaving] = useState(false)
const effectiveScore = imp.adjusted_score ?? imp.impact_score
const effectiveDir = imp.adjusted_direction || imp.direction
const save = async () => {
setSaving(true)
await fetch(`/api/market-events/${eventId}/impacts/${imp.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ adjusted_score: +score, adjusted_direction: dir, override_rationale: note }),
})
setSaving(false)
setEditing(false)
onUpdated()
}
const del = async () => {
if (!confirm(`Supprimer l'impact sur ${imp.instrument_id} ?`)) return
await fetch(`/api/market-events/${eventId}/impacts/${imp.id}`, { method: 'DELETE' })
onDeleted()
}
return (
<div className="border-b border-slate-800/60 last:border-0">
<div className="flex items-center gap-3 py-2 px-3 hover:bg-slate-800/20">
{/* Ticker */}
<span className="font-mono text-sm text-slate-200 w-24 shrink-0">{imp.instrument_id}</span>
{/* Direction */}
<span className={clsx('text-sm font-bold w-6 shrink-0', DIR_COLORS[effectiveDir])}>
{DIR_ICONS[effectiveDir] ?? '●'}
</span>
{/* Score bar */}
<div className="w-28 shrink-0">
<ScoreBar score={effectiveScore} />
</div>
{/* Confidence */}
<span className="text-xs text-slate-500 w-10 shrink-0">{Math.round(imp.confidence * 100)}%</span>
{/* Rationale */}
<span className="text-xs text-slate-400 flex-1 min-w-0 truncate">{imp.rationale}</span>
{/* AI badge */}
{imp.ai_generated ? (
<span className="text-xs text-blue-400 shrink-0">AI</span>
) : (
<span className="text-xs text-slate-600 shrink-0">manual</span>
)}
{imp.manually_adjusted ? <span className="text-xs text-amber-400 shrink-0">adj.</span> : null}
{/* Actions */}
<button onClick={() => setEditing(x => !x)} className="text-slate-500 hover:text-slate-300 shrink-0"><Edit3 size={12} /></button>
<button onClick={del} className="text-slate-600 hover:text-red-400 shrink-0"><Trash2 size={12} /></button>
</div>
{editing && (
<div className="px-3 pb-3 grid grid-cols-3 gap-2 bg-slate-800/30">
<label className="flex flex-col gap-1">
<span className="text-xs text-slate-500">Score ajusté</span>
<input type="range" min="0" max="1" step="0.05"
value={score} onChange={e => setScore(e.target.value)}
className="w-full accent-amber-400" />
<span className="text-xs text-center text-amber-300">{(+score).toFixed(2)}</span>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-slate-500">Direction</span>
<select value={dir} onChange={e => setDir(e.target.value)}
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm">
{['bullish','bearish','neutral','depends_on_outcome'].map(d => (
<option key={d} value={d}>{d}</option>
))}
</select>
</label>
<label className="flex flex-col gap-1">
<span className="text-xs text-slate-500">Note</span>
<input value={note} onChange={e => setNote(e.target.value)}
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm"
placeholder="Raison de l'ajustement..." />
</label>
<div className="col-span-3 flex gap-2 mt-1">
<button onClick={save} disabled={saving}
className="flex items-center gap-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs rounded px-3 py-1">
{saving ? <Loader2 size={11} className="animate-spin" /> : <CheckCircle size={11} />}
Sauvegarder
</button>
<button onClick={() => setEditing(false)} className="text-xs text-slate-500 hover:text-slate-300 px-2">Annuler</button>
</div>
</div>
)}
</div>
)
}
// ── Add instrument panel ──────────────────────────────────────────────────────
function AddInstrumentPanel({ eventId, onAdded }: { eventId: number; onAdded: () => void }) {
const [open, setOpen] = useState(false)
const [ticker, setTicker] = useState('')
const [score, setScore] = useState(0.5)
const [dir, setDir] = useState('neutral')
const [rationale, setRat] = useState('')
const [saving, setSaving] = useState(false)
const submit = async () => {
if (!ticker) return
setSaving(true)
await fetch(`/api/market-events/${eventId}/impacts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ instrument_id: ticker, impact_score: score, direction: dir, rationale, confidence: 0.8 }),
})
setSaving(false)
setOpen(false)
setTicker('')
setRat('')
onAdded()
}
if (!open) {
return (
<button onClick={() => setOpen(true)}
className="flex items-center gap-1.5 text-xs text-slate-500 hover:text-emerald-400 py-2 px-3 border-t border-slate-800/60">
<Plus size={12} /> Ajouter instrument
</button>
)
}
return (
<div className="border-t border-slate-800/60 px-3 py-3 bg-slate-800/30 grid grid-cols-4 gap-2">
<select value={ticker} onChange={e => setTicker(e.target.value)}
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm col-span-1">
<option value="">Ticker...</option>
{ALL_INSTRUMENTS.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<select value={dir} onChange={e => setDir(e.target.value)}
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm">
{['bullish','bearish','neutral','depends_on_outcome'].map(d => <option key={d} value={d}>{d}</option>)}
</select>
<div className="flex flex-col gap-0.5">
<input type="range" min="0" max="1" step="0.05" value={score}
onChange={e => setScore(+e.target.value)} className="accent-emerald-400" />
<span className="text-xs text-center text-emerald-300">{score.toFixed(2)}</span>
</div>
<input value={rationale} onChange={e => setRat(e.target.value)} placeholder="Rationale..."
className="bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm" />
<div className="col-span-4 flex gap-2">
<button onClick={submit} disabled={saving || !ticker}
className="flex items-center gap-1 bg-emerald-700 hover:bg-emerald-600 disabled:bg-slate-700 disabled:text-slate-500 text-white text-xs rounded px-3 py-1">
{saving ? <Loader2 size={11} className="animate-spin" /> : <Plus size={11} />} Ajouter
</button>
<button onClick={() => setOpen(false)} className="text-xs text-slate-500 hover:text-slate-300 px-2">Annuler</button>
</div>
</div>
)
}
// ── Event detail panel (right side) ──────────────────────────────────────────
function EventDetail({
event, onClose, onUpdated, onDeleted,
}: {
event: MarketEvent
onClose: () => void
onUpdated: (ev: MarketEvent) => void
onDeleted: (id: number) => void
}) {
const [detail, setDetail] = useState<MarketEvent>(event)
const [evaluating, setEval] = useState(false)
const [evalMsg, setEvalMsg] = useState<string | null>(null)
const [editing, setEditing] = useState(false)
const [editData, setEditData] = useState<Partial<MarketEvent>>({})
const [saving, setSaving] = useState(false)
const loadDetail = useCallback(async () => {
const r = await fetch(`/api/market-events/${event.id}`)
if (r.ok) {
const d = await r.json()
setDetail(d)
}
}, [event.id])
useEffect(() => { loadDetail() }, [loadDetail])
const evaluate = async (force = false) => {
setEval(true)
setEvalMsg(null)
try {
const r = await fetch(`/api/market-events/${event.id}/evaluate?force=${force}`, { method: 'POST' })
const d = await r.json()
if (!r.ok) throw new Error(d.detail || r.statusText)
setEvalMsg(`${d.n_instruments} instruments évalués`)
await loadDetail()
} catch (e: any) {
setEvalMsg(`${e.message}`)
} finally {
setEval(false)
}
}
const saveEdit = async () => {
setSaving(true)
const payload = { ...detail, ...editData, source_refs: detail.source_refs ?? [] }
const r = await fetch(`/api/market-events/${event.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
setSaving(false)
if (r.ok) {
setEditing(false)
await loadDetail()
onUpdated({ ...detail, ...editData } as MarketEvent)
}
}
const del = async () => {
if (!confirm(`Supprimer "${detail.name}" ?`)) return
await fetch(`/api/market-events/${event.id}`, { method: 'DELETE' })
onDeleted(event.id)
onClose()
}
const impacts = detail.impacts || []
const evalScore = detail.impact_score
return (
<div className="flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="flex items-start gap-3 px-5 py-4 border-b border-slate-700/40 shrink-0">
<div className="flex-1 min-w-0">
{editing ? (
<input
defaultValue={detail.name}
onChange={e => setEditData(d => ({ ...d, name: e.target.value }))}
className="bg-dark-900 border border-amber-500/50 rounded px-3 py-1.5 text-slate-100 font-bold text-sm w-full"
/>
) : (
<h2 className="text-slate-100 font-bold text-sm leading-snug">{detail.name}</h2>
)}
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
<CatBadge cat={detail.category} />
{detail.sub_type && <span className="text-xs text-slate-500">{detail.sub_type}</span>}
<span className={clsx('text-xs font-semibold', LEVEL_COLORS[detail.level])}>{detail.level}</span>
<span className="text-xs text-slate-500">{detail.start_date}</span>
{detail.end_date && <span className="text-xs text-slate-600"> {detail.end_date}</span>}
</div>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<button onClick={() => { setEditing(x => !x); setEditData({}) }}
className={clsx('p-1.5 rounded hover:bg-slate-700/50', editing ? 'text-amber-400' : 'text-slate-500 hover:text-slate-300')}>
<Edit3 size={14} />
</button>
<button onClick={del} className="p-1.5 rounded text-slate-600 hover:text-red-400 hover:bg-red-900/20">
<Trash2 size={14} />
</button>
<button onClick={onClose} className="p-1.5 rounded text-slate-500 hover:text-slate-300 hover:bg-slate-700/50">
<X size={14} />
</button>
</div>
</div>
{/* Scrollable body */}
<div className="flex-1 overflow-y-auto space-y-4 px-5 py-4">
{/* Impact score + absorption */}
<div className="grid grid-cols-2 gap-3">
<div className="bg-slate-800/40 rounded-lg p-3">
<div className="text-xs text-slate-500 mb-1">Score impact</div>
<ScoreBar score={evalScore} />
</div>
{detail.absorption_pct != null && (
<div className="bg-slate-800/40 rounded-lg p-3">
<div className="text-xs text-slate-500 mb-1">Absorption marché</div>
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div className="h-full bg-blue-500 rounded-full" style={{ width: `${detail.absorption_pct}%` }} />
</div>
<span className="text-xs text-blue-300">{detail.absorption_pct}%</span>
</div>
</div>
)}
</div>
{/* Description */}
{editing ? (
<textarea
defaultValue={detail.description}
onChange={e => setEditData(d => ({ ...d, description: e.target.value }))}
rows={3}
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-300 text-sm resize-none"
/>
) : detail.description ? (
<p className="text-sm text-slate-300 leading-relaxed">{detail.description}</p>
) : null}
{editing ? (
<div className="grid grid-cols-3 gap-2">
<div>
<div className="text-xs text-slate-500 mb-1">Catégorie</div>
<select defaultValue={detail.category}
onChange={e => setEditData(d => ({ ...d, category: e.target.value }))}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm">
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Niveau</div>
<select defaultValue={detail.level}
onChange={e => setEditData(d => ({ ...d, level: e.target.value as any }))}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm">
{LEVELS.map(l => <option key={l} value={l}>{l}</option>)}
</select>
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Score impact</div>
<input type="number" step="0.05" min="0" max="1" defaultValue={detail.impact_score}
onChange={e => setEditData(d => ({ ...d, impact_score: +e.target.value }))}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-200 text-sm" />
</div>
</div>
) : null}
{editing && (
<div className="flex gap-2">
<button onClick={saveEdit} disabled={saving}
className="flex items-center gap-1.5 bg-emerald-700 hover:bg-emerald-600 text-white text-sm rounded-lg px-4 py-1.5">
{saving ? <Loader2 size={13} className="animate-spin" /> : <CheckCircle size={13} />} Sauvegarder
</button>
<button onClick={() => setEditing(false)} className="text-sm text-slate-500 hover:text-slate-300 px-3">Annuler</button>
</div>
)}
{/* Sources */}
{(detail.source_refs?.length ?? 0) > 0 && (
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">Sources</div>
<div className="space-y-1.5">
{detail.source_refs!.map((ref, i) => (
<div key={i} className="flex items-start gap-2 bg-slate-800/40 rounded-lg px-3 py-2">
<div className="flex-1 min-w-0">
<div className="text-xs text-slate-200 font-medium truncate">{ref.title}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-slate-500">{ref.source}</span>
<span className="text-xs text-slate-600">{ref.date}</span>
<span className="text-xs text-slate-600">score brut: {ref.original_score?.toFixed(2)}</span>
</div>
</div>
{ref.url && (
<a href={ref.url} target="_blank" rel="noreferrer"
className="text-slate-500 hover:text-blue-400 shrink-0 mt-0.5">
<ExternalLink size={12} />
</a>
)}
</div>
))}
</div>
</div>
)}
{/* Instrument impacts */}
<div>
<div className="flex items-center justify-between mb-2">
<div className="text-xs text-slate-500 uppercase tracking-wide">
Impacts instruments
<span className="text-slate-600 ml-1.5 normal-case">({impacts.length})</span>
</div>
<div className="flex items-center gap-2">
{evalMsg && (
<span className={clsx('text-xs', evalMsg.startsWith('✓') ? 'text-emerald-400' : 'text-red-400')}>
{evalMsg}
</span>
)}
<button
onClick={() => evaluate(impacts.length > 0)}
disabled={evaluating}
className="flex items-center gap-1.5 text-xs bg-blue-700/40 hover:bg-blue-700/60 border border-blue-700/50 text-blue-300 rounded px-3 py-1"
>
{evaluating ? <Loader2 size={11} className="animate-spin" /> : <Sparkles size={11} />}
{impacts.length > 0 ? 'Ré-analyser' : 'Analyser IA'}
</button>
</div>
</div>
{impacts.length === 0 ? (
<div className="text-xs text-slate-600 italic py-3 px-3 bg-slate-800/30 rounded-lg">
Aucun impact évalué cliquez sur "Analyser IA" pour lancer l'évaluation.
</div>
) : (
<div className="bg-slate-800/30 rounded-lg overflow-hidden">
{/* Header */}
<div className="grid text-xs text-slate-600 px-3 py-1.5 border-b border-slate-800 bg-slate-800/50"
style={{ gridTemplateColumns: '6rem 1.5rem 8rem 2.5rem 1fr 3rem 3rem 1.5rem 1.5rem' }}>
<span>Instrument</span>
<span></span>
<span>Score</span>
<span>Conf.</span>
<span>Rationale</span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{impacts
.slice()
.sort((a, b) => (b.adjusted_score ?? b.impact_score) - (a.adjusted_score ?? a.impact_score))
.map(imp => (
<ImpactRow
key={imp.id}
imp={imp}
eventId={detail.id}
onUpdated={loadDetail}
onDeleted={loadDetail}
/>
))}
<AddInstrumentPanel eventId={detail.id} onAdded={loadDetail} />
</div>
)}
{impacts.length === 0 && (
<div className="mt-2">
<AddInstrumentPanel eventId={detail.id} onAdded={loadDetail} />
</div>
)}
</div>
</div>
</div>
)
}
// ── Create event modal ────────────────────────────────────────────────────────
function CreateModal({ onCreated, onClose }: { onCreated: () => void; onClose: () => void }) {
const [form, setForm] = useState({
name: '', start_date: new Date().toISOString().slice(0, 10),
category: 'fundamental', level: 'short', sub_type: '',
description: '', impact_score: 0.5,
})
const [saving, setSaving] = useState(false)
const submit = async () => {
if (!form.name || !form.start_date) return
setSaving(true)
await fetch('/api/market-events', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...form, source_refs: [] }),
})
setSaving(false)
onCreated()
onClose()
}
const f = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
setForm(d => ({ ...d, [k]: e.target.value }))
return (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center">
<div className="bg-dark-800 border border-slate-700/50 rounded-xl w-full max-w-lg shadow-2xl">
<div className="flex items-center justify-between px-5 py-4 border-b border-slate-700/40">
<h3 className="text-slate-100 font-semibold">Nouvel événement marché</h3>
<button onClick={onClose} className="text-slate-500 hover:text-slate-300"><X size={16} /></button>
</div>
<div className="p-5 space-y-3">
<input value={form.name} onChange={f('name')} placeholder="Nom de l'événement *"
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm" />
<div className="grid grid-cols-2 gap-3">
<div>
<div className="text-xs text-slate-500 mb-1">Date début</div>
<input type="date" value={form.start_date} onChange={f('start_date')}
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm" />
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Catégorie</div>
<select value={form.category} onChange={f('category')}
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm">
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
</select>
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Niveau</div>
<select value={form.level} onChange={f('level')}
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm">
{LEVELS.map(l => <option key={l} value={l}>{l}</option>)}
</select>
</div>
<div>
<div className="text-xs text-slate-500 mb-1">Sous-type</div>
<input value={form.sub_type} onChange={f('sub_type')} placeholder="ex: CPI, Conflit..."
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm" />
</div>
</div>
<textarea value={form.description} onChange={f('description')} placeholder="Description..." rows={3}
className="w-full bg-dark-900 border border-slate-700 rounded px-3 py-2 text-slate-200 text-sm resize-none" />
<div>
<div className="text-xs text-slate-500 mb-1">Score impact: {form.impact_score.toFixed(2)}</div>
<input type="range" min="0" max="1" step="0.05" value={form.impact_score}
onChange={e => setForm(d => ({ ...d, impact_score: +e.target.value }))}
className="w-full accent-amber-400" />
</div>
</div>
<div className="flex gap-2 px-5 pb-5">
<button onClick={submit} disabled={saving || !form.name}
className="flex items-center gap-2 bg-emerald-700 hover:bg-emerald-600 disabled:bg-slate-700 disabled:text-slate-500 text-white rounded-lg px-5 py-2 text-sm font-medium">
{saving ? <Loader2 size={14} className="animate-spin" /> : <Plus size={14} />} Créer
</button>
<button onClick={onClose} className="text-sm text-slate-500 hover:text-slate-300 px-4">Annuler</button>
</div>
</div>
</div>
)
}
// ── Event list row ────────────────────────────────────────────────────────────
function EventRow({
ev, selected, onClick,
}: { ev: MarketEvent; selected: boolean; onClick: () => void }) {
return (
<div
onClick={onClick}
className={clsx(
'flex items-center gap-3 px-4 py-3 cursor-pointer border-b border-slate-800/50 hover:bg-slate-800/40 transition-colors',
selected && 'bg-slate-800/60 border-l-2 border-l-blue-500',
)}
>
<span className={clsx('w-1.5 h-1.5 rounded-full shrink-0', CAT_DOT[ev.category] ?? 'bg-slate-500')} />
<div className="flex-1 min-w-0">
<div className="text-sm text-slate-200 truncate font-medium">{ev.name}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-slate-500">{ev.start_date}</span>
{ev.sub_type && <span className="text-xs text-slate-600">{ev.sub_type}</span>}
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-xs font-bold" style={{ color: ev.impact_score >= 0.7 ? '#ef4444' : ev.impact_score >= 0.5 ? '#f59e0b' : '#10b981' }}>
{ev.impact_score.toFixed(2)}
</div>
<div className={clsx('text-xs', LEVEL_COLORS[ev.level])}>{ev.level}</div>
</div>
</div>
)
}
// ── Main page ─────────────────────────────────────────────────────────────────
export default function MarketEvents() {
const [events, setEvents] = useState<MarketEvent[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [selected, setSelected] = useState<MarketEvent | null>(null)
const [showCreate, setShowCreate] = useState(false)
const [bulkIds, setBulkIds] = useState<Set<number>>(new Set())
const [bulking, setBulking] = useState(false)
const [bulkMsg, setBulkMsg] = useState<string | null>(null)
// Filters
const [search, setSearch] = useState('')
const [category, setCategory] = useState('')
const [level, setLevel] = useState('')
const [minScore, setMinScore] = useState(0)
const [evaluated, setEvaluated] = useState<'' | 'true' | 'false'>('')
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [showFilters, setShowFilters] = useState(false)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const load = useCallback(async () => {
setLoading(true)
const params = new URLSearchParams()
if (search) params.set('search', search)
if (category) params.set('category', category)
if (level) params.set('level', level)
if (minScore) params.set('min_score', String(minScore))
if (dateFrom) params.set('date_from', dateFrom)
if (dateTo) params.set('date_to', dateTo)
if (evaluated) params.set('evaluated', evaluated)
params.set('limit', '500')
const r = await fetch(`/api/market-events?${params}`)
if (r.ok) {
const d = await r.json()
setEvents(d.events)
setTotal(d.total)
}
setLoading(false)
}, [search, category, level, minScore, dateFrom, dateTo, evaluated])
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(load, 250)
}, [load])
const bulkEvaluate = async () => {
if (bulkIds.size === 0) return
setBulking(true)
setBulkMsg(null)
const r = await fetch('/api/market-events/bulk-evaluate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify([...bulkIds]),
})
const d = await r.json()
setBulkMsg(`✓ ${d.evaluated} évalués${d.errors > 0 ? `, ${d.errors} erreurs` : ''}`)
setBulkIds(new Set())
setBulking(false)
load()
}
const unevaluatedIds = events.filter(e => !e.evaluated).map(e => e.id)
return (
<div className="flex h-screen overflow-hidden">
{/* ── Left panel: filters + list ── */}
<div className="w-[380px] shrink-0 flex flex-col border-r border-slate-700/40 overflow-hidden">
{/* Top bar */}
<div className="px-4 pt-4 pb-3 border-b border-slate-700/30 space-y-2">
<div className="flex items-center gap-2">
<h1 className="text-base font-bold text-slate-100 flex-1">Market Events</h1>
<button onClick={() => setShowCreate(true)}
className="flex items-center gap-1 bg-emerald-700 hover:bg-emerald-600 text-white text-xs rounded-lg px-2.5 py-1.5">
<Plus size={12} /> Nouveau
</button>
<button onClick={load} disabled={loading}
className="p-1.5 text-slate-500 hover:text-slate-300 hover:bg-slate-700/50 rounded">
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
</button>
</div>
{/* Search */}
<div className="relative">
<Search size={13} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-500" />
<input value={search} onChange={e => setSearch(e.target.value)}
placeholder="Recherche..."
className="w-full bg-slate-800/60 border border-slate-700/50 rounded-lg pl-8 pr-3 py-1.5 text-sm text-slate-200 placeholder:text-slate-600" />
</div>
{/* Quick filter chips */}
<div className="flex flex-wrap gap-1.5">
{CATEGORIES.map(c => (
<button key={c} onClick={() => setCategory(category === c ? '' : c)}
className={clsx(
'px-2 py-0.5 rounded-full text-xs border transition-colors',
category === c
? (CAT_COLORS[c] ?? 'bg-slate-700 text-slate-200 border-slate-600')
: 'border-slate-800 text-slate-600 hover:border-slate-600 hover:text-slate-400',
)}>
{c.replace('_', ' ')}
</button>
))}
<button onClick={() => setShowFilters(x => !x)}
className={clsx('px-2 py-0.5 rounded-full text-xs border transition-colors ml-auto',
showFilters ? 'bg-slate-700 text-slate-300 border-slate-600' : 'border-slate-800 text-slate-600 hover:border-slate-600')}>
<SlidersHorizontal size={11} className="inline mr-0.5" />
Filtres
</button>
</div>
{/* Extended filters */}
{showFilters && (
<div className="grid grid-cols-2 gap-2 pt-1">
<div>
<div className="text-xs text-slate-600 mb-1">Niveau</div>
<select value={level} onChange={e => setLevel(e.target.value)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs">
<option value="">Tous</option>
{LEVELS.map(l => <option key={l} value={l}>{l}</option>)}
</select>
</div>
<div>
<div className="text-xs text-slate-600 mb-1">Évaluación</div>
<select value={evaluated} onChange={e => setEvaluated(e.target.value as any)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs">
<option value="">Tous</option>
<option value="true">Évalués</option>
<option value="false">Non évalués</option>
</select>
</div>
<div>
<div className="text-xs text-slate-600 mb-1">Date depuis</div>
<input type="date" value={dateFrom} onChange={e => setDateFrom(e.target.value)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" />
</div>
<div>
<div className="text-xs text-slate-600 mb-1">Date jusqu'à</div>
<input type="date" value={dateTo} onChange={e => setDateTo(e.target.value)}
className="w-full bg-dark-900 border border-slate-700 rounded px-2 py-1 text-slate-300 text-xs" />
</div>
<div className="col-span-2">
<div className="text-xs text-slate-600 mb-1">Score min: {minScore.toFixed(2)}</div>
<input type="range" min="0" max="1" step="0.05" value={minScore}
onChange={e => setMinScore(+e.target.value)}
className="w-full accent-slate-400" />
</div>
</div>
)}
</div>
{/* Bulk actions */}
{unevaluatedIds.length > 0 && (
<div className="px-4 py-2 border-b border-slate-800/50 flex items-center gap-2 bg-blue-900/10">
<Users size={12} className="text-blue-400" />
<span className="text-xs text-blue-300 flex-1">
{unevaluatedIds.length} non évalués
</span>
{bulkMsg && <span className="text-xs text-emerald-400">{bulkMsg}</span>}
<button
onClick={() => { setBulkIds(new Set(unevaluatedIds)); setTimeout(bulkEvaluate, 50) }}
disabled={bulking}
className="flex items-center gap-1 text-xs bg-blue-700/50 hover:bg-blue-700/70 text-blue-200 rounded px-2 py-1">
{bulking ? <Loader2 size={10} className="animate-spin" /> : <Sparkles size={10} />}
Évaluer tous
</button>
</div>
)}
{/* Count */}
<div className="px-4 py-1.5 text-xs text-slate-600 border-b border-slate-800/30">
{total} événement{total !== 1 ? 's' : ''}
{(category || level || search || minScore > 0 || evaluated || dateFrom || dateTo) && ' (filtré)'}
</div>
{/* List */}
<div className="flex-1 overflow-y-auto">
{loading && events.length === 0 ? (
<div className="flex items-center justify-center h-32 text-slate-500 text-sm">
<Loader2 size={16} className="animate-spin mr-2" /> Chargement...
</div>
) : events.length === 0 ? (
<div className="text-center py-12 text-slate-600 text-sm">Aucun événement trouvé</div>
) : (
events.map(ev => (
<EventRow
key={ev.id}
ev={ev}
selected={selected?.id === ev.id}
onClick={() => setSelected(ev)}
/>
))
)}
</div>
</div>
{/* ── Right panel: detail ── */}
<div className="flex-1 overflow-hidden bg-dark-900/40">
{selected ? (
<EventDetail
key={selected.id}
event={selected}
onClose={() => setSelected(null)}
onUpdated={updated => {
setEvents(evs => evs.map(e => e.id === updated.id ? { ...e, ...updated } : e))
}}
onDeleted={id => {
setEvents(evs => evs.filter(e => e.id !== id))
setSelected(null)
}}
/>
) : (
<div className="flex flex-col items-center justify-center h-full text-slate-600 gap-3">
<BarChart2 size={40} className="opacity-30" />
<div className="text-sm">Sélectionne un événement pour voir le détail</div>
<div className="text-xs opacity-60">Sources · Impacts par instrument · Analyse IA</div>
</div>
)}
</div>
{showCreate && <CreateModal onCreated={load} onClose={() => setShowCreate(false)} />}
</div>
)
}