手に入らない価格
バックテストはデータ上の価格で売買しますが、実際の注文は別の価格で約定します。スリッページはその差で、見た価格より少し高く買い、少し安く売ることになります。原因は2つあります。スプレッド:成行注文は売り気配で買い、買い気配で売るので、仲値に対して片道スプレッドの半分を払います。そしてマーケット・インパクト:大きな注文は良い気配を食い尽くし、自分に不利な方向へ価格を押します。
def fill_price(mid, side, slippage_bps):
if side == "buy":
return mid * (1 + slippage_bps / 10000)
if side == "sell":
return mid * (1 - slippage_bps / 10000)
raise ValueError("side must be 'buy' or 'sell'")
print(round(fill_price(50.0, "buy", 4), 4)) # 50.02 pays more
print(round(fill_price(50.0, "sell", 4), 4)) # 49.98 receives less# Crossing the spread: a market buy pays the ask, a market sell receives the bid
bid, ask = 49.98, 50.02
mid = (bid + ask) / 2
half_spread_bps = (ask - bid) / 2 / mid * 10000
print(mid, round(half_spread_bps, 2)) # 50.0 4.0 each way, relative to the mid price
print(round((ask - mid) / mid * 10000, 2), round((mid - bid) / mid * 10000, 2)) # 4.0 4.0基準に対して測る
スリッページを測るには基準が必要で、それが注文を決めた時点の仲値である到着価格です。不足分は、正なら常にコストを意味するよう符号を付け、買いなら fill - arrival、売りなら arrival - fill を到着価格のベーシスポイントで表します。買い手に不利な約定は売り手には有利なので、符号は売買の向きに依存します。大きな注文は分割して約定するため、報告する数字は出来高加重の平均約定価格です。
import numpy as np
def slippage_bps(fill, arrival, side):
if side == "buy":
return (fill - arrival) / arrival * 10000
if side == "sell":
return (arrival - fill) / arrival * 10000
raise ValueError("side must be 'buy' or 'sell'")
def average_fill(prices, quantities):
prices, quantities = np.asarray(prices, dtype=float), np.asarray(quantities, dtype=float)
return float(np.sum(prices * quantities) / np.sum(quantities))
# Buying 600 shares in three pieces; the price at arrival was 50.00
avg = average_fill([50.0, 50.1, 50.3], [100, 200, 300])
print(round(avg, 4)) # 50.1833
print(round(slippage_bps(avg, 50.0, "buy"), 2)) # 36.67 bps: it cost us
print(round(slippage_bps(avg, 50.0, "sell"), 2)) # -36.67: the same fills would have helped a sellerインパクトはサイズとともに増える
スプレッドは固定費ですが、インパクトは売買量に依存します。よく検証された経験則が平方根則で、価格に対する期待インパクトはおよそ k * volatility * sqrt(quantity / ADV) です。ADV は平均日次出来高、k は1に近い定数です。注文を4倍にしてもインパクトは2倍にしかならないため、大きな注文を時間をかけて分割するのは有効ですが、期待するほどの効果はありません。
import math
def sqrt_impact_bps(volatility, quantity, adv, k=1.0):
return k * volatility * math.sqrt(quantity / adv) * 10000
# A stock with 2% daily volatility and 1,000,000 shares of average daily volume
for shares in (10_000, 50_000, 200_000, 800_000):
print(shares, round(sqrt_impact_bps(0.02, shares, 1_000_000), 1), "bps")
# 10,000: 20.0, 50,000: 44.7, 200,000: 89.4, 800,000: 178.9すべてのバックテストにスリッページを粗いモデルでも、ないよりずっとましです。取引ごとに提示スプレッドの半分と、注文サイズに応じて増えるインパクト項を課し、それでも戦略が生き残るかを確かめます。スリッページゼロでしか動かないなら、動いていません。誠実な検証は、仮定したスリッページを実際の約定と比べ、データが許すときだけ仮定を下げることです。
- 買いは仲値より上、売りは下で約定する:
mid * (1 +/- bps / 10000)。スプレッドだけで片道その幅の半分がかかる - 到着価格に対して、正がコストになる符号で測る。部分約定は出来高加重平均でまとめる
- インパクトは平方根則
k * volatility * sqrt(quantity / ADV)に従う。サイズ4倍でインパクト2倍
次は現実的な注文の約定です。板情報やバーのデータに対して注文が実際にどう約定するかをシミュレートします。
ラボ:採点される内容
fill_priceで約定価格を求める25点slippage_bpsで不利になった分を測る25点average_fillで部分約定を平均する25点sqrt_impact_bpsでインパクトを予測する25点