Implementation shortfall en zelfgebouwde TCA: meten wat executie je werkelijk kost
Elke institutionele desk heeft een pipeline voor transaction cost analysis. Vrijwel niemand die een crypto-bot draait heeft dat. De typische opzet logt fills, telt fees op, en noemt het verschil tussen backtest-PnL en live-PnL "slippage" — een enkele onverklaarde residu die latency, spread, impact, adverse selection en elke niet-gevulde order die wegliep in zich opneemt. Je kunt een kost die je als één getal meet niet oplossen. De machinerie om het fatsoenlijk te meten bestaat al sinds 1988, is niet proprietary, en over je eigen fill-logs is het ruwweg 200 regels Python. Dit artikel bouwt hem.
De opbrengst is geen mooiere dashboard. Je backtest bevat een kostenmodel — een slippage-constante, een fill-waarschijnlijkheid, een impact-coëfficiënt — en elke parameter daarin is momenteel een gok. TCA over live fills is de enige ground truth waartegen die parameters gekalibreerd kunnen worden. We bouwden de simulatiezijde van die loop in Fill-simulatie: de ladder van close-price-fantasie naar queue-aware realiteit; dit artikel bouwt de meetzijde.
Papier versus realiteit: wat Perold werkelijk mat
De fundamentele truc komt van André Perold (1988, "The Implementation Shortfall: Paper Versus Reality," Journal of Portfolio Management 14(3), 4–9). Draai twee portefeuilles parallel. De papieren portefeuille voert elke beslissing onmiddellijk uit, in onbeperkte omvang, tegen nul kosten, tegen de prijs die gold op het moment van beslissing. De echte portefeuille is wat je bot daadwerkelijk deed: partial fills, achterna gejaagde quotes, geannuleerde resten, fees. De implementation shortfall is het verschil in hun rendementen.
De definitie is belangrijk vanwege wat zij weigert te verbergen. Een fee-overzicht toont commissies. Een fill-versus-limietprijs-rapport toont helemaal niets (je vult per constructie nooit slechter dan je limiet). De papieren portefeuille rekent je alles aan: drift tussen beslissing en aankomst, de spread die je oversteekte, de impact die je veroorzaakte, en — cruciaal — de orders die nooit vulden terwijl de prijs wegliep. Wagner en Edwards (1993, "Best Execution," Financial Analysts Journal 49(1), 65–71) noemden zichtbare fees het topje van de ijsberg; voor alles met turnover domineert het onderwater liggende deel.
Laten we de notatie vastleggen. Een parent order: side (koop/verkoop), omvang . De beslissingsprijs is de mid die je strategie zag toen het signaal afvuurde. Fills komen binnen als met totaal gevulde hoeveelheid . Op de horizon (parent voltooid, geannuleerd of getimed out) is de mid . Expliciete fees zijn . Implementation shortfall in valutatermen:
genormaliseerd naar basispunten door te delen door de papieren notional . Positief betekent dat je betaald hebt. De eerste term is wat je fills kostten ten opzichte van de papieren portefeuille; de tweede is Perolds opportunity cost — de ongevulde rest gemarkeerd tegen de prijs waar hij naartoe wegliep; de derde is het enige deel dat je exchange-overzicht toegeeft.
Één conventienoot: de industrie zegt "arrival price," en in de meeste equity-TCA betekent arrival de mid toen de order de markt bereikte. Voor een bot verschillen beslissingstijd en aankomsttijd door je eigen interne latency plus rate-limit-queueing — een reële, meetbare kost. Dus we houden beide timestamps en beide prijzen bij, en laten de decompositie ze scheiden.
De decompositie: delay, impact, timing, opportunity, fees
Eén enkel IS-getal vertelt je dat executie duur is. Het vertelt je niet waarom, en de oplossingen voor verschillende componenten zijn volstrekt verschillend — je lost delay cost en impact cost niet met dezelfde aanpassing op. Robert Kissells uitgebreide implementation shortfall (Kissell, 2006, "The Expanded Implementation Shortfall: Understanding Transaction Cost Components," Journal of Trading 1(3), 6–16; op boeklengte uitgewerkt in The Science of Algorithmic Trading and Portfolio Management, Academic Press, 2013) ontrafelt het totaal in componenten, elk toe te schrijven aan een onderscheiden fase van de order-lifecycle. De praktijkversie, met arrival-mid (mid bij eerste exchange-acknowledgment):
De identiteit telescopeert exact terug naar Perolds definitie — werk de termen uit en valt weg. Elk stuk heeft een onderscheiden eigenaar:
Delay cost : prijsdrift tussen het afvuren van het signaal en je eerste child order die live staat op de exchange. Dit is je infrastructuur — serialisatie, netwerk, rate-limit-queues, risk checks. Als je signaal echte alpha op korte horizon heeft, is delay cost waar hij het eerst weglekt; een consequent positief gemiddelde zegt dat de markt jouw kant op beweegt voordat je aankomt — vervallende momentum-alpha, of iemand die sneller hetzelfde signaal handelt.
Trading cost : wat je fills betaalden ten opzichte van arrival — de overgestoken spread plus market impact plus intra-schedule drift. Dit is het rapport van het executie-algoritme, en de grootheid die executieonderzoek daadwerkelijk modelleert. Almgren, Thum, Hauptmann en Li (2005, "Direct Estimation of Equity Market Impact," Risk 18(7), 58–62) maten het over ruwweg 700.000 US-equity-orders van Citigroups desks (december 2001–juni 2003) en vonden dat trading cost schaalt met de dagelijkse volatiliteit en de participation rate — tijdelijke impact volgens een machtswet in de trade rate met exponent dicht bij 3/5, permanente impact bijna lineair in omvang. We hergebruiken hun functionele vorm bij de kalibratie.
Timing risk: geen term in de gemiddelde decompositie maar de variantie eromheen. Een parent over de tijd uitsmeren verlaagt de verwachte impact en stelt je bloot aan volatiliteit; voor een schedule met resterende positie schaalt de standaarddeviatie van de kost als . Dit is precies de afweging die het Almgren–Chriss-raamwerk optimaliseert. In je TCA-rapport verschijnt het als de dispersie van IS over parents — rapporteer de standaarddeviatie naast elk gemiddelde, anders krijgt het gemiddelde alle aandacht en de staarten al je geld.
Opportunity cost : de ongevulde hoeveelheid gemarkeerd tegen de terminale prijs. Voor passieve strategieën is dit routinematig de grootste en minst onderzochte component, en het is de term die het hele raamwerk eerlijk maakt — meer daarover in het valkuilen-hoofdstuk, want het weglaten ervan is de allergewoonste manier waarop mensen zichzelf voor de gek houden met TCA.
Fees : het expliciete deel. In crypto: log fees na kortingen (VIP-tiers, token-rebates omgerekend tegen de fill-time-prijs) en houd maker-rebates ondertekend — een negatieve fee is data, geen ruis.
Een uitgewerkt voorbeeld
Koop BTC. Signaal vuurt af met ; papieren notional $600.000. Eerste child order geackt met mid . Over de volgende twee minuten vult 8 BTC tegen VWAP 60.072; de prijs beweegt weg, het algo respecteert zijn limiet, en de resterende 2 BTC worden geannuleerd met mid op . Gemengde fees 2,5 bps op gevulde notional.
| Component | Formule | USD | bps van papier |
|---|---|---|---|
| Delay | 120 | 2.0 | |
| Trading cost | 480 | 8.0 | |
| Opportunity | 456 | 7.6 | |
| Fees | 120 | 2.0 | |
| Totaal IS | 1,176 | 19.6 |
Het exchange-overzicht toont $120. De werkelijke kost van het implementeren van de beslissing was $1.176 — een factor tien, met de twee grootste componenten onzichtbaar voor fee-gebaseerde boekhouding. Ruwweg 40% ervan kwam van hoeveelheid die nooit gehandeld werd. Een TCA-rapport dat alleen fills analyseerde zou deze parent op 8 bps scoren en prima noemen.

