feat(backtest): metrics模块—empyrical算10指标+5时序(聚宽同源口径)
This commit is contained in:
@@ -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
|
||||
|
||||
# ============================================
|
||||
# 工具和监控
|
||||
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user