feat(paper/live): 生命周期管理补全: paper停止/恢复(实走20:30 step跳过stopped)/删除(连带净值成交持仓挂单)/编辑(名称标的资金); live删除(运行中拒绝)/编辑(stopped才可改); 前端列表+结果页按钮/编辑弹窗/删除确认 [vps]
CI/CD / test (push) Successful in 10s
CI/CD / nas-deploy (push) Failing after 11s
CI/CD / nas-verify (push) Has been skipped

This commit is contained in:
2026-08-13 18:27:32 +08:00
parent d8c156e6de
commit ccf958c5d6
8 changed files with 488 additions and 8 deletions
+66
View File
@@ -176,3 +176,69 @@ def get_status(aid: int):
"strategy_name": acc["strategy_name"], "updated_at": acc["updated_at"],
"error_msg": acc.get("error_msg", ""),
}
# ===== 生命周期管理补全:删除/编辑(停止/启动已有)=====
class LiveUpdateRequest(BaseModel):
"""可编辑字段。仅 stopped 状态可改(运行中改配置会与 engine 失配)。"""
name: str | None = None
account: str | None = None
vt_symbol: str | None = None
strategy_class: str | None = None
strategy_name: str | None = None
setting: dict | None = None
interval: str | None = None
@router.put("/live/{aid}", dependencies=[Depends(verify_token)])
def update_live(aid: int, req: LiveUpdateRequest):
import json
import sqlite3
from sanguo_live.persistence import get_account
db = _db_path["path"]
acc = get_account(db, aid)
if not acc:
raise HTTPException(404, "account not found")
if acc["status"] == "running":
raise HTTPException(400, "运行中不可编辑,请先停止实例")
sets, args = [], []
field_map = {
"name": req.name, "account": req.account, "vt_symbol": req.vt_symbol,
"strategy_class": req.strategy_class, "strategy_name": req.strategy_name,
"interval": req.interval,
}
for col, v in field_map.items():
if v is not None:
sets.append(f"{col}=?"); args.append(v)
if req.setting is not None:
sets.append("setting=?"); args.append(json.dumps(req.setting))
if not sets:
return {"account_id": aid, "updated": False}
with sqlite3.connect(db) as conn:
conn.execute(f"UPDATE live_accounts SET {', '.join(sets)} WHERE id=?", (*args, aid))
conn.commit()
return {"account_id": aid, "updated": True}
@router.delete("/live/{aid}", dependencies=[Depends(verify_token)])
def delete_live(aid: int):
"""删除实盘实例及其数据。运行中拒绝删除(先 stop)。"""
import sqlite3
from sanguo_live.persistence import get_account
db = _db_path["path"]
acc = get_account(db, aid)
if not acc:
raise HTTPException(404, "account not found")
if acc["status"] == "running":
raise HTTPException(400, "运行中不可删除,请先停止实例")
with sqlite3.connect(db) as conn:
conn.execute("DELETE FROM live_accounts WHERE id=?", (aid,))
for t in ("live_trades", "live_positions", "live_balance"):
conn.execute(f"DELETE FROM {t} WHERE account_id=?", (aid,))
conn.commit()
return {"account_id": aid, "deleted": True}
+85
View File
@@ -4,6 +4,7 @@ create 建 paper_account(持久化配置);GET 查询净值/成交/状态
回放执行engine.run orchestrator 异步触发或容器内同步跑端到端冒烟在容器
本机无 NAS parquet + vnpy 完整依赖本模块只做 account 管理 + 查询
"""
import json
import sqlite3
from fastapi import APIRouter, Depends, Header, HTTPException
@@ -164,6 +165,90 @@ def get_pending(aid: int):
return load_pending_orders(_db_path["path"], aid)
# ===== 生命周期管理(spec §10 补全:停止/恢复/删除/编辑)=====
def _get_account_row(db, aid: int) -> dict:
with sqlite3.connect(db) as conn:
conn.row_factory = sqlite3.Row
row = conn.execute("SELECT * FROM paper_accounts WHERE id=?", (aid,)).fetchone()
if not row:
raise HTTPException(404, "account not found")
return dict(row)
@router.post("/paper/{aid}/stop", dependencies=[Depends(verify_token)])
def stop_paper(aid: int):
"""停止实走:置 status=stopped,每日 20:30 全局 step 只选 running → 自动跳过。"""
from sanguo_trader.persistence import update_account_status
db = _db_path["path"]
acc = _get_account_row(db, aid)
if acc.get("mode") != "live":
raise HTTPException(400, "仅实走(live)账户支持停止;回放账户为一次性任务")
update_account_status(db, aid, "stopped")
return {"account_id": aid, "status": "stopped"}
@router.post("/paper/{aid}/resume", dependencies=[Depends(verify_token)])
def resume_paper(aid: int):
"""恢复实走:次日 20:30 起继续 step。"""
from sanguo_trader.persistence import update_account_status
db = _db_path["path"]
acc = _get_account_row(db, aid)
if acc.get("mode") != "live":
raise HTTPException(400, "仅实走(live)账户支持恢复")
update_account_status(db, aid, "running")
return {"account_id": aid, "status": "running"}
@router.delete("/paper/{aid}", dependencies=[Depends(verify_token)])
def delete_paper(aid: int):
"""删除模拟盘账户及其全部数据(净值/成交/持仓/挂单,不可恢复)。"""
db = _db_path["path"]
_get_account_row(db, aid)
tables = ("paper_accounts", "paper_daily_balance", "paper_trades",
"paper_positions", "paper_pending_orders", "paper_shadow_orders")
with sqlite3.connect(db) as conn:
for t in tables:
if t == "paper_accounts":
conn.execute("DELETE FROM paper_accounts WHERE id=?", (aid,))
else:
conn.execute(f"DELETE FROM {t} WHERE account_id=?", (aid,))
conn.commit()
return {"account_id": aid, "deleted": True}
class PaperUpdateRequest(BaseModel):
"""可编辑字段(其余字段沿用原值;策略参数/标的改动自下次 step 生效)。"""
name: str | None = None
symbols: list[str] | None = None
strategies: list[StrategyCfg] | None = None
initial_capital: float | None = None
@router.put("/paper/{aid}", dependencies=[Depends(verify_token)])
def update_paper(aid: int, req: PaperUpdateRequest):
db = _db_path["path"]
_get_account_row(db, aid)
sets, args = [], []
if req.name is not None:
sets.append("name=?"); args.append(req.name)
if req.symbols is not None:
sets.append("symbols=?"); args.append(json.dumps(req.symbols))
if req.strategies is not None:
sets.append("strategies=?")
args.append(json.dumps([s.model_dump() for s in req.strategies]))
if req.initial_capital is not None:
sets.append("initial_capital=?"); args.append(req.initial_capital)
if not sets:
return {"account_id": aid, "updated": False}
with sqlite3.connect(db) as conn:
conn.execute(f"UPDATE paper_accounts SET {', '.join(sets)} WHERE id=?", (*args, aid))
conn.commit()
return {"account_id": aid, "updated": True}
class _DataSourceWrapper:
"""包装 iter_bars/fetch_day 给 PaperEngine/live_orchestrator。"""