Files
sanguo_vnpy_v2/sanguo_api/routes_portfolio.py
T
claude_dev c4042a50d1 fix(portfolio): routes_portfolio 本地/SSH 自动检测(VPS后端405修复)
VPS 后端(schtasks sanguo-api → run_web.py)独立部署,之前没同步 routes_portfolio
致前端调 /api/v1/portfolio/backtest 报 405 Method Not Allowed。
- routes_portfolio 加本地/SSH 自动检测:os.path.isdir(_VPS_WORKDIR) 判定
  (VPS 有 C:\sanguo_vnpy_v2 → 本地直接 subprocess 跑 runner;Mac 无 → SSH 到 VPS)
  避免 VPS 后端 SSH 自连外网 IP 绕路,无需 env 配置同源两端自适应
- VPS 端同步 routes_portfolio.py + app.py 注册 portfolio_router + 重启 schtasks
- 验证:POST /api/v1/portfolio/backtest 从 405 → 401(路由注册成功,verify_token 生效)
2026-07-19 08:18:53 +08:00

138 lines
5.5 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 os
import subprocess
import sys
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 后端直接跑 runner,避免 SSH 自连外网 IP 绕路);
# SSH 模式(Mac 后端 → VPS)。env SANGUO_PORTFOLIO_LOCAL=1 切本地。
# 自动检测:VPS 上 _VPS_WORKDIR(C:\sanguo_vnpy_v2) 存在 → 本地直接跑 runner;
# Mac 上无该目录 → SSH 到 VPS。无需 env 配置,同源代码两端自适应。
if os.path.isdir(_VPS_WORKDIR):
argv = [
sys.executable, "-X", "utf8", "-m", "sanguo_portfolio.runner_backtest",
"--json", "--start", req.start_date, "--end", req.end_date,
"--cash", str(req.initial_cash), "--benchmark", req.benchmark, "--max-pool", "30",
]
logger.info("[portfolio] 本地跑 runner: %s", " ".join(argv[3:]))
try:
proc = subprocess.run(
argv, cwd=_VPS_WORKDIR, capture_output=True, text=True,
timeout=_VPS_TIMEOUT, check=False,
)
except subprocess.TimeoutExpired:
raise HTTPException(504, f"回测超时(>{_VPS_TIMEOUT}s)")
except Exception as exc:
logger.exception("[portfolio] 本地 runner 调用失败")
raise HTTPException(500, f"本地 runner 调用失败: {exc}")
else:
# Mac 端 SSH 到 VPS(远端命令手动拼,不用 shlex.quote:产 POSIX 单引号 cmd 不认)
remote_cmd = (
f"cd {_VPS_WORKDIR} && "
f"{_VPS_PYTHON} -X utf8 -m sanguo_portfolio.runner_backtest --json "
f"--start {req.start_date} --end {req.end_date} "
f"--cash {req.initial_cash} --benchmark {req.benchmark} "
f"--max-pool 30"
)
ssh_argv = [
"ssh", "-o", "ConnectTimeout=15", "-o", "StrictHostKeyChecking=no",
_VPS_HOST, remote_cmd,
]
logger.info("[portfolio] SSH 触发: %s", remote_cmd)
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