Backtesting.py / A 股适配
Backtesting.py 适配 A 股与港股:T+1、涨跌停、复权与交易成本怎么处理
Backtesting.py 是一个通用的 OHLCV 回测引擎,它本身不含 A 股交易制度:T+1、涨跌停、整手、印花税与复权口径都要由使用者声明或近似建模。本文把「哪些能原生支持、哪些能用代码模拟、哪些只能近似」逐条分清,并给出 6 只 A 股与 1 只港股在同一区间、同一策略下的本站实测数字。
A 股规则里,Backtesting.py 原生支持哪些、要自己补哪些?
先分清三档再看细节:库原生支持的是「下单、成交、统计」这一层;交易制度要你用代码约束;数据口径它完全不介入。下表逐条对应本文后面的实测章节。
| A 股/港股规则 | Backtesting.py 的默认行为 | 你要做什么 | 属于哪一档 |
|---|---|---|---|
| T+1(当日买入不可当日卖出) | 先处理上一根 bar 的挂单再调用 next(),成交天然跨日 | 默认设置即可,无需额外防护 | 原生支持(由撮合顺序决定) |
| 成交时点(当日收盘 / 次日开盘) | 默认按下一根 bar 开盘成交;trade_on_close=True 时按本根收盘成交 | 明确声明并在报告里记录你用的是哪一种 | 原生支持 |
| 涨跌停不可成交 | 不判断涨跌停,一律按开盘价(或收盘价)成交 | 自行过滤不可成交日或把订单延后 | 只能近似 |
| 停牌 | 没有停牌概念,只按你给的数据行推进 | 在数据层处理缺日与零成交 | 只能近似 |
| 整手(100 股) | 支持整数股,但不校验是否为一手整数倍 | 自己按 100 股取整后再下单 | 可代码模拟 |
| 佣金 / 印花税 / 过户费 | 单一 commission 在买卖各收一次;也可传 callable 自定义 | 用 callable 建模「卖出才收印花税」的非对称费用 | 原生支持(结构上有取舍) |
| 成交量约束与冲击成本 | 没有成交量模型,订单数量不受当日成交量限制 | 自建约束或接受偏差并写明 | 无法精确模拟 |
| 复权口径 | 不关心复权,按你传入的价格计算损益 | 自己选定口径并声明「复权价不等于可成交价」 | 只能近似(口径问题) |
| 多资产组合与共享资金 | 单资产引擎;MultiBacktest 是每个标的各跑一套独立账户 | 组合级资金分配需换支持组合的框架 | 不支持 |
Backtesting.py 怎么处理 A 股 T+1 与成交时点?
T+1 不是靠参数开关实现的,而是撮合顺序的自然结果:订单在 next() 里创建,要等下一根 bar 才处理。真正需要你决定的是「按哪个价格成交」——它比多数参数调优更能左右结论。
| 撮合设置 | 区间收益(贵州茅台 600519) | 交易笔数 | 当日买当日卖 | 首笔成交 |
|---|---|---|---|---|
| 默认:订单在下一根 bar 开盘成交 | -69.16% | 123 | 0 笔 | 2018-01-31 买入 |
| trade_on_close=True:本根 bar 收盘成交 | -35.22% | 123 | 0 笔 | 2018-01-30 买入 |
| 刻意「同根 bar 买后立刻平」的策略(默认设置) | 见下代码 | 1057 | 0 笔 | 最短持有 1 天 |
| 同上策略,trade_on_close=True | 见下代码 | 1057 | 0 笔 | 最短持有 1 天 |
| 默认撮合(内置 GOOG 样例 2004-08-19~2013-03-01) | +469.86% | 94 | 0 笔 | — |
| 两套设置合计(SmaCross 123×2 + 刻意往返策略 1057×2) | — | 2360 | 0 笔 | 最短持有均为 1 天 |
class SameBar(Strategy):
"""故意在每根 bar 上「买完就平」,用来验证会不会出现当日往返"""
def init(self):
pass
def next(self):
if not self.position:
self.buy(size=.9) # 本根 bar 挂单
else:
self.position.close() # 下一根 bar 才处理
stats = Backtest(data, SameBar, cash=100000, trade_on_close=True).run()
t = stats['_trades']
print((t.EntryTime.dt.date == t.ExitTime.dt.date).sum()) # 实测输出:0
print((t.ExitTime - t.EntryTime).dt.days.min()) # 实测输出:1
Backtesting.py 遇到涨跌停与停牌怎么成交?实测给出答案
库不判断「这一天能不能成交」:涨跌停日照样按开盘价撮合,停牌则因为你给的数据里没有那一行而被跳过。下面先看 6 只 A 股样本里的涨跌停分布与真实成交情况。
| 标的 | 涨停日 | 跌停日 | 一字板日 | 策略在涨停日买入成交 | 策略在跌停日卖出成交 |
|---|---|---|---|---|---|
| 贵州茅台 600519 | 0 | 1 | 1 | 0 | 0 |
| 平安银行 000001 | 3 | 1 | 0 | 0 | 0 |
| 招商银行 600036 | 1 | 0 | 0 | 0 | 0 |
| 比亚迪 002594 | 13 | 5 | 0 | 0 | 0 |
| 宁德时代 300750 | 23 | 4 | 7 | 0 | 0 |
| 赛力斯 601127 | 85 | 19 | 3 | 4 | 1 |
# 把「不可成交日」的订单延后一天:用 tag 记录待执行意图
class LimitAware(Strategy):
def init(self):
self.bars_since_limit = 0
def next(self):
prev_pct = self.data.Close[-2] / self.data.Close[-3] - 1 if len(self.data) > 2 else 0
limit_up = prev_pct >= 0.098 # 主板 10%;创业板/科创板 20%;ST 5%,需按板块取值
if limit_up:
self.bars_since_limit = 1 # 当日不可成交:跳过本次下单
return
... # 正常的信号与下单逻辑
Backtesting.py 里 A 股交易成本怎么建模才不离谱?
佣金、过户费与印花税并不是同一件事:前两者双边收,印花税只在卖出侧收。用单一对称 commission 近似,会系统性低估卖出侧成本。下面的 callable 把三者分开建模。
def ashare_cost(order_size, price, comm=0.00025, transfer=0.00001, stamp=0.0005):
"""order_size 为负代表卖出(空头),这也是判断印花税侧的依据"""
notional = abs(order_size) * price
fee = notional * comm + notional * transfer # 佣金 + 过户费:双边
if order_size < 0:
fee += notional * stamp # 印花税:仅卖出侧
return fee
bt = Backtest(data, SmaCross, cash=100000, commission=ashare_cost)
| 佣金 | 卖出印花税 | 区间收益 | 费用合计 | Sharpe | 最大回撤 |
|---|---|---|---|---|---|
| 0 | 0 | -64.44% | 0 | -0.448 | -82.88% |
| 0 | 5bp | -67.00% | 5243 | -0.482 | -83.37% |
| 2.5bp | 0 | -66.97% | 5171 | -0.482 | -83.36% |
| 2.5bp(A 股常用) | 5bp(卖出) | -69.20% | 10119 | -0.515 | -83.86% |
| 5bp | 0 | -69.14% | 10043 | -0.514 | -83.85% |
| 5bp | 5bp | -70.64% | 14875 | -0.536 | -84.30% |
| 10bp | 0 | -72.22% | 19229 | -0.562 | -84.63% |
| 10bp | 5bp | -73.63% | 23468 | -0.587 | -84.96% |
| 20bp | 0 | -78.22% | 34545 | -0.678 | -86.21% |
| 20bp | 5bp | -79.46% | 37907 | -0.707 | -86.48% |
前复权数据能直接喂给 Backtesting.py 做回测吗?
可以拿来算信号,但不能拿来说「这就是我能成交的价格」:前复权把历史价格按累计分红送股向下调整,成交价、股数、现金占用与基准都会随之变化。同一策略换口径,结论能差几十个百分点。
| 复权口径 | 区间收益 | 买入持有基准 | 交易笔数 | 首日收盘价 | 首笔成交价 |
|---|---|---|---|---|---|
| 前复权(qfq) | -69.16% | +107.61% | 123 | 578.82 | 607.72(2018-01-31) |
| 不复权(raw) | -71.90% | +70.73% | 125 | 703.85 | 739.00(2018-01-31) |
| 前复权 · 零成本对照 | -64.44% | +107.61% | 123 | 578.82 | 607.72(2018-01-31) |
| 00700 前复权 · 零成本 | +5.03% | +1.43% | 113 | 365.98 | — |
| 00700 前复权 · 计入港股费用 | -20.91% | +1.43% | 113 | 365.98 | — |
| 前复权 / 不复权 累计调整幅度(600519) | 前复权价 ÷ 不复权价 在样本内介于 0.8224 ~ 1.0000 之间,即八年内最大向下调整约 17.8% | ||||
| 口径 | 算法含义 | 适合用来做什么 | 不适合做什么 / 注意点 |
|---|---|---|---|
| 前复权 qfq | 以最新价为基准向前调整历史价 | 看长期走势、算均线与回撤 | 历史价被下调,不能当成当时的真实成交价;分红越多偏差越大 |
| 后复权 hfq | 以上市首日为基准向后调整 | 做跨期累计收益对比 | 价格量级会失真,看图与计算仓位时容易误判 |
| 不复权 raw | 原样保留当日成交价 | 核对真实成交价与税费、做成本核算 | 除权除息日会出现价格跳空,均线类指标会被污染 |
| 复权因子自算 | 用复权因子自行换算价格 | 需要精确控制口径的严谨研究 | 要自己校验因子来源与拼接方式,出错不易发现 |
| 港股复权 | 同样受分红与拆股影响 | 港股长周期回测 | 与 A 股除权除息节奏不同,跨市场拼接不可直接相加 |
| 指数 / ETF 复权 | 指数点位本身不含分红,价格指数与全收益指数不同 | 做基准对比时明确用的是哪一种 | 用价格指数当基准会低估「买入持有」,从而高估策略 |
Backtesting.py 的下单数量有哪些硬约束?
A 股按「手」下单,而库只保证「整数股」。这两者之间的差距要由你自己的代码补上,下面是从 0.6.6 源码核验到的三条硬规则与对应写法。
| 规则 | 源码/实测表现 | 正确写法 | 写错的后果 |
|---|---|---|---|
| 股数必须是整数 | buy()/sell() 断言 round(size) == size;传 size=100.5 实测抛 AssertionError | 下单前用 int() 取整 | 直接报错中断回测 |
| 不校验一手(100 股) | 库接受 1 股的订单,不会自动取整到 100 | size = int(shares // 100 * 100) | 回测买了现实中买不到的零股 |
| 比例下单的取整 | size 在 0~1 时按可用权益与含费价格向下取整,取整到 0 时订单被取消并给出告警 | 资金边界处留余量,或改用绝对股数 | 小额账户频繁出现「下单被取消」 |
| 未平仓交易是否计入统计 | finalize_trades 默认 False;改为 True 会在最后一根 bar 平掉未平仓交易 | 按你的报告口径显式设置 | 同一策略出现两个收益(实测 -68.99%/122 笔 与 -69.16%/123 笔) |
| 做空方向 | sell() 的订单数为负,库支持空头 | A 股策略自行约束不做空 | 回测出现现实中无法执行的空头持仓 |
| 每手股数(100 股) | 库只校验「整数股」,不校验是否为一手整数倍 | 下单前 size = int(shares // 100 * 100) | 出现零股成交,小资金偏差被放大 |
class Lots(Strategy):
def next(self):
if not self.position and crossover(self.ma1, self.ma2):
lots = int(self.equity * 0.95 // (self.data.Close[-1] * 100)) # 想买几手
if lots >= 1:
self.buy(size=lots * 100) # 传绝对股数:必须是 100 的整数倍
Backtesting.py 的 A 股与港股数据源有哪些字段和日历坑?
回测的所有结论都建立在数据上,而 A 股数据的坑集中在三处:接口字段名不同、交易日历与港股不一致、以及偶发的数据源异常。下面是本站实测的数据体检结果与字段映射。
| 数据集(实测) | 根数 | 重复日期 | 收盘价越界(超出当日高低) | 相对 A 股日历缺日 | 备注 |
|---|---|---|---|---|---|
| 600519 前复权 | 2116 | 0 | 0 | 0 | 通过全部体检项 |
| 600519 不复权 | 2116 | 0 | 0 | 0 | 通过全部体检项 |
| 00700 前复权 | 2144 | 0 | 0 | 63 | 缺日 = 港股休市与 A 股不同 |
| 00700 不复权 | 2144 | 0 | 12 | 63 | 数据源异常,使用前需处理 |
| 002594 前复权 | 2116 | 0 | 0 | 0 | 通过全部体检项 |
| 601127 前复权 | 2112 | 0 | 0 | 4 | 缺日需与停牌核对 |
| 取数方式 | 返回字段(实测) | 需要转换成的列 | 注意点 |
|---|---|---|---|
| 新浪 A 股日线 | date / open / high / low / close / volume / amount | Open / High / Low / Close / Volume + 时间索引 | 小写字段名,需改大小写 |
| 新浪港股日线 | date / open / high / low / close / volume / amount | 同上 | 不传区间会返回全历史,需自行截断 |
| 东财日线 | 中文列名(日期/开盘/收盘/最高/最低/成交量…) | 同上 | 本次实测 4 次重试均连接失败,可用性本身是变量 |
| 本地 CSV | 自定义 | 必须含开高低收,Volume 可选 | 索引必须是时间类型且单调递增,否则回测报错 |
| 指数 / ETF 日线 | 日期 + 开高低收(+ 成交量) | 同上,Volume 可省略或置 0 | 指数无「成交量」语义,别把成交额当成交量 |
| Tushare 日线(本站未实测) | — | 按官方字段字典映射为 OHLCV | 本站未实测该接口,字段与复权参数以 Tushare 官方文档为准 |
Backtesting.py 做 A 股回测要检查哪些规则?适配自检清单
下面 8 项按「先数据、后规则、再成本」的顺序排列,每一项都给出判据与不合格的典型表现;这份清单同时适用于你自己写的策略和网上下载的示例代码。
| 检查项 | 怎么查 | 不合格的典型表现 |
|---|---|---|
| 数据列与索引 | 确认存在开高低收四列、索引为时间类型且单调递增 | 回测一开跑就报列名或索引相关异常 |
| 复权口径 | 确认用的是前复权/后复权/不复权中的哪一种,并写进记录 | 均线在除权日出现跳空,回撤计算失真 |
| 交易日历 | 用与标的匹配的市场日历核对缺日 | 用 A 股日历校验港股,误报大量缺日 |
| T+1 与成交时点 | 确认 trade_on_close 取值,并检查是否存在同 bar 往返假设 | 策略收益对时点设置高度敏感而不自知 |
| 涨跌停与停牌 | 统计样本内涨停/跌停/一字板天数,确认成交是否落在这些日子 | 回测在不可能成交的价格上有成交记录 |
| 成本模型 | 确认卖出印花税是否单独建模、费率是否为当前口径 | 费用被低估,策略「接近盈亏平衡」的假象 |
| 整手与取整 | 检查下单数量是否按 100 股取整 | 出现零股成交,小资金账户偏差明显 |
| 结论声明 | 口径、区间、成本、基准是否都写在结论旁边 | 别人换个假设就跑出完全相反的结论 |
A 股适配常见问题
Backtesting.py 支持 A 股吗?
更准确的说法是:它提供通用 OHLCV 回测引擎,A 股交易规则需要由使用者自行扩展或近似建模。实测中,T+1 由撮合顺序天然满足,但涨跌停、停牌、整手、印花税等要你自己处理;具体能力边界以官方 README 与你安装的版本源码为准。
需要为 A 股写一个自定义券商类吗?
多数情况下不需要。成本可以用 commission 的 callable 形式建模,股数与整手可以在下单前处理,成交限制可以通过延后下单近似。只有在需要更细的撮合细节(逐笔、盘口、部分成交)时,才需要考虑自己实现订单处理逻辑,而那通常意味着要换框架或自建撮合层。
印花税只在卖出时收,单一 commission 参数怎么办?
用 callable:commission=lambda order_size, price: ...,订单数量为负时代表卖出方向,在那里加上印花税即可。要注意库会把佣金摊进成交价,因此带最低佣金的模型会轻微改变下单数量——实测影响在 0.1 个百分点以内,但值得知道。
回测里的「买入持有」基准可以信吗?
要看口径:统计里的买入持有是从「所有指标预热结束后的首根 bar」起算的,不是从数据首根开始。实测中把均线窗口换成 200 日后,同一区间的买入持有基准从 117.19% 变成 187.65%(差 70.46 个百分点)。所以报基准时要写清楚起点,具体实现以官方源码为准。
为什么加了涨跌停过滤后收益变化不大?
因为大多数标的的涨停日很少,而均线类策略的信号日期是分散的。实测中 6 只样本合计 125 个涨停日,只有 4 笔买入落在涨停日。但如果你做的是小盘股或题材股短线策略,命中率会明显上升——判断依据是「先统计你自己的策略有多少笔成交落在不可成交日」,而不是凭感觉。
港股和 A 股能用同一套代码吗?
代码可以复用,但成本模型、交易日历与复权口径要分别处理:港股没有印花税的卖出单边结构(费率与征收方式不同),无涨跌停,休市日与 A 股不一致。实测中腾讯 00700 双均线在零成本下略跑赢买入持有(+5.03% vs +1.43%),计入费用后变成 -20.91%。
这些实测数字能直接用在我的标的上吗?
不能。本文所有数字来自 2018-01-02~2026-09-18 的 6 只 A 股与 1 只港股、单一策略与单一成本假设,只说明方法差异有多大,不代表任何标的的未来表现,也不构成投资建议。要得到你自己的结论,请用同一套可复现的记录方式重跑。