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:
@@ -22,7 +22,11 @@ _MOCK_FACTORIES = {
|
||||
}
|
||||
_SAVED_MODULES = {}
|
||||
for _name, _factory in _MOCK_FACTORIES.items():
|
||||
if importlib.util.find_spec(_name) is None:
|
||||
try:
|
||||
_found = importlib.util.find_spec(_name)
|
||||
except ModuleNotFoundError:
|
||||
_found = None
|
||||
if _found is None:
|
||||
_SAVED_MODULES[_name] = sys.modules.get(_name)
|
||||
sys.modules[_name] = _factory()
|
||||
|
||||
@@ -42,7 +46,7 @@ def _restore_modules_after():
|
||||
metrics from the module cache (they were imported while mocks were active) so they
|
||||
re-import fresh with real dependencies."""
|
||||
yield
|
||||
for cached in ("sanguo_backtest.cta_engine", "sanguo_backtest.metrics"):
|
||||
for cached in ("sanguo_backtest.cta_engine", "sanguo_backtest.metrics", "sanguo_backtest.ashare_engine"):
|
||||
sys.modules.pop(cached, None)
|
||||
for k, orig in _SAVED_MODULES.items():
|
||||
if orig is None:
|
||||
@@ -63,7 +67,10 @@ class TestRunCtaBacktest:
|
||||
# Mock BacktestingEngine — calculate_result() returns daily_df (DataFrame),
|
||||
# calculate_statistics(df) returns the stats dict (vnpy API, matches cta_engine)
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.calculate_result.return_value = MagicMock(name="daily_df")
|
||||
mock_engine.calculate_result.return_value = pd.DataFrame(
|
||||
{"balance": [1_000_000.0, 1_010_000.0], "net_pnl": [0.0, 10000.0]},
|
||||
index=pd.date_range("2024-01-01", periods=2, freq="D"),
|
||||
)
|
||||
mock_engine.calculate_statistics.return_value = {
|
||||
"total_return": 0.15,
|
||||
"sharpe_ratio": 1.2,
|
||||
@@ -78,11 +85,13 @@ class TestRunCtaBacktest:
|
||||
# Mock config
|
||||
mock_cfg = Mock()
|
||||
|
||||
# Create mock module with BacktestingEngine
|
||||
mock_module = MagicMock()
|
||||
mock_module.BacktestingEngine = Mock(return_value=mock_engine)
|
||||
# Create mock for AShareBacktestingEngine
|
||||
mock_ashare = MagicMock()
|
||||
mock_ashare.AShareBacktestingEngine = Mock(return_value=mock_engine)
|
||||
mock_engine.trades = {"t1": MagicMock()} # 非空 trades 避免 degenerate
|
||||
mock_engine.history_data = [] # 空 history → 定寸跳过(mock 无真实 bar)
|
||||
|
||||
with patch.dict("sys.modules", {"vnpy_ctastrategy.backtesting": mock_module}):
|
||||
with patch.dict("sys.modules", {"sanguo_backtest.ashare_engine": mock_ashare}):
|
||||
result = run_cta_backtest(
|
||||
strategy_class=mock_strategy_class,
|
||||
symbol="600000",
|
||||
@@ -101,12 +110,12 @@ class TestRunCtaBacktest:
|
||||
assert result.params == {"window": 20}
|
||||
assert result.start == "2024-01-01"
|
||||
assert result.end == "2024-03-31"
|
||||
assert result.statistics == {
|
||||
"total_return": 0.15,
|
||||
"sharpe_ratio": 1.2,
|
||||
"max_drawdown": -0.08,
|
||||
"win_rate": 0.55
|
||||
}
|
||||
# statistics 是 dict——vnpy calculate_statistics 的原始键会被 compute_metrics
|
||||
# 的 scalars 覆盖/扩充(容器内 config 加载成功时 metrics 会跑,覆盖 mock 的 0.15;
|
||||
# Mac 无 config 时 metrics 跳过,保留 mock 值)。具体数值随环境,真实值由
|
||||
# test_integration_ashare 验证;此处只验结构 + mock 特有字段。
|
||||
assert isinstance(result.statistics, dict)
|
||||
assert result.statistics.get("sizing_shares_per_lot") == 0
|
||||
assert result.error_msg is None
|
||||
|
||||
# Verify BacktestingEngine methods were called
|
||||
@@ -130,11 +139,11 @@ class TestRunCtaBacktest:
|
||||
# Mock config
|
||||
mock_cfg = Mock()
|
||||
|
||||
# Create mock module with BacktestingEngine
|
||||
mock_module = MagicMock()
|
||||
mock_module.BacktestingEngine = Mock(return_value=mock_engine)
|
||||
# Create mock for AShareBacktestingEngine
|
||||
mock_ashare = MagicMock()
|
||||
mock_ashare.AShareBacktestingEngine = Mock(return_value=mock_engine)
|
||||
|
||||
with patch.dict("sys.modules", {"vnpy_ctastrategy.backtesting": mock_module}):
|
||||
with patch.dict("sys.modules", {"sanguo_backtest.ashare_engine": mock_ashare}):
|
||||
result = run_cta_backtest(
|
||||
strategy_class=mock_strategy_class,
|
||||
symbol="000001",
|
||||
@@ -165,11 +174,11 @@ class TestRunCtaBacktest:
|
||||
|
||||
mock_cfg = Mock()
|
||||
|
||||
# Create mock module with BacktestingEngine
|
||||
mock_module = MagicMock()
|
||||
mock_module.BacktestingEngine = Mock(return_value=mock_engine)
|
||||
# Create mock for AShareBacktestingEngine
|
||||
mock_ashare = MagicMock()
|
||||
mock_ashare.AShareBacktestingEngine = Mock(return_value=mock_engine)
|
||||
|
||||
with patch.dict("sys.modules", {"vnpy_ctastrategy.backtesting": mock_module}):
|
||||
with patch.dict("sys.modules", {"sanguo_backtest.ashare_engine": mock_ashare}):
|
||||
result1 = run_cta_backtest(
|
||||
strategy_class=mock_strategy_class,
|
||||
symbol="600000",
|
||||
@@ -243,16 +252,18 @@ class TestRunCtaBacktest:
|
||||
"drawdown": pd.Series([0.0, -0.01, -0.02])
|
||||
}
|
||||
|
||||
# Create mock module with BacktestingEngine
|
||||
mock_module = MagicMock()
|
||||
mock_module.BacktestingEngine = Mock(return_value=mock_engine)
|
||||
# Create mock for AShareBacktestingEngine
|
||||
mock_ashare = MagicMock()
|
||||
mock_ashare.AShareBacktestingEngine = Mock(return_value=mock_engine)
|
||||
mock_engine.trades = {"t1": MagicMock()} # 非空 trades 避免 degenerate
|
||||
mock_engine.history_data = [] # 空 history → 定寸跳过(mock 无真实 bar)
|
||||
|
||||
# Mock tzlocal and vnpy modules to avoid import errors
|
||||
mock_tzlocal = MagicMock()
|
||||
mock_tzlocal.get_localzone_name = Mock(return_value="UTC")
|
||||
|
||||
with patch.dict("sys.modules", {
|
||||
"vnpy_ctastrategy.backtesting": mock_module,
|
||||
"sanguo_backtest.ashare_engine": mock_ashare,
|
||||
"tzlocal": mock_tzlocal,
|
||||
"vnpy.trader.setting": MagicMock()
|
||||
}):
|
||||
@@ -320,15 +331,17 @@ class TestRunCtaBacktest:
|
||||
mock_metrics_result.scalars = {"alpha": 0.05}
|
||||
mock_metrics_result.series = {}
|
||||
|
||||
mock_module = MagicMock()
|
||||
mock_module.BacktestingEngine = Mock(return_value=mock_engine)
|
||||
mock_ashare = MagicMock()
|
||||
mock_ashare.AShareBacktestingEngine = Mock(return_value=mock_engine)
|
||||
mock_engine.trades = {"t1": MagicMock()} # 非空 trades 避免 degenerate
|
||||
mock_engine.history_data = [] # 空 history → 定寸跳过(mock 无真实 bar)
|
||||
|
||||
# Mock tzlocal and vnpy modules to avoid import errors
|
||||
mock_tzlocal = MagicMock()
|
||||
mock_tzlocal.get_localzone_name = Mock(return_value="UTC")
|
||||
|
||||
with patch.dict("sys.modules", {
|
||||
"vnpy_ctastrategy.backtesting": mock_module,
|
||||
"sanguo_backtest.ashare_engine": mock_ashare,
|
||||
"tzlocal": mock_tzlocal,
|
||||
"vnpy.trader.setting": MagicMock()
|
||||
}):
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""A股适配层真实集成测试(容器内跑,Mac 本机无法运行)。
|
||||
|
||||
需要 vnpy_ctastrategy + quant_trading.db + A 股 K 线数据。
|
||||
用法:容器内 `pytest tests/backtest/test_integration_ashare.py -m integration`
|
||||
|
||||
验证 Phase 1+2 四项核心断言:
|
||||
- C1 定寸:成交金额 ≈ 满仓量级(size=N 方案,volume=1 手=N 股)
|
||||
- C2 做空拦截:无 SHORT+OPEN 成交
|
||||
- H3 真实费用:end_balance != capital(非空转,费用+盈亏反映在余额)
|
||||
- 非噪声:|total_return| > 1e-3
|
||||
"""
|
||||
import pytest
|
||||
|
||||
# 容器内才有 vnpy_ctastrategy;Mac 本机自动 skip(不中断 pytest 全量跑)
|
||||
pytest.importorskip("vnpy_ctastrategy")
|
||||
|
||||
pytestmark = [pytest.mark.integration]
|
||||
|
||||
|
||||
def test_double_ma_600000_2022_2024():
|
||||
"""DoubleMa 600000 2022-2024 真实回测验证。
|
||||
|
||||
标记 integration → 仅容器内跑(需 vnpy + quant_trading.db + A 股日线数据)。
|
||||
用 3 年窗口确保 ArrayManager(100) 充分暖机 + 产生足够多 MA 交叉信号
|
||||
(2024H1 窗口太短,仅 111 根 bar 暖机后信号窗口不足,会误判退化)。
|
||||
"""
|
||||
from vnpy_ctastrategy.strategies.double_ma_strategy import DoubleMaStrategy
|
||||
from sanguo_backtest.cta_engine import run_cta_backtest
|
||||
|
||||
capital = 1_000_000
|
||||
position_pct = 0.95
|
||||
|
||||
result = run_cta_backtest(
|
||||
strategy_class=DoubleMaStrategy,
|
||||
symbol="600000",
|
||||
params={"fast_window": 5, "slow_window": 10},
|
||||
start="2022-01-01",
|
||||
end="2024-12-31",
|
||||
cfg=None, # cta_engine 内部 load_config
|
||||
db_path="/tmp/test_integration_ashare.db",
|
||||
benchmark="hs300",
|
||||
capital=capital,
|
||||
position_pct=position_pct,
|
||||
)
|
||||
|
||||
# 基本成功检查
|
||||
assert result.status in ("done", "degenerate"), f"回测失败: {result.error_msg}"
|
||||
assert result.status == "done", f"回测退化(不应退化): {result.statistics.get('degenerate_reason')}"
|
||||
|
||||
stats = result.statistics
|
||||
trades = result.trades
|
||||
|
||||
# H3: end_balance != capital(非空转——有费用+盈亏)
|
||||
end_balance = stats.get("end_balance")
|
||||
assert end_balance is not None, "statistics 缺 end_balance"
|
||||
assert abs(end_balance - 1_000_000) > 1.0, f"end_balance={end_balance} 与 capital 几乎相同(空转)"
|
||||
|
||||
# C2: 做空拦截——无 SHORT+OPEN
|
||||
if trades is not None and not trades.empty:
|
||||
short_opens = trades[
|
||||
(trades["direction"].str.contains("SHORT"))
|
||||
& (trades["offset"].str.contains("OPEN"))
|
||||
]
|
||||
assert len(short_opens) == 0, f"存在 SHORT+OPEN 成交(做空未拦截): {short_opens}"
|
||||
|
||||
# C1: 定寸生效——成交金额 ≈ 满仓量级(size=N 方案:volume=1 手,turnover=1*N*price)
|
||||
sizing_shares_per_lot = stats.get("sizing_shares_per_lot", 0)
|
||||
if trades is not None and not trades.empty and sizing_shares_per_lot > 0:
|
||||
first_trade = trades.iloc[0]
|
||||
turnover = first_trade["volume"] * sizing_shares_per_lot * first_trade["price"]
|
||||
assert turnover > capital * position_pct * 0.5, (
|
||||
f"首笔成交金额={turnover:.0f} 未达满仓量级 "
|
||||
f"(capital={capital} pct={position_pct} N={sizing_shares_per_lot})"
|
||||
)
|
||||
|
||||
# 非噪声:|total_return| > 1e-3
|
||||
total_return = stats.get("total_return")
|
||||
if total_return is not None:
|
||||
assert abs(total_return) > 1e-3, f"|total_return|={abs(total_return)} <= 1e-3(噪声)"
|
||||
|
||||
# H3: 费用可见——statistics 含 stamp_duty 或 commission > 0
|
||||
total_commission = stats.get("total_commission", 0)
|
||||
assert total_commission > 0, f"total_commission={total_commission}(费用未计入)"
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user