Database migration: - Add 'origin' and 'source_refs' columns to market_events ALTER TABLE migration (sub_type/actual_value/expected_value/surprise_pct were already there) - All new tables (macro_gauge_snapshots, ai_desks) created via CREATE TABLE IF NOT EXISTS on next init_db() call (container restart) Backend: - GET /api/market-events/db-status — health check returning row counts, latest dates, and missing columns for all 6 tables needed by the detector - list_events() now accepts gen_date_from / gen_date_to query params filtering by date(created_at) — separate from start_date event date filters Frontend (MarketEvents.tsx): - MarketEvent interface: add created_at field - EventRow: show generation date as ⚡MM-DD next to event date - Extended filters: new '⚡ Date de génération' section with from/to inputs filtered independently from the event date range - Clear-all button includes genFrom/genTo reset Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
404 lines
15 KiB
Python
404 lines
15 KiB
Python
"""
|
|
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 ─────────────────────────────────────────────────────────────
|
|
|
|
_SORT_COLS = {
|
|
"date": "me.start_date",
|
|
"score": "me.impact_score",
|
|
"name": "me.name",
|
|
"instrument_score": "COALESCE(ii.adjusted_score, ii.impact_score)",
|
|
}
|
|
|
|
@router.get("/db-status")
|
|
def db_status() -> Dict[str, Any]:
|
|
"""Quick health-check of all tables needed for market event generation."""
|
|
from services.database import get_conn
|
|
conn = get_conn()
|
|
status: Dict[str, Any] = {}
|
|
|
|
tables = {
|
|
"market_events": "SELECT COUNT(*), MAX(created_at) FROM market_events",
|
|
"economic_events": "SELECT COUNT(*), MAX(fetched_at) FROM economic_events",
|
|
"macro_gauge_snapshots":"SELECT COUNT(*), MAX(snapshot_date) FROM macro_gauge_snapshots",
|
|
"macro_regime_history": "SELECT COUNT(*), MAX(timestamp) FROM macro_regime_history",
|
|
"institutional_reports":"SELECT COUNT(*), MAX(fetch_date) FROM institutional_reports",
|
|
"ai_desks": "SELECT COUNT(*), NULL FROM ai_desks",
|
|
}
|
|
for table, sql in tables.items():
|
|
try:
|
|
row = conn.execute(sql).fetchone()
|
|
status[table] = {"count": row[0], "latest": row[1]}
|
|
except Exception as e:
|
|
status[table] = {"count": 0, "latest": None, "error": str(e)}
|
|
|
|
# Check market_events columns
|
|
existing_cols = {row[1] for row in conn.execute("PRAGMA table_info(market_events)").fetchall()}
|
|
required_cols = ["sub_type", "origin", "source_refs", "actual_value", "expected_value", "surprise_pct"]
|
|
status["market_events"]["columns_ok"] = all(c in existing_cols for c in required_cols)
|
|
status["market_events"]["missing_columns"] = [c for c in required_cols if c not in existing_cols]
|
|
|
|
# Summary of ai_desks types
|
|
try:
|
|
desks = conn.execute("SELECT type, active FROM ai_desks").fetchall()
|
|
status["ai_desks"]["desks"] = [{"type": r[0], "active": bool(r[1])} for r in desks]
|
|
except Exception:
|
|
pass
|
|
|
|
conn.close()
|
|
return status
|
|
|
|
|
|
@router.get("")
|
|
def list_events(
|
|
# text / category / level
|
|
search: Optional[str] = Query(None),
|
|
category: Optional[str] = Query(None),
|
|
level: Optional[str] = Query(None),
|
|
origin: Optional[str] = Query(None),
|
|
# event date range (start_date)
|
|
date_from: Optional[str] = Query(None),
|
|
date_to: Optional[str] = Query(None),
|
|
# generation date range (created_at)
|
|
gen_date_from: Optional[str] = Query(None, description="Filter by generation date (created_at) from"),
|
|
gen_date_to: Optional[str] = Query(None, description="Filter by generation date (created_at) to"),
|
|
# global impact score
|
|
min_score: float = Query(0.0, ge=0.0, le=1.0),
|
|
# instrument impact filter
|
|
instrument: Optional[str] = Query(None, description="Filter by impact on this ticker"),
|
|
min_instrument_score: float = Query(0.0, ge=0.0, le=1.0),
|
|
instrument_direction: Optional[str] = Query(None, description="bullish|bearish|neutral"),
|
|
# evaluation state
|
|
evaluated: Optional[bool] = Query(None),
|
|
# sort
|
|
sort_by: str = Query("date", description="date|score|name|instrument_score"),
|
|
sort_dir: str = Query("desc", description="asc|desc"),
|
|
# pagination
|
|
limit: int = Query(200, ge=1, le=1000),
|
|
offset: int = Query(0, ge=0),
|
|
) -> Dict[str, Any]:
|
|
from services.database import get_conn
|
|
|
|
conn = get_conn()
|
|
|
|
# Decide join type: INNER when filtering by instrument, LEFT otherwise
|
|
join_type = "INNER" if instrument else "LEFT"
|
|
join_clause = (
|
|
f"{join_type} JOIN instrument_impacts ii "
|
|
"ON ii.source_type='event' AND ii.source_id=me.id"
|
|
+ (f" AND ii.instrument_id='{instrument}'" if instrument else "")
|
|
)
|
|
|
|
where_parts: List[str] = []
|
|
params: List[Any] = []
|
|
|
|
if search:
|
|
where_parts.append("(me.name LIKE ? OR me.description LIKE ? OR me.sub_type LIKE ?)")
|
|
like = f"%{search}%"
|
|
params += [like, like, like]
|
|
if category:
|
|
where_parts.append("me.category=?")
|
|
params.append(category)
|
|
if level:
|
|
where_parts.append("me.level=?")
|
|
params.append(level)
|
|
if origin:
|
|
where_parts.append("me.origin=?")
|
|
params.append(origin)
|
|
if date_from:
|
|
where_parts.append("me.start_date>=?")
|
|
params.append(date_from)
|
|
if date_to:
|
|
where_parts.append("me.start_date<=?")
|
|
params.append(date_to)
|
|
if gen_date_from:
|
|
where_parts.append("date(me.created_at)>=?")
|
|
params.append(gen_date_from)
|
|
if gen_date_to:
|
|
where_parts.append("date(me.created_at)<=?")
|
|
params.append(gen_date_to)
|
|
if min_score > 0:
|
|
where_parts.append("me.impact_score>=?")
|
|
params.append(min_score)
|
|
if instrument and min_instrument_score > 0:
|
|
where_parts.append("COALESCE(ii.adjusted_score, ii.impact_score)>=?")
|
|
params.append(min_instrument_score)
|
|
if instrument and instrument_direction:
|
|
where_parts.append("ii.direction=?")
|
|
params.append(instrument_direction)
|
|
if evaluated is True:
|
|
where_parts.append(
|
|
"EXISTS (SELECT 1 FROM instrument_impacts x WHERE x.source_type='event' AND x.source_id=me.id)"
|
|
)
|
|
if evaluated is False:
|
|
where_parts.append(
|
|
"NOT EXISTS (SELECT 1 FROM instrument_impacts x WHERE x.source_type='event' AND x.source_id=me.id)"
|
|
)
|
|
|
|
where_sql = ("WHERE " + " AND ".join(where_parts)) if where_parts else ""
|
|
|
|
sort_col = _SORT_COLS.get(sort_by, "me.start_date")
|
|
if sort_by == "instrument_score" and not instrument:
|
|
sort_col = "me.impact_score"
|
|
order_sql = f"ORDER BY {sort_col} {'DESC' if sort_dir == 'desc' else 'ASC'}"
|
|
|
|
# Always GROUP BY — the LEFT JOIN on instrument_impacts creates duplicate rows
|
|
# when an event has multiple impacts (one row per impact without GROUP BY)
|
|
group_sql = "GROUP BY me.id"
|
|
|
|
# Extra columns: evaluated flag + optional instrument score
|
|
evaluated_col = (
|
|
", (CASE WHEN EXISTS ("
|
|
"SELECT 1 FROM instrument_impacts _ei "
|
|
"WHERE _ei.source_type='event' AND _ei.source_id=me.id"
|
|
") THEN 1 ELSE 0 END) as evaluated"
|
|
)
|
|
inst_col = (
|
|
", MAX(COALESCE(ii.adjusted_score, ii.impact_score)) as inst_score"
|
|
", ii.direction as inst_direction"
|
|
) if instrument else ", NULL as inst_score, NULL as inst_direction"
|
|
|
|
count_sql = f"""
|
|
SELECT COUNT(DISTINCT me.id) FROM market_events me {join_clause} {where_sql}
|
|
"""
|
|
data_sql = f"""
|
|
SELECT me.* {evaluated_col} {inst_col}
|
|
FROM market_events me {join_clause}
|
|
{where_sql} {group_sql} {order_sql}
|
|
LIMIT ? OFFSET ?
|
|
"""
|
|
|
|
try:
|
|
total = conn.execute(count_sql, params).fetchone()[0]
|
|
rows = conn.execute(data_sql, params + [limit, offset]).fetchall()
|
|
finally:
|
|
conn.close()
|
|
|
|
events = []
|
|
for row in rows:
|
|
ev = _parse_event(dict(row))
|
|
if instrument and ev.get("inst_score") is not None:
|
|
ev["instrument_filter"] = {
|
|
"ticker": instrument,
|
|
"score": round(float(ev.pop("inst_score")), 3),
|
|
"direction": ev.pop("inst_direction", None),
|
|
}
|
|
else:
|
|
ev.pop("inst_score", None)
|
|
ev.pop("inst_direction", None)
|
|
events.append(ev)
|
|
|
|
return {"total": total, "offset": offset, "limit": limit, "events": events}
|
|
|
|
|
|
# ── 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]
|
|
data.setdefault("origin", "manual")
|
|
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"}
|