8d55e414fa
审计发现包装层系统性失真(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.
103 lines
4.5 KiB
Python
103 lines
4.5 KiB
Python
"""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)
|