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:
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"}
|
||||
Reference in New Issue
Block a user