Markouts: adverse selection beprijzen
Implementation shortfall beoordeelt de parent order. Het zegt niets over de kwaliteit van individuele fills — specifiek, of je systematisch handelt met tegenpartijen die iets weten wat jij niet weet. Dat wordt gemeten door markouts: markeer elke fill tegen de mid op een vaste horizon nadat hij plaatsvond.
waarbij de fill-prijs op tijd is en de mid op tijd . Dit is de mark-to-market-PnL per eenheid van de fill op horizon , en het heeft een schone anatomie bij : een maker-fill start op half-spread (je kocht op de bid, de mid ligt boven je); een taker-fill start op half-spread. Wat er gebeurt naarmate groeit is de informatie-inhoud van de trade:
- s isoleert sniping en het oppikken van stale quotes. Als je maker-fills al onder water staan één seconde na het vullen, raken snellere deelnemers je quotes precies op het moment dat ze verkeerd geprijsd raken — je quote-update-loop is trager dan hun trigger-loop. Deze markout is een latency-diagnose, geen strategie-diagnose.
- s meet klassieke adverse selection: fills gevolgd door voortgezette beweging door je prijs heen. Voor een market maker is dit de kost die spread capture moet verslaan; equity-microstructuur noemt de gerelateerde grootheid realized spread, geïnstitutionaliseerd in SEC Rule 605-rapportage op een horizon van 5 minuten. Crypto beweegt sneller; 10–60s is de equivalente band.
- s vertelt je of fills momentum tegen je in dragen voorbij de microstructuur-horizon — voor taker-strategieën, of de alpha van je signaal op 60s de spread-plus-impact die je betaalde om in te stappen overtreft. Een taker-markout-curve die op bps start en nooit nul kruist, is een strategie die betaalt voor entries die haar alpha niet kan financieren.
De maker/taker-asymmetrie is de hele economie van passief handelen in twee curves. Een maker-curve die op bps (half-spread) start en tot bps op 60s vervalt zegt: je vangt de spread en geeft er meer van terug, en geen enkele rebate-tier lost dat op. Diezelfde curve die op bps uitkomt zegt dat de quoting-engine zijn brood verdient. Dit is ook precies wat naïeve touch-fill-backtests wegveronderstellen — een simulator die je vult telkens wanneer de prijs je limiet raakt negeert dat gevuld worden gecorreleerd is met ongelijk hebben, en daarom heeft de queue-aware sport van de fill-simulatie-ladder gemeten markouts als input nodig in plaats van als aanname.

