""" 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"}