checking system…
Docs / back / src/maf/dashboard/routers/arenas.py · line 268
Python · 484 lines
  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))