建模滑点

在实盘交易中,订单很少能以回测所假设的确切价格成交。买卖价差、延迟以及你自己订单造成的市场冲击等因素,都会使成交价格朝不利方向偏移。这种差异被称为*滑点*。如果回测忽略了滑点,就会高估策略的真实表现。

本文档演示了 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 通过施加以下两种机制来考虑有限的市场流动性:

  1. 成交量限制: 成交股数会被限制在该 K 线总成交量的一定比例以内(volume_limit * volume)。超出该限制的股数会被取消,而不会延后到下一根 K 线成交。

  2. 价格冲击: 成交价格会根据你的订单规模相对于市场的大小向不利方向变动。该调整量的计算方式为 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")、symbolshares 以及初始的 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%