Files
sanguo_vnpy_v2/sanguo_api/routes_strategy.py
T

392 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""策略管理路由:代码文件 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())}