96b1924fd5
[live] 实盘模拟 vnpy+miniQMT 直连(supervisor 轮询, 前后端): - sanguo_live: LiveTradingEngine + AShareCtaTemplate(定寸/禁做空) + runner_supervisor(DB驱动) + persistence(4表WAL) - sanguo_api/routes_live: 9路由(create/start/stop/positions/trades/account/status) - frontend live: New/List/Monitor + api/live.ts; config/live.yaml [portfolio] 组合回测 MVP(BulletTrade, 链路代码完成待验证): - runner_backtest 加 JSON 入口(--json, BacktestEngine 顶层 import) - sanguo_api/routes_portfolio: POST /portfolio/backtest SSH 触发 VPS 跑 - frontend PortfolioBacktest.vue + api/portfolio.ts: 表单+结果+净值曲线 - 路由/菜单注册(/backtest/portfolio 组合回测) - 已知: MVP 链路未端到端验证, agent 改至中途被停; 待 Mac 起服务联调
179 lines
6.3 KiB
Python
179 lines
6.3 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 = ""
|
|
|
|
|
|
@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()
|
|
# 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", ""),
|
|
}
|