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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user