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
+79 -25
View File
@@ -2,6 +2,7 @@
import sys
import os
import math
import logging
import traceback
import uuid
from datetime import datetime
@@ -66,9 +67,25 @@ def guess_exchange(symbol: str) -> Exchange:
return Exchange("SSE")
def run_cta_backtest(strategy_class, symbol: str, params: dict, start: str, end: str, cfg, db_path: str, benchmark: str = "hs300", task_id: str | None = None) -> BacktestResult:
def run_cta_backtest(
strategy_class,
symbol: str,
params: dict,
start: str,
end: str,
cfg,
db_path: str,
benchmark: str = "hs300",
task_id: str | None = None,
capital: float = 1_000_000,
position_pct: float = 0.95,
commission_rate: float = 0.00025,
min_commission: float = 5.0,
stamp_duty_rate: float = 0.0005,
transfer_fee_rate: float = 0.00001,
) -> BacktestResult:
"""
Run CTA strategy backtest using vnpy_ctastrategy BacktestingEngine.
Run CTA strategy backtest using AShareBacktestingEngine (vnpy 子类化).
Args:
strategy_class: CTA strategy class to backtest
@@ -81,6 +98,12 @@ def run_cta_backtest(strategy_class, symbol: str, params: dict, start: str, end:
benchmark: Benchmark code (hs300/zz500)
task_id: Optional task ID from runner (reused as the persisted task_id so
runner-id == DB task_id; if omitted a fresh uuid is generated).
capital: Starting capital (元)
position_pct: 仓位占比 0~1(定寸用:shares = floor(capital*pct/price/100)*100
commission_rate: A 股佣金率双边(默认万 2.5)
min_commission: 单笔最低佣金(默认 5 元)
stamp_duty_rate: 印花税率卖方(默认 0.0005)
transfer_fee_rate: 过户费率沪市(默认 0.00001)
Returns:
BacktestResult: Result object with backtest statistics and status
@@ -90,18 +113,19 @@ def run_cta_backtest(strategy_class, symbol: str, params: dict, start: str, end:
task_id = f"cta_{uuid.uuid4().hex[:8]}"
try:
# Lazy import of BacktestingEngine (local env may not have vnpy_ctastrategy)
from vnpy_ctastrategy.backtesting import BacktestingEngine
# Lazy import of AShareBacktestingEngine (subclass of vnpy BacktestingEngine)
from sanguo_backtest.ashare_engine import AShareBacktestingEngine
# Build vt_symbol for A-shares
vt_symbol = f"{symbol}.{guess_exchange(symbol).value}"
_exchange = guess_exchange(symbol)
vt_symbol = f"{symbol}.{_exchange.value}"
# Convert date strings to datetime objects
start_dt = datetime.strptime(start, "%Y-%m-%d")
end_dt = datetime.strptime(end, "%Y-%m-%d") if end else None
# Create and configure backtesting engine
engine = BacktestingEngine()
# Create and configure A-share backtesting engine
engine = AShareBacktestingEngine()
# Set parameters with A-share specific values
engine.set_parameters(
@@ -109,13 +133,21 @@ def run_cta_backtest(strategy_class, symbol: str, params: dict, start: str, end:
interval="d", # Interval.DAILY.value — vnpy enum uses "d" not "1d"
start=start_dt,
end=end_dt,
rate=0.001, # Commission rate (0.1% for A-shares)
rate=commission_rate, # 佣金率(AShareDailyResult 用自身 commission_rate,此处仅保持一致)
slippage=0, # No slippage for simplicity
size=1, # Contract size (1 for stocks)
pricetick=0.01, # Minimum price tick (0.01 yuan for A-shares)
capital=1_000_000 # Starting capital — 0 causes instant liquidation on first trade
capital=capital, # Starting capital
)
# A 股适配参数(定寸 + 费用)
engine.position_pct = position_pct
engine.commission_rate = commission_rate
engine.min_commission = min_commission
engine.stamp_duty_rate = stamp_duty_rate
engine.transfer_fee_rate = transfer_fee_rate
engine.is_sse = (_exchange.value == "SSE")
# Add strategy
engine.add_strategy(strategy_class, params)
@@ -130,8 +162,8 @@ def run_cta_backtest(strategy_class, symbol: str, params: dict, start: str, end:
_dcfg = load_config(find_config_path())
SETTINGS["database.name"] = "sqlite"
SETTINGS["database.database"] = _dcfg.data_paths["vnpy_db"]
except Exception:
pass
except Exception as e:
logging.warning("vnpy 数据库配置加载失败(回测可能无法加载历史数据): %s", e)
# Capture engine run output (load/run/stats) to per-task log file, so the
# result page 日志 tab has real content. Tee stdout inside the worker process
@@ -150,6 +182,22 @@ def run_cta_backtest(strategy_class, symbol: str, params: dict, start: str, end:
# Load historical data
engine.load_data()
# C1 定寸:size = N 股/手。load_data 后取首根 bar close 算满仓手数。
# vnpy 的 turnover/PnL/commission 自动 ×size,策略 volume 保持 1 手=N 股=满仓,
# 所有策略(DoubleMa/BollChannel/DualThrust)无需改。
_sizing_shares_per_lot = 0
if engine.history_data:
_first_close = engine.history_data[0].close_price
if _first_close > 0:
_sizing_shares_per_lot = int(
math.floor(capital * position_pct / _first_close / 100) * 100
)
engine.size = _sizing_shares_per_lot
logging.info(
"定寸: N=%s 股/手 (capital=%s pct=%s first_close=%s)",
_sizing_shares_per_lot, capital, position_pct, _first_close,
)
# Run backtesting
engine.run_backtesting()
@@ -169,6 +217,20 @@ def run_cta_backtest(strategy_class, symbol: str, params: dict, start: str, end:
for k, v in raw_stats.items()
}
# H7 degenerate 检测:零成交或空数据不静默 done
_degenerate_reason = None
trades_dict_check = engine.trades if isinstance(engine.trades, dict) else {}
if not trades_dict_check:
_degenerate_reason = "零成交记录(策略未触发任何交易信号)"
elif daily_df is None or (hasattr(daily_df, "empty") and daily_df.empty):
_degenerate_reason = "日度盈亏数据为空"
if _degenerate_reason:
statistics["degenerate_reason"] = _degenerate_reason
logging.warning("回测退化: %s (symbol=%s strategy=%s)", _degenerate_reason, symbol, strategy_class.__name__)
# C1: 暴露定寸参数到结果(集成测试 + 前端可查)
statistics["sizing_shares_per_lot"] = _sizing_shares_per_lot
# Calculate relative metrics against benchmark (Task 3)
# Ensure daily_df index is datetime for compute_metrics
if daily_df is not None and not daily_df.empty:
@@ -193,15 +255,8 @@ def run_cta_backtest(strategy_class, symbol: str, params: dict, start: str, end:
benchmark_returns = bench_df["close"].pct_change().dropna()
benchmark_returns.index = pd.to_datetime(bench_df["date"].iloc[1:])
# vnpy daily_df must have "return" column for compute_metrics
# If not present, calculate from balance
if "return" not in daily_df.columns:
if "balance" in daily_df.columns:
daily_df["return"] = daily_df["balance"].pct_change().fillna(0)
elif "net_pnl" in daily_df.columns:
daily_df["return"] = (daily_df["net_pnl"] / 1_000_000).fillna(0)
else:
daily_df["return"] = 0.0
# H4: compute_metrics 内部从 daily_df["balance"] 自算 simple return
# 不再依赖 vnpy 的 log return 列(删原三路 fallback
# Compute relative metrics
metrics_result = compute_metrics(daily_df, benchmark_returns)
@@ -228,8 +283,7 @@ def run_cta_backtest(strategy_class, symbol: str, params: dict, start: str, end:
except Exception as metrics_error:
# Log but don't fail backtest if metrics calculation fails
import logging
logging.warning(f"Failed to compute relative metrics: {metrics_error}")
logging.warning("相对指标计算失败(回测结果不受影响): %s", metrics_error)
# Build equity curve DataFrame (S1.2): use the daily_df returned by
# calculate_result (index=date, has a 'balance' column). get_all_daily_results
@@ -238,7 +292,7 @@ def run_cta_backtest(strategy_class, symbol: str, params: dict, start: str, end:
if "balance" in daily_df.columns:
_bal = daily_df["balance"].astype(float)
elif "net_pnl" in daily_df.columns:
_bal = daily_df["net_pnl"].astype(float).cumsum() + 1_000_000
_bal = daily_df["net_pnl"].astype(float).cumsum() + capital
else:
_bal = None
equity_df = pd.DataFrame({
@@ -262,11 +316,11 @@ def run_cta_backtest(strategy_class, symbol: str, params: dict, start: str, end:
for t in trades_dict.values()
])
# Build result object
# Build result object — H7: degenerate 时标记状态(不静默 done)
result = BacktestResult(
task_id=task_id,
type="cta",
status="done",
status="degenerate" if _degenerate_reason else "done",
strategy=strategy_class.__name__,
symbol=symbol,
params=params,