Files
sanguo_vnpy_v2/docs/superpowers/plans/2026-07-11-backtest-result-page.md
T

22 KiB
Raw Blame History

富回测结果页(聚宽级)实施计划

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.pyempyrical 纯函数)算 10 标量指标 + 5 逐日时序 → 存 DB+json → FastAPI 扩端点返回 → 前端 Result.vue 重构渲染(echarts)。不碰回测引擎撮合逻辑,只加结果计算层。

Tech Stack: Python 3.10(容器)/3.14(本机)、vnpy_ctastrategy、empyrical(新增)、pandas、FastAPI、pytestVue3 <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.pyrun_cta_backtest 跑完后调 compute_metrics,结果落盘
  • sanguo_api/routes.py/backtest/ctabenchmark 入参;/task/:id/resultrelative_metrics;新增 4 端点
  • config/backtest.yaml — 加默认 benchmark: hs300
  • requirements-docker.txt — 加 empyrical

新增(前端 frontend/src/

  • components/backtest/MetricCards.vue — 10 指标卡
  • components/backtest/BenchmarkCurve.vue — 策略 vs 基准累计收益
  • components/backtest/AlphaChart.vue — 逐日 alpha
  • components/backtest/BetaChart.vue — 逐日 beta
  • components/backtest/VolatilityChart.vue — 策略 vs 基准波动率
  • components/backtest/DrawdownChart.vue — 逐日回撤
  • 对应 *.spec.ts vitest 测试

修改(前端)

  • 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:

    • MetricsResult dataclassscalars: dict[str,float] + series: dict[str,pd.Series]
    • compute_metrics(daily_df: pd.DataFrame, benchmark_returns: pd.Series, period=252) -> MetricsResult
    • BenchmarkCode = 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: 基准日收益率 Seriesindex 对齐 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.pymock 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

用 baostockbs.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.pyrun_cta_backtest 跑完后算 metrics
  • Modify: config/backtest.yaml(加 benchmark: hs300
  • Test: tests/backtest/test_cta_engine.py(新增/扩展)

Interfaces:

  • Consumes: Task 1 compute_metrics、Task 2 read_index_daily

  • Produces: run_cta_backtest 返回值/落盘含 metrics: MetricsResultscalars 入 DBseries 写 {task_id}_metrics.json

  • Step 1: 写失败测试

mock 一个 vnpy daily_df + mock read_index_daily,断言 run_cta_backtest 结果含 relative_metricsalpha/beta 键)且写了 {task_id}_metrics.json

  • Step 2: 跑确认失败

  • Step 3: 改 cta_engine.run_cta_backtest

engine.calculate_statistics(daily_df) 之后:

  1. 从 config 读 benchmark(默认 hs300)→ BENCHMARK_SYMBOL 映射 code
  2. read_index_daily(code, start, end) → 算基准日收益(close.pct_change().dropna()
  3. metrics = compute_metrics(daily_df, benchmark_returns)
  4. scalars 合入现有 statisticsseries 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 入参 CtaBacktestRequestbenchmark: str = "hs300"
    • GET /task/:id/result 出参加 relative_metrics: dict10 标量)
    • 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_benchmarkPOST 带 benchmark=zz500,断言接受)、test_task_result_includes_relative_metricstest_benchmark_curve_endpointtest_risk_series_endpointtest_daily_holdings_endpointtest_log_endpoint。用现有 test_routes.py 的 mock 模式(参考已有的 task/result 测试)。

  • Step 2: 跑确认失败

  • Step 3: 实现 routes.py

  • CtaBacktestRequestbenchmark: str = "hs300",校验 benchmark in ("hs300","zz500")

  • /task/:id/result{task_id}_metrics.json,附加 relative_metrics

  • 4 个新端点从 {task_id}_metrics.json / daily_df 读对应序列返回(JSON 可序列化:dates→strSeries→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.vueAlphaChart.vueBetaChart.vueVolatilityChart.vueDrawdownChart.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 Resultmock API 返回固定数据,断言:10 指标卡渲染、4 tab 可切换、时间缩放选择器存在、图容器渲染。

  • Step 2: 跑确认失败

  • Step 3: 重构 Result.vue

布局(高仿聚宽官方截图 edit_alg6_1.png):

  • 顶部:<MetricCards :metrics="result.relative_metrics" />

  • 中部:时间缩放 el-radio-group1周/1月/6月/1年/全部,按 dates 过滤)+ 5 图纵向堆叠

  • tabel-tabs):收益概述(5图) / 交易详情(复用 TradesTable) / 每日持仓&收益(新表) / 日志输出(pre)

  • onMounted 并发拉 result + benchmark-curve + risk-series + daily-holdings + log

  • Step 4: 跑测试通过 + npm run buildvue-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) -> MetricsResultMetricsResult.scalars/seriesBENCHMARK_SYMBOLbenchmark: "hs300"|"zz500"read_index_daily(code,start,end) 在 Task 1→2→3→4 引用一致。✓