建模滑点
在实盘交易中,订单很少能以回测所假设的确切价格成交。买卖价差、延迟以及你自己订单造成的市场冲击等因素,都会使成交价格朝不利方向偏移。这种差异被称为*滑点*。如果回测忽略了滑点,就会高估策略的真实表现。
本文档演示了 PyBroker 在 v2 中新增的三种内置滑点模型,并介绍了 SlippageModel 基类,你可以使用它来编写自己的自定义模型。
基准策略
为了观察每种模型的效果,我们复用 回测策略 中的逢低买入策略。规则很简单:当最新收盘价跌破前一天的最低价时买入。我们使用 calc_target_shares 将投资组合的 25% 分配给该仓位,并通过 hold_bars 持有 3 根 K 线。由于该策略交易频繁,每次成交的微小成本会累积成总回报中一个明显的差异。
[1]:
import pybroker
from pybroker import Strategy, YFinance
pybroker.enable_data_source_cache("slippage")
def buy_low(ctx):
# If shares were already purchased and are currently being held, then
# return.
if ctx.long_pos():
return
# If the latest close price is less than the previous day's low price,
# then place a buy order.
if ctx.bars >= 2 and ctx.close[-1] < ctx.low[-2]:
# Buy a number of shares that is equal to 25% of the portfolio.
ctx.buy_shares = ctx.calc_target_shares(0.25)
# Hold the position for 3 bars before liquidating.
ctx.hold_bars = 3
symbols = ["F", "BAC", "T"]
strategy = Strategy(YFinance(), start_date="1/1/2021", end_date="1/1/2026")
strategy.add_execution(buy_low, symbols)
现在,我们运行没有滑点的基准回测。默认情况下,每笔订单都以该 K 线最低价与最高价的中点(PriceType.MIDDLE)成交:
[2]:
result = strategy.backtest()
print(f"Total return: {result.metrics.total_return_pct:.2f}%")
result.orders.head()
Backtesting: 2021-01-01 00:00:00 to 2026-01-01 00:00:00
Loading bar data...
[*********************100%***********************] 3 of 3 completed
Loaded bar data: 0:00:01
Test split: 2021-01-04 00:00:00 to 2025-12-31 00:00:00
100% (1255 of 1255) |####################| Elapsed Time: 0:00:00 Time: 0:00:00
Finished backtest: 0:00:01
Total return: 18.02%
[2]:
| type | symbol | date | created | order_type | intent | shares | limit_price | market_price | fill_price | fees | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| id | |||||||||||
| 1 | buy | BAC | 2021-01-11 | 2021-01-08 | market | buy_to_open | 768 | NaN | 32.52 | 32.52 | 0.0 |
| 2 | buy | T | 2021-01-11 | 2021-01-08 | market | buy_to_open | 1140 | NaN | 21.79 | 21.79 | 0.0 |
| 3 | sell | BAC | 2021-01-14 | NaT | stop_bar | sell_to_close | 768 | NaN | 33.89 | 33.89 | 0.0 |
| 4 | sell | T | 2021-01-14 | NaT | stop_bar | sell_to_close | 1140 | NaN | 22.02 | 22.02 | 0.0 |
| 5 | buy | BAC | 2021-01-19 | 2021-01-15 | market | buy_to_open | 767 | NaN | 32.90 | 32.90 | 0.0 |
固定滑点
FixedSlippageModel 会施加一个固定的、不利的价格调整,以基点为单位(1 个基点等于 0.01%)。买入价格会上调 bps,卖出价格则会下调 bps。传入 bps=0 会完全禁用该调整。
由于接下来的示例还会运行多次回测,我们也会使用 disable_logging 禁用日志记录,以保持输出简洁。接下来,我们使用 set_slippage_model 将模型附加到 Strategy:
[3]:
from pybroker import FixedSlippageModel
pybroker.disable_logging()
strategy.set_slippage_model(FixedSlippageModel(bps=10))
result = strategy.backtest()
print(f"Total return: {result.metrics.total_return_pct:.2f}%")
result.orders[result.orders["symbol"] == "T"].head()
Total return: -8.66%
[3]:
| type | symbol | date | created | order_type | intent | shares | limit_price | market_price | fill_price | fees | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| id | |||||||||||
| 2 | buy | T | 2021-01-11 | 2021-01-08 | market | buy_to_open | 1140 | NaN | 21.79 | 21.81 | 0.0 |
| 4 | sell | T | 2021-01-14 | NaT | stop_bar | sell_to_close | 1140 | NaN | 22.02 | 22.00 | 0.0 |
| 11 | buy | T | 2021-01-29 | 2021-01-28 | market | buy_to_open | 1128 | NaN | 21.76 | 21.78 | 0.0 |
| 14 | sell | T | 2021-02-03 | NaT | stop_bar | sell_to_close | 1128 | NaN | 21.58 | 21.56 | 0.0 |
| 17 | buy | T | 2021-02-09 | 2021-02-08 | market | buy_to_open | 1158 | NaN | 21.65 | 21.67 | 0.0 |
波动率滑点
由于滑点往往会随着波动率上升而增大,VolatilitySlippageModel 将其不利价格调整直接与市场波动联系起来。它使用成交 K 线的 平均真实波幅(ATR) (参见 atr) 来缩放滑点,按 scale * ATR 将成交价格推向不利于你订单的方向。ATR 是基于截至成交 K 线为止的 atr_period 根 K 线计算的;在预热期内(即尚未形成完整 ATR 窗口之前)的成交将不会被调整:
[4]:
from pybroker import VolatilitySlippageModel
strategy.set_slippage_model(VolatilitySlippageModel(atr_period=14, scale=0.1))
result = strategy.backtest()
print(f"Total return: {result.metrics.total_return_pct:.2f}%")
result.orders.head()
Total return: -39.55%
[4]:
| type | symbol | date | created | order_type | intent | shares | limit_price | market_price | fill_price | fees | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| id | |||||||||||
| 1 | buy | BAC | 2021-01-11 | 2021-01-08 | market | buy_to_open | 768 | NaN | 32.52 | 32.52 | 0.0 |
| 2 | buy | T | 2021-01-11 | 2021-01-08 | market | buy_to_open | 1140 | NaN | 21.79 | 21.79 | 0.0 |
| 3 | sell | BAC | 2021-01-14 | NaT | stop_bar | sell_to_close | 768 | NaN | 33.89 | 33.89 | 0.0 |
| 4 | sell | T | 2021-01-14 | NaT | stop_bar | sell_to_close | 1140 | NaN | 22.02 | 22.02 | 0.0 |
| 5 | buy | BAC | 2021-01-19 | 2021-01-15 | market | buy_to_open | 767 | NaN | 32.90 | 32.90 | 0.0 |
成交量滑点
VolumeSlippageModel 通过施加以下两种机制来考虑有限的市场流动性:
成交量限制: 成交股数会被限制在该 K 线总成交量的一定比例以内(
volume_limit * volume)。超出该限制的股数会被取消,而不会延后到下一根 K 线成交。价格冲击: 成交价格会根据你的订单规模相对于市场的大小向不利方向变动。该调整量的计算方式为
price_impact * (filled_shares / volume) ** 2乘以最初的成交价格。
一个 10 万美元的账户在交易流动性充足的大盘股时,很少会触及这些限制。例如,25% 的配置比例在福特汽车的日成交量中甚至算不上一个舍入误差。然而,同样的配置比例在交易清淡的小盘股中,却可能占当日成交量的相当大一部分。如果没有成交量模型,回测就会不切实际地假设整笔订单都能以报价成交:
[5]:
from pybroker import VolumeSlippageModel
smallcaps = Strategy(YFinance(), start_date="1/1/2021", end_date="1/1/2026")
smallcaps.add_execution(buy_low, ["ESCA", "BSET", "HURC"])
result = smallcaps.backtest()
print(f"Return without a volume model: {result.metrics.total_return_pct:.2f}%")
smallcaps.set_slippage_model(
VolumeSlippageModel(price_impact=0.1, volume_limit=0.025)
)
result = smallcaps.backtest()
print(f"Return with a volume model: {result.metrics.total_return_pct:.2f}%")
result.orders.head()
Return without a volume model: 16.67%
Return with a volume model: 12.46%
[5]:
| type | symbol | date | created | order_type | intent | shares | limit_price | market_price | fill_price | fees | |
|---|---|---|---|---|---|---|---|---|---|---|---|
| id | |||||||||||
| 1 | buy | HURC | 2021-01-06 | 2021-01-05 | market | buy_to_open | 874 | NaN | 30.30 | 30.30 | 0.0 |
| 2 | sell | HURC | 2021-01-11 | NaT | stop_bar | sell_to_close | 874 | NaN | 30.28 | 30.28 | 0.0 |
| 3 | buy | ESCA | 2021-01-11 | 2021-01-08 | market | buy_to_open | 677 | NaN | 21.79 | 21.79 | 0.0 |
| 4 | buy | BSET | 2021-01-12 | 2021-01-11 | market | buy_to_open | 1268 | NaN | 20.39 | 20.39 | 0.0 |
| 5 | sell | ESCA | 2021-01-14 | NaT | stop_bar | sell_to_close | 677 | NaN | 22.85 | 22.85 | 0.0 |
编写自定义滑点模型
要创建你自己的模型,请继承 SlippageModel 并重写 apply_slippage 方法。该方法接受一个 SlippageContext 对象,其中包含订单的 side ("buy" 或 "sell")、symbol、shares 以及初始的 fill_price。你的方法随后必须返回一个包含调整后 (shares, fill_price) 的元组。
以下示例演示了一个对每笔成交都施加随机数量的不利滑点的模型:
[6]:
from decimal import Decimal
import numpy as np
from pybroker import SlippageContext, SlippageModel
class RandomSlippageModel(SlippageModel):
"""Applies random adverse slippage of up to ``max_bps`` per fill."""
def __init__(self, max_bps: float = 10, seed: int = 42):
self.max_bps = max_bps
self._rng = np.random.default_rng(seed)
def apply_slippage(self, ctx: SlippageContext) -> tuple[Decimal, Decimal]:
bps = self._rng.uniform(0, self.max_bps)
adjustment = ctx.fill_price * Decimal(str(bps / 10_000))
if ctx.side == "buy":
fill_price = ctx.fill_price + adjustment
else:
fill_price = ctx.fill_price - adjustment
return ctx.shares, fill_price
strategy.set_slippage_model(RandomSlippageModel(max_bps=10, seed=42))
result = strategy.backtest()
print(f"Total return: {result.metrics.total_return_pct:.2f}%")
Total return: 3.89%