De berekening is een merge_asof over je eigen 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)
Snijd het rapport verder op per symbool, uur van de dag, en quote-afstand tot de mid. De meest actionable snede voor een maker-strategie is markout-op-queue-positie-bij-fill: fills vooraan in een verse queue prijzen heel anders dan fills waarbij het level door je heen werd weggeveegd.
De pipeline: wat te loggen
TCA sterft in de logging-laag, niet in de wiskunde-laag. De wiskunde hierboven heeft getallen nodig die de meeste bots weggooien, en geen ervan is achteraf uit exchange-historie te reconstrueren. De niet-onderhandelbare zaken:
- Beslissings-mid, uit je eigen feed, op signaaltijd. Niet de exchange-candle-close, geen latere reconstructie. De benchmark is "de prijs die mijn strategie geloofde toen ze besloot" — alleen je proces op dat moment kent hem.
- Beide timestamps: beslissingstijd en eerste-ack-tijd, anders is delay cost niet-meetbaar en versmelt hij stilzwijgend met trading cost.
- Elke fill met de exchange-timestamp, fee en maker/taker-flag — je lokale receive-time is vervuild door je eigen inbound latency.
- Geannuleerde en verlopen parents, gelogd zoals al het andere. De parents met nul fills zijn de duurste rijen in de tabel.
- Een persistente mid-stream (of L1-stream) op 100–250ms-granulariteit, lang genoeg bewaard om markouts te berekenen. Als je al books opneemt voor de fill-simulator, is dit gratis.
Twee platte tabellen zijn genoeg:
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",
}
De attributiefunctie is een directe transcriptie van de decompositie:
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")
En de attributie-queries, waar TCA ophoudt boekhouding te zijn en onderzoek wordt:
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 de inputtabel waarover een smart order router optimaliseert — routeren zonder per-venue-TCA is routeren op fee-schedule, dat wil zeggen op de kleinste kostencomponent (Smart order routing in crypto neemt deze tabel als startpunt). Samen met de markout-module zijn dit de beloofde ~200 regels.
De loop sluiten: het backtest-kostenmodel kalibreren
Hier verdient de pipeline zichzelf terug. Je backtest beweert getallen: slippage_bps = 5, een fill-waarschijnlijkheidscurve, een impact-coëfficiënt. Elk ervan is een claim over live executie, en de TCA-tabel is live executie. De loop: meet met TCA, fit het kostenmodel, draai de backtest met het gefitte model, handel, hermeet.

