Implementation shortfall and DIY TCA: measuring what execution actually costs you
Every institutional desk has a transaction cost analysis pipeline. Almost nobody running a crypto bot does. The typical setup logs fills, sums fees, and calls the difference between backtest PnL and live PnL "slippage" — a single unexplained residual that absorbs latency, spread, impact, adverse selection, and every unfilled order that ran away. You cannot fix a cost you measure as one number. The machinery for measuring it properly has existed since 1988, it is not proprietary, and over your own fill logs it is roughly 200 lines of Python. This article builds it.
The payoff is not a prettier dashboard. Your backtest contains a cost model — a slippage constant, a fill probability, an impact coefficient — and every parameter in it is currently a guess. TCA over live fills is the only ground truth those parameters can be calibrated against. We built the simulation side of that loop in Fill simulation: the ladder from close-price fantasy to queue-aware reality; this article builds the measurement side.
Paper versus reality: what Perold actually measured
The foundational trick is due to André Perold (1988, "The Implementation Shortfall: Paper Versus Reality," Journal of Portfolio Management 14(3), 4–9). Run two portfolios in parallel. The paper portfolio executes every decision instantly, in unlimited size, at zero cost, at the price prevailing at the moment of decision. The real portfolio is what your bot actually did: partial fills, chased quotes, cancelled remainders, fees. The implementation shortfall is the difference in their returns.
The definition matters because of what it refuses to hide. A fee statement shows commissions. A fill-versus-limit-price report shows nothing at all (you never fill worse than your limit — by construction). The paper portfolio charges you for everything: drift between decision and arrival, the spread you crossed, the impact you caused, and — crucially — the orders that never filled while the price ran away. Wagner and Edwards (1993, "Best Execution," Financial Analysts Journal 49(1), 65–71) called visible fees the tip of the iceberg; for anything with turnover, the submerged part dominates.
Fix notation. A parent order: side (buy/sell), size . The decision price is the mid your strategy saw when the signal fired. Fills arrive as with total filled quantity . At the horizon (parent completed, cancelled, or timed out), the mid is . Explicit fees are . Implementation shortfall in currency terms:
normalized to basis points by dividing by the paper notional . Positive means you paid. The first term is what your fills cost relative to the paper portfolio; the second is Perold's opportunity cost — the unfilled remainder marked at the price it got away to; the third is the only part your exchange statement admits to.
One convention note: the industry says "arrival price," and in most equity TCA arrival means the mid when the order reached the market. For a bot, decision time and arrival time differ by your own internal latency plus rate-limit queueing — a real, measurable cost. So we keep both timestamps and both prices, and let the decomposition separate them.
The decomposition: delay, impact, timing, opportunity, fees
A single IS number tells you that execution is expensive. It does not tell you why, and the fixes for different components are completely different — you do not solve delay cost and impact cost with the same change. Robert Kissell's expanded implementation shortfall (Kissell, 2006, "The Expanded Implementation Shortfall: Understanding Transaction Cost Components," Journal of Trading 1(3), 6–16; developed at book length in The Science of Algorithmic Trading and Portfolio Management, Academic Press, 2013) unbundles the total into components, each attributable to a distinct stage of the order lifecycle. The practitioner's version, using arrival mid (mid at first exchange acknowledgment):
The identity telescopes exactly back to Perold's definition — expand the terms and cancels. Each piece has a distinct owner:
Delay cost : price drift between the signal firing and your first child order being live on the exchange. This is your infrastructure — serialization, network, rate-limit queues, risk checks. If your signal has genuine short-horizon alpha, delay cost is where it leaks first; a consistently positive mean says the market moves your way before you arrive — decaying momentum alpha, or someone faster trading the same signal.
Trading cost : what your fills paid relative to arrival — spread crossed plus market impact plus intra-schedule drift. This is the execution algorithm's report card, and the quantity that execution research actually models. Almgren, Thum, Hauptmann and Li (2005, "Direct Estimation of Equity Market Impact," Risk 18(7), 58–62) measured it over roughly 700,000 US equity orders from Citigroup's desks (December 2001–June 2003) and found trading cost scales with daily volatility and participation rate — temporary impact following a power law in the trade rate with exponent close to 3/5, permanent impact close to linear in size. We will reuse their functional form when we calibrate.
Timing risk: not a term in the mean decomposition but the variance around it. Spreading a parent over time reduces expected impact and exposes you to volatility; for a schedule with remaining position the standard deviation of cost scales as . This is precisely the trade-off the Almgren–Chriss framework optimizes. In your TCA report it shows up as the dispersion of IS across parents — report the standard deviation next to every mean, or the mean will get all the attention and the tails will get all your money.
Opportunity cost : the unfilled quantity marked at the terminal price. For passive strategies this is routinely the largest and least-examined component, and it is the term that makes the whole framework honest — more on that in the traps section, because omitting it is the single most common way people lie to themselves with TCA.
Fees : the explicit part. In crypto, log fees after discounts (VIP tiers, token rebates converted at fill-time price) and keep maker rebates signed — a negative fee is data, not noise.
A worked example
Buy BTC. Signal fires with ; paper notional $600,000. First child order acked with mid . Over the next two minutes, 8 BTC fills at VWAP 60,072; the price is trending away, the algo respects its limit, and the remaining 2 BTC are cancelled with mid at . Blended fees 2.5 bps on filled notional.
| Component | Formula | USD | bps of paper |
|---|---|---|---|
| Delay | 120 | 2.0 | |
| Trading cost | 480 | 8.0 | |
| Opportunity | 456 | 7.6 | |
| Fees | 120 | 2.0 | |
| Total IS | 1,176 | 19.6 |
The exchange statement shows $120. The real cost of implementing the decision was $1,176 — a factor of ten, with the two largest components invisible to fee-based accounting. Roughly 40% of it came from quantity that never traded. A TCA report that only analyzed fills would score this parent 8 bps and call it fine.

Markouts: pricing adverse selection
Implementation shortfall grades the parent order. It says nothing about the quality of individual fills — specifically, whether you systematically trade with counterparties who know something you don't. That is measured by markouts: mark each fill to the mid at a fixed horizon after it happened.
where is the fill price at time and is the mid at time . This is the per-unit mark-to-market PnL of the fill at horizon , and it has a clean anatomy at : a maker fill starts at half-spread (you bought at the bid, mid is above you); a taker fill starts at half-spread. What happens as grows is the information content of the trade:
- s isolates sniping and stale-quote pickoff. If your maker fills are already underwater one second after filling, faster participants are hitting your quotes at the moment they become mispriced — your quote update loop is slower than their trigger loop. This markout is a latency diagnostic, not a strategy diagnostic.
- s measures classic adverse selection: fills followed by continued movement through your price. For a market maker this is the cost spread capture must beat; equity microstructure calls the related quantity realized spread, institutionalized in SEC Rule 605 reporting at a 5-minute horizon. Crypto moves faster; 10–60s is the equivalent band.
- s tells you whether fills carry momentum against you beyond the microstructure horizon — for taker strategies, whether your signal's alpha at 60s exceeds the spread-plus-impact you paid to enter. A taker markout curve that starts at bps and never crosses zero is a strategy that pays for entries its alpha cannot fund.
The maker/taker asymmetry is the entire economics of passive trading in two curves. A maker curve starting at bps (half-spread) and decaying to bps by 60s says: you capture the spread and give back more, and no rebate tier fixes that. The same curve settling at bps says the quoting engine earns its keep. This is also precisely what naive touch-fill backtests assume away — a simulator that fills you whenever price touches your limit ignores that being filled is correlated with being wrong, which is why the queue-aware rung of the fill-simulation ladder needs measured markouts as an input rather than an assumption.

Computation is a merge_asof over your own mid stream:
import pandas as pd
def markouts(fills: pd.DataFrame, mids: pd.DataFrame,
horizons=("1s", "10s", "60s")) -> pd.DataFrame:
"""fills: [ts, price, qty, side, liquidity]; mids: [ts, mid].
Both UTC-indexed and sorted. Mid stream must be from YOUR captured
feed, not candles reconstructed later."""
fills = fills.sort_values("ts").reset_index(drop=True)
mids = mids.sort_values("ts")
out = fills.copy()
for h in horizons:
probe = fills[["ts"]].copy()
probe["ts"] = probe["ts"] + pd.Timedelta(h)
m = pd.merge_asof(probe, mids, on="ts", direction="backward")
out[f"mo_{h}"] = (fills["side"] * (m["mid"].values - fills["price"])
/ fills["price"] * 1e4)
return out
def markout_report(mo: pd.DataFrame) -> pd.DataFrame:
"""Qty-weighted markouts by liquidity flag. Weighting matters:
a 0.001 BTC fill and a 2 BTC fill are not equal evidence."""
cols = [c for c in mo.columns if c.startswith("mo_")]
def agg(g):
w = g["qty"] / g["qty"].sum()
return pd.Series({c: (g[c] * w).sum() for c in cols}
| {"n": len(g), "qty": g["qty"].sum()})
return mo.groupby("liquidity").apply(agg)
Slice the report further by symbol, hour of day, and quote distance from mid. The most actionable cut for a maker strategy is markout-by-queue-position-at-fill: front-of-fresh-queue fills price very differently from fills where the level was swept through you.
The pipeline: what to log
TCA dies at the logging layer, not the math layer. The math above needs numbers that most bots throw away, and none of them can be reconstructed afterward from exchange history. The non-negotiables:
- Decision mid, from your own feed, at signal time. Not the exchange candle close, not a later reconstruction. The benchmark is "the price my strategy believed when it decided" — only your process at that moment knows it.
- Both timestamps: decision time and first-ack time, or delay cost is unmeasurable and silently merges into trading cost.
- Every fill with the exchange timestamp, fee, and maker/taker flag — your local receive time is polluted by your own inbound latency.
- Cancelled and expired parents, logged like everything else. The parents with zero fills are the most expensive rows in the table.
- A persistent mid stream (or L1 stream) at 100–250ms granularity, retained long enough to compute markouts. If you already record books for the fill simulator, this is free.
Two flat tables are enough:
PARENTS = {
"parent_id": "str",
"strategy": "str",
"symbol": "str",
"venue": "str",
"algo": "str", # twap | pov | sniper | quote | ...
"side": "int8", # +1 buy, -1 sell
"qty": "float64", # parent size, base units
"limit_px": "float64", # NaN for unconstrained
"decision_ts": "datetime64[ns, UTC]",
"decision_mid": "float64", # mid your feed showed at decision_ts
"arrival_ts": "datetime64[ns, UTC]", # first exchange ack
"arrival_mid": "float64",
"end_ts": "datetime64[ns, UTC]", # filled / cancelled / expired
"terminal_mid": "float64",
"mkt_volume": "float64", # market volume over [arrival_ts, end_ts]
"sigma_bps": "float64", # realized vol estimate at decision time
}
FILLS = {
"parent_id": "str",
"ts": "datetime64[ns, UTC]", # exchange timestamp
"price": "float64",
"qty": "float64",
"fee": "float64", # quote ccy, post-discount, signed
"liquidity": "str", # maker | taker
"venue": "str",
}
The attribution function is a direct transcription of the decomposition:
import numpy as np
def is_decomposition(p: pd.Series, fills: pd.DataFrame) -> dict:
"""Expanded implementation shortfall for one parent, bps of paper
notional. Sign convention: positive = cost."""
s, X = p["side"], p["qty"]
paper = X * p["decision_mid"]
x = fills["qty"].sum()
delay = s * X * (p["arrival_mid"] - p["decision_mid"])
trade = s * ((fills["price"] - p["arrival_mid"]) * fills["qty"]).sum()
oppty = s * (X - x) * (p["terminal_mid"] - p["arrival_mid"])
fees = fills["fee"].sum()
bps = lambda v: 1e4 * v / paper
return {
"parent_id": p["parent_id"],
"delay_bps": bps(delay), "trade_bps": bps(trade),
"oppty_bps": bps(oppty), "fees_bps": bps(fees),
"is_bps": bps(delay + trade + oppty + fees),
"fill_ratio": x / X,
"participation": x / max(p["mkt_volume"], x),
}
def tca_table(parents: pd.DataFrame, fills: pd.DataFrame) -> pd.DataFrame:
fg = dict(tuple(fills.groupby("parent_id")))
empty = fills.iloc[0:0]
rows = [is_decomposition(p, fg.get(p["parent_id"], empty))
for _, p in parents.iterrows()]
return parents.merge(pd.DataFrame(rows), on="parent_id")
And the attribution queries, which is where TCA stops being accounting and starts being research:
tca = tca_table(parents, fills)
def wavg(g: pd.DataFrame, col: str) -> float:
w = g["qty"] * g["decision_mid"] # notional weights
return (g[col] * w).sum() / w.sum()
comp = ["delay_bps", "trade_bps", "oppty_bps", "fees_bps", "is_bps"]
by_strat = tca.groupby("strategy").apply(
lambda g: pd.Series({c: wavg(g, c) for c in comp}
| {"is_std": g["is_bps"].std(), "n": len(g)}))
done = tca[tca["fill_ratio"] > 0.99]
done["pov_bin"] = pd.qcut(done["participation"], 6)
impact_curve = done.groupby("pov_bin").apply(lambda g: wavg(g, "trade_bps"))
tca["hour"] = tca["arrival_ts"].dt.hour
by_hour = tca.groupby("hour").apply(lambda g: wavg(g, "is_bps"))
by_venue = tca.groupby("venue").apply(
lambda g: pd.Series({"is_bps": wavg(g, "is_bps"),
"is_std": g["is_bps"].std(),
"fill_ratio": g["fill_ratio"].mean(), "n": len(g)}))
Query 4 is the input table a smart order router optimizes over — routing without per-venue TCA is routing by fee schedule, i.e. by the smallest cost component (Smart order routing in crypto takes this table as its starting point). Together with the markout module, this is the promised ~200 lines.
Closing the loop: calibrating the backtest cost model
Here is where the pipeline pays for itself. Your backtest asserts numbers: slippage_bps = 5, a fill probability curve, an impact coefficient. Every one of them is a claim about live execution, and the TCA table is live execution. The loop: measure with TCA, fit the cost model, run the backtest with the fitted model, trade, re-measure.

For taker cost, borrow the functional form from Almgren et al. (2005) rather than inventing one. Their result — cost proportional to daily volatility times a power of the participation rate — gives a two-parameter model:
with the equity estimate as a prior. Fit it on binned means, not raw parents — individual parent costs are dominated by noise (that is the timing-risk term), and a log-log regression on raw data will happily fit the noise:
done = tca[(tca["fill_ratio"] > 0.99) & (tca["participation"] > 0)]
done["norm_cost"] = done["trade_bps"] / done["sigma_bps"]
bins = done.groupby(pd.qcut(done["participation"], 8)).agg(
pov=("participation", "mean"), cost=("norm_cost", "mean"))
bins = bins[bins["cost"] > 0] # can't log a negative bin
b, log_a = np.polyfit(np.log(bins["pov"]), np.log(bins["cost"]), 1)
def taker_cost_bps(participation, sigma_bps, a=np.exp(log_a), b=b):
return a * sigma_bps * participation ** b
Then be honest about the error bars. Almgren's team had 700,000 orders and still reported the temporary-impact exponent as ; a bot with 2,000 parents does not get to estimate freely per symbol. The practical regime: pool across symbols after normalizing by , shrink hard toward 0.6 (or just fix it and fit only ), and refit monthly. If your fitted drifts up, your footprint grew or the market thinned — either way the backtest needed to know.
For maker strategies the calibration targets are different and come from the other two modules:
- Fill probability: the fill simulator's queue model predicts per (spread-distance, queue-position) cell; your parent log provides realized fill ratios per cell. Disagreement is a simulator bug with a grid reference attached.
- Adverse selection: replace the simulator's implicit "fills are exchangeable" assumption with the measured markout table — a simulated maker fill at distance in hour carries the measured as an immediate mark-to-market haircut. This single change is the difference between a maker backtest that hallucinates and one that tracks; it was the missing calibration input flagged in the fill-simulation article.
- Cost dispersion: feed the distribution of IS, not its mean, into the backtest. A cost model that only shifts the mean cannot reproduce the drawdowns that timing risk creates; even a lognormal fitted to per-parent IS beats a constant.
The full slippage-curve treatment — functional forms, regime conditioning, when the power law breaks — is its own article: Slippage curves and cost models for backtests. The point here is architectural: that article's models are unfittable without this article's tables.
Traps
The TCA literature is decades old, and so are the ways of gaming it. Three failure modes account for most self-deception.
Benchmark gaming. Any benchmark other than arrival price can be hugged. The classic is VWAP: an algorithm graded against interval VWAP can track it within a basis point while the position bleeds twenty against arrival, because the benchmark drifts with the price you are pushing — and at meaningful participation your own prints are the VWAP, so tracking it is self-grading homework. We dissected the benchmark politics in TWAP vs VWAP vs POV; the TCA-side rule is simpler: benchmarks are chosen before trading, and IS-versus-arrival is always computed even when a scheduler is graded against its scheduling benchmark. The subtler variant is arrival gaming: if the component that sets decision_ts can see short-term momentum, it can time "decisions" to flatter the delay term. Decision timestamps belong to the signal layer, logged before any execution logic runs.
Survivorship bias in filled-only analysis. Condition your cost analysis on fills and passive execution looks free. Concretely: 100 passive buy parents, one tick below mid. Sixty fill, and — being passive — fill at prices averaging 3 bps better than arrival: measured "cost" bps, a report to be proud of. The forty that never filled were exactly the ones where price lifted away; mark them 25 bps adverse at cancel and the honest number is bps. The filled-only report and the true report differ by 11 bps and by sign. This is not a corner case — it is the mechanism of passive trading: getting filled is correlated with the price coming through you, which is the same conditioning that makes touch-fill backtests fantasy. Opportunity cost is not an optional refinement of IS; it is the term that defends the whole measurement against selection. The same bias has a taker variant: IOC orders that missed, orders rejected by rate limits or risk checks — if misses are not logged, the cost of missing is unmeasured, and it is largest precisely in the fast markets where your strategy most wanted the trade.
Assorted foot-guns, briefly: arrival mids reconstructed from exchange candles (your feed and the candle disagree exactly when it matters); averaging per-parent bps without notional weights (a thousand dust fills outvoting one real order); fees logged pre-discount; markout mids taken from a different venue than the fill (cross-venue basis masquerading as adverse selection); and on perps, letting funding accrual bleed into the execution window — funding is a cost, but not an execution cost, and mixing them poisons both analyses.
What to do this week
Add the two log tables to your bot — the schema above is a column list, not a project. Backfill nothing; two weeks of honest logs beat a year of reconstructions. Run the decomposition and the three markout horizons. You will learn which component dominates (almost never fees), whether your passive fills are adversely selected beyond their spread capture, and how far your backtest's cost constant sits from the measured curve. Then wire the fitted curve back into the simulator and re-run the backtest that told you to run this strategy in the first place. Perold's paper portfolio, thirty-eight years on, is still the only honest opponent your real one has.
Authors
Trading-systems engineer
Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.