392 lines
15 KiB
Python
392 lines
15 KiB
Python
"""策略管理路由:代码文件 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())}
|