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 起服务联调
127 lines
4.6 KiB
Python
127 lines
4.6 KiB
Python
"""组合策略 API 路由(MVP)。
|
|
|
|
POST /portfolio/backtest: SSH 触发 VPS 跑 BulletTrade + all_weather,
|
|
捕获 stdout JSON 返回前端。同步模式(回测耗时,前端 loading,timeout 600s)。
|
|
|
|
风格参考 routes_paper.py / routes_live.py。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import shlex
|
|
import subprocess
|
|
from typing import Any, Optional
|
|
|
|
from fastapi import APIRouter, Depends, Header, HTTPException
|
|
from pydantic import BaseModel, Field
|
|
|
|
from .auth import verify_token as verify_token_impl
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter()
|
|
|
|
# VPS SSH 连接配置(~/.ssh/config 已配 49.232.102.198 免密 key)
|
|
_VPS_HOST = "49.232.102.198"
|
|
_VPS_WORKDIR = r"C:\\sanguo_vnpy_v2"
|
|
_VPS_PYTHON = "python"
|
|
_VPS_TIMEOUT = 600 # 回测耗时,给 10 分钟
|
|
|
|
|
|
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 PortfolioBacktestRequest(BaseModel):
|
|
pool: str = Field(default="hs300_subset", description="标的池(占位,MVP 用默认)")
|
|
start_date: str = Field(default="2024-01-01", description="YYYY-MM-DD")
|
|
end_date: str = Field(default="2024-02-29", description="YYYY-MM-DD")
|
|
initial_cash: float = Field(default=1_000_000.0, description="初始资金(元)")
|
|
benchmark: str = Field(default="000300.XSHG", description="基准代码")
|
|
|
|
|
|
@router.post("/portfolio/backtest", dependencies=[Depends(verify_token)])
|
|
def run_portfolio_backtest(req: PortfolioBacktestRequest):
|
|
"""SSH 触发 VPS 跑 BulletTrade + all_weather 回测,同步返回 JSON 结果。
|
|
|
|
Windows SSH 坑:
|
|
- GBK 编码:python -X utf8 避免中文 print 编码崩
|
|
- 引号嵌套:用 list 形式 argv,避免 shell 引号 escape 噩梦
|
|
- 没有 tail/head/grep:用 python 后处理(本函数在 Mac 端直接解析 stdout)
|
|
- 中文路径:VPS_WORKDIR / userdata_mini 走 env(DEFAULT_DATA_PROVIDER=miniqmt)
|
|
"""
|
|
# 在 VPS 上跑的命令:cd workdir && set ENV && python -m sanguo_portfolio.runner_backtest --json
|
|
# Windows cmd: set X=Y&&cmd2 (注意 & 必须紧贴前一条,不能有空格,否则 set 会把尾部空格算进 value)
|
|
cmd_parts = [
|
|
"set", "DEFAULT_DATA_PROVIDER=miniqmt", "&&",
|
|
"cd", _VPS_WORKDIR, "&&",
|
|
_VPS_PYTHON, "-X", "utf8", "-m", "sanguo_portfolio.runner_backtest",
|
|
"--json",
|
|
"--start", req.start_date,
|
|
"--end", req.end_date,
|
|
"--cash", str(req.initial_cash),
|
|
"--benchmark", req.benchmark,
|
|
]
|
|
# 用 ssh host "cmd string" 形式;argv 在 ssh 远端走 cmd /c 解析
|
|
ssh_argv = [
|
|
"ssh",
|
|
"-o", "ConnectTimeout=15",
|
|
"-o", "StrictHostKeyChecking=no",
|
|
_VPS_HOST,
|
|
" ".join(shlex.quote(p) if p != "&&" else "&&" for p in cmd_parts),
|
|
]
|
|
|
|
logger.info("[portfolio] SSH 触发: %s", ssh_argv[-1])
|
|
try:
|
|
proc = subprocess.run(
|
|
ssh_argv,
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=_VPS_TIMEOUT,
|
|
check=False,
|
|
)
|
|
except subprocess.TimeoutExpired:
|
|
raise HTTPException(504, f"VPS 回测超时(>{_VPS_TIMEOUT}s)")
|
|
except FileNotFoundError:
|
|
raise HTTPException(500, "本机未找到 ssh 命令")
|
|
except Exception as exc:
|
|
logger.exception("[portfolio] SSH 调用失败")
|
|
raise HTTPException(500, f"SSH 调用失败: {exc}")
|
|
|
|
if proc.returncode != 0:
|
|
stderr_tail = (proc.stderr or "")[-2000:]
|
|
logger.error("[portfolio] VPS 回测失败 rc=%s stderr=%s", proc.returncode, stderr_tail)
|
|
raise HTTPException(
|
|
500,
|
|
f"VPS 回测失败(rc={proc.returncode}): {stderr_tail}",
|
|
)
|
|
|
|
# 从 stdout 提取最后一行 JSON(runner --json 只 print 一行)
|
|
stdout = proc.stdout or ""
|
|
import json as _json
|
|
result: Optional[dict[str, Any]] = None
|
|
parse_err: Optional[str] = None
|
|
for line in reversed(stdout.strip().splitlines()):
|
|
line = line.strip()
|
|
if not line.startswith("{"):
|
|
continue
|
|
try:
|
|
result = _json.loads(line)
|
|
break
|
|
except _json.JSONDecodeError as exc:
|
|
parse_err = str(exc)
|
|
continue
|
|
|
|
if result is None:
|
|
logger.error(
|
|
"[portfolio] stdout 无 JSON 行。parse_err=%s stdout_tail=%s",
|
|
parse_err, stdout[-2000:],
|
|
)
|
|
raise HTTPException(
|
|
500,
|
|
f"VPS stdout 解析失败: {parse_err or 'no JSON line'}; stdout_tail={stdout[-500:]!r}",
|
|
)
|
|
|
|
return result
|