← Back to articles
July 20, 2026
5 min read

Implementation shortfall and DIY TCA: measuring what execution actually costs you

Implementation shortfall and DIY TCA: measuring what execution actually costs you
#execution
#tca
#implementation-shortfall
#markouts
#transaction-costs
#algotrading
#backtest
#market-microstructure

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 s{+1,1}s \in \{+1, -1\} (buy/sell), size XX. The decision price PdP_d is the mid your strategy saw when the signal fired. Fills arrive as (pj,xj)(p_j, x_j) with total filled quantity x=jxjXx = \sum_j x_j \le X. At the horizon TT (parent completed, cancelled, or timed out), the mid is PTP_T. Explicit fees are FF. Implementation shortfall in currency terms:

IS=sjxj(pjPd)execution cost  +  s(Xx)(PTPd)opportunity cost  +  F,\mathrm{IS} = \underbrace{s \sum_j x_j\,(p_j - P_d)}_{\text{execution cost}} \;+\; \underbrace{s\,(X - x)(P_T - P_d)}_{\text{opportunity cost}} \;+\; F,

normalized to basis points by dividing by the paper notional XPdX \cdot P_d. 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 P0P_0 (mid at first exchange acknowledgment):

IS=sX(P0Pd)delay  +  sjxj(pjP0)trading cost  +  s(Xx)(PTP0)opportunity  +  F.\mathrm{IS} = \underbrace{s\,X\,(P_0 - P_d)}_{\text{delay}} \;+\; \underbrace{s \sum_j x_j\,(p_j - P_0)}_{\text{trading cost}} \;+\; \underbrace{s\,(X - x)(P_T - P_0)}_{\text{opportunity}} \;+\; F.

The identity telescopes exactly back to Perold's definition — expand the terms and P0P_0 cancels. Each piece has a distinct owner:

Delay cost sX(P0Pd)sX(P_0 - P_d): 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 sjxj(pjP0)s\sum_j x_j(p_j - P_0): 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 q(t)q(t) the standard deviation of cost scales as σ0Tq(t)2dt\sigma \sqrt{\int_0^T q(t)^2\,dt}. 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 s(Xx)(PTP0)s(X - x)(P_T - P_0): 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 FF: 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 X=10X = 10 BTC. Signal fires with Pd=60,000P_d = 60{,}000; paper notional $600,000. First child order acked with mid P0=60,012P_0 = 60{,}012. 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 PT=60,240P_T = 60{,}240. Blended fees 2.5 bps on filled notional.

Component Formula USD bps of paper
Delay 10×(60,01260,000)10 \times (60{,}012 - 60{,}000) 120 2.0
Trading cost 8×(60,07260,012)8 \times (60{,}072 - 60{,}012) 480 8.0
Opportunity 2×(60,24060,012)2 \times (60{,}240 - 60{,}012) 456 7.6
Fees 8×60,072×2.5e-48 \times 60{,}072 \times 2.5\text{e-}4 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.

Waterfall decomposition of implementation shortfall from paper portfolio to real portfolio

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.

πτ=s(mtf+τpf)mtf×104 bps,\pi_\tau = \frac{s\,(m_{t_f + \tau} - p_f)}{m_{t_f}} \times 10^4 \ \text{bps},

where pfp_f is the fill price at time tft_f and mtm_t is the mid at time tt. This is the per-unit mark-to-market PnL of the fill at horizon τ\tau, and it has a clean anatomy at τ=0\tau = 0: 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 τ\tau grows is the information content of the trade:

  • τ=1\tau = 1s 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.
  • τ=10\tau = 10s 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.
  • τ=60\tau = 60s 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 4-4 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 +2+2 bps (half-spread) and decaying to 1-1 bps by 60s says: you capture the spread and give back more, and no rebate tier fixes that. The same curve settling at +0.7+0.7 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.

Markout curves versus horizon for maker and taker fills showing adverse selection decay

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.

Closed calibration loop from live fills through TCA attribution to backtest cost model

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:

trade_bpsσbps=a(xV)b,\frac{\text{trade\_bps}}{\sigma_{\text{bps}}} = a \cdot \left(\frac{x}{V}\right)^{b},

with the equity estimate b0.6b \approx 0.6 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 0.600±0.0380.600 \pm 0.038; a bot with 2,000 parents does not get to estimate bb freely per symbol. The practical regime: pool across symbols after normalizing by σ\sigma, shrink bb hard toward 0.6 (or just fix it and fit only aa), and refit monthly. If your fitted aa 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 P(fill)P(\text{fill}) 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 dd in hour hh carries the measured π10s(d,h)\pi_{10s}(d, h) 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" 3-3 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 0.6×(3)+0.4×25=+8.20.6 \times (-3) + 0.4 \times 25 = +8.2 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.

Disclaimer: The information provided in this article is for educational and informational purposes only and does not constitute financial, investment, or trading advice. Trading cryptocurrencies involves significant risk of loss.

Authors

Eugen Soloviov
Eugen Soloviov

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.

Newsletter

Stay Ahead of the Market

Subscribe to our newsletter for exclusive AI trading insights, market analysis, and platform updates.

We respect your privacy. Unsubscribe at any time.