Calibrating costs from your own fills

Recipe 04/02 varies fees, slippage and latency to see whether a conclusion survives them. That is the right first move, and it leaves an obvious question open: which setting is true? A strategy whose edge is smaller than the error in your slippage guess is not a strategy you have tested.

The number is measurable. A replay against a book with depth produces fills that walked that book, and the distance each fill travelled from the mid, as a function of how much of the displayed size it took, is a cost model. This recipe fits one with quant.costs, then validates it the only way a cost model can be validated: by charging it in a cheaper backtest and checking that the cheaper backtest reproduces what the full book actually did.

Terms used here#

term meaning
slippage the gap between the price you referenced and the price you got
participation order size as a fraction of the size displayed at the touch
effective spread twice the average cost of crossing, one side measured
market impact how much worse the price gets as the order gets bigger
square-root law the usual empirical shape: cost grows with the square root of size
top of book a backtest that sees only the best bid and offer, with no depth behind them
tick the smallest price increment a venue quotes

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

import datetime as dt

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import pyarrow as pa

import h5i_db
from h5i_db import backtest
from h5i_db.quant import costs
import cookbook_utils as cu

SYMBOL = "ACME"
TICK = 0.01
PROBE_SIZES = (100, 250, 500, 1_000, 2_000, 4_000, 8_000)

1. A book with something behind the touch#

Every earlier recipe in this section quoted one level a side, which is enough to fill a small order and useless for measuring what a large one costs. This tape carries eight levels, one tick apart, each holding more size than the one in front of it.

column type meaning
ts_init timestamp[ns] the instant the book event arrived
side string buy for bids, sell for offers
price float64 the level's price
size float64 shares displayed at that price
event_index int64 rows sharing one value are one atomic book
quotes = cu.make_quotes(symbols=[SYMBOL], days=1, quotes_per_day=2_000, seed=23)
tape = cu.make_equity_tape(
    quotes, levels=8, level_growth=1.6, print_every=3, tick_size=TICK
)
print(f"{tape['book_deltas'].num_rows:,} book rows, "
      f"{tape['book_deltas'].num_rows // (2 * 8):,} events")
tape["book_deltas"].to_pandas().head(9)[["ts_init", "side", "price", "size", "is_last"]]
output
24,864 book rows, 1,554 events
ts_init side price size is_last
0 2026-06-01 13:30:00.067981 buy 289.79 1000.0000 False
1 2026-06-01 13:30:00.067981 buy 289.78 1600.0000 False
2 2026-06-01 13:30:00.067981 buy 289.77 2560.0000 False
3 2026-06-01 13:30:00.067981 buy 289.76 4096.0000 False
4 2026-06-01 13:30:00.067981 buy 289.75 6553.6000 False
5 2026-06-01 13:30:00.067981 buy 289.74 10485.7600 False
6 2026-06-01 13:30:00.067981 buy 289.73 16777.2160 False
7 2026-06-01 13:30:00.067981 buy 289.72 26843.5456 False
8 2026-06-01 13:30:00.067981 sell 289.85 300.0000 False
db = h5i_db.Database(cu.fresh_db("04_calibrating_costs_from_fills"), create=True)
for name, table in tape.items():
    db.create_table(name, table.schema, time_column="ts_init")
    db.append(name, table, note="eight-level ladder")
db.snapshot("l2-v1", tables=list(tape), note="The full book the cost model is fitted on")

book = tape["book_deltas"].to_pandas()
bids, asks = book[book["side"] == "buy"], book[book["side"] == "sell"]
top_bid = bids.loc[bids.groupby("ts_init")["price"].idxmax()].set_index("ts_init")
top_ask = asks.loc[asks.groupby("ts_init")["price"].idxmin()].set_index("ts_init")
quote = pd.DataFrame(
    {
        "bid": top_bid["price"],
        "bid_size": top_bid["size"],
        "bid_depth": bids.groupby("ts_init")["size"].sum(),
        "ask": top_ask["price"],
        "ask_size": top_ask["size"],
        "ask_depth": asks.groupby("ts_init")["size"].sum(),
    }
).sort_index()
quote["mid"] = (quote["bid"] + quote["ask"]) / 2
quote.head()
output
bid bid_size bid_depth ask ask_size ask_depth mid
ts_init
2026-06-01 13:30:00.067981 289.79 1000.0 69916.12160 289.85 300.0 20974.83648 289.820
2026-06-01 13:30:01.150309 289.75 400.0 27966.44864 289.80 200.0 13983.22432 289.775
2026-06-01 13:30:01.964625 289.76 2000.0 139832.24320 289.79 100.0 6991.61216 289.775
2026-06-01 13:30:07.271106 289.81 100.0 6991.61216 289.84 1200.0 83899.34592 289.825
2026-06-01 13:30:09.176965 289.76 200.0 13983.22432 289.80 800.0 55932.89728 289.780

