docs(phase3b): Phase 3b Vue 前端实施计划(S0-S3,含 result_id bug 修复)

This commit is contained in:
2026-07-07 00:33:28 +08:00
parent f0f08d32c3
commit 759acf2f6c
@@ -0,0 +1,405 @@
# Phase 3b 投研+回测 Web 控制台 实施计划
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 建一个 Vue 3 前端控制台(投研+回测),对齐 vnpy client 回测模块,从公网 `vnpy.mysanguo.top` 可用。
**Architecture:** Vue 3 SPAVite 构建)→ FastAPI `sanguo_api`(容器:8000,从旧 `sanguo_web` 切过来)静态挂 `/` + 研究 API 在 `/api/v1/*`;后端调 `sanguo_backtest`/`sanguo_factor` 引擎。4 切片 S0→S1→S2→S3,每片可独立演示+测试。
**Tech Stack:** Vue3 + Vite + TypeScript + Element Plus + ECharts + Pinia + Vue Router + Axios;后端 FastAPI + pytest(现有);容器 Python 3.10。
## Global Constraints
- **端口/反代红线**:容器 8000 不变;不碰 `vnpy.mysanguo.top` 的 frpc/socat/Caddy。只改容器内 uvicorn 目标 + 静态挂载。
- **vnpy 零修改**:不改 `vnpy_v4.4.0/`;策略/参数从类属性读。
- **配置源**`config/backtest.yaml``backtest.db_path`/`file_dir``auth.username/password_hash/jwt_secret/token_expire_minutes``api.port`)。
- **NAS 访问**`ssh sanguo-nas`key 免密);docker 全路径 `/var/packages/Docker/target/usr/bin/docker`rsync/scp 不稳时用 `ssh sanguo-nas "cat > /path" < local`
- **默认登录**admin / admin`backtest.yaml` 的 password_hash;部署后改)。
- **A 股 DB**`/volume1/stock/sanguo_vnpy/data/quant_trading.db`K线,via `read_db_daily`bare symbol 如 `600000`)。
- **JSON 安全**:所有新接口返回值 Timestamp→str、DataFrame→list[dict]。
- **TDD**:后端每个新接口先写 pytest 失败测试;前端关键逻辑(auth store/api client)用 Vitest。
---
## File Structure
**前端(新建 `frontend/`):**
```
frontend/
├── package.json, vite.config.ts, tsconfig.json, index.html
├── src/
│ ├── main.ts, App.vue
│ ├── router/index.ts # 路由 + 登录守卫
│ ├── stores/auth.ts # Pinia: token/user
│ ├── api/client.ts # axios 实例 + JWT 拦截器 + 401 处理
│ ├── api/backtest.ts, api/factor.ts, api/strategy.ts
│ ├── composables/useTask.ts # 任务状态轮询 + WS
│ ├── views/Login.vue, Layout.vue # Layout = 4 入口侧栏 shell
│ ├── views/backtest/{New,Progress,Result,Optimize,History}.vue
│ ├── views/factor/{New,Result}.vue
│ └── components/charts/{EquityChart,DailyPnlChart,KlineChart}.vue
│ components/TradesTable.vue
└── tests/{auth.test.ts,client.test.ts} # vitest
```
**后端(修改/新建):**
```
sanguo_api/main.py # 新:读 backtest.yaml → create_app → 暴露 appuvicorn 目标)
sanguo_api/app.py # 改:create_app 加 static_dir 参数,挂 SPA + history fallback
sanguo_api/routes.py # 改:加 strategy/factor/kline/equity/daily-pnl/trades/ic-summary/report/opt-results/task-list
sanguo_api/schemas.py # 改(S3):CtaBacktestRequest 加 rate/slippage/capital
sanguo_api/strategy_registry.py # 新:枚举 vnpy_ctastrategy 策略 + 参数
sanguo_api/kline.py # 新:read_db_daily → K线 list[dict]
sanguo_backtest/result_store.py # 改:BacktestResult 加 idsave_result 设 result.id
sanguo_backtest/cta_engine.py # 改:构建 equity_curve/trades DataFramesave 传 file_dir
sanguo_orchestrator/runner.py # 改:_on_done 用 result.id(修 bug
docker/entrypoint.sh # 改:uvicorn sanguo_web.api:app → sanguo_api.main:app
scripts/smoke_phase3b.py # 新:端到端冒烟(登录→回测→进度→结果接口齐)
tests/api/test_*.py # 新接口单测
```
---
# 切片 S0:脚手架 + 切 app + 登录
## Task S0.1sanguo_api/main.py —— 容器 app 入口
**Files:**
- Create: `sanguo_api/main.py`
- Test: `tests/api/test_main.py`
**Interfaces:**
- Produces: `sanguo_api.main:app`(模块级 FastAPI,供 uvicorn),`sanguo_api.main.build_app(config_path: str, static_dir: str | None) -> FastAPI`
- [ ] **Step 1: 写失败测试**
```python
# tests/api/test_main.py
from sanguo_api.main import build_app
def _cfg(tmp_path):
cfg = tmp_path / "bt.yaml"
cfg.write_text(
"backtest:\n max_workers: 1\n db_path: %s\n file_dir: %s\n"
"api:\n host: 0.0.0.0\n port: 8000\n"
"auth:\n username: admin\n password_hash: x\n jwt_secret: s\n token_expire_minutes: 60\n"
"pool:\n max_workers: 1\n" % (tmp_path / "r.db", tmp_path / "f")
)
return str(cfg)
def test_build_app_has_api_routes(tmp_path):
app = build_app(_cfg(tmp_path))
paths = [getattr(r, "path", "") for r in app.routes]
assert "/api/v1/auth/login" in paths
def test_build_app_mounts_spa(tmp_path):
spa = tmp_path / "spa"; spa.mkdir(); (spa / "index.html").write_text("<h1>SPA</h1>")
app = build_app(_cfg(tmp_path), static_dir=str(spa))
assert any(getattr(r, "path", "") == "/" for r in app.routes)
```
- [ ] **Step 2: 运行验证失败**`pytest tests/api/test_main.py -v` → FAILmodule not found
- [ ] **Step 3: 实现 main.py**
```python
# sanguo_api/main.py
"""容器 uvicorn 入口:读 config/backtest.yaml 构建 app,暴露模块级 `app`。"""
from __future__ import annotations
import os
from pathlib import Path
import yaml
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from starlette.responses import FileResponse
from .app import create_app as _create_app
def _load_config(config_path: str) -> dict:
p = Path(config_path)
if not p.exists():
return {}
with open(p, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
def build_app(config_path: str = "config/backtest.yaml", static_dir: str | None = None) -> FastAPI:
cfg = _load_config(config_path)
bt = cfg.get("backtest", {})
auth = cfg.get("auth", {})
pool = cfg.get("pool", {})
db_path = bt.get("db_path", "/tmp/backtest_results.db")
file_dir = bt.get("file_dir", "/tmp/backtest_files")
auth_config = {
"username": auth.get("username", "admin"),
"password_hash": auth.get("password_hash", ""),
"jwt_secret": auth.get("jwt_secret", "change-me"),
"expire_minutes": auth.get("token_expire_minutes", 60),
} if auth else None
max_workers = pool.get("max_workers", bt.get("max_workers", 2))
app = _create_app(db_path=db_path, file_dir=file_dir, auth_config=auth_config, max_workers=max_workers)
# SPA 静态挂载(history fallback)。根路由先注册,再 mount 兜底。
if static_dir and os.path.isdir(static_dir):
index_html = os.path.join(static_dir, "index.html")
@app.get("/", include_in_schema=False)
async def _spa_root():
return FileResponse(index_html)
app.mount("/", StaticFiles(directory=static_dir, html=True), name="spa")
return app
_REPO = Path(__file__).resolve().parent.parent
app = build_app(
str(_REPO / "config" / "backtest.yaml"),
static_dir=os.environ.get("SPA_STATIC_DIR", str(_REPO / "frontend" / "dist")),
)
```
- [ ] **Step 4: 运行验证通过**`pytest tests/api/test_main.py -v` → PASS
- [ ] **Step 5: 提交**`git add sanguo_api/main.py tests/api/test_main.py && git commit -m "feat(api): sanguo_api.main 容器入口(读 backtest.yaml + 挂 SPA"`
---
## Task S0.2:切 docker/entrypoint.sh uvicorn 目标
**Files:** Modify: `docker/entrypoint.sh:282-283`
- [ ] **Step 1: 改目标**`sanguo_web.api:app``sanguo_api.main:app`(两行 log_info + exec
- [ ] **Step 2: 本地冒烟**`python -c "from sanguo_api.main import app; print([getattr(r,'path','') for r in app.routes][:5])"``/api/v1/auth/login`
- [ ] **Step 3: 提交**`git commit -am "chore(deploy): 容器 uvicorn 切到 sanguo_api.main:app"`
---
## Task S0.3:前端脚手架
**Files:** Create: `frontend/{package.json,vite.config.ts,tsconfig.json,index.html,src/main.ts,src/App.vue,src/env.d.ts}`
- [ ] **Step 1: 初始化**`npm create vite@latest frontend -- --template vue-ts && cd frontend && npm install && npm install element-plus echarts pinia vue-router axios && npm install -D vitest @vue/test-utils jsdom @types/node`
- [ ] **Step 2: vite.config.ts**alias @→srcdev proxy /api、/ws → 192.168.2.154:8000build outDir=distvitest jsdom
- [ ] **Step 3: main.ts** 挂 Pinia + Router + ElementPlusApp.vue = `<router-view/>`
- [ ] **Step 4: `npm run build`**`dist/index.html` 生成
- [ ] **Step 5: `.gitignore`**`frontend/node_modules/``frontend/dist/`
- [ ] **Step 6: 提交**`git add frontend/ .gitignore && git commit -m "feat(frontend): Vue3+Vite+TS 脚手架"`
---
## Task S0.4:前端 authclient + store + 登录页 + 守卫)
**Files:** Create: `src/api/client.ts`, `src/stores/auth.ts`, `src/views/Login.vue`, `src/router/index.ts`, `tests/auth.test.ts`
- [ ] **Step 1: 写失败测试**auth store setToken/logout/isAuthenticated,见 plan 源)
- [ ] **Step 2: 验证失败**`npx vitest run tests/auth.test.ts` FAIL
- [ ] **Step 3: stores/auth.ts**Piniatoken/username 持久化 localStorageisAuthenticated gettersetToken/logout actions
- [ ] **Step 4: api/client.ts**axios baseURL=/api/v1;请求拦截附 Bearer;响应 401→logout+跳登录)
- [ ] **Step 5: router/index.ts**(路由表 + beforeEach 未登录跳 /login
- [ ] **Step 6: Login.vue**(用户名+密码 → POST /auth/login → setToken → push '/';失败 ElMessage
- [ ] **Step 7: 验证通过**`npx vitest run` PASS
- [ ] **Step 8: 提交**`git commit -am "feat(frontend): authJWT store + axios 拦截器 + 登录页 + 路由守卫)"`
---
## Task S0.5Layout shell4 入口侧栏)
**Files:** Create: `src/views/Layout.vue`
- [ ] **Step 1: Layout.vue** — el-container + el-menu 侧栏 4 项(回测/投研 active;模拟/实盘 disabled"敬请期待");顶栏 用户名+登出
- [ ] **Step 2: `npm run build`** 验证
- [ ] **Step 3: 提交**`git commit -am "feat(frontend): Layout shell4 入口侧栏,模拟/实盘灰显)"`
---
## Task S0.6:部署 S0 + 公网验证
- [ ] **Step 1: 本机 `cd frontend && npm run build`**
- [ ] **Step 2: 同步 NAS**rsync frontend/ + ssh-exec 传 main.py/entrypoint.sh
- [ ] **Step 3: `ssh sanguo-nas "$DOCKER restart sanguo_vnpy_v2"`**
- [ ] **Step 4: 公网验证**`curl https://vnpy.mysanguo.top/` 返回 SPA index`POST /api/v1/auth/login` admin/admin 返回 token
- [ ] **Step 5: 浏览器验收** — 登录 → 空壳控制台(侧栏 4 入口)
---
# 切片 S1:回测核心(对齐 vnpy client 回测模块)
## Task S1.1:修 result_id bug(阻塞所有结果查看)
**Files:** Modify: `result_store.py`(加 id)、`runner.py:_on_done`(用 result.id)、`cta_engine.py`save 传 file_dir);Test: `tests/backtest/test_result_store.py`
**Interfaces — Produces:** `BacktestResult.id: int | None``save_result``result.id`=DB 行 id`get_result` 正确 load_result(result.id)equity_curve 经 parquet 落盘读回
- [ ] **Step 1: 写失败测试**save→设 r.id→load_result(r.id) 读回 statistics+equity_curve
- [ ] **Step 2: 验证失败** → FAIL
- [ ] **Step 3: result_store.py** — dataclass 加 `id: Optional[int] = None``save_result` commit 后 `result.id = cur.lastrowid`
- [ ] **Step 4: runner._on_done**`task.complete(result_id=result.id)`
- [ ] **Step 5: cta_engine** — save_result 传 file_dircfg 或 `os.path.dirname(db_path)` 兜底)
- [ ] **Step 6: 验证通过** → PASS
- [ ] **Step 7: 提交**`git commit -am "fix(backtest): result_id 用 DB 行 idequity_curve 落盘读回(修 get_result bug"`
---
## Task S1.2cta_engine 构建 equity_curve + trades DataFrame
**Files:** Modify: `cta_engine.py`
**Interfaces — Produces:** `equity_curve`=DataFrame[date,balance]engine.get_all_daily_results);`trades`=DataFrame[datetime,direction,offset,price,volume,vt_symbol]engine.trades
- [ ] **Step 1: calculate_statistics 后构建 DataFrame**try/except 兜底版本差异)
- [ ] **Step 2: result 用 equity_curve=equity_df, trades=trades_df(替换原 daily_results/None**
- [ ] **Step 3: 容器 diag_cta.py 验证** result.equity_curve/trades 非空 + result.id 有值
- [ ] **Step 4: 提交**`git commit -am "feat(backtest): cta_engine 构建 equity_curve/trades 并落盘"`
---
## Task S1.3strategy_registry —— 枚举 vnpy_ctastrategy 策略
**Files:** Create: `sanguo_api/strategy_registry.py`Test: `tests/api/test_strategy_registry.py`
**Interfaces — Produces:** `list_strategies()->list[{name,class_name}]``strategy_params(name)->{parameters,defaults}``get_strategy_class(name)->type|None`;常量 `STRATEGY_NAMES`
- [ ] **Step 1: 写失败测试**list shapeparams 含 parameters list
- [ ] **Step 2: 验证失败** → FAIL
- [ ] **Step 3: 实现**pkgutil 枚举 vnpy_ctastrategy.strategies;导入失败兜底 STRATEGY_NAMES=[DoubleMaStrategy,BollChannelStrategy,AtrRsiStrategy]
- [ ] **Step 4: 验证通过** → PASS
- [ ] **Step 5: 提交**`git commit -am "feat(api): strategy_registry 枚举策略与参数"`
---
## Task S1.4:回测后端接口(strategy + equity/pnl/trades
**Files:** Modify: `routes.py`Test: `tests/api/test_backtest_routes_switch.py`
**Interfaces:**
- Consumes: orchestrator.get_result(id).equity_curve/tradesstrategy_registry
- Produces: `GET /strategy/list``/strategy/{name}/params``/task/{id}/equity-curve``/task/{id}/daily-pnl``/task/{id}/trades`
- [ ] **Step 1: 写失败测试**FakeOrch.get_result 返回带 equity 的 BacktestResult;无 token 401;有 token 200 + records
- [ ] **Step 2: 验证失败** → FAIL
- [ ] **Step 3: 加路由** + `_df_to_records(df)` 工具(DataFrame→list[dict],空安全);daily-pnl 由 balance.diff 计算
- [ ] **Step 4: 验证通过** → PASS
- [ ] **Step 5: 提交**`git commit -am "feat(api): 回测结果接口(strategy + equity-curve/daily-pnl/trades"`
---
## Task S1.5K线接口 `/kline`
**Files:** Create: `sanguo_api/kline.py`Modify: `routes.py`Test: `tests/api/test_kline.py`
**Interfaces — Produces:** `load_kline(symbol,start,end,cfg=None)->list[{datetime,open,high,low,close,volume,vt_symbol}]``GET /kline?symbol&start&end`
- [ ] **Step 1: 写失败测试**mock read_db_daily 返回假 BarData → records
- [ ] **Step 2: 验证失败** → FAIL
- [ ] **Step 3: 实现 kline.load_kline**read_db_daily → list[dict]+ 路由
- [ ] **Step 4: 验证通过** → PASS
- [ ] **Step 5: 提交**`git commit -am "feat(api): K线接口 /kline"`
---
## Task S1.6:前端 回测-新建页
**Files:** Create: `src/api/{strategy,backtest}.ts`, `src/views/backtest/New.vue`
- [ ] **Step 1: api/strategy.ts**getStrategies/getParams);**api/backtest.ts**submitCta
- [ ] **Step 2: New.vue** — 策略 el-selectonchange 拉参数渲染动态 el-form+ symbol + el-date-picker 区间 + 提交 → push `/backtest/progress/:id`
- [ ] **Step 3: `npm run build`** 验证
- [ ] **Step 4: 提交**`git commit -am "feat(frontend): 回测-新建页"`
---
## Task S1.7:前端 回测-进度页 + useTask
**Files:** Create: `src/composables/useTask.ts`, `src/views/backtest/Progress.vue`
- [ ] **Step 1: useTask.ts** — 轮询 GET /task/{id}(2s) + WS /ws/task/{id}?token=;导出 {status,stage}done→resolve
- [ ] **Step 2: Progress.vue** — 状态徽标 + 阶段文字 + 进度条;done→push resultfailed→ElMessage error_msg
- [ ] **Step 3: build** 验证
- [ ] **Step 4: 提交**`git commit -am "feat(frontend): 回测-进度页(WS 实时阶段)"`
---
## Task S1.8:前端 回测-结果页(EChartsvnpy client 对齐)
**Files:** Create: `src/components/charts/{EquityChart,DailyPnlChart,KlineChart}.vue`, `src/components/TradesTable.vue`, `src/views/backtest/Result.vue`
- [ ] **Step 1: EquityChart**(折线 date×balance);**DailyPnlChart**(柱状红绿);**KlineChart**candlestick + markPoint 成交买卖点)
- [ ] **Step 2: TradesTable.vue**el-table
- [ ] **Step 3: Result.vue** — 并行拉 result/equity-curve/daily-pnl/trades/kline → 统计卡片 + 图表/表布局
- [ ] **Step 4: build** 验证
- [ ] **Step 5: 提交**`git commit -am "feat(frontend): 回测-结果页(对齐 vnpy client"`
---
## Task S1.9S1 部署 + 端到端冒烟
- [ ] **Step 1: scripts/smoke_phase3b.py** — 登录→POST /backtest/cta(DoubleMaStrategy,600000,2024-01-01..06-30)→轮询 done→校验 equity-curve/daily-pnl/trades/kline 非空
- [ ] **Step 2: 容器跑冒烟** `ssh sanguo-nas "$DOCKER exec sanguo_vnpy_v2 python /app/scripts/smoke_phase3b.py"`
- [ ] **Step 3: 同步前端 dist + 后端 → restart**
- [ ] **Step 4: 公网验收** — 浏览器跑 DoubleMaStrategy 看完整结果页
- [ ] **Step 5: 提交**`git commit -am "test(phase3b): S1 端到端冒烟"`
---
# 切片 S2:投研核心
## Task S2.1factor 列表 + ic-summary + report 接口(含 factor 结果持久化)
**Files:** Modify: `routes.py`, `sanguo_factor/analyzer.py`, `sanguo_orchestrator/runner.py`Create: `sanguo_api/factor_registry.py`Test: `tests/api/test_factor_routes.py`
**Interfaces — Produces:** `GET /factor/list``GET /task/{id}/ic-summary``GET /task/{id}/report/{factor}`FileResponse HTML
> ⚠️ **实现注意(factor 结果持久化)**factor worker 返回 `FactorReport`(非 BacktestResult),当前 orchestrator 对其无持久化。S2.1 补:analyzer 把 `{ic_summary, report_paths}` 写 `output_dir/<task_id>_summary.json`orchestrator 在 `_pending[task_id]` 记 output_dirsubmit_factor 已有);`/ic-summary` 与 `/report/{factor}` 从 output_dir 读。task_id 需稳定(当前 `factor_{id(factor_names)}` 不稳定,改含 symbols+时间戳哈希)。
- [ ] **Step 1: 写失败测试**FakeOrch 暴露 get_factor_summary(tid)->dict;测 /factor/list、/ic-summary、/report 非空)
- [ ] **Step 2: 验证失败** → FAIL
- [ ] **Step 3: factor_registry.py**(枚举 sanguo_factor.registry);analyzer 落 summary.jsonrunner factor task_id 稳定化 + 暴露 output_dir
- [ ] **Step 4: 加路由** /factor/list、/task/{id}/ic-summary、/task/{id}/report/{factor}
- [ ] **Step 5: 验证通过** → PASS
- [ ] **Step 6: 提交**`git commit -am "feat(api): 投研接口(factor list + ic-summary + tears 报告服务)"`
---
## Task S2.2:前端 投研-新建 + 结果页
**Files:** Create: `src/api/factor.ts`, `src/views/factor/{New,Result}.vue`
- [ ] **Step 1: api/factor.ts**getFactors/submitFactor/getIcSummary/reportUrl
- [ ] **Step 2: New.vue** — 因子多选 + 多标的 tag 输入 + 日期 → 提交 → 进度(useTask)→ 结果
- [ ] **Step 3: Result.vue** — IC 表(period × mean/std/icir/t_stat/count+ tears iframe
- [ ] **Step 4: build** 验证
- [ ] **Step 5: 提交**`git commit -am "feat(frontend): 投研-新建/结果页(IC 表 + tears"`
## Task S2.3S2 部署 + 冒烟
- [ ] 冒烟(ma5,[600000,000001,300750])→ ic-summary/report 非空 → 公网验收 → 提交
---
# 切片 S3:优化 + 收尾
## Task S3.1:回测可配置费率/滑点/资金
- [ ] schemas CtaBacktestRequest 加 `rate: float=0.001, slippage: float=0, capital: float=1_000_000`routes 透传;cta_engine 用参数(替换硬编码);测试;提交
## Task S3.2:优化结果 + 任务列表接口
- [ ] `GET /task/{id}/optimization-results`{results:[{params,statistics}]});`GET /task?type=&status=`list_results);测试;提交
## Task S3.3:前端 优化页(热力图)+ 历史页
- [ ] Optimize.vue(参数网格表单 + ECharts heatmap);History.vue(任务表 + 回看);build;提交
## Task S3.4S3 部署 + 全量冒烟 + 收尾
- [ ] 全链路冒烟(回测+优化+因子)→ 公网验收 → 更新 nas-deploy-plan.mduvicorn 目标)→ 最终提交 → finishing-a-development-branch
---
## Self-Review(计划自检)
1. **Spec 覆盖**:§6 页面 → S0.5/S1.6-1.8/S2.2/S3.3 ✓;§8.2 接口 → S1.3-1.5/S2.1/S3.2 ✓;§10 切片验收 → 每片末 ✓;result_id bug(实现发现)→ S1.1 ✓;factor 持久化缺口(实现发现)→ S2.1 ✓。
2. **占位符**:S3 为切片级任务(按 writing-plans scope-check,每片可独立成 plan,到达时按 S0/S1 粒度细化)。无 TBD/TODO 散落。
3. **类型一致**`build_app(config_path, static_dir)``list_strategies()``strategy_params(name)``load_kline(...)``_df_to_records` 跨任务一致 ✓。
4. **兜底**vnpy_ctastrategy 本地不可导入(STRATEGY_NAMES);read_db_daily cfg(默认 None);rsync 不稳(ssh-exec)。
## Execution Handoff
用户已睡 + /goal 自主完成 → **Inline Executionsuperpowers:executing-plans**:本会话按任务顺序执行,后端 TDD(先红后绿)、前端 build 验证、切片末部署 + 公网验收,频繁提交,不阻塞等用户。