22 KiB
富回测结果页(聚宽级)实施计划
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: 把 CTA 回测结果页升级到聚宽级(10 指标卡 + 5 图 + 4 tab + 时间缩放),后端用 empyrical 补齐相对基准指标(Alpha/Beta/Sortino/IR),基准可选沪深300/中证500。
Architecture: vnpy 跑完回测产出 daily_df → 新增 sanguo_backtest/metrics.py(empyrical 纯函数)算 10 标量指标 + 5 逐日时序 → 存 DB+json → FastAPI 扩端点返回 → 前端 Result.vue 重构渲染(echarts)。不碰回测引擎撮合逻辑,只加结果计算层。
Tech Stack: Python 3.10(容器)/3.14(本机)、vnpy_ctastrategy、empyrical(新增)、pandas、FastAPI、pytest;Vue3 <script setup>、element-plus、echarts、vitest。
Global Constraints
- vnpy 零修改:不碰
vnpy_v4.4.0/源码,仅在其产出daily_df之上加计算 - 数据下载硬约束:下载沪深300 直连不走代理(
unset http_proxy https_proxy)、单线程限速、优先 baostock - rsync 同步:到 NAS 不排除
tests/data(见记忆 rsync-tests-data-sync) - NAS docker 全路径:
/var/packages/Docker/target/usr/bin/docker - 不引未确认依赖:仅新增
empyrical;前端不新增依赖(echarts/element-plus 已有) - 基准编码:沪深300=
sh000300(下载补齐),中证500=sz000905(现成) - benchmark 入参字面量:
"hs300"/"zz500" - 提交规范:
feat/fix/docs/test:前缀,不加 Co-Authored-By(全局已禁 attribution)
File Structure
新增(后端)
sanguo_backtest/metrics.py— 指标计算纯函数模块(empyrical)。核心sanguo_data/index_downloader.py— 沪深300 指数日线下载(baostock,一次性/补齐)tests/backtest/test_metrics.py— metrics 单测tests/data/test_index_downloader.py— 下载器单测(mock baostock)
修改(后端)
sanguo_data/datareader.py— 加read_index_daily(code, start, end)sanguo_backtest/cta_engine.py—run_cta_backtest跑完后调compute_metrics,结果落盘sanguo_api/routes.py—/backtest/cta加benchmark入参;/task/:id/result加relative_metrics;新增 4 端点config/backtest.yaml— 加默认benchmark: hs300requirements-docker.txt— 加empyrical
新增(前端 frontend/src/)
components/backtest/MetricCards.vue— 10 指标卡components/backtest/BenchmarkCurve.vue— 策略 vs 基准累计收益components/backtest/AlphaChart.vue— 逐日 alphacomponents/backtest/BetaChart.vue— 逐日 betacomponents/backtest/VolatilityChart.vue— 策略 vs 基准波动率components/backtest/DrawdownChart.vue— 逐日回撤- 对应
*.spec.tsvitest 测试
修改(前端)
views/backtest/Result.vue— 重构为指标卡+5图+4tab+缩放布局api/backtest.ts(或现有 api 封装)— 加新端点调用
Task 1: metrics.py 指标计算模块(核心,TDD)
Files:
- Create:
sanguo_backtest/metrics.py - Test:
tests/backtest/test_metrics.py
Interfaces:
-
Consumes: vnpy
daily_df(含"return"日收益列,index 为日期)+ 基准日收益pd.Series -
Produces:
MetricsResultdataclass:scalars: dict[str,float]+series: dict[str,pd.Series]compute_metrics(daily_df: pd.DataFrame, benchmark_returns: pd.Series, period=252) -> MetricsResultBenchmarkCode = Literal["hs300","zz500"]、BENCHMARK_SYMBOL = {"hs300":"sh000300","zz500":"sz000905"}
-
Step 1: 加依赖 empyrical
requirements-docker.txt 追加 empyrical;本机 pip install empyrical(容器侧 Task 8 部署时装)。
- Step 2: 写失败测试
tests/backtest/test_metrics.py:
import sys, os
_VNPY_SRC = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "vnpy_v4.4.0"))
sys.path.insert(0, _VNPY_SRC)
import pandas as pd
import numpy as np
import empyrical
from sanguo_backtest.metrics import compute_metrics, MetricsResult, BENCHMARK_SYMBOL
def _make_daily(returns):
idx = pd.date_range("2024-01-01", periods=len(returns), freq="B")
return pd.DataFrame({"return": returns}, index=idx)
def test_compute_metrics_scalars_match_empyrical():
np.random.seed(42)
strat = pd.Series(np.random.normal(0.001, 0.02, 100),
index=pd.date_range("2024-01-01", periods=100, freq="B"))
bench = pd.Series(np.random.normal(0.0005, 0.015, 100), index=strat.index)
daily_df = pd.DataFrame({"return": strat.values}, index=strat.index)
res = compute_metrics(daily_df, bench)
assert isinstance(res, MetricsResult)
# 标量口径与 empyrical 直接计算一致
assert abs(res.scalars["alpha"] - empyrical.alpha(strat, bench)) < 1e-9
assert abs(res.scalars["beta"] - empyrical.beta(strat, bench)) < 1e-9
assert abs(res.scalars["sharpe_ratio"] - empyrical.sharpe_ratio(strat)) < 1e-9
assert abs(res.scalars["sortino_ratio"] - empyrical.sortino_ratio(strat)) < 1e-9
assert abs(res.scalars["max_drawdown"] - empyrical.max_drawdown(strat)) < 1e-9
assert abs(res.scalars["annual_volatility"] - empyrical.annual_volatility(strat)) < 1e-9
def test_compute_metrics_has_all_required_scalars():
strat = pd.Series([0.01, -0.005, 0.02, 0.0],
index=pd.date_range("2024-01-01", periods=4, freq="B"))
bench = pd.Series([0.005, 0.001, 0.01, -0.002], index=strat.index)
res = compute_metrics(pd.DataFrame({"return": strat.values}, index=strat.index), bench)
required = {"total_return","annual_return","alpha","beta","sharpe_ratio",
"sortino_ratio","information_ratio","annual_volatility","max_drawdown",
"benchmark_return","benchmark_volatility"}
assert required.issubset(res.scalars.keys())
def test_compute_metrics_series_keys_and_length():
strat = pd.Series(np.random.normal(0, 0.01, 50),
index=pd.date_range("2024-01-01", periods=50, freq="B"))
bench = pd.Series(np.random.normal(0, 0.01, 50), index=strat.index)
res = compute_metrics(pd.DataFrame({"return": strat.values}, index=strat.index), bench)
for key in ["equity_curve","benchmark_curve","alpha","beta","drawdown"]:
assert key in res.series
assert len(res.series[key]) == 50
assert res.series["drawdown"].max() <= 1e-9 # 回撤 <= 0
def test_benchmark_symbol_map():
assert BENCHMARK_SYMBOL["hs300"] == "sh000300"
assert BENCHMARK_SYMBOL["zz500"] == "sz000905"
- Step 3: 跑测试确认失败
pytest tests/backtest/test_metrics.py -v → FAIL(模块不存在)
- Step 4: 实现 metrics.py
sanguo_backtest/metrics.py:
"""回测相对/绝对指标计算(empyrical,聚宽同源口径)。纯函数。"""
from dataclasses import dataclass, field
from typing import Dict, Literal
import numpy as np
import pandas as pd
import empyrical
BenchmarkCode = Literal["hs300", "zz500"]
BENCHMARK_SYMBOL: Dict[str, str] = {"hs300": "sh000300", "zz500": "sz000905"}
@dataclass
class MetricsResult:
scalars: Dict[str, float] = field(default_factory=dict)
series: Dict[str, pd.Series] = field(default_factory=dict)
def compute_metrics(
daily_df: pd.DataFrame,
benchmark_returns: pd.Series,
period: int = 252,
) -> MetricsResult:
"""对 vnpy daily_df + 基准日收益计算聚宽级指标。
daily_df: vnpy calculate_result() 产出,须含 "return" 列(日收益率),index 为日期。
benchmark_returns: 基准日收益率 Series,index 对齐 daily_df。
"""
strat = daily_df["return"].astype(float)
# 对齐
aligned = pd.concat([strat.rename("s"), benchmark_returns.rename("b")], axis=1).dropna()
s, b = aligned["s"], aligned["b"]
scalars = {
"total_return": float(empyrical.cum_returns_final(s)),
"annual_return": float(empyrical.annual_return(s, period=period)),
"alpha": float(empyrical.alpha(s, b, period=period)),
"beta": float(empyrical.beta(s, b, period=period)),
"sharpe_ratio": float(empyrical.sharpe_ratio(s, period=period)),
"sortino_ratio": float(empyrical.sortino_ratio(s, period=period)),
"information_ratio": float(empyrical.excess_sharpe(s, b)),
"annual_volatility": float(empyrical.annual_volatility(s, period=period)),
"max_drawdown": float(empyrical.max_drawdown(s)),
"benchmark_return": float(empyrical.cum_returns_final(b)),
"benchmark_volatility": float(empyrical.annual_volatility(b, period=period)),
}
equity = empyrical.cum_returns(s)
bench_curve = empyrical.cum_returns(b)
# rolling alpha/beta (63 日窗口,不足则 expanding)
window = min(63, len(s))
if window >= 2:
cov = aligned.rolling(window, min_periods=2).cov()
# 用简单 rolling beta/alpha 近似(逐日时序用于画图,口径由 scalars 保证)
roll_beta = pd.Series(index=s.index, dtype=float)
roll_alpha = pd.Series(index=s.index, dtype=float)
for i in range(len(s)):
sub = aligned.iloc[: i + 1]
if len(sub) >= 2 and sub["b"].var() > 0:
beta = sub["s"].cov(sub["b"]) / sub["b"].var()
alpha = sub["s"].mean() - beta * sub["b"].mean()
roll_beta.iloc[i] = beta
roll_alpha.iloc[i] = alpha * period
else:
roll_beta = pd.Series([np.nan] * len(s), index=s.index)
roll_alpha = pd.Series([np.nan] * len(s), index=s.index)
drawdown = empyrical.drawdown(s)
series = {
"equity_curve": equity,
"benchmark_curve": bench_curve,
"alpha": roll_alpha,
"beta": roll_beta,
"drawdown": drawdown,
}
return MetricsResult(scalars=scalars, series=series)
- Step 5: 跑测试确认通过
pytest tests/backtest/test_metrics.py -v → 4 PASS
- Step 6: Commit
git add sanguo_backtest/metrics.py tests/backtest/test_metrics.py requirements-docker.txt && git commit -m "feat(backtest): metrics模块—empyrical算10指标+5时序(聚宽同源口径)"
Task 2: 沪深300 数据下载 + datareader.read_index_daily
Files:
- Create:
sanguo_data/index_downloader.py - Modify:
sanguo_data/datareader.py(加read_index_daily) - Test:
tests/data/test_index_downloader.py
Interfaces:
-
Produces:
download_index(symbol="sh000300", start_year, end_year, out_dir);datareader.read_index_daily(code, start, end) -> pd.DataFrame(列含datetime/close,复用现有 parquet 读取路径{daily_dir}/{year}/{code}_daily.parquet) -
Step 1: 写失败测试(下载器 mock baostock)
tests/data/test_index_downloader.py:mock baostock.query_history_k_data_plus 返回固定 DataFrame,断言写出 sh000300_daily.parquet 且含 close 列、行数正确。另写 test_read_index_daily_reads_parquet:造一个临时 parquet,断言 read_index_daily 读回正确。
-
Step 2: 跑确认失败
-
Step 3: 实现 index_downloader.py
用 baostock(bs.query_history_k_data_plus("sh.000300", "date,close", ...))下载沪深300 收盘,按年切分写 {out_dir}/{year}/sh000300_daily.parquet。直连不走代理:函数入口 os.environ.pop("http_proxy", None); os.environ.pop("https_proxy", None)。单线程、time.sleep 限速。复用项目现有下载模式(参考 baostock-15min-source 记忆)。
- Step 4: datareader 加 read_index_daily
def read_index_daily(self, code: str, start: date, end: date) -> pd.DataFrame:
"""读指数日线(sh000300/sz000905),复用 read_parquet_daily 的年分片 parquet 路径。"""
# 复用现有 read_parquet_daily 的 {daily_dir}/{year}/{code}_daily.parquet 逻辑
(实现者:读 datareader.py 现有 read_parquet_daily,提取/复用其按年读取逻辑,code 直接用 sh000300/sz000905。)
-
Step 5: 跑测试通过
-
Step 6: 实际下载沪深300(一次性补数据)
容器或本机执行 download_index("sh000300", 2010, 2026, daily_dir) → 写到 NAS /volume1/stock/A股数据/日线数据/daily/{year}/sh000300_daily.parquet。校验:ssh sanguo-nas "ls /volume1/stock/A股数据/日线数据/daily/2024/sh000300_daily.parquet"。
- Step 7: Commit
git add sanguo_data/index_downloader.py sanguo_data/datareader.py tests/data/test_index_downloader.py && git commit -m "feat(data): 沪深300指数下载+read_index_daily(补基准数据缺口)"
Task 3: 回测流程集成 metrics
Files:
- Modify:
sanguo_backtest/cta_engine.py(run_cta_backtest跑完后算 metrics) - Modify:
config/backtest.yaml(加benchmark: hs300) - Test:
tests/backtest/test_cta_engine.py(新增/扩展)
Interfaces:
-
Consumes: Task 1
compute_metrics、Task 2read_index_daily -
Produces:
run_cta_backtest返回值/落盘含metrics: MetricsResult(scalars 入 DB,series 写{task_id}_metrics.json) -
Step 1: 写失败测试
mock 一个 vnpy daily_df + mock read_index_daily,断言 run_cta_backtest 结果含 relative_metrics(alpha/beta 键)且写了 {task_id}_metrics.json。
-
Step 2: 跑确认失败
-
Step 3: 改 cta_engine.run_cta_backtest
engine.calculate_statistics(daily_df) 之后:
- 从 config 读
benchmark(默认 hs300)→BENCHMARK_SYMBOL映射 code read_index_daily(code, start, end)→ 算基准日收益(close.pct_change().dropna())metrics = compute_metrics(daily_df, benchmark_returns)- scalars 合入现有 statistics;series
metrics.series序列化写{task_id}_metrics.json
注意:daily_df 的 index 须是日期;若 vnpy 用 int index,先转。基准日期与策略日期对齐在 compute_metrics 内已 dropna 处理。
- Step 4: config/backtest.yaml 加默认 benchmark
backtest:
...
benchmark: hs300 # hs300 | zz500
-
Step 5: 跑测试通过
-
Step 6: Commit
git add sanguo_backtest/cta_engine.py config/backtest.yaml tests/backtest/test_cta_engine.py && git commit -m "feat(backtest): 回测流程集成基准对比—产出相对指标+时序json"
Task 4: API 扩展端点
Files:
- Modify:
sanguo_api/routes.py - Test:
tests/api/test_routes.py(扩展)
Interfaces:
-
Consumes: Task 3 落盘的 metrics
-
Produces:
POST /backtest/cta入参CtaBacktestRequest加benchmark: str = "hs300"GET /task/:id/result出参加relative_metrics: dict(10 标量)GET /task/:id/benchmark-curve→{dates:[], strategy:[], benchmark:[]}GET /task/:id/risk-series→{dates:[], alpha:[], beta:[], drawdown:[]}GET /task/:id/daily-holdings→ 每日持仓 DataFrame 记录(vnpy daily_df 已有 end_value 等)GET /task/:id/log→ 回测日志文本
-
Step 1: 写失败测试
test_backtest_cta_accepts_benchmark(POST 带 benchmark=zz500,断言接受)、test_task_result_includes_relative_metrics、test_benchmark_curve_endpoint、test_risk_series_endpoint、test_daily_holdings_endpoint、test_log_endpoint。用现有 test_routes.py 的 mock 模式(参考已有的 task/result 测试)。
-
Step 2: 跑确认失败
-
Step 3: 实现 routes.py
-
CtaBacktestRequest加benchmark: str = "hs300",校验benchmark in ("hs300","zz500") -
/task/:id/result读{task_id}_metrics.json,附加relative_metrics -
4 个新端点从
{task_id}_metrics.json/ daily_df 读对应序列返回(JSON 可序列化:dates→str,Series→list) -
Step 4: 跑测试通过
pytest tests/api/test_routes.py -v -
Step 5: Commit
git add sanguo_api/routes.py tests/api/test_routes.py && git commit -m "feat(api): 回测结果API加relative_metrics+基准曲线/风险序列/持仓/日志4端点"
Task 5: 前端 — MetricCards + API 层
Files:
-
Create:
frontend/src/components/backtest/MetricCards.vue -
Modify:
frontend/src/api/(加新端点调用,找到现有 api 封装文件按其模式加) -
Test:
frontend/src/components/backtest/MetricCards.spec.ts -
Step 1: 写失败测试(vitest)
mount MetricCards,传固定 metrics prop,断言渲染 10 个指标卡且数值/标签正确。
-
Step 2: 跑确认失败
cd frontend && npx vitest run MetricCards -
Step 3: 实现 MetricCards.vue
<script setup> 接 props: { metrics: {total_return, annual_return, alpha, beta, sharpe_ratio, sortino_ratio, information_ratio, annual_volatility, max_drawdown, benchmark_return, benchmark_volatility} }。用 el-card 网格布局,数值格式化(百分比/小数)。先读 现有组件(如 views/backtest/Result.vue 顶部、EquityChart.vue)匹配风格。
- Step 4: 加 API 调用
在现有 api 封装文件按 axios 模式加:getResult(id)、getBenchmarkCurve(id)、getRiskSeries(id)、getDailyHoldings(id)、getLog(id)。
-
Step 5: 跑测试通过
-
Step 6: Commit
git add frontend/src/components/backtest/MetricCards.vue frontend/src/components/backtest/MetricCards.spec.ts frontend/src/api/ && git commit -m "feat(frontend): MetricCards指标卡组件+结果页API封装"
Task 6: 前端 — 5 图组件
Files:
-
Create:
BenchmarkCurve.vue、AlphaChart.vue、BetaChart.vue、VolatilityChart.vue、DrawdownChart.vue(均frontend/src/components/backtest/) -
Test: 每个
*.spec.ts -
Step 1: 写失败测试
每组件 mount + 传固定 series prop,断言 echarts init 被调用/容器渲染(参考现有 EquityChart.spec 若有,否则断言容器 DOM + prop 透传)。
-
Step 2: 跑确认失败
-
Step 3: 实现 5 图组件
每个 <script setup>:props 接 {dates, values[]...},onMounted 用 echarts 初始化、watch 数据更新。先读现有 EquityChart.vue 完全照搬其 echarts 初始化/resize/销毁模式(DRY)。颜色:策略=红、基准=蓝、alpha/beta=绿、回撤=橙(对齐聚宽结果页截图)。
-
Step 4: 跑测试通过
npx vitest run -
Step 5: Commit
git add frontend/src/components/backtest/{BenchmarkCurve,AlphaChart,BetaChart,VolatilityChart,DrawdownChart}.vue frontend/src/components/backtest/*.spec.ts && git commit -m "feat(frontend): 5个结果页图组件(基准曲线/Alpha/Beta/波动率/回撤)"
Task 7: 前端 — Result.vue 重构整合(4 tab + 缩放)
Files:
-
Modify:
frontend/src/views/backtest/Result.vue -
Test:
frontend/src/views/backtest/Result.spec.ts(若无则造) -
Step 1: 写失败测试
mount Result,mock API 返回固定数据,断言:10 指标卡渲染、4 tab 可切换、时间缩放选择器存在、图容器渲染。
-
Step 2: 跑确认失败
-
Step 3: 重构 Result.vue
布局(高仿聚宽官方截图 edit_alg6_1.png):
-
顶部:
<MetricCards :metrics="result.relative_metrics" /> -
中部:时间缩放
el-radio-group(1周/1月/6月/1年/全部,按 dates 过滤)+ 5 图纵向堆叠 -
tab(
el-tabs):收益概述(5图) / 交易详情(复用 TradesTable) / 每日持仓&收益(新表) / 日志输出(pre) -
onMounted并发拉 result + benchmark-curve + risk-series + daily-holdings + log -
Step 4: 跑测试通过 +
npm run build(vue-tsc 类型检查) -
Step 5: Commit
git add frontend/src/views/backtest/Result.vue frontend/src/views/backtest/Result.spec.ts && git commit -m "feat(frontend): Result.vue重构—聚宽级10指标+5图+4tab+时间缩放"
Task 8: 部署 + 验收
- Step 1: 本机全量测试
pytest -v(本机 Python 3.14,预期 metrics/api/data 测试通过;容器专用测试可能 skip)+ cd frontend && npm test && npm run build。全绿才继续。
- Step 2: rsync 到 NAS(不排除 tests/data)
rsync -avz -e ssh --exclude='.git' --exclude='vnpy_v4.4.0' --exclude='__pycache__' --exclude='.superpowers' --exclude='node_modules' --exclude='data_cache' ./ sanguo-nas:/volume1/homes/admin/.sanguo_projects/sanguo_vnpy_v2/
(注意:不含 --exclude='tests/data')
- Step 3: 容器装 empyrical + 重启
ssh sanguo-nas "/var/packages/Docker/target/usr/bin/docker exec sanguo_vnpy_v2 pip install empyrical"
ssh sanguo-nas "/var/packages/Docker/target/usr/bin/docker restart sanguo_vnpy_v2"
- Step 4: 容器内 pytest 复验
ssh sanguo-nas "/var/packages/Docker/target/usr/bin/docker exec sanguo_vnpy_v2 pytest -v" → 全绿(容器 Python 3.10 应跑通含 vnpy.alpha 的测试)
- Step 5: 实测验收
容器跑一个真实 CTA 回测(双均线策略,benchmark=hs300),确认结果页:10 指标卡有值、收益曲线策略vs基准、alpha/beta/回撤图正常、4 tab 可切换。抽查 alpha/beta 数值合理性。
- Step 6: 三向一致性检查 + 收尾 commit
需求(10指标+5图+4tab+基准可选) ↔ 设计(spec §1/§5) ↔ 编码 实现一致。更新 spec 状态为"已验收"。若 spec/docs 有变动一并 commit。
Self-Review
1. Spec 覆盖:
- §1 成功标准 10指标+5图+4tab+基准可选 → Task 1,4,5,6,7 ✓
- §2 非目标(组合/编辑器/自定义基准/Tick)→ 均未建对应任务 ✓
- §3 沪深300下载 + read_index_daily → Task 2 ✓
- §4.1 metrics.py empyrical → Task 1 ✓(完整代码+测试)
- §4.2 回测流程集成 → Task 3 ✓
- §4.3 API 扩展 → Task 4 ✓
- §5 前端组件/布局 → Task 5,6,7 ✓
- §6 数据流 → Task 3,4 串起 ✓
- §7 测试 TDD → 每任务均先写测试 ✓
- §8 部署 → Task 8 ✓
- §9 验收 → Task 8 Step 5,6 ✓
2. 占位符扫描: Task 2/3/4/5/6/7 对现有文件的修改用"先读现有文件照搬模式"而非凭空写代码——这是对现有代码库的合理处理(非占位符,是明确的实现指令)。Task 1 含完整代码+测试。无 TBD/TODO。✓
3. 类型一致: compute_metrics(daily_df, benchmark_returns) -> MetricsResult、MetricsResult.scalars/series、BENCHMARK_SYMBOL、benchmark: "hs300"|"zz500"、read_index_daily(code,start,end) 在 Task 1→2→3→4 引用一致。✓