2. Probe orders#

A cost model needs fills across a range of sizes. Here they come from probe orders: the same size ladder, alternating side, spread through the session so each one meets a different book.

On a desk you would use your own historical fills instead, and the arithmetic below is unchanged. Probes are used here because a fixture has no trading history, and because a deliberately spaced ladder makes the fit legible.

instants = pd.DatetimeIndex(book["ts_init"].unique()).sort_values()
probes = []
for index, size in enumerate(PROBE_SIZES * 8):
    at = instants[150 + index * 20]
    probes.append(
        {
            "ts": at.floor("us").to_pydatetime() + dt.timedelta(microseconds=1),
            "instrument_id": SYMBOL,
            "side": "buy" if index % 2 == 0 else "sell",
            "quantity": float(size),
            "kind": "market",
            "tag": f"probe-{size}",
        }
    )
signals = backtest.signal_table(sorted(probes, key=lambda row: row["ts"]))
backtest.create_signal_table(db)
db.append("signals", signals, note="size ladder, both sides")
print(f"{signals.num_rows} probe orders, {len(PROBE_SIZES)} sizes x 8 repeats")
signals.to_pandas().head()
output
56 probe orders, 7 sizes x 8 repeats
ts instrument_id outcome side quantity kind limit_price time_in_force tag reduce_only
0 2026-06-01 13:41:46.496481024 ACME 0 buy 100.0 market NaN NaN probe-100 False
1 2026-06-01 13:43:53.775106816 ACME 0 sell 250.0 market NaN NaN probe-250 False
2 2026-06-01 13:46:13.138299904 ACME 0 buy 500.0 market NaN NaN probe-500 False
3 2026-06-01 13:47:49.601224960 ACME 0 sell 1000.0 market NaN NaN probe-1000 False
4 2026-06-01 13:49:17.292925952 ACME 0 buy 2000.0 market NaN NaN probe-2000 False

Fees are set to zero for the probe run. The question is what the book costs; a commission schedule is known exactly and does not need fitting.

probe_run = backtest.run(
    db,
    "cost-probe",
    starting_cash=10_000_000.0,
    signals="signals",
    snapshot="l2-v1",
    fee_kind="proportional",
    fee_rate=0.0,
    equity_interval_nanos=600_000_000_000,
)
probe_fork = db.fork(probe_run["fork"])
fills = probe_fork.read("bt_fills").to_pandas()
orders = probe_fork.read("bt_orders").to_pandas()
print(f"{len(orders)} orders produced {len(fills)} fills")
print(f"fills per order: {len(fills) / len(orders):.1f} on average, "
      f"{fills.groupby('order_id').size().max()} at most")
fills.head()
output
56 orders produced 194 fills
fills per order: 3.5 on average, 8 at most
ts order_id instrument_id outcome side price quantity commission is_taker tag
0 2026-06-01 13:41:49.041193 1 ACME 0 buy 290.85 100.0 0.0 True probe-100
1 2026-06-01 13:44:11.371913 2 ACME 0 sell 290.24 200.0 0.0 True probe-250
2 2026-06-01 13:44:11.371913 2 ACME 0 sell 290.23 50.0 0.0 True probe-250
3 2026-06-01 13:46:16.885152 3 ACME 0 buy 290.79 300.0 0.0 True probe-500
4 2026-06-01 13:46:16.885152 3 ACME 0 buy 290.80 200.0 0.0 True probe-500

3. One sample per order#

A SlippageSample is one execution measured against the price that stood when it was decided. Several fills belong to one decision, so the fills of an order are collapsed into their size-weighted average price first. Measuring each partial fill separately would count the same decision many times and weight the deepest levels least.

