Files
sanguo_vnpy_v2/sanguo_api/routes_strategy.py
T
claude_dev bab7c4d893
CI/CD / test (push) Successful in 13s
CI/CD / nas-deploy (push) Successful in 32s
CI/CD / nas-verify (push) Successful in 12s
feat(strategy): TODO#68-#75八项全做+导航自测——#68灯职责拆分:实走/影子在跑=直达/paper/live/{aid}监控页,实盘在跑=/live/monitor/{aid},未跑=发起;回放灯done=直达/paper/result/{aid}(run_meta.replay.account_id);#69回测任务按实例过滤:backtest_stats加instance_id列(ALTER迁移)+_write_back落列+GET /task?instance=N+任务中心过滤条+策略库「回测历史」按钮;回测灯done=直达最新结果页(run_meta.backtest.task_id按前缀分流),跑过无结果=任务中心过滤;#70模拟盘创建后直接跳列表(不跳结果/监控页);列宽重排(策略/实例150模式96频率60操作250,折行实测消除);#71列内容对齐实盘:策略/实例列显示实例名(instances映射,#id退化)+持仓数列(list_papers顺带count volume>0)+删创建时间列(净值日期已含);#72实盘名自动生成={实例名}_v{YYYYMMDD}{minor}(同实例同日递增,listLives计数);模拟盘名={实例名}·{模式};#73术语统一档案→实例(全局8文件UI文案+后端409提示,精确短语防误伤,spec比喻保留文档);#74MonacoDiff v4:优先monaco原生diff(差异高亮),挂载450ms自检original栏占比<30%自动降级双只读编辑器(滚动联动)——全屏模态下大概率吃到原生diff;#75收益标签与数值同源(retInfoOf:运行ret优先含kind,fallback run_returns,标签跟随数值来源,根治影子1044%实为回测收益挂影子标签);927绿+build绿+dev浏览器自测:策略库6入口(回测历史/模拟历史/实盘历史/回测灯/实走灯直达监控/组合无回放)与模拟盘列表新列全过 [vps]
2026-08-16 09:34:39 +08:00

335 lines
13 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():
"""实例列表 + 读时聚合:持续运行覆盖四格的实走/实盘格 + 在跑账户 + 漂移标记。
注意必须注册在 /strategy/instances/{inst_id} 之前(否则 enriched 被当 inst_id 解析)。
"""
rt = _instance_runtime()
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"],
})
return {"instances": 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())}