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:
@@ -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("/")
|
||||
|
||||
269
backend/routers/market_events.py
Normal file
269
backend/routers/market_events.py
Normal 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"}
|
||||
@@ -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:
|
||||
|
||||
@@ -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'])} "
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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' },
|
||||
|
||||
893
frontend/src/pages/MarketEvents.tsx
Normal file
893
frontend/src/pages/MarketEvents.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user