executed = (
    fills.assign(notional=lambda frame: frame["price"] * frame["quantity"])
    .groupby("order_id")
    .agg(
        ts=("ts", "first"),
        side=("side", "first"),
        quantity=("quantity", "sum"),
        notional=("notional", "sum"),
        levels=("price", "nunique"),
    )
)
executed["avg_price"] = executed["notional"] / executed["quantity"]
decision = pd.merge_asof(
    executed.sort_values("ts").reset_index(),
    quote.reset_index()[["ts_init", "mid", "bid_size", "ask_size"]],
    left_on="ts",
    right_on="ts_init",
)
decision["displayed"] = np.where(
    decision["side"] == "buy", decision["ask_size"], decision["bid_size"]
)
samples = [
    costs.SlippageSample(
        direction=1 if row.side == "buy" else -1,
        fill_price=float(row.avg_price),
        reference_price=float(row.mid),
        quantity=float(row.quantity),
        reference_size=float(row.displayed),
    )
    for row in decision.itertuples()
]
decision["slippage"] = [sample.slippage for sample in samples]
decision["participation"] = [sample.participation for sample in samples]
decision[["side", "quantity", "levels", "avg_price", "mid", "slippage", "participation"]].head()
output
side quantity levels avg_price mid slippage participation
0 buy 100.0 1 290.8500 290.835 0.0150 0.333333
1 sell 250.0 2 290.2380 290.250 0.0120 1.250000
2 buy 500.0 2 290.7940 290.755 0.0390 1.666667
3 sell 1000.0 2 290.8750 290.905 0.0300 2.000000
4 buy 2000.0 3 290.7228 290.700 0.0228 5.000000

Two headline numbers before any fitting. The effective spread is what crossing costs on a round trip; the implementation shortfall is what this particular set of orders paid per share, weighted by size so that the large orders count for what they were.

print(f"effective spread          {costs.effective_spread(samples):.4f}")
print(f"implementation shortfall  {costs.implementation_shortfall(samples):.4f} per share")
print(f"quoted spread (median)    {(quote['ask'] - quote['bid']).median():.4f}")
output
effective spread          0.0770
implementation shortfall  0.0534 per share
quoted spread (median)    0.0500

4. Fitting the shape#

fit_impact regresses slippage on size, either against the square root of participation or against participation itself. The square root is the default because it is the shape impact studies keep recovering, and because fitting a straight line to a concave process extrapolates badly exactly where it matters: in the large orders that decide whether a strategy has capacity.

sqrt_fit = costs.fit_impact(samples, shape="sqrt")
linear_fit = costs.fit_impact(samples, shape="linear")
pd.DataFrame([sqrt_fit.to_dict(), linear_fit.to_dict()]).round(5)
output
intercept coefficient shape r_squared residual_std observations usable
0 0.01865 0.00772 sqrt 0.64098 0.01126 56 True
1 0.02881 0.00095 linear 0.53372 0.01283 56 True

usable is not a statistical test. It is a reminder that a cost model fitted to eleven fills is not a cost model, and thirty is the conventional floor for pretending otherwise.

assert sqrt_fit.is_usable, "not enough fills to claim a cost model"
for label, fit in (("sqrt", sqrt_fit), ("linear", linear_fit)):
    print(f"{label:<7} R^2 {fit.r_squared:.3f}  residual {fit.residual_std:.4f}  "
          f"cost at 1x displayed {fit.predict(1.0):.4f}")
output
sqrt    R^2 0.641  residual 0.0113  cost at 1x displayed 0.0264
linear  R^2 0.534  residual 0.0128  cost at 1x displayed 0.0298
fig, ax = plt.subplots(figsize=(9, 4.5))
ax.scatter(decision["participation"], decision["slippage"], s=18, alpha=0.6, label="orders")
grid = np.linspace(0.01, decision["participation"].max(), 100)
ax.plot(grid, [sqrt_fit.predict(x) for x in grid], linewidth=1.8, label="square-root fit")
ax.plot(grid, [linear_fit.predict(x) for x in grid], linewidth=1.4, linestyle="--", label="linear fit")
ax.set_title("What each order paid, against how much of the book it took")
ax.set_xlabel("Participation (order size / displayed size)")
ax.set_ylabel("Slippage vs mid ($ per share)")
ax.legend()
fig.tight_layout()
output
output figure

