"""策略管理路由:代码文件 CRUD + 实例 CRUD(spec §12 三层模型)。 鉴权说明:MVP 不挂 verify_token,便于本地联调与单测;敏感写操作(写文件) 已由 write_strategy_file 的 py_compile 语法门禁兜底。 """ from fastapi import APIRouter, HTTPException from pydantic import BaseModel from .strategy_registry import ( list_strategy_files, read_strategy_file, write_strategy_file, ) from . import instance_store router = APIRouter() class FileWriteRequest(BaseModel): code: str class InstancePayload(BaseModel): code_file: str = "" name: str = "" type: str = "cta" params: dict = {} symbol_or_pool: str = "" interval: str = "d" match_session: str = "next_open" @router.get("/strategy/files") def get_files(): """代码文件列表(不含 code 体)。""" return list_strategy_files() @router.get("/strategy/file/{name}") def get_file(name: str): """读单个策略文件全量(含 code)。""" try: return read_strategy_file(name) except ValueError: raise HTTPException(status_code=404, detail="策略文件不存在") @router.post("/strategy/file/{name}") def post_file(name: str, req: FileWriteRequest): """保存策略代码(py_compile 校验,失败 400)。""" try: write_strategy_file(name, req.code) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) return {"ok": True} @router.post("/strategy/file/{name}/check") def check_file(name: str, req: FileWriteRequest): """语法检查(不落盘):编辑器「语法检查」按钮真接线。 原前端按钮是原型期假实现(sleep 600ms 恒报通过,2026-08-15 用户实况 删括号仍报成功)。compile() 不执行代码,只做语法编译;SyntaxError 返回行号+消息供编辑器定位。 """ try: compile(req.code, name, "exec") except SyntaxError as e: return {"ok": False, "error": str(e.msg or e), "line": e.lineno} except ValueError as e: # null bytes 等 compile 级错误 return {"ok": False, "error": str(e), "line": None} return {"ok": True} @router.get("/strategy/instances") def get_instances(): return instance_store.list_instances() @router.post("/strategy/instances") def post_instance(req: InstancePayload): new_id = instance_store.create_instance(req.model_dump()) return {"id": new_id} @router.get("/strategy/instances/enriched") def get_instances_enriched(): """实例列表 + 读时聚合:持续运行覆盖四格的实走/实盘格 + 在跑账户 + 漂移标记 + 各类型运行数 counts(#77 灯 ×N 徽标/空则新建)。 注意必须注册在 /strategy/instances/{inst_id} 之前(否则 enriched 被当 inst_id 解析)。 """ rt = _instance_runtime() counts = _instance_run_counts() out = [] for inst in instance_store.list_instances()["instances"]: ent = rt.get(inst["id"], {"running_accounts": [], "drift": False, "code_changed": False}) paper_runs = [a for a in ent["running_accounts"] if a["kind"] in ("paper", "shadow")] live_runs = [a for a in ent["running_accounts"] if a["kind"] == "live"] status = dict(inst.get("status") or {}) if paper_runs: status["paper_live"] = "running" if live_runs: status["live"] = "running" out.append({ **inst, "status": status, "running_accounts": ent["running_accounts"], "drift": ent["drift"], "code_changed": ent["code_changed"], "counts": counts.get(inst["id"], {"backtest": 0, "replay": 0, "paper_live": 0, "live": 0}), }) return {"instances": out} def _instance_run_counts() -> dict[int, dict]: """每实例各类型运行总数(#77 灯徽标;查询失败返回 {} → 前端退化为无徽标)。 口径:backtest=任务表行数(老任务无 instance_id 不计);replay=回放账户数; paper_live=实走+影子账户数(不分状态);live=实盘账户数。 """ import sqlite3 out: dict[int, dict] = {} _zero = lambda: {"backtest": 0, "replay": 0, "paper_live": 0, "live": 0} # noqa: E731 try: from .routes import get_orchestrator orch = get_orchestrator() if orch and orch.db_path: with sqlite3.connect(orch.db_path) as _c: for iid, n in _c.execute( "SELECT instance_id, COUNT(*) FROM backtest_stats " "WHERE instance_id IS NOT NULL GROUP BY instance_id" ).fetchall(): out.setdefault(int(iid), _zero())["backtest"] = n except Exception: pass try: from .routes_paper import _db_path as paper_db pdb = paper_db["path"] if pdb: with sqlite3.connect(pdb) as conn: for iid, mode, n in conn.execute( "SELECT instance_id, mode, COUNT(*) FROM paper_accounts " "WHERE instance_id IS NOT NULL GROUP BY instance_id, mode" ).fetchall(): key = "replay" if mode == "replay" else "paper_live" out.setdefault(int(iid), _zero())[key] += n except Exception: pass try: from .routes_live import _db_path as live_db ldb = live_db["path"] if ldb: with sqlite3.connect(ldb) as conn: for iid, n in conn.execute( "SELECT instance_id, COUNT(*) FROM live_accounts " "WHERE instance_id IS NOT NULL GROUP BY instance_id" ).fetchall(): out.setdefault(int(iid), _zero())["live"] = n except Exception: pass return out @router.get("/strategy/instances/{inst_id}") def get_instance(inst_id: int): return instance_store.get_instance(inst_id) @router.put("/strategy/instances/{inst_id}") def put_instance(inst_id: int, req: InstancePayload): instance_store.update_instance(inst_id, req.model_dump()) return {"ok": True} @router.delete("/strategy/instances/{inst_id}") def del_instance(inst_id: int): # §12.6 D5 删除保护:有运行中账户(模拟/实盘)的档案禁止删 running = _instance_runtime().get(inst_id, {}).get("running_accounts", []) if running: names = "、".join(a["label"] for a in running[:3]) raise HTTPException( status_code=409, detail=f"该实例下有运行中的账户({names}…),请先停止再删除", ) ok = instance_store.delete_instance(inst_id) if not ok: raise HTTPException(status_code=404, detail="实例不存在") return {"ok": True} # ===== §12.6 补:代码版本快照查询 ===== @router.get("/strategy/code-versions") def get_code_versions(file: str): """该策略文件的全部发起快照(新→旧)。""" from .code_versions import list_versions return {"file": file, "versions": list_versions(file)} @router.get("/strategy/code-versions/{file}/{h8}") def get_code_version(file: str, h8: str): """读某版本全文(编辑器 diff 视图左栏)。""" from .code_versions import read_version code = read_version(file, h8) if code is None: raise HTTPException(status_code=404, detail="版本不存在") return {"file": file, "code_version": h8, "code": code} # ===== §12.6 做实:读时聚合(持续型运行)+ P1 实例全景 ===== def _instance_runtime() -> dict[int, dict]: """读时聚合每个档案的模拟/实盘账户(持续型运行的"回写")。 返回 {instance_id: {running_accounts: [...], drift: bool, code_changed: bool}}; DB 不可达返回 {}(策略库退化为纯 JSON 状态展示,不阻塞)。 """ import json as _json import sqlite3 from .code_versions import code_changed as _code_changed out: dict[int, dict] = {} try: from .routes_paper import _db_path as paper_db pdb = paper_db["path"] if pdb: with sqlite3.connect(pdb) as conn: conn.row_factory = sqlite3.Row rows = conn.execute( "SELECT id, name, mode, engine, status, strategies, instance_id, " "initial_capital, code_hash FROM paper_accounts WHERE instance_id IS NOT NULL" ).fetchall() from sanguo_trader.persistence import load_last_balance for r in rows: ent = out.setdefault(r["instance_id"], {"running_accounts": [], "drift": False, "code_changed": False}) ret = None last = load_last_balance(pdb, r["id"]) if last and r["initial_capital"]: ret = (last.get("total_equity", 0) - r["initial_capital"]) / r["initial_capital"] snapshot = instance_store.get_instance_params_snapshot(r["instance_id"]) drifted = bool(snapshot and instance_store.account_params_drifted(snapshot, dict(r))) if drifted: ent["drift"] = True chg = _code_changed((snapshot or {}).get("code_file") or "", r["code_hash"]) if chg: ent["code_changed"] = True if r["status"] == "running": ent["running_accounts"].append({ "kind": "shadow" if r["engine"] == "shadow" else "paper", "aid": r["id"], "label": r["name"] or f"paper#{r['id']}", "ret": ret, "drifted": drifted, "code_changed": chg, "code_version": (r["code_hash"] or "")[:8] or None, }) except Exception: pass try: from .routes_live import _db_path as live_db from sanguo_live.persistence import get_first_balance, get_last_balance, list_accounts ldb = live_db["path"] if ldb: for a in list_accounts(ldb): iid = a.get("instance_id") if not iid: continue ent = out.setdefault(iid, {"running_accounts": [], "drift": False, "code_changed": False}) ret = None first, last = get_first_balance(ldb, a["id"]), get_last_balance(ldb, a["id"]) if first and last and first.get("total"): ret = (last["total"] - first["total"]) / first["total"] # D2:实盘账户也标漂移(只亮角标不提供在线同步——实盘停了重发) snapshot = instance_store.get_instance_params_snapshot(iid) drifted = bool(snapshot and instance_store.account_params_drifted(snapshot, dict(a))) if drifted: ent["drift"] = True chg = _code_changed((snapshot or {}).get("code_file") or "", a.get("code_hash")) if chg: ent["code_changed"] = True if a.get("status") == "running": ent["running_accounts"].append({ "kind": "live", "aid": a["id"], "label": a.get("name") or f"live#{a['id']}", "ret": ret, "drifted": drifted, "code_changed": chg, "code_version": (a.get("code_hash") or "")[:8] or None, }) except Exception: pass return out @router.get("/strategy/instances/{inst_id}/overview") def get_instance_overview(inst_id: int): """P1 实例全景:档案 + 全部运行账户(含净值尾部)+ 合并持仓归因。""" import sqlite3 from .code_versions import code_changed as _code_changed snap = instance_store.get_instance_params_snapshot(inst_id) if snap is None: raise HTTPException(status_code=404, detail="实例不存在") runs: list[dict] = [] positions: dict[str, dict] = {} def _absorb(symbol: str, volume: float, avg: float, label: str) -> None: p = positions.setdefault(symbol, {"symbol": symbol, "volume": 0.0, "avg_price": avg, "accounts": []}) p["volume"] += volume p["accounts"].append({"label": label, "volume": volume}) try: from .routes_paper import _db_path as paper_db pdb = paper_db["path"] if pdb: from sanguo_trader.persistence import load_last_balance, load_positions with sqlite3.connect(pdb) as conn: conn.row_factory = sqlite3.Row rows = conn.execute( "SELECT * FROM paper_accounts WHERE instance_id=? ORDER BY id", (inst_id,) ).fetchall() for r in rows: last = load_last_balance(pdb, r["id"]) ret = None if last and r["initial_capital"]: ret = (last["total_equity"] - r["initial_capital"]) / r["initial_capital"] with sqlite3.connect(pdb) as conn: curve = conn.execute( "SELECT date, total_equity FROM paper_daily_balance " "WHERE account_id=? ORDER BY date DESC LIMIT 120", (r["id"],), ).fetchall() runs.append({ "kind": "shadow" if r["engine"] == "shadow" else "paper", "aid": r["id"], "label": r["name"], "mode": r["mode"], "status": r["status"], "ret": ret, "code_version": (r["code_hash"] or "")[:8] or None, "code_changed": _code_changed(snap.get("code_file") or "", r["code_hash"]), "equity": [{"date": c[0], "equity": c[1]} for c in reversed(curve)], }) if r["status"] == "running": scope = "shadow" if r["engine"] == "shadow" else r["mode"] for sym, p in load_positions(pdb, r["id"], scope).items(): _absorb(sym, p.get("volume", 0), p.get("avg_price", 0), r["name"] or f"paper#{r['id']}") except Exception: pass try: from .routes_live import _db_path as live_db ldb = live_db["path"] if ldb: from sanguo_live.persistence import ( get_first_balance, get_last_balance, list_accounts, load_positions, ) for a in list_accounts(ldb): if a.get("instance_id") != inst_id: continue first, last = get_first_balance(ldb, a["id"]), get_last_balance(ldb, a["id"]) ret = None if first and last and first.get("total"): ret = (last["total"] - first["total"]) / first["total"] runs.append({ "kind": "live", "aid": a["id"], "label": a.get("name"), "status": a.get("status"), "ret": ret, "equity": [], "code_version": (a.get("code_hash") or "")[:8] or None, "code_changed": _code_changed(snap.get("code_file") or "", a.get("code_hash")), }) if a.get("status") == "running": for p in load_positions(ldb, a["id"]): _absorb(p["symbol"], p.get("volume", 0), p.get("avg_price", 0), a.get("name") or f"live#{a['id']}") except Exception: pass return {"instance": snap, "runs": runs, "positions": list(positions.values())}