Your first event-driven backtest

The backtests in section 02 are vectorized. Compute a signal for every date, multiply it by the return that followed, and sum. That is the right tool for a monthly rebalance, and it quietly assumes you traded at a price nobody quoted you.

An event-driven backtest drops that assumption. Orders are submitted at an instant, they meet the order book as it was recorded at that instant, and the engine decides whether they filled, at what price, and after what fees and delay. It costs more to run. It is also the only way to ask whether a strategy survives its own execution.

This recipe turns timestamped order intent into an auditable simulation. Market data is pinned before the strategy is written. Orders, fills, positions, and equity are ordinary versioned tables on an isolated fork.

Terms used here#

term meaning
event-driven backtest orders meet recorded market data event by event, not a bar price
signal here the order intent: what to trade, which way, how much, and at what instant
fill an execution against your order, produced by the engine rather than assumed
position the running quantity held in an instrument, with its cost basis
equity curve cumulative account value over time
run fork an isolated branch of the database a run writes its results into, so runs never collide
pin fixing the run's market data to an exact snapshot, before the strategy is written

New to any of these? GLOSSARY.md defines them at more length, along with every other term the cookbook uses.

The fixture contains reference data, atomic L2 snapshots, and prints for one prediction market. One book row is one price level within an event_index. is_last marks the atomic event boundary.

table rows time column purpose
instruments 2 ts_init Venue rules and outcome metadata
book_deltas 360 ts_init Bid/ask snapshots used for matching
trades 180 ts_init Prints used by queue-aware fill models
import datetime as dt

import matplotlib.pyplot as plt
import pyarrow as pa

import h5i_db
from h5i_db import backtest
import cookbook_utils as cu

fixture = cu.make_backtest_fixture(steps=180)
for name, table in fixture.items():
    print(f"{name}: {table.num_rows:,} rows x {table.num_columns} columns")
fixture["book_deltas"].to_pandas().head()
output
instruments: 2 rows x 10 columns
book_deltas: 360 rows x 11 columns
trades: 180 rows x 9 columns
ts_init ts_event instrument_id outcome action side price size event_index is_last source_vendor
0 2026-06-01 14:00:01 2026-06-01 14:00:01 RATE-CUT-YES 0 snapshot buy 0.4766 90.0 1 False cookbook-sim
1 2026-06-01 14:00:01 2026-06-01 14:00:01 RATE-CUT-YES 0 snapshot sell 0.4866 90.0 1 True cookbook-sim
2 2026-06-01 14:00:02 2026-06-01 14:00:02 RATE-CUT-YES 0 snapshot buy 0.4783 100.0 2 False cookbook-sim
3 2026-06-01 14:00:02 2026-06-01 14:00:02 RATE-CUT-YES 0 snapshot sell 0.4883 100.0 2 True cookbook-sim
4 2026-06-01 14:00:03 2026-06-01 14:00:03 RATE-CUT-YES 0 snapshot buy 0.4799 110.0 3 False cookbook-sim

The canonical schemas distinguish event time from arrival time. This example makes them equal. Production loaders should retain both when feeds expose them, because replay order follows ts_init.

fixture["book_deltas"].schema
output
ts_init: timestamp[ns] not null
ts_event: timestamp[ns] not null
instrument_id: string not null
outcome: uint16 not null
action: string not null
side: string
price: double
size: double
event_index: int64 not null
is_last: bool not null
source_vendor: string

Create one time-indexed table per event type. The named snapshot is the immutable market-data cut used by every run in this recipe.

db = h5i_db.Database(cu.fresh_db("04_first_event_driven_run"), create=True)
for name, table in fixture.items():
    db.create_table(name, table.schema, time_column="ts_init")
    db.append(name, table, note="deterministic cookbook fixture")
db.snapshot(
    "market-cut-2026-06-01",
    tables=["instruments", "book_deltas", "trades"],
    note="Approved input for the first event-driven run",
)
db.tables()
output
['book_deltas', 'instruments', 'trades']

The signals table is the strategy boundary. Each row states intent, not a fill. The engine decides the executable price after applying market data, fees, latency, and fill rules.

column type meaning
ts timestamp[ns] Earliest arrival time at the strategy boundary
instrument_id string Instrument to trade
side string buy or sell
quantity float64 Requested units
kind string market or limit
tag string Stable research label carried into fills
base = dt.datetime(2026, 6, 1, 14, 0, 0)
signals = backtest.signal_table(
    [
        {
            "ts": base + dt.timedelta(seconds=20),
            "instrument_id": "RATE-CUT-YES",
            "side": "buy",
            "quantity": 50.0,
            "tag": "open",
        },
        {
            "ts": base + dt.timedelta(seconds=120),
            "instrument_id": "RATE-CUT-YES",
            "side": "sell",
            "quantity": 50.0,
            "tag": "close",
        },
    ]
)
print(f"{signals.num_rows:,} rows x {signals.num_columns} columns")
signals.to_pandas()
output
2 rows x 10 columns
ts instrument_id outcome side quantity kind limit_price time_in_force tag reduce_only
0 2026-06-01 14:00:20 RATE-CUT-YES 0 buy 50.0 market NaN NaN open False
1 2026-06-01 14:02:00 RATE-CUT-YES 0 sell 50.0 market NaN NaN close False

