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()
../_images/0ba62490b00221646d7640868bb6ce55060d7657f31f59659f7b78fcdefe1726.png

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()
../_images/d48e49396c25d816a1e12eef00221c1f1f0ffb61e18876653ef0ae7f368b683b.png

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()
../_images/b7b6b023a1acb2c6572443bd471b15922cc18ef47aa7d9ae33a105cfc433bc97.png

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