feat: Impact Monitor — AI impact evaluation for eco events & geopolitical news
- New DB tables: event_categories (18 bootstrap categories) + instrument_impacts - impact_categories_bootstrap.py: FOMC/NFP/CPI/GDP/PCE/ISM/BOJ/ECB/BOE/OPEC+ + 7 géopolitical categories with per-instrument sensitivity & direction defaults - impact_service.py: GPT-4o-mini evaluation (0-1 score, direction, rationale) + monitor data aggregation - routers/impact.py: GET/POST endpoints for evaluate/bulk/monitor/adjust/categories - ImpactMonitor.tsx: two-tab page (Évalués / À évaluer), top instruments bar, inline score override modal, category browser - Startup auto-bootstrap for impact categories - Route /impact + sidebar nav entry Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
155
backend/routers/impact.py
Normal file
155
backend/routers/impact.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Impact Monitor — instrument impact evaluation for market events and news.
|
||||
Prefix: /api/impact
|
||||
"""
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/impact", tags=["impact"])
|
||||
|
||||
|
||||
# ── Schemas ───────────────────────────────────────────────────────────────────
|
||||
|
||||
class ImpactAdjustment(BaseModel):
|
||||
adjusted_score: float
|
||||
adjusted_direction: str
|
||||
override_rationale: str = ""
|
||||
|
||||
|
||||
class CategoryUpdate(BaseModel):
|
||||
type: str
|
||||
sub_type: str = ""
|
||||
description: str = ""
|
||||
default_impacts: List[Dict[str, Any]] = []
|
||||
|
||||
|
||||
# ── Categories ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/categories")
|
||||
def list_categories() -> List[Dict[str, Any]]:
|
||||
from services.database import get_all_event_categories
|
||||
import json
|
||||
cats = get_all_event_categories()
|
||||
for c in cats:
|
||||
try:
|
||||
c["default_impacts"] = json.loads(c.get("default_impacts") or "[]")
|
||||
except Exception:
|
||||
c["default_impacts"] = []
|
||||
return cats
|
||||
|
||||
|
||||
@router.put("/categories/{name}")
|
||||
def update_category(name: str, body: CategoryUpdate) -> Dict[str, Any]:
|
||||
from services.database import upsert_event_category
|
||||
import json
|
||||
cat_id = upsert_event_category({
|
||||
"name": name,
|
||||
"type": body.type,
|
||||
"sub_type": body.sub_type,
|
||||
"description": body.description,
|
||||
"default_impacts": json.dumps(body.default_impacts),
|
||||
})
|
||||
return {"id": cat_id, "name": name, "status": "updated"}
|
||||
|
||||
|
||||
# ── Evaluate ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/evaluate/event/{event_id}")
|
||||
def evaluate_event(
|
||||
event_id: int,
|
||||
force: bool = Query(False, description="Re-evaluate even if already done"),
|
||||
) -> Dict[str, Any]:
|
||||
"""Trigger AI impact evaluation for a market event."""
|
||||
from services.impact_service import evaluate_event_impacts
|
||||
try:
|
||||
result = evaluate_event_impacts(event_id, force=force)
|
||||
if "error" in result:
|
||||
raise HTTPException(400, result["error"])
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"[impact] evaluate_event {event_id} failed: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@router.post("/evaluate/bulk")
|
||||
def evaluate_bulk(
|
||||
event_ids: List[int],
|
||||
force: bool = Query(False),
|
||||
) -> Dict[str, Any]:
|
||||
"""Evaluate multiple events sequentially."""
|
||||
from services.impact_service import evaluate_event_impacts
|
||||
results = []
|
||||
errors = []
|
||||
for eid in event_ids[:20]: # cap at 20 per call
|
||||
try:
|
||||
r = evaluate_event_impacts(eid, force=force)
|
||||
if "error" not in r:
|
||||
results.append({"event_id": eid, "n_instruments": r.get("n_instruments", 0)})
|
||||
else:
|
||||
errors.append({"event_id": eid, "error": r["error"]})
|
||||
except Exception as e:
|
||||
errors.append({"event_id": eid, "error": str(e)})
|
||||
return {"evaluated": len(results), "errors": len(errors), "results": results, "error_details": errors}
|
||||
|
||||
|
||||
# ── Monitor ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/monitor")
|
||||
def get_monitor(
|
||||
days: int = Query(7, ge=1, le=90, description="Fenêtre temporelle en jours"),
|
||||
min_score: float = Query(0.3, ge=0.0, le=1.0, description="Score minimum d'impact"),
|
||||
) -> Dict[str, Any]:
|
||||
"""Weekly impact monitor — evaluated events with instrument scores."""
|
||||
from services.impact_service import get_impact_monitor_data
|
||||
try:
|
||||
return get_impact_monitor_data(days=days, min_score=min_score)
|
||||
except Exception as e:
|
||||
logger.error(f"[impact] monitor failed: {e}")
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@router.get("/event/{event_id}")
|
||||
def get_event_impacts(event_id: int) -> Dict[str, Any]:
|
||||
"""Get stored impacts for a specific event."""
|
||||
from services.database import get_impacts_for_source, get_all_market_events
|
||||
impacts = get_impacts_for_source("event", event_id)
|
||||
events = get_all_market_events()
|
||||
event = next((e for e in events if e["id"] == event_id), None)
|
||||
return {
|
||||
"event_id": event_id,
|
||||
"event": event,
|
||||
"impacts": impacts,
|
||||
"evaluated": len(impacts) > 0,
|
||||
}
|
||||
|
||||
|
||||
# ── Adjust ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.put("/adjust/{impact_id}")
|
||||
def adjust_impact(impact_id: int, body: ImpactAdjustment) -> Dict[str, Any]:
|
||||
"""Manually override an AI-estimated impact score/direction."""
|
||||
from services.database import update_impact_adjustment
|
||||
ok = update_impact_adjustment(
|
||||
impact_id,
|
||||
body.adjusted_score,
|
||||
body.adjusted_direction,
|
||||
body.override_rationale,
|
||||
)
|
||||
if not ok:
|
||||
raise HTTPException(404, f"Impact {impact_id} not found")
|
||||
return {"id": impact_id, "status": "adjusted"}
|
||||
|
||||
|
||||
# ── Bootstrap ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.post("/bootstrap-categories")
|
||||
def bootstrap_categories(force: bool = False) -> Dict[str, Any]:
|
||||
"""Seed default impact categories."""
|
||||
from services.impact_categories_bootstrap import bootstrap_impact_categories
|
||||
return bootstrap_impact_categories(force=force)
|
||||
Reference in New Issue
Block a user