5. The shortcut, and what it assumes#

costs.fit_from_fills does all of the above from a run's fork with no arguments. It has to choose a reference price without being told one, and it uses the size-weighted average of the other fills at the same instant, which measures how far through the book each fill reached rather than how far from the mid the order ended up.

That makes it a measure of depth consumed, available whenever an order walked more than one level, and it is the right tool when the decision price was never recorded. When you do know the decision price, build the samples yourself as above.

shortcut = costs.fit_from_fills(probe_fork, shape="sqrt")
print(f"shortcut  intercept {shortcut.intercept:+.5f}  coefficient {shortcut.coefficient:+.5f}  "
      f"observations {shortcut.observations}")
print(f"explicit  intercept {sqrt_fit.intercept:+.5f}  coefficient {sqrt_fit.coefficient:+.5f}  "
      f"observations {sqrt_fit.observations}")
print("\nThey answer different questions and should not be expected to agree.")
output
shortcut  intercept -0.01706  coefficient +0.00049  observations 194
explicit  intercept +0.01865  coefficient +0.00772  observations 56

They answer different questions and should not be expected to agree.

6. Charging the model somewhere cheaper#

The reason to fit a cost model is to use it where the data cannot support the measurement: a daily bar backtest, or any replay with only a top-of-book quote. Collapsing this book to one level per side is exactly that backtest -- all the depth, none of the walking, everything filled at the touch.

sides = pd.concat(
    [
        quote.reset_index()[["ts_init", "bid", "bid_depth"]]
        .rename(columns={"bid": "price", "bid_depth": "size"})
        .assign(side="buy"),
        quote.reset_index()[["ts_init", "ask", "ask_depth"]]
        .rename(columns={"ask": "price", "ask_depth": "size"})
        .assign(side="sell"),
    ]
).sort_values(["ts_init", "side"])
sides["ts_event"] = sides["ts_init"]
sides["instrument_id"] = SYMBOL
sides["outcome"] = 0
sides["action"] = "snapshot"
sides["event_index"] = sides.groupby("ts_init", sort=False).ngroup()
sides["is_last"] = sides["side"] == "sell"
sides["source_vendor"] = "collapsed"
collapsed = pa.Table.from_pandas(
    sides[cu.BOOK_DELTAS_SCHEMA.names], schema=cu.BOOK_DELTAS_SCHEMA, preserve_index=False
)
print(f"{collapsed.num_rows:,} rows, one level a side, all the size at the touch")
collapsed.to_pandas().head(4)
output
3,108 rows, one level a side, all the size at the touch
ts_init ts_event instrument_id outcome action side price size event_index is_last source_vendor
0 2026-06-01 13:30:00.067981 2026-06-01 13:30:00.067981 ACME 0 snapshot buy 289.79 69916.12160 0 False collapsed
1 2026-06-01 13:30:00.067981 2026-06-01 13:30:00.067981 ACME 0 snapshot sell 289.85 20974.83648 0 True collapsed
2 2026-06-01 13:30:01.150309 2026-06-01 13:30:01.150309 ACME 0 snapshot buy 289.75 27966.44864 1 False collapsed
3 2026-06-01 13:30:01.150309 2026-06-01 13:30:01.150309 ACME 0 snapshot sell 289.80 13983.22432 1 True collapsed
cheap = h5i_db.Database(cu.fresh_db("04_calibrating_costs_top_of_book"), create=True)
for name, table in (
    ("instruments", tape["instruments"]),
    ("book_deltas", collapsed),
    ("trades", tape["trades"]),
):
    cheap.create_table(name, table.schema, time_column="ts_init")
    cheap.append(name, table)
cheap.snapshot("top-v1", tables=["instruments", "book_deltas", "trades"])
backtest.create_signal_table(cheap)
cheap.append("signals", signals, note="the same probe orders, cheaper book")
print(f"{collapsed.num_rows:,} book rows and the same {signals.num_rows} orders")
output
3,108 book rows and the same 56 orders

