Event-driven backtests: bars, tape, and quotes#
BacktestPriceTarget (notebook 14) marks a position series to a single price. The
rest of the backtest family models how the order actually fills against a richer
view of the market: OHLC bars, the trade tape, and top-of-book quotes. Every
engine emits [equity, pnl, position, cost] and works with backtest_report.
The examples below drive four engines from one real Deribit BTC-perpetual trade tape.
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from screamer import (BacktestOHLCTarget, BacktestTradesOrders,
BacktestL1Orders, BacktestL1TradesOrders,
RollingMean, Resample, Lag, backtest_report)
trades = pd.read_csv("data/deribit_btc_perp_6h.csv")
ts = trades["timestamp"].to_numpy(np.int64)
price = trades["price"].to_numpy(np.float64)
size = np.abs(trades["volume"].to_numpy(np.float64)) # signed in the file; magnitude here
print(f"{len(price)} prints over {(ts[-1]-ts[0])/3.6e6:.1f} hours")
4093 prints over 6.0 hours
Bars: BacktestOHLCTarget#
Resample the tape into one-minute OHLC bars and trade a moving-average-crossover
target with market orders. BacktestOHLCTarget is causal by design: the target
computed from a bar's close is deferred and executed at the next bar's open, so
we feed the raw signal with no manual lag.
o, idx = Resample(freq=60_000, agg="first")(price, ts) # one-minute OHLC
h, _ = Resample(freq=60_000, agg="max")(price, ts)
l, _ = Resample(freq=60_000, agg="min")(price, ts)
c, _ = Resample(freq=60_000, agg="last")(price, ts)
tb = pd.to_datetime(idx, unit="ms")
fast, slow = RollingMean(5)(c), RollingMean(30)(c)
signal = np.sign(np.nan_to_num(fast - slow)) # decided on each close; engine defers it
ohlc = BacktestOHLCTarget(taker_fee=0.0002)(signal, o, h, l, c)
for k, v in backtest_report(ohlc)[1].items():
print(f"{k:13s} {v:10.4f}")
total_pnl -152.5854
max_drawdown -224.5854
total_cost 130.5854
turnover 27.0000
num_trades 15.0000
sharpe -0.0872
Tape: BacktestTradesOrders#
Work event by event on the raw prints. A mean-reverting maker rests a buy one tick below the print when price is under a rolling mid and a sell one tick above it when price is over the mid. Each resting order is lagged one event, so it fills only when a later print trades through it. Inventory is left uncapped here; the L1 engines below add the inventory bound.
mid = np.nan_to_num(RollingMean(50)(price), nan=price[0]) # a smooth reference
buy = (price - mid) < 0 # buy under the mid, sell over it
bid_price = np.where(buy, price - 1.0, np.nan) # bid only when buying
ask_price = np.where(~buy, price + 1.0, np.nan) # ask only when selling
# lag quotes one event so the fill comes from a later print (causal)
op_bid = Lag(1)(bid_price)
op_ask = Lag(1)(ask_price)
one = np.ones(len(price))
tp = BacktestTradesOrders(maker_fee=-0.0001)(op_bid, one, op_ask, one, price, size)
eq, pos = tp[:, 0], tp[:, 2]
tt = pd.to_datetime(ts, unit="ms")
fig, (a0, a1) = plt.subplots(2, 1, sharex=True, figsize=(9, 5),
gridspec_kw={"height_ratios": [2, 1]})
a0.plot(tt, eq, color="steelblue"); a0.axhline(0, color="k", lw=0.5)
a0.set_ylabel("equity ($)"); a0.set_title("BacktestTradesOrders: a mean-reverting maker on the tape")
a1.plot(tt, pos, color="darkorange"); a1.set_ylabel("inventory (uncapped)")
fig.tight_layout()
Quotes only: BacktestL1Orders#
With quotes but no trades, fills are a documented heuristic. The default breach
fills only when the market trades through your quote; touch also captures a
participation share once per lock. We rest last event's touch on both sides and
bound inventory to +/-15. Fills here can over- or under-count because a quote-size
change cannot be told from a cancel, which is exactly what the trade feed fixes
below.
Inputs are own-quote first: (bid_price, bid_size, ask_price, ask_size, market_bid, market_ask, market_bid_size, market_ask_size).
half = 1.0
market_bid, market_ask = price - half, price + half
# rest last event's touch; a market move through it is the fill (Lag avoids lookahead)
my_bid = np.nan_to_num(Lag(1)(market_bid), nan=market_bid[0])
my_ask = np.nan_to_num(Lag(1)(market_ask), nan=market_ask[0])
one, five = np.ones(len(price)), np.full(len(price), 5.0)
l1 = BacktestL1Orders(fill="touch", maker_fee=-0.0001, participation_ratio=0.5,
max_position=15.0, min_position=-15.0)(
my_bid, one, my_ask, one, market_bid, market_ask, five, five)
eq1, pos1 = l1[:, 0], l1[:, 2]
fig, (a0, a1) = plt.subplots(2, 1, sharex=True, figsize=(9, 5),
gridspec_kw={"height_ratios": [2, 1]})
a0.plot(tt, eq1, color="steelblue"); a0.axhline(0, color="k", lw=0.5)
a0.set_ylabel("equity ($)"); a0.set_title("BacktestL1Orders: quotes-only maker (heuristic fills)")
a1.plot(tt, pos1, color="mediumpurple"); a1.axhline(15, color="0.7", lw=0.5, ls="--")
a1.axhline(-15, color="0.7", lw=0.5, ls="--"); a1.set_ylabel("inventory")
fig.tight_layout()
Quotes + trades: BacktestL1TradesOrders#
The preferred engine. Quotes mark the position and the real trade tape drives the fills, so there is no fill-versus-cancel ambiguity. We feed the same lagged quotes plus the actual prints; each trade fills at most once. This is the honest market-making backtest of the three.
Inputs are own-quote first: (bid_price, bid_size, ask_price, ask_size, market_bid, market_ask, market_bid_size, market_ask_size, trade_price, trade_size).
l1t = BacktestL1TradesOrders(fill="touch", maker_fee=-0.0001, participation_ratio=0.3,
max_position=15.0, min_position=-15.0)(
my_bid, one, my_ask, one, market_bid, market_ask, five, five, price, size)
eqt, post = l1t[:, 0], l1t[:, 2]
fig, (a0, a1) = plt.subplots(2, 1, sharex=True, figsize=(9, 5),
gridspec_kw={"height_ratios": [2, 1]})
a0.plot(tt, eqt, color="steelblue"); a0.axhline(0, color="k", lw=0.5)
a0.set_ylabel("equity ($)"); a0.set_title("BacktestL1TradesOrders: trade-driven maker")
a1.plot(tt, post, color="seagreen"); a1.set_ylabel("inventory")
fig.tight_layout()
Comparing engines with backtest_report#
Every engine emits [equity, pnl, position, cost], so backtest_report reads
the same summary off any of them. Pick the engine that matches the market data
you have; the accounting, costs, and reporting are shared.
for name, out in [("OHLCTarget", ohlc), ("TradesOrders", tp), ("L1Orders", l1), ("L1TradesOrders", l1t)]:
s = backtest_report(out)[1]
print(f"{name:15s} pnl={s['total_pnl']:10.2f} cost={s['total_cost']:9.2f} "
f"trades={s['num_trades']:6.0f} maxDD={s['max_drawdown']:9.2f}")
OHLCTarget pnl= -152.59 cost= 130.59 trades= 15 maxDD= -224.59
TradesOrders pnl= -8581.43 cost= -653.57 trades= 3609 maxDD=-19066.07
L1Orders pnl= 264.86 cost= -179.86 trades= 455 maxDD= -1152.21
L1TradesOrders pnl= -166.98 cost= -916.52 trades= 713 maxDD= -1061.57