From b50a0f97bea754fa7bcc170c33f8a15d4f64ad02 Mon Sep 17 00:00:00 2001 From: claude_dev Date: Sat, 11 Jul 2026 13:27:44 +0800 Subject: [PATCH] =?UTF-8?q?feat(backtest):=20metrics=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E2=80=94empyrical=E7=AE=9710=E6=8C=87=E6=A0=87+5=E6=97=B6?= =?UTF-8?q?=E5=BA=8F(=E8=81=9A=E5=AE=BD=E5=90=8C=E6=BA=90=E5=8F=A3?= =?UTF-8?q?=E5=BE=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- requirements-docker.txt | 1 + sanguo_backtest/metrics.py | 71 ++++++++++++++++++++++++++++++++++ tests/backtest/test_metrics.py | 53 +++++++++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 sanguo_backtest/metrics.py create mode 100644 tests/backtest/test_metrics.py diff --git a/requirements-docker.txt b/requirements-docker.txt index a9e40ac..7edaa32 100644 --- a/requirements-docker.txt +++ b/requirements-docker.txt @@ -69,6 +69,7 @@ scikit-learn>=1.6.1 lightgbm>=4.6.0 torch>=2.6.0 pyarrow>=19.0.1 +empyrical>=0.5.5 # ============================================ # 工具和监控 diff --git a/sanguo_backtest/metrics.py b/sanguo_backtest/metrics.py new file mode 100644 index 0000000..46a7b35 --- /dev/null +++ b/sanguo_backtest/metrics.py @@ -0,0 +1,71 @@ +"""回测相对/绝对指标计算(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。 + period: 年化周期(整数,默认252交易日),empyrical 内部使用 'daily' + """ + 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='daily')), + "alpha": float(empyrical.alpha(s, b, period='daily')), + "beta": float(empyrical.beta(s, b)), + "sharpe_ratio": float(empyrical.sharpe_ratio(s, period='daily')), + "sortino_ratio": float(empyrical.sortino_ratio(s, period='daily')), + "information_ratio": float(empyrical.excess_sharpe(s, b)), + "annual_volatility": float(empyrical.annual_volatility(s, period='daily')), + "max_drawdown": float(empyrical.max_drawdown(s)), + "benchmark_return": float(empyrical.cum_returns_final(b)), + "benchmark_volatility": float(empyrical.annual_volatility(b, period='daily')), + } + + equity = empyrical.cum_returns(s) + bench_curve = empyrical.cum_returns(b) + # rolling alpha/beta (expanding window 用于画图,口径由 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 + # drawdown: 从峰值回落 (值 <= 0) + cummax = equity.cummax() + drawdown = (equity - cummax) / cummax + + series = { + "equity_curve": equity, + "benchmark_curve": bench_curve, + "alpha": roll_alpha, + "beta": roll_beta, + "drawdown": drawdown, + } + return MetricsResult(scalars=scalars, series=series) diff --git a/tests/backtest/test_metrics.py b/tests/backtest/test_metrics.py new file mode 100644 index 0000000..247b5d0 --- /dev/null +++ b/tests/backtest/test_metrics.py @@ -0,0 +1,53 @@ +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"