Files
sanguo_vnpy_v2/sanguo_api/routes_live.py
T

259 lines
9.4 KiB
Python

"""实盘模拟 API 路由(spec §live-api)。
create 建 live_account(持久化配置,status=stopped);start/stop 改 status 字段;
GET 查询持仓/成交/账户/状态。runner(supervisor) 是独立常驻进程,轮询 status 字段
决定起停 LiveTradingEngine;两者只通过 DB 通信,本模块不实例化 engine。
风格参考 ``sanguo_api/routes_paper.py``。
"""
from __future__ import annotations
import os
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel
from .auth import verify_token as verify_token_impl
router = APIRouter()
_db_path = {"path": None}
# miniQMT 默认 userdata_mini 路径(国金QMT交易端模拟);
# req.mini_path 空 → env SANGUO_QMT_PATH → 此默认(双保险,避免 connect=-1)
_DEFAULT_MINI_PATH = r"C:\国金QMT交易端模拟\userdata_mini"
def set_db_path(p):
_db_path["path"] = p
if p:
from sanguo_live.persistence import init_db
init_db(p) # app 启动建表(幂等)
async def verify_token(authorization: str | None = Header(None)):
if authorization is None or not authorization.startswith("Bearer "):
raise HTTPException(401, "Missing/invalid authorization")
return verify_token_impl(authorization.split(" ", 1)[1])
class LiveCreateRequest(BaseModel):
name: str = "live"
account: str
vt_symbol: str = "600000.SSE"
strategy_class: str = "AShareDoubleMaStrategy"
strategy_name: str
setting: dict = {}
interval: str = "15m"
initial_capital: float = 1_000_000
connect_wait_sec: int = 10
init_wait_sec: int = 60
mini_path: str = ""
# 组合实盘(strategy_type='portfolio'):strategy_class 存组合策略名(all_weather 等)
strategy_type: str = "cta"
pool: str = ""
max_pool: int = 0
benchmark: str = ""
@router.post("/live/create", dependencies=[Depends(verify_token)])
def create_live(req: LiveCreateRequest):
"""创建实盘实例(写 live_accounts,status=stopped)。需调 start 才会启动。"""
from sanguo_live.persistence import init_db, save_account
db = _db_path["path"] or ":memory:"
init_db(db)
payload = req.model_dump()
if payload.get("strategy_type") == "portfolio":
# 组合实盘:vt_symbol 占位为池名;setting 存组合参数(supervisor 转发 env)
if not payload.get("strategy_class"):
raise HTTPException(400, "组合实盘需选择策略(strategy_class)")
payload.setdefault("pool", "hs300_subset")
payload.setdefault("max_pool", 30)
payload.setdefault("benchmark", "000300.XSHG")
payload["vt_symbol"] = payload["pool"]
payload["interval"] = "d"
# mini_path 兜底:req → env SANGUO_QMT_PATH → 内置默认(空值会导致 connect=-1)
if not payload.get("mini_path"):
payload["mini_path"] = (
os.environ.get("SANGUO_QMT_PATH") or _DEFAULT_MINI_PATH
)
aid = save_account(db, {**payload, "status": "stopped"})
return {"account_id": aid, "status": "stopped"}
@router.get("/live", dependencies=[Depends(verify_token)])
def list_lives():
"""实盘实例列表。每行带最新账户快照摘要(total/收益率)。
收益率用首快照基线:(last_total - first_total) / first_total,
避免用 initial_capital 兜底导致入金/出金瞬间收益率失真。
无快照时 total_return=None(不兜底 initial_capital)。
"""
from sanguo_live.persistence import (
list_accounts, get_last_balance, get_first_balance, load_positions,
)
db = _db_path["path"]
if not db:
return {"accounts": []}
items = list_accounts(db)
for item in items:
last = get_last_balance(db, item["id"])
first = get_first_balance(db, item["id"])
if last:
item["latest_equity"] = last.get("total")
item["latest_date"] = last.get("date")
else:
item["latest_equity"] = None
item["latest_date"] = None
# 收益率:首快照 total 为 baseline;last/first 同条时为 0
baseline = (first or {}).get("total") if first else None
if last and baseline:
item["total_return"] = (last.get("total", 0) - baseline) / baseline
else:
item["total_return"] = None
item["position_count"] = len(load_positions(db, item["id"]))
return {"accounts": items}
@router.get("/live/{aid}", dependencies=[Depends(verify_token)])
def get_live(aid: int):
from sanguo_live.persistence import get_account
acc = get_account(_db_path["path"], aid)
if not acc:
raise HTTPException(404, "account not found")
return acc
@router.post("/live/{aid}/start", dependencies=[Depends(verify_token)])
def start_live(aid: int):
"""启动实例(status=running)。supervisor 轮询发现后起 engine。"""
from sanguo_live.persistence import get_account, update_account_status
acc = get_account(_db_path["path"], aid)
if not acc:
raise HTTPException(404, "account not found")
if not acc["account"]:
raise HTTPException(400, "account 字段(交易账号)不能为空")
update_account_status(_db_path["path"], aid, "running")
return {"account_id": aid, "status": "running"}
@router.post("/live/{aid}/stop", dependencies=[Depends(verify_token)])
def stop_live(aid: int):
"""停止实例(status=stopped)。supervisor 轮询发现后停 engine。"""
from sanguo_live.persistence import get_account, update_account_status
if not get_account(_db_path["path"], aid):
raise HTTPException(404, "account not found")
update_account_status(_db_path["path"], aid, "stopped")
return {"account_id": aid, "status": "stopped"}
@router.get("/live/{aid}/positions", dependencies=[Depends(verify_token)])
def get_positions(aid: int):
"""持仓快照(读 live_positions,supervisor 定时落库)。"""
from sanguo_live.persistence import load_positions
return load_positions(_db_path["path"], aid)
@router.get("/live/{aid}/trades", dependencies=[Depends(verify_token)])
def get_trades(aid: int):
"""成交明细(读 live_trades,supervisor 事件回调落库)。"""
from sanguo_live.persistence import list_trades
return list_trades(_db_path["path"], aid)
@router.get("/live/{aid}/account", dependencies=[Depends(verify_token)])
def get_account_balance(aid: int):
"""账户最新快照(读 live_balance 最新一条)。"""
from sanguo_live.persistence import get_last_balance
last = get_last_balance(_db_path["path"], aid)
return last or {}
@router.get("/live/{aid}/status", dependencies=[Depends(verify_token)])
def get_status(aid: int):
"""运行状态(读 live_accounts.status)。"""
from sanguo_live.persistence import get_account
acc = get_account(_db_path["path"], aid)
if not acc:
raise HTTPException(404, "account not found")
return {
"account_id": aid, "status": acc["status"], "name": acc["name"],
"account": acc["account"], "vt_symbol": acc["vt_symbol"],
"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}