Files
sanguo_vnpy_v2/sanguo_backtest/ashare_engine.py
T
claude_dev 8d55e414fa 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.
2026-07-12 23:39:45 +08:00

177 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""A 股适配层:子类化 vnpy_ctastrategy BacktestingEngine + DailyResult。
vnpy 源码零修改,全部覆写在本文件。
- AShareDailyResultA 股费用模型(佣金保底、印花税卖方、过户费沪市)
- AShareBacktestingEngine
- send_order 覆写 → 做空拦截(SHORT+OPEN 拒单)+ 定寸重算 volume
- update_daily_close 覆写 → 工厂换 AShareDailyResult(父类在 :647 实例化 DailyResult
"""
from __future__ import annotations
import logging
from vnpy_ctastrategy.backtesting import BacktestingEngine, DailyResult
from vnpy.trader.constant import Direction, Offset
logger = logging.getLogger(__name__)
class AShareDailyResult(DailyResult):
"""A 股日度盈亏:佣金双边保底 + 印花税卖方 + 过户费沪市。
父类 calculate_pnl 用 turnover*rate 算佣金(单边商),A 股实际:
- 佣金 = max(turnover * commission_rate, min_commission),买卖都收
- 印花税 = turnover * stamp_duty_rate,卖方 onlydirection==SHORT
- 过户费 = turnover * transfer_fee_rate,沪市 only
"""
def __init__(
self,
date,
close_price: float,
commission_rate: float = 0.00025,
min_commission: float = 5.0,
stamp_duty_rate: float = 0.0005,
transfer_fee_rate: float = 0.00001,
is_sse: bool = False,
) -> None:
super().__init__(date, close_price)
self.commission_rate: float = commission_rate
self.min_commission: float = min_commission
self.stamp_duty_rate: float = stamp_duty_rate
self.transfer_fee_rate: float = transfer_fee_rate
self.is_sse: bool = is_sse
# 费用明细(持久化可见——父类 calculate_result 会遍历 __dict__ 落列)
self.stamp_duty: float = 0.0
self.transfer_fee: float = 0.0
def calculate_pnl(
self,
pre_close: float,
start_pos: float,
size: float,
rate: float,
slippage: float,
) -> None:
"""覆写父类:用 A 股费用模型替换 commission = turnover * rate。
签名与父类一致(calculate_result 传 self.rate/self.slippage/self.size),
但 rate 参数被忽略——佣金由 self.commission_rate + self.min_commission 决定。
"""
# 首日无 pre_close 时用 1 防除零(与父类逻辑一致)
self.pre_close = pre_close if pre_close else 1
self.start_pos = start_pos
self.end_pos = start_pos
self.holding_pnl = self.start_pos * (self.close_price - self.pre_close) * size
self.trade_count = len(self.trades)
for trade in self.trades:
if trade.direction == Direction.LONG:
pos_change = trade.volume
else:
pos_change = -trade.volume
self.end_pos += pos_change
turnover: float = trade.volume * size * trade.price
self.trading_pnl += pos_change * (self.close_price - trade.price) * size
self.slippage += trade.volume * size * slippage
self.turnover += turnover
# A 股佣金:双边,最低 min_commission 元
self.commission += max(turnover * self.commission_rate, self.min_commission)
# 印花税:卖方 onlySHORT = 卖出)
if trade.direction == Direction.SHORT:
self.stamp_duty += turnover * self.stamp_duty_rate
# 过户费:沪市 only
if self.is_sse:
self.transfer_fee += turnover * self.transfer_fee_rate
# net_pnl 扣除全部费用
self.total_pnl = self.trading_pnl + self.holding_pnl
self.net_pnl = (
self.total_pnl
- self.commission
- self.slippage
- self.stamp_duty
- self.transfer_fee
)
class AShareBacktestingEngine(BacktestingEngine):
"""A 股回测引擎:long-only 拦截 + A 股费用。
覆写点:
1. send_order → 拦截 SHORT+OPENA股不可做空)
2. update_daily_close → 工厂换 AShareDailyResult(父类 :647 实例化 DailyResult
定寸(C1)通过 engine.size = N 实现,不在此处处理:
cta_engine 在 load_data 后取首根 bar close 算 N = floor(capital*pct/close/100)*100
设置 engine.size = N。vnpy 的 turnover/PnL 自动 ×size,策略 volume 保持 1 手=N 股=满仓。
"""
def __init__(self) -> None:
super().__init__()
# A 股费用参数(默认值,cta_engine 可覆盖)
self.commission_rate: float = 0.00025 # 万 2.5
self.min_commission: float = 5.0 # 最低 5 元
self.stamp_duty_rate: float = 0.0005 # 卖方 0.05%
self.transfer_fee_rate: float = 0.00001 # 沪市 0.001%
self.is_sse: bool = False
def send_order(
self,
strategy,
direction: Direction,
offset: Offset,
price: float,
volume: float,
stop: bool,
lock: bool,
net: bool,
) -> list:
"""覆写父类 send_order:做空拦截(C2)。
C2 做空拦截:SSE/SZSE 标的不可做空,SHORT+OPEN 直接拒单。
SHORT+CLOSE(平多)允许。
volume 不动——定寸由 engine.size = N 实现(见类文档)。
"""
if direction == Direction.SHORT and offset == Offset.OPEN:
logger.warning(
"A股不支持做空,拒单: direction=%s offset=%s price=%s volume=%s",
direction, offset, price, volume,
)
return []
return super().send_order(
strategy, direction, offset, price, volume, stop, lock, net
)
def update_daily_close(self, price: float) -> None:
"""覆写父类工厂方法:用 AShareDailyResult 替换 DailyResult。
父类原实现(backtesting.py:639-647):
daily_result = self.daily_results.get(d)
if daily_result: daily_result.close_price = price
else: self.daily_results[d] = DailyResult(d, price)
"""
d = self.datetime.date()
daily_result = self.daily_results.get(d, None)
if daily_result:
daily_result.close_price = price
else:
self.daily_results[d] = AShareDailyResult(
d,
price,
commission_rate=self.commission_rate,
min_commission=self.min_commission,
stamp_duty_rate=self.stamp_duty_rate,
transfer_fee_rate=self.transfer_fee_rate,
is_sse=self.is_sse,
)