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.
This commit is contained in:
2026-07-12 23:39:45 +08:00
parent 292de31eaf
commit 8d55e414fa
11 changed files with 578 additions and 85 deletions
+65 -16
View File
@@ -1,3 +1,8 @@
"""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)
@@ -7,20 +12,36 @@ import numpy as np
import empyrical
from sanguo_backtest.metrics import compute_metrics, MetricsResult, BENCHMARK_SYMBOL
def _make_daily(returns):
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")
return pd.DataFrame({"return": returns}, index=idx)
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)
strat = pd.Series(np.random.normal(0.001, 0.02, 100),
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"))
bench = pd.Series(np.random.normal(0.0005, 0.015, 100), index=strat.index)
daily_df = pd.DataFrame({"return": strat.values}, index=strat.index)
daily_df = _make_daily_balance(raw_returns)
res = compute_metrics(daily_df, bench)
assert isinstance(res, MetricsResult)
# 标量口径与 empyrical 直接计算一致
# 标量口径与 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
@@ -28,26 +49,54 @@ def test_compute_metrics_scalars_match_empyrical():
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],
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"))
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"}
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():
strat = pd.Series(np.random.normal(0, 0.01, 50),
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"))
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"]:
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)