Files
sanguo_vnpy_v2/docs/superpowers/plans/2026-07-07-phase3b-vue-frontend.md
T

406 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 验证、切片末部署 + 公网验收,频繁提交,不阻塞等用户。