Store the strategy after the market snapshot. Signals are versioned independently from the market-data pin, so research created later can replay the approved historical cut without pretending it existed at that time.

backtest.create_signal_table(db)
db.append("signals", signals, note="two-leg demonstration strategy")
output
{'table': 'signals',
 'sequence': 1,
 'op': 'append',
 'rows_total': 2,
 'segments_total': 1,
 'segments_added': 1,
 'segments_deduped': 0,
 'committed_at_ns': 1785448957768230317}

Run with an explicit fee model and equity sampling interval. A run writes only to bt-first-run; the source database remains unchanged.

report = backtest.run(
    db,
    "first-run",
    starting_cash=10_000.0,
    signals="signals",
    snapshot="market-cut-2026-06-01",
    fee_rate=0.02,
    equity_interval_nanos=5_000_000_000,
)
report
output
{'run_id': 'first-run',
 'fork': 'bt-first-run',
 'digest': '05b50c3deb55c74bbda930f0d2abb61d8f003b8642c59b98d908f7b0e1e7317f',
 'starting_cash': 10000.0,
 'final_cash': 10000.5410324,
 'realized_pnl': 0.5410324,
 'commissions': 0.4989676,
 'funding_paid': 0.0,
 'fills': 2,
 'orders': 2,
 'records_processed': 360,
 'simulated_through_ns': 1780322580000000000,
 'equity_points': 37,
 'settlement_applied': False,
 'coverage': None,
 'liquidations': 0,
 'rejected_for_margin': 0,
 'self_trades_prevented': 0,
 'calibration_samples': [],
 'set_operations': [],
 'forecasts': 0,
 'mark_points': 37,
 'expirations': [],
 'metrics': {'orders_submitted': 2,
  'orders_filled': 2,
  'orders_cancelled_unfilled': 0,
  'orders_rejected_margin': 0,
  'orders_rejected_risk': 0,
  'orders_rejected_self_trade': 0,
  'orders_rejected_naked_short': 0,
  'orders_rejected_expired': 0,
  'fills_taker': 2,
  'fills_maker': 0,
  'book_gaps': 0,
  'liquidations': 0,
  'set_operations': 0,
  'set_operations_rejected': 0,
  'instruments_expired': 0},
 'warnings': []}

The report is a compact run manifest. The fork contains the detailed audit trail. bt_fills is authoritative; positions can always be rebuilt from it.

run_db = db.fork(report["fork"])
fills = run_db.read("bt_fills").to_pandas()
orders = run_db.read("bt_orders").to_pandas()
positions = run_db.read("bt_positions").to_pandas()
print(orders[["order_id", "side", "quantity", "filled", "status", "tag"]])
fills
output
   order_id  side  quantity  filled  status    tag
0         1   buy      50.0    50.0  filled   open
1         2  sell      50.0    50.0  filled  close
ts order_id instrument_id outcome side price quantity commission is_taker tag
0 2026-06-01 14:00:20 1 RATE-CUT-YES 0 buy 0.5098 50.0 0.249904 True open
1 2026-06-01 14:02:00 2 RATE-CUT-YES 0 sell 0.5306 50.0 0.249064 True close

Validate the contract before analysing performance. Both tagged orders must fill, and the closing order must leave no net exposure.

assert report["fills"] == 2
assert fills["tag"].tolist() == ["open", "close"]
assert positions.empty or abs(positions["quantity"].sum()) < 1e-9
print(f"digest={report['digest']}")
print(f"commissions={report['commissions']:.6f}")
print(f"realized P&L={report['realized_pnl']:.6f}")
output
digest=05b50c3deb55c74bbda930f0d2abb61d8f003b8642c59b98d908f7b0e1e7317f
commissions=0.498968
realized P&L=0.541032

Equity is sampled in simulated time, not per input row. This bounds output size on tick data while retaining a useful risk series.

equity = run_db.read("bt_equity").to_pandas()
fig, ax = plt.subplots(figsize=(9, 4))
ax.plot(equity["ts"], equity["equity"], linewidth=1.8)
ax.set_title("Event-driven backtest equity")
ax.set_xlabel("Simulated time")
ax.set_ylabel("Portfolio value")
fig.tight_layout()
output
output figure

Takeaways#

run_db.close()
db.close()