One thing to check against your own build before charging anything. slippage_ticks shifts the book by a fixed 0.0001 per unit in the current Python binding, not by the instrument's tick_size. On a prediction market those are the same number; on a 290-dollar share they differ by a hundred times. The charge below is therefore expressed in units of 0.0001, and the assertion at the end of this section is what would catch it if that changed.

SLIPPAGE_UNIT = 0.0001
probe = backtest.run(
    cheap,
    "top-unit-check",
    starting_cash=10_000_000.0,
    signals="signals",
    snapshot="top-v1",
    fee_kind="proportional",
    fee_rate=0.0,
    slippage_ticks=100,
    equity_interval_nanos=600_000_000_000,
)
probe_prices = cheap.fork(probe["fork"]).read("bt_fills").to_pandas()
plain = backtest.run(
    cheap,
    "top-unit-base",
    starting_cash=10_000_000.0,
    signals="signals",
    snapshot="top-v1",
    fee_kind="proportional",
    fee_rate=0.0,
    equity_interval_nanos=600_000_000_000,
)
plain_prices = cheap.fork(plain["fork"]).read("bt_fills").to_pandas()
shift = (
    probe_prices[probe_prices["side"] == "buy"]["price"].mean()
    - plain_prices[plain_prices["side"] == "buy"]["price"].mean()
)
print(f"100 slippage units moved the average buy by {shift:.5f}")
assert abs(shift - 100 * SLIPPAGE_UNIT) < 1e-6, "slippage_ticks is not what this recipe assumes"

typical = float(decision["participation"].median())
charge = int(round(sqrt_fit.predict(typical) / SLIPPAGE_UNIT))
print(f"median participation {typical:.2f} -> predicted cost "
      f"{sqrt_fit.predict(typical):.4f} = {charge} slippage units")

rows = []
for label, ticks in (("uncharged", 0), ("fitted charge", charge)):
    run = backtest.run(
        cheap,
        f"top-{label.split()[0]}",
        starting_cash=10_000_000.0,
        signals="signals",
        snapshot="top-v1",
        fee_kind="proportional",
        fee_rate=0.0,
        slippage_ticks=ticks,
        equity_interval_nanos=600_000_000_000,
    )
    fork = cheap.fork(run["fork"])
    got = fork.read("bt_fills").to_pandas()
    priced = pd.merge_asof(
        got.sort_values("ts"),
        quote.reset_index()[["ts_init", "mid"]],
        left_on="ts",
        right_on="ts_init",
    )
    signed = np.where(priced["side"] == "buy", 1.0, -1.0)
    cost = float(
        (signed * (priced["price"] - priced["mid"]) * priced["quantity"]).sum()
        / priced["quantity"].sum()
    )
    rows.append({"book": label, "slippage units": ticks, "cost per share": cost,
                 "shares": float(priced["quantity"].sum())})
    fork.close()

truth = costs.implementation_shortfall(samples)
rows.append({"book": "full ladder (truth)", "slippage units": None,
             "cost per share": truth, "shares": float(decision["quantity"].sum())})
comparison = pd.DataFrame(rows)
comparison.round(4)
output
100 slippage units moved the average buy by 0.01000
median participation 3.33 -> predicted cost 0.0328 = 328 slippage units
book slippage units cost per share shares
0 uncharged 0.0 0.0225 125791.6122
1 fitted charge 328.0 0.0553 125791.6122
2 full ladder (truth) NaN 0.0534 125791.6122

The uncharged top-of-book run pays half a tick on every order regardless of size, which is the error a cost model exists to remove. Charging the fitted number moves it into the neighbourhood of what the real book did.

It does not land exactly, and it cannot: a constant tick charge is a flat approximation to a curve, so it overcharges the small orders and undercharges the largest. What the fit buys is a defensible average and, more importantly, a statement about which orders the backtest is still lying about.

uncharged = comparison.loc[comparison["book"] == "uncharged", "cost per share"].iloc[0]
charged = comparison.loc[comparison["book"] == "fitted charge", "cost per share"].iloc[0]
print(f"error before charging  {abs(uncharged - truth):.4f} per share")
print(f"error after charging   {abs(charged - truth):.4f} per share")
assert abs(charged - truth) < abs(uncharged - truth), "the charge made it worse"
output
error before charging  0.0309 per share
error after charging   0.0019 per share

Takeaways#

probe_fork.close()
cheap.close()
db.close()