Replaying an account's ledger

Every other recipe in this section asks what a strategy would have done. This one asks the strictest question a backtester can be asked: here are the trades an account actually took, published on a public ledger. Does the engine reproduce that portfolio against the historical book?

The answer is usually no, and that is the finding rather than the failure. A simulator that forced the fills would reproduce any ledger by construction and would have tested nothing. So the ledger is compiled into intent -- a limit order at the price the account got, immediate-or-cancel, with sells marked reduce-only -- and the recorded book is allowed to refuse it.

Where it refuses is the useful output. It is either a fact about your market data (the book you have is not the book they traded against) or a fact about the ledger (that fill did not happen the way it is reported).

Terms used here#

term meaning
ledger a public record of trades an account executed
intent an order as submitted, before any venue decided whether it fills
immediate-or-cancel fill whatever is available right now and cancel the rest
reduce-only an order that may only shrink a position, never open the opposite one
reconciliation comparing what was reported against what the replay produced, per market
fill ratio the share of ledger quantity the replay managed to reproduce

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

import pandas as pd
import pyarrow as pa

import h5i_db
from h5i_db import backtest, venues
import cookbook_utils as cu

TICK = 0.001

1. The market data the account traded against#

A panel of binary event contracts, with the book, the prints and the resolutions. This is the historical record; the ledger below is what somebody claims they did inside it.

The fixture updates every market at the same instant, and each market is given its own millisecond before anything is ingested. A real feed does not quote twelve markets at one nanosecond either, and the stagger removes an ambiguity that would otherwise decide the answer: an order released into a batch of same-timestamp events meets the new book for whichever market the merge reached first, and the previous one for all the others.

raw = cu.make_prediction_markets(n_markets=12, steps=24, seed=5)
offsets = {
    name: pd.Timedelta(milliseconds=index)
    for index, name in enumerate(sorted(set(raw["instruments"].column("instrument_id").to_pylist())))
}


def stagger(table):
    """Give every market its own instant, as a real feed would."""
    frame = table.to_pandas()
    if "instrument_id" not in frame.columns:
        return table
    frame["ts_init"] = frame["ts_init"] + frame["instrument_id"].map(offsets)
    if "ts_event" in frame.columns:
        frame["ts_event"] = frame["ts_init"]
    frame = frame.sort_values("ts_init", kind="stable")
    return pa.Table.from_pandas(frame, schema=table.schema, preserve_index=False)


panel = {name: stagger(table) for name, table in raw.items()}
db = h5i_db.Database(cu.fresh_db("04_replaying_an_account_ledger"), create=True)
for name, table in panel.items():
    db.create_table(name, table.schema, time_column="ts_init")
    db.append(name, table, note="panel fixture, one instant per market")
db.snapshot("books-v1", tables=list(panel), note="The book the ledger is checked against")
for name, table in panel.items():
    print(f"{name}: {table.num_rows:,} rows x {table.num_columns} columns")

specs = venues.polymarket_markets_from_json(cu.polymarket_market_payloads(raw))
print(f"\n{len(specs)} market specs, tokens resolved positionally to outcomes")
panel["book_deltas"].to_pandas().head(4)
output
instruments: 24 rows x 10 columns
book_deltas: 1,344 rows x 11 columns
trades: 288 rows x 9 columns
resolutions: 12 rows x 6 columns

12 market specs, tokens resolved positionally to outcomes
ts_init ts_event instrument_id outcome action side price size event_index is_last source_vendor
0 2026-05-01 12:00:00 2026-05-01 12:00:00 EVENT-0000 0 snapshot buy 0.123 55.073 1 False cookbook-sim
1 2026-05-01 12:00:00 2026-05-01 12:00:00 EVENT-0000 0 snapshot sell 0.139 114.264 1 True cookbook-sim
2 2026-05-01 12:00:00 2026-05-01 12:00:00 EVENT-0000 1 snapshot buy 0.826 73.635 2 False cookbook-sim
3 2026-05-01 12:00:00 2026-05-01 12:00:00 EVENT-0000 1 snapshot sell 0.842 155.604 2 True cookbook-sim

2. A ledger, including the parts that will not replay#

A real ledger is downloaded. This one is constructed, so that the reconciliation can be checked against a known answer: three kinds of row go in and the comparison should find exactly the two kinds that cannot replay.

kind what it is what the book should do
at the touch a buy at the offer, smaller than the displayed size fill it
better than the touch a buy reported below the best bid refuse: nothing was resting that low
oversized a buy at the offer, larger than the displayed size fill part of it
book = panel["book_deltas"].to_pandas()
quotes = (
    book.pivot_table(
        index=["ts_init", "instrument_id", "outcome"],
        columns="side",
        values=["price", "size"],
        aggfunc="last",
    )
    .dropna()
    .reset_index()
)
quotes.columns = ["ts_init", "instrument_id", "outcome", "bid", "ask", "bid_size", "ask_size"]
quotes = quotes[quotes["outcome"] == 0].sort_values("ts_init")
print(f"{len(quotes):,} usable two-sided quotes")
quotes.head(3)
output
336 usable two-sided quotes
ts_init instrument_id outcome bid ask bid_size ask_size
0 2026-05-01 12:00:00.000 EVENT-0000 0 0.123 0.139 55.073 114.264
2 2026-05-01 12:00:00.001 EVENT-0001 0 0.199 0.213 90.543 163.522
4 2026-05-01 12:00:00.002 EVENT-0002 0 0.250 0.262 84.210 161.497
rows, kinds = [], []
for index, quote in enumerate(quotes.iloc[40:100:6].itertuples()):
    kind = ("at the touch", "better than the touch", "oversized")[index % 3]
    # Prices stay on the market's 0.001 grid; an off-grid order is refused
    # before it ever meets the book.
    price = (
        round(float(quote.bid) - TICK, 3)
        if kind == "better than the touch"
        else float(quote.ask)
    )
    quantity = float(quote.ask_size) * (3.0 if kind == "oversized" else 0.4)
    rows.append(
        venues.LedgerRow(
            ts_ns=int(pd.Timestamp(quote.ts_init).value),
            instrument_id=quote.instrument_id,
            outcome=0,
            side="buy",
            quantity=round(quantity, 2),
            price=price,
            trade_id=f"0x{index:04d}",
        )
    )
    kinds.append(kind)

ledger = venues.ledger_table(rows).to_pandas().assign(kind=kinds)
print(f"{len(rows)} ledger rows over {ledger['instrument_id'].nunique()} markets")
ledger.head(6)
output
10 ledger rows over 2 markets
ts_ns instrument_id outcome side quantity price trade_id kind
0 1777639500004000000 EVENT-0004 0 buy 47.53 0.371 0x0000 at the touch
1 1777639500010000000 EVENT-0010 0 buy 39.03 0.799 0x0001 better than the touch
2 1777640400004000000 EVENT-0004 0 buy 391.71 0.371 0x0002 oversized
3 1777640400010000000 EVENT-0010 0 buy 34.43 0.812 0x0003 at the touch
4 1777641300004000000 EVENT-0004 0 buy 56.52 0.366 0x0004 better than the touch
5 1777641300010000000 EVENT-0010 0 buy 227.24 0.806 0x0005 oversized

LedgerRow refuses the things that would silently invert a position: a price outside (0, 1) is not a probability, a non-positive quantity is not a fill, and a side that is neither buy nor sell is not a trade.

for description, build in (
    ("price of 1.4", lambda: venues.LedgerRow(
        ts_ns=0, instrument_id="m", outcome=0, side="buy", quantity=10.0, price=1.4)),
    ("quantity of 0", lambda: venues.LedgerRow(
        ts_ns=0, instrument_id="m", outcome=0, side="buy", quantity=0.0, price=0.5)),
    ("side 'long'", lambda: venues.LedgerRow(
        ts_ns=0, instrument_id="m", outcome=0, side="long", quantity=10.0, price=0.5)),
):
    try:
        build()
    except ValueError as error:
        print(f"{description:16} refused: {str(error).split(';')[0][:70]}")
output
price of 1.4     refused: m: price 1.4 is outside (0, 1)
quantity of 0    refused: a ledger row with non-positive quantity is not a fill
side 'long'      refused: side must be buy or sell, got 'long'

3. Compiling the ledger into intent#

commands_from_ledger turns each row into a limit order at the price the ledger reports, immediate-or-cancel, stamped a microsecond after the instant it claims. Sells become reduce-only, so a replay cannot invent short exposure the ledger never showed.

Vendor dictionaries work too: pass the raw rows and the market specs, and tokens are resolved to outcomes positionally. Typed rows are used here because the reconciliation below wants them anyway.

commands = venues.commands_from_ledger(rows, specs)
backtest.create_command_table(db, "commands")
db.append("commands", commands, note="one account's published trades, as intent")
frame = commands.to_pandas()
print(f"{commands.num_rows} commands")
frame[["ts", "action", "instrument_id", "side", "quantity", "kind", "limit_price",
       "time_in_force", "reduce_only"]].head(4)
output
10 commands
ts action instrument_id side quantity kind limit_price time_in_force reduce_only
0 2026-05-01 12:45:00.004001 submit EVENT-0004 buy 47.53 limit 0.371 ioc False
1 2026-05-01 12:45:00.010001 submit EVENT-0010 buy 39.03 limit 0.799 ioc False
2 2026-05-01 13:00:00.004001 submit EVENT-0004 buy 391.71 limit 0.371 ioc False
3 2026-05-01 13:00:00.010001 submit EVENT-0010 buy 34.43 limit 0.812 ioc False

4. The replay#

DataConfig(commands=...) replaces the signals table with the command stream. Everything else is an ordinary run against the pinned book.

result = backtest.execute(
    db,
    backtest.BacktestConfig(
        run_id="ledger-replay",
        portfolio=backtest.PortfolioConfig(starting_cash=100_000.0),
        data=backtest.DataConfig(commands="commands", snapshot="books-v1"),
        execution=backtest.ExecutionConfig(fee_kind="prediction_market", fee_rate=0.02),
        output=backtest.OutputConfig(equity_interval_nanos=900_000_000_000),
    ),
)
print(f"orders {result['orders']}  fills {result['fills']}")
result.orders.to_pandas()[["order_id", "instrument_id", "side", "quantity", "filled", "status"]].head(6)
output
orders 10  fills 7
order_id instrument_id side quantity filled status
0 1 EVENT-0004 buy 47.53 47.530 filled
1 2 EVENT-0010 buy 39.03 0.000 cancelled
2 3 EVENT-0004 buy 391.71 130.571 cancelled
3 4 EVENT-0010 buy 34.43 34.430 filled
4 5 EVENT-0004 buy 56.52 0.000 cancelled
5 6 EVENT-0010 buy 227.24 75.745 cancelled

5. Reconciliation, per market#

compare_to_ledger reports where the replay and the ledger disagree rather than a single pass or fail, because where the book refused is the finding.

comparison = venues.compare_to_ledger(result, rows)
print(f"ledger rows        {comparison['ledger_rows']}")
print(f"replay fills       {comparison['replay_fills']}")
print(f"ledger quantity    {comparison['ledger_quantity']:,.2f}")
print(f"replay quantity    {comparison['replay_quantity']:,.2f}")
print(f"fill ratio         {comparison['fill_ratio']:.1%}")
print(f"markets reproduced {comparison['markets_reproduced']} of {len(comparison['markets'])}")
pd.DataFrame(comparison["markets"]).round(3).head(8)
output
ledger rows        10
replay fills       7
ledger quantity    1,380.85
replay quantity    530.41
fill ratio         38.4%
markets reproduced 0 of 2
instrument_id outcome ledger_buy ledger_sell replay_buy replay_sell ledger_notional replay_notional reproduced
0 EVENT-0004 0 1029.08 0.0 396.009 0.0 389.205 149.887 False
1 EVENT-0010 0 351.77 0.0 134.405 0.0 282.662 108.222 False

The rows that failed to replay are the ones that were built to fail. Joining the per-row outcome back to the kind of row it was turns the reconciliation into a statement about the data rather than a number.

submitted = result.orders.to_pandas().sort_values("order_id").reset_index(drop=True)
assert (submitted["quantity"].round(2).to_numpy() == ledger["quantity"].round(2).to_numpy()).all(), (
    "orders and ledger rows are no longer in the same order"
)
ledger["replayed"] = submitted["filled"].to_numpy()
by_kind = ledger.groupby("kind").agg(
    rows=("quantity", "size"),
    ledger_quantity=("quantity", "sum"),
    replayed_quantity=("replayed", "sum"),
)
by_kind["ratio"] = by_kind["replayed_quantity"] / by_kind["ledger_quantity"]
by_kind.round(3)
output
rows ledger_quantity replayed_quantity ratio
kind
at the touch 4 166.39 166.390 1.000
better than the touch 3 122.39 0.000 0.000
oversized 3 1092.07 364.024 0.333

Read the three rows as three different conversations. "At the touch" reproduced, so nothing is wrong. "Better than the touch" did not fill at all: on real data that is either hidden liquidity, a venue you are not looking at, or a mid-quoted report. "Oversized" filled partially, which says the displayed book could not have supported that trade and is a statement about the depth in your archive.

assert by_kind.loc["at the touch", "ratio"] > 0.99
assert by_kind.loc["better than the touch", "ratio"] < 0.01
assert by_kind.loc["oversized", "ratio"] < 1.0
print("the reconciliation found exactly the rows that were built to fail")
output
the reconciliation found exactly the rows that were built to fail

6. Why the orders that did not fill are worth reading#

The run's own explanation carries the count of orders that produced no fill, which is the number to watch when the ledger is real and nobody planted the failures.

explanation = result.explain()
print(f"orders without fills {explanation.get('orders_without_fills')}")
unfilled = result.orders.to_pandas()
unfilled = unfilled[unfilled["filled"] == 0]
print(f"{len(unfilled)} of {result['orders']} orders never filled")
unfilled[["instrument_id", "side", "quantity", "limit_price", "status"]].head(5)
output
orders without fills 3
3 of 10 orders never filled
instrument_id side quantity limit_price status
1 EVENT-0010 buy 39.03 0.799 cancelled
4 EVENT-0004 buy 56.52 0.366 cancelled
7 EVENT-0010 buy 26.84 0.788 cancelled

Takeaways#

db.close()