Files
sanguo_vnpy_v2/tests/backtest/test_metrics.py
T
claude_dev 8d55e414fa fix(backtest): A股适配层—定寸/做空拦截/真实费用/口径统一(Phase1+2)
审计发现包装层系统性失真(2 CRITICAL+7 HIGH),vnpy底座可信但A股场景未适配:
- C1 定寸: engine.size=N(满仓手数),策略volume=1手=N股,开平对称(pos归零)
- C2 做空拦截: SHORT+OPEN拒单,long-only,SHORT+CLOSE平多允许
- H3 A股费用: AShareDailyResult重算(佣金保底5元/印花税卖方/过户费沪市)
- H4 收益口径: simple return从balance算(不再用vnpy log return喂empyrical)
- H5+口径: benchmark ffill对齐不缩样本; sizing_shares_per_lot暴露
- H7 退化检测: 零成交/空数据标degenerate不静默done
- H8 task_id: optimize/factor用uuid4(原id()内存地址)
- 静默except改warning

验证: 容器内真实vnpy DoubleMa 600000 2022-2024, total_return 1e-6→42.3%,
end_balance 100万→142万, SHORT+OPEN成交0笔, N=7800股/手.
22 backtest测试全绿(含集成测试), API健康200.
2026-07-12 23:39:45 +08:00

103 lines
4.5 KiB
Python
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.
"""metrics.py 纯函数单测。
H4: compute_metrics 从 daily_df["balance"] 自算 simple return(不再用 vnpy log return 列),
所以测试需构造含 "balance" 列的 daily_df。
"""
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_balance(returns: np.ndarray) -> pd.DataFrame:
"""从收益率数组构造含 balance 列的 daily_df。
compute_metrics 内部算 simple return = balance.pct_change().fillna(0)
所以实际传入 metrics 的 strat = [0, r0, r1, ...](首项 NaN→0)。
"""
idx = pd.date_range("2024-01-01", periods=len(returns), freq="B")
balance = (1 + pd.Series(returns, index=idx)).cumprod()
return pd.DataFrame({"balance": balance}, index=idx)
def _actual_strat(returns: np.ndarray) -> pd.Series:
"""compute_metrics 从 balance 推导出的实际 strat 序列(首项=0)。"""
idx = pd.date_range("2024-01-01", periods=len(returns), freq="B")
balance = (1 + pd.Series(returns, index=idx)).cumprod()
return balance.pct_change().fillna(0)
def test_compute_metrics_scalars_match_empyrical():
np.random.seed(42)
raw_returns = np.random.normal(0.001, 0.02, 100)
strat = _actual_strat(raw_returns)
bench = pd.Series(np.random.normal(0.0005, 0.015, 100),
index=pd.date_range("2024-01-01", periods=100, freq="B"))
daily_df = _make_daily_balance(raw_returns)
res = compute_metrics(daily_df, bench)
assert isinstance(res, MetricsResult)
# 标量口径与 empyrical 直接计算一致(用推导出的 strat)
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():
daily_df = _make_daily_balance([0.01, -0.005, 0.02, 0.0])
bench = pd.Series([0.005, 0.001, 0.01, -0.002],
index=pd.date_range("2024-01-01", periods=4, freq="B"))
res = compute_metrics(daily_df, 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():
raw_returns = np.random.normal(0, 0.01, 50)
daily_df = _make_daily_balance(raw_returns)
bench = pd.Series(np.random.normal(0, 0.01, 50),
index=pd.date_range("2024-01-01", periods=50, freq="B"))
res = compute_metrics(daily_df, 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"
def test_compute_metrics_ffill_aligns_benchmark():
"""H5: benchmark 有缺失日期时 ffill 对齐,不丢策略交易日。"""
raw_returns = np.random.normal(0, 0.01, 10)
daily_df = _make_daily_balance(raw_returns)
# benchmark 只有部分日期(模拟停盘日缺失)
partial_dates = daily_df.index[::2] # 隔日取一个
bench = pd.Series([0.001] * len(partial_dates), index=partial_dates)
res = compute_metrics(daily_df, bench)
# 不应崩溃,且 strat 长度 = daily_df 行数(没有被 dropna 削短)
assert len(res.series["equity_curve"]) == 10
def test_compute_metrics_raises_without_balance():
"""缺少 balance 列时应抛 ValueError。"""
idx = pd.date_range("2024-01-01", periods=5, freq="B")
daily_df = pd.DataFrame({"net_pnl": [1, 2, 3, 4, 5]}, index=idx)
bench = pd.Series([0.01] * 5, index=idx)
try:
compute_metrics(daily_df, bench)
assert False, "应抛 ValueError"
except ValueError as e:
assert "balance" in str(e)