1"""Arena-centric endpoints — list, config, run, backtest, trail, memory. 2 3Also owns the two arena-related HTML pages: 4 5* ``/`` — dashboard with arena cards. 6* ``/arenas/{name}`` — per-arena detail page (Run / Setup / History / Memory tabs). 7 8Everything that names an arena lives here so adding a new arena route 9is one file change. 10""" 11 12from __future__ import annotations 13 14import hashlib 15import logging 16import os 17import tempfile 18from pathlib import Path 19from typing import Any 20 21import yaml 22from fastapi import APIRouter, HTTPException, Request, Response 23from fastapi.responses import HTMLResponse 24from fastapi.templating import Jinja2Templates 25from pydantic import BaseModel 26 27from maf.dashboard import state 28 29logger = logging.getLogger(__name__) 30 31router = APIRouter() 32_TEMPLATES_DIR = Path(__file__).resolve().parents[1] / "templates" 33templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) 34 35 36# ── Request bodies ─────────────────────────────────────────────────── 37 38 39class RunArenaRequest(BaseModel): 40 target: dict[str, Any] = {} 41 42 43class BacktestRequest(BaseModel): 44 ticker: str 45 start_date: str 46 end_date: str 47 reflect: bool = False 48 reflect_every_n: int = 5 49 50 51class UpdateConfigRequest(BaseModel): 52 config: dict[str, Any] 53 54 55# ── Arena display metadata (drives dashboard cards) ────────────────── 56 57 58_ARENA_DISPLAY: dict[str, dict[str, str]] = { 59 "trading_intelligence": { 60 "label": "Trading Intelligence", 61 "tagline": "Multi-source quant trading deliberation → BUY / HOLD / SELL with confidence.", 62 "speed": "balanced", 63 "input_hint": "ticker + optional trade date", 64 }, 65 "market_pulse": { 66 "label": "Market Pulse", 67 "tagline": "1-minute pulse on a watched symbol. Light, fast, replan-aware.", 68 "speed": "fast", 69 "input_hint": "ticker symbol", 70 }, 71 "mastermind": { 72 "label": "Mastermind", 73 "tagline": "Open-ended deliberation: many arenas → synthesised Decision envelope.", 74 "speed": "heavy", 75 "input_hint": "free-text question", 76 }, 77 "report_to_action": { 78 "label": "Report → Action", 79 "tagline": "Read a fomo2 enriched report, condense to one tradeable action.", 80 "speed": "fast", 81 "input_hint": "ticker symbol (optional — defaults to report's primary)", 82 }, 83 "crowd_simulation": { 84 "label": "Crowd Simulation (Oracle)", 85 "tagline": "MiroFish synthetic-crowd simulation → consensus + dissent → contrarian edge.", 86 "speed": "heavy", 87 "input_hint": "document / report markdown (use the CLI for full input)", 88 }, 89 "research_debate": { 90 "label": "Research Debate", 91 "tagline": "Multi-stakeholder (engineering / legal / business) deliberation on a question → grounded verdict.", 92 "speed": "balanced", 93 "input_hint": "question_id + proposal text (this is the non-quant arena)", 94 }, 95} 96 97 98def _arena_display(name: str) -> dict[str, str]: 99 return _ARENA_DISPLAY.get(name, { 100 "label": name.replace("_", " ").title(), 101 "tagline": "", 102 "speed": "", 103 "input_hint": "ticker symbol", 104 }) 105 106 107# ── YAML path + ETag helpers ───────────────────────────────────────── 108 109 110def _arena_yaml_path(name: str) -> Path: 111 app = state.get_maf_app() 112 arena_dir = Path(app.config.arena_dir) if app else Path("config/arenas") 113 return arena_dir / f"{name}.yaml" 114 115 116def _arena_yaml_etag(path: Path) -> str: 117 """Stable sha256 of the on-disk YAML — used as a weak ETag for optimistic locking.""" 118 return hashlib.sha256(path.read_bytes()).hexdigest()[:16] 119 120 121def _serialize_debates(debates: dict[str, Any]) -> dict[str, Any]: 122 """Convert DebateState TypedDicts to JSON-safe dicts.""" 123 return { 124 key: { 125 "history": d.get("history", []), 126 "per_agent": d.get("per_agent", {}), 127 "judge_decision": d.get("judge_decision", ""), 128 "count": d.get("count", 0), 129 } 130 for key, d in debates.items() 131 } 132 133 134# ── HTML page routes ───────────────────────────────────────────────── 135 136 137@router.get("/", response_class=HTMLResponse) 138async def page_index(request: Request) -> HTMLResponse: 139 """Dashboard — arena cards with last-decision badges and a Run button.""" 140 maf_app = state.get_maf_app() 141 trail = state.get_trail() 142 arenas = [] 143 if maf_app: 144 for name in maf_app.list_arenas(): 145 arena = maf_app.get_arena(name) 146 if arena: 147 disp = _arena_display(name) 148 trail_count = trail.count(name) if trail else 0 149 arenas.append({ 150 "name": name, 151 "label": disp["label"], 152 "tagline": disp["tagline"] or arena.config.description, 153 "speed": disp["speed"], 154 "input_hint": disp["input_hint"], 155 "schedule": arena.config.schedule, 156 "trail_count": trail_count, 157 "target_key": arena.config.target_key or "ticker", 158 }) 159 160 return templates.TemplateResponse("index.html", { 161 "request": request, 162 "active_nav": "dashboard", 163 "arenas": arenas, 164 }) 165 166 167@router.get("/arenas/{name}", response_class=HTMLResponse) 168async def page_arena(request: Request, name: str) -> HTMLResponse: 169 """Arena detail page.""" 170 maf_app = state.require_maf_app() 171 arena = maf_app.get_arena(name) 172 if not arena: 173 raise HTTPException(404, f"Arena {name!r} not found") 174 175 config = arena.config.model_dump(exclude_none=True) 176 adapter_modules: dict[str, str] = {} 177 for module in maf_app.modules: 178 for adapter_name in module.get_adapters(): 179 adapter_modules[adapter_name] = module.module_name 180 181 return templates.TemplateResponse("arena.html", { 182 "request": request, 183 "active_nav": "dashboard", 184 "arena_name": name, 185 "arena": config, 186 "adapter_modules": adapter_modules, 187 }) 188 189 190# ── /api/arenas — list + per-arena reads ───────────────────────────── 191 192 193@router.get("/api/arenas") 194async def list_arenas() -> list[dict[str, Any]]: 195 """List all registered arenas with status.""" 196 maf_app = state.get_maf_app() 197 if not maf_app: 198 return [] 199 result = [] 200 for name in maf_app.list_arenas(): 201 arena = maf_app.get_arena(name) 202 if arena: 203 result.append({ 204 "name": name, 205 "description": arena.config.description, 206 "schedule": arena.config.schedule, 207 "phases": len(arena.config.phases), 208 "sources": len(arena.config.sources), 209 }) 210 return result 211 212 213@router.get("/api/arenas/{name}") 214async def get_arena(name: str) -> dict[str, Any]: 215 """Get detailed arena configuration.""" 216 maf_app = state.require_maf_app() 217 arena = maf_app.get_arena(name) 218 if not arena: 219 raise HTTPException(404, f"Arena {name!r} not found") 220 return {"name": name, "config": arena.config.model_dump(exclude_none=True)} 221 222 223@router.get("/api/arenas/{name}/last_decision") 224async def get_arena_last_decision(name: str) -> dict[str, Any]: 225 """Return the most recent trail entry for ``name`` (verdict + when). 226 227 The dashboard cards call this for each arena to render a 'last run' badge. 228 Returns ``{"empty": true}`` when the arena has never been run. 229 """ 230 trail = state.get_trail() 231 if not trail: 232 return {"empty": True, "reason": "trail not initialized"} 233 entries = trail.list_entries(name, limit=1, offset=0) 234 if not entries: 235 return {"empty": True} 236 e = entries[0] 237 return { 238 "empty": False, 239 "id": e.get("id", ""), 240 "timestamp": e.get("timestamp", ""), 241 "signal": e.get("signal", ""), 242 "synthesis": e.get("synthesis") or {}, 243 "target": e.get("target") or {}, 244 } 245 246 247# ── Config GET (with ETag) + PUT (validated + optimistic-locked + atomic) ── 248 249 250@router.get("/api/arenas/{name}/config") 251async def get_arena_config(name: str, response: Response) -> dict[str, Any]: 252 """Get raw arena config as dict, with an ETag header for optimistic locking on PUT.""" 253 maf_app = state.require_maf_app() 254 arena = maf_app.get_arena(name) 255 if not arena: 256 raise HTTPException(404, f"Arena {name!r} not found") 257 258 yaml_path = _arena_yaml_path(name) 259 if response is not None and yaml_path.exists(): 260 try: 261 response.headers["ETag"] = _arena_yaml_etag(yaml_path) 262 except Exception: 263 pass 264 return arena.config.model_dump(exclude_none=True) 265 266 267@router.put("/api/arenas/{name}/config") 268async def update_arena_config( 269 name: str, req: UpdateConfigRequest, request: Request, 270) -> dict[str, Any]: 271 """Save arena config to YAML. 272 273 Three guard rails before the write: 274 1. **Pydantic validation** — round-trip through :class:`ArenaConfig` so 275 an invalid role / missing field / wrong type is rejected with 422 276 *before* corrupting the file. 277 2. **ETag / If-Match** — if the client supplies ``If-Match`` and the 278 on-disk ETag doesn't match, return 412 so concurrent editors can't 279 silently overwrite each other. 280 3. **Atomic write** — write to a tempfile in the same directory, then 281 rename. Either the new content fully replaces the old or nothing 282 changes; no half-written YAML. 283 """ 284 state.require_maf_app() 285 286 yaml_path = _arena_yaml_path(name) 287 if not yaml_path.exists(): 288 raise HTTPException(404, f"Arena config file not found: {yaml_path}") 289 290 from maf.config import ArenaConfig 291 try: 292 validated = ArenaConfig.model_validate(req.config) 293 except Exception as exc: 294 raise HTTPException(422, f"Config validation failed: {exc}") 295 if validated.name != name: 296 raise HTTPException( 297 422, f"Arena name mismatch: file is {name!r}, config has {validated.name!r}", 298 ) 299 300 if_match = request.headers.get("if-match") or request.headers.get("If-Match") 301 if if_match: 302 current = _arena_yaml_etag(yaml_path) 303 if if_match.strip('"') != current: 304 raise HTTPException( 305 412, 306 f"YAML was modified by another writer (etag {current}, " 307 f"you sent {if_match!r}). Refresh and retry.", 308 ) 309 310 try: 311 fd, tmp_name = tempfile.mkstemp( 312 prefix=f".{name}.", suffix=".yaml.tmp", dir=str(yaml_path.parent), 313 ) 314 with os.fdopen(fd, "w") as f: 315 yaml.dump( 316 validated.model_dump(exclude_none=True), 317 f, default_flow_style=False, sort_keys=False, 318 ) 319 os.replace(tmp_name, yaml_path) 320 except Exception as exc: 321 try: os.unlink(tmp_name) 322 except Exception: pass 323 raise HTTPException(500, f"Failed to save config: {exc}") 324 325 return { 326 "status": "ok", 327 "message": f"Config saved to {yaml_path.name}. Restart to apply.", 328 "etag": _arena_yaml_etag(yaml_path), 329 } 330 331 332# ── Memory + trail ─────────────────────────────────────────────────── 333 334 335@router.get("/api/arenas/{name}/memory") 336async def get_arena_memory(name: str) -> list[dict[str, Any]]: 337 """List memory instances and entry counts for an arena.""" 338 maf_app = state.require_maf_app() 339 arena = maf_app.get_arena(name) 340 if not arena: 341 raise HTTPException(404, f"Arena {name!r} not found") 342 343 result = [] 344 for mem_name in arena.memory_store.all_names(): 345 mem = arena.memory_store.get(mem_name) 346 entry_count = 0 347 if mem and hasattr(mem, "documents"): 348 entry_count = len(mem.documents) 349 result.append({"name": mem_name, "entries": entry_count}) 350 return result 351 352 353@router.get("/api/arenas/{name}/trail") 354async def list_trail(name: str, limit: int = 50, offset: int = 0) -> dict[str, Any]: 355 """List decision trail entries for an arena (most recent first).""" 356 trail = state.get_trail() 357 if not trail: 358 return {"entries": [], "total": 0} 359 entries = trail.list_entries(name, limit=limit, offset=offset) 360 return {"entries": entries, "total": trail.count(name)} 361 362 363@router.get("/api/arenas/{name}/trail/{entry_id}") 364async def get_trail_entry(name: str, entry_id: str) -> dict[str, Any]: 365 """Get a full decision trail entry.""" 366 trail = state.get_trail() 367 if not trail: 368 raise HTTPException(404, "Trail not initialized") 369 entry = trail.get_entry(name, entry_id) 370 if not entry: 371 raise HTTPException(404, f"Trail entry {entry_id!r} not found") 372 return entry 373 374 375@router.delete("/api/arenas/{name}/trail/{entry_id}") 376async def delete_trail_entry(name: str, entry_id: str) -> dict[str, Any]: 377 """Delete a decision trail entry.""" 378 trail = state.get_trail() 379 if not trail: 380 raise HTTPException(404, "Trail not initialized") 381 if trail.delete_entry(name, entry_id): 382 return {"status": "ok", "message": f"Entry {entry_id} deleted"} 383 raise HTTPException(404, f"Trail entry {entry_id!r} not found") 384 385 386@router.get("/api/arenas/{name}/trail/{entry_id}/signals") 387async def get_trail_signals(name: str, entry_id: str) -> dict[str, Any]: 388 """Get the structured agent signals and synthesis data for a trail entry.""" 389 trail = state.get_trail() 390 if not trail: 391 raise HTTPException(404, "Trail not initialized") 392 entry = trail.get_entry(name, entry_id) 393 if not entry: 394 raise HTTPException(404, f"Trail entry {entry_id!r} not found") 395 return { 396 "id": entry_id, 397 "signal": entry.get("signal", ""), 398 "agent_signals": entry.get("agent_signals", []), 399 "synthesis": entry.get("synthesis", {}), 400 "source_metrics": entry.get("source_metrics", []), 401 "target": entry.get("target", {}), 402 "timestamp": entry.get("timestamp", ""), 403 } 404 405 406# ── Run + Backtest ─────────────────────────────────────────────────── 407 408 409@router.post("/api/arenas/{name}/run") 410async def run_arena(name: str, req: RunArenaRequest) -> dict[str, Any]: 411 """Run a specific arena and return results including execution trace.""" 412 maf_app = state.require_maf_app() 413 trail = state.get_trail() 414 try: 415 result_state = await maf_app.run_arena(name, req.target or None) 416 417 trail_id = "" 418 if trail: 419 try: 420 trail_id = trail.save(name, result_state) 421 except Exception as exc: 422 logger.warning("Failed to save decision trail: %s", exc) 423 424 return { 425 "arena": name, 426 "trail_id": trail_id, 427 "signal": result_state.get("signal", ""), 428 "agent_signals": result_state.get("agent_signals") or [], 429 "synthesis": { 430 "score": result_state.get("synthesis_score", 0.0), 431 "verdict": result_state.get("synthesis_verdict", ""), 432 "confidence": result_state.get("synthesis_confidence", 0.0), 433 "reasoning": result_state.get("synthesis_reasoning", ""), 434 }, 435 "source_metrics": result_state.get("source_metrics") or [], 436 "reports": result_state.get("reports", {}), 437 "decisions": result_state.get("decisions", {}), 438 "debates": _serialize_debates(result_state.get("debates", {})), 439 "trace": result_state.get("trace", {}), 440 "target": result_state.get("target", {}), 441 } 442 except KeyError: 443 raise HTTPException(404, f"Arena {name!r} not found") 444 except Exception as exc: 445 raise HTTPException(500, str(exc)) 446 447 448@router.post("/api/arenas/{name}/backtest") 449async def run_backtest(name: str, req: BacktestRequest) -> dict[str, Any]: 450 """Run a backtest for a trading arena.""" 451 maf_app = state.require_maf_app() 452 arena = maf_app.get_arena(name) 453 if not arena: 454 raise HTTPException(404, f"Arena {name!r} not found") 455 456 try: 457 from maf.arenas.trading_intelligence.backtest import BacktestRunner 458 459 runner = BacktestRunner(arena) 460 result = await runner.run( 461 ticker=req.ticker, 462 start_date=req.start_date, 463 end_date=req.end_date, 464 reflect=req.reflect, 465 reflect_every_n=req.reflect_every_n, 466 ) 467 return { 468 "ticker": result.ticker, 469 "start_date": result.start_date, 470 "end_date": result.end_date, 471 "total_days": result.total_days, 472 "metrics": result.metrics, 473 "baseline": result.baseline, 474 "equity_curve": result.equity_curve, 475 "trades": result.trades, 476 "signals": [ 477 {"date": s["date"], "signal": s["signal"]} 478 for s in result.signals 479 ], 480 } 481 except Exception as exc: 482 logger.exception("Backtest failed for %s", name) 483 raise HTTPException(500, str(exc))