Voor taker-kost, leen de functionele vorm van Almgren et al. (2005) in plaats van er een te verzinnen. Hun resultaat — kost evenredig met de dagelijkse volatiliteit maal een macht van de participation rate — geeft een tweeparametermodel:
met de equity-schatting als prior. Fit hem op gebinde gemiddelden, niet op ruwe parents — individuele parent-kosten worden gedomineerd door ruis (dat is de timing-risk-term), en een log-log-regressie op ruwe data zal maar al te graag de ruis fitten:
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
Wees vervolgens eerlijk over de foutmarges. Almgrens team had 700.000 orders en rapporteerde de temporary-impact-exponent nog steeds als ; een bot met 2.000 parents mag niet vrij per symbool schatten. Het praktische regime: pool over symbolen na normalisatie met , krimp hard richting 0.6 (of fixeer het gewoon en fit alleen ), en herfit maandelijks. Als je gefitte omhoog drift, is je footprint gegroeid of is de markt dunner geworden — hoe dan ook moest de backtest dat weten.
Voor maker-strategieën zijn de kalibratiedoelen anders en komen ze uit de andere twee modules:
- Fill-waarschijnlijkheid: het queue-model van de fill-simulator voorspelt per (spread-afstand, queue-positie)-cel; je parent-log levert gerealiseerde fill-ratio's per cel. Onenigheid is een simulatorbug met een grid-referentie eraan vast.
- Adverse selection: vervang de impliciete "fills zijn uitwisselbaar"-aanname van de simulator door de gemeten markout-tabel — een gesimuleerde maker-fill op afstand in uur draagt de gemeten als onmiddellijke mark-to-market-korting. Deze ene verandering is het verschil tussen een maker-backtest die hallucineert en een die klopt; het was de ontbrekende kalibratie-input gemarkeerd in het fill-simulatie-artikel.
- Kostendispersie: voer de verdeling van IS in de backtest, niet het gemiddelde ervan. Een kostenmodel dat alleen het gemiddelde verschuift kan de drawdowns die timing risk creëert niet reproduceren; zelfs een lognormaal gefit op per-parent-IS verslaat een constante.
De volledige behandeling van slippage-curves — functionele vormen, regime-conditionering, wanneer de machtswet breekt — is een eigen artikel: Slippage-curves en kostenmodellen voor backtests. Het punt hier is architecturaal: de modellen uit dat artikel zijn niet fitbaar zonder de tabellen uit dit artikel.
Valkuilen
De TCA-literatuur is decennia oud, en zo ook de manieren om haar te gamen. Drie faalmodi zijn verantwoordelijk voor de meeste zelfmisleiding.
Benchmark-gaming. Elke benchmark anders dan arrival price kan omhelsd worden. De klassieker is VWAP: een algoritme dat wordt beoordeeld tegen interval-VWAP kan die binnen een basispunt volgen terwijl de positie er twintig tegen arrival bloedt, want de benchmark drift mee met de prijs die je zelf omhoog duwt — en bij noemenswaardige participatie zijn je eigen prints de VWAP, zodat hem volgen huiswerk-op-eigen-cijfers is. We ontleedden de benchmark-politiek in TWAP vs VWAP vs POV; de TCA-zijde-regel is simpeler: benchmarks worden gekozen vóór het handelen, en IS-versus-arrival wordt altijd berekend, zelfs wanneer een scheduler tegen zijn scheduling-benchmark wordt beoordeeld. De subtielere variant is arrival-gaming: als de component die decision_ts zet momentum op korte termijn kan zien, kan hij "beslissingen" timen om de delay-term te flatteren. Beslissings-timestamps horen bij de signaallaag, gelogd voordat enige executielogica draait.
Survivorship bias in analyse van alleen gevulde orders. Conditioneer je kostenanalyse op fills en passieve executie lijkt gratis. Concreet: 100 passieve koop-parents, één tick onder de mid. Zestig vullen, en — passief zijnde — vullen tegen prijzen die gemiddeld 3 bps beter zijn dan arrival: gemeten "kost" bps, een rapport om trots op te zijn. De veertig die nooit vulden waren precies die waar de prijs wegtilde; markeer ze op 25 bps adverse bij annulering en het eerlijke getal is bps. Het alleen-gevuld-rapport en het ware rapport verschillen met 11 bps en van teken. Dit is geen randgeval — het is het mechanisme van passief handelen: gevuld worden is gecorreleerd met de prijs die door je heen komt, wat dezelfde conditionering is die touch-fill-backtests tot fantasie maakt. Opportunity cost is geen optionele verfijning van IS; het is de term die de hele meting tegen selectie verdedigt. Dezelfde bias heeft een taker-variant: IOC-orders die misten, orders geweigerd door rate limits of risk checks — als missers niet gelogd worden, is de kost van missen ongemeten, en die is precies het grootst in de snelle markten waar je strategie de trade het meest wilde.
Diverse foot-guns, kort: arrival-mids gereconstrueerd uit exchange-candles (je feed en de candle verschillen precies wanneer het ertoe doet); per-parent-bps middelen zonder notional-gewichten (duizend dust-fills die één echte order overstemmen); fees gelogd vóór korting; markout-mids genomen van een andere venue dan de fill (cross-venue-basis vermomd als adverse selection); en op perps, funding-accrual in het executievenster laten bloeden — funding is een kost, maar geen executiekost, en ze mengen vergiftigt beide analyses.
Wat deze week te doen
Voeg de twee log-tabellen toe aan je bot — het schema hierboven is een kolommenlijst, geen project. Backfill niets; twee weken eerlijke logs verslaan een jaar aan reconstructies. Draai de decompositie en de drie markout-horizons. Je zult leren welke component domineert (vrijwel nooit fees), of je passieve fills adverse selected worden voorbij hun spread capture, en hoe ver de kostenconstante van je backtest van de gemeten curve ligt. Bedraad vervolgens de gefitte curve terug in de simulator en herdraai de backtest die je überhaupt vertelde deze strategie te draaien. Perolds papieren portefeuille is, achtendertig jaar later, nog steeds de enige eerlijke tegenstander die je echte portefeuille heeft.
Auteurs
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.