Implementation shortfall и самодельный TCA: как измерить, сколько реально стоит исполнение
У каждого институционального деска есть пайплайн анализа транзакционных издержек (TCA). Почти ни у кого, кто гоняет крипто-бота, его нет. Типичная схема: логируются сделки, суммируются комиссии, а разница между PnL бэктеста и PnL в реальной торговле называется "slippage" — единственный необъясненный остаток, в который сваливаются задержка, спред, импакт, неблагоприятный отбор (adverse selection) и все неисполненные заявки, по которым цена ушла. Издержку, измеряемую одним числом, невозможно исправить. Механизм для правильного измерения существует с 1988 года, он не проприетарный, и поверх собственных логов сделок это примерно 200 строк Python. В этой статье мы его строим.
Выигрыш — не более красивый дашборд. В вашем бэктесте есть модель издержек: константа slippage, вероятность исполнения, коэффициент импакта — и каждый параметр в ней сейчас угадан. TCA поверх реальных сделок — единственная точка опоры, относительно которой эти параметры можно откалибровать. Сторону симуляции этого контура мы построили в статье Симуляция исполнения: лестница от фантазий цены закрытия к реальности с учетом очереди; эта статья строит сторону измерения.
Бумага против реальности: что на самом деле измерял Перольд
Основополагающий прием принадлежит Андре Перольду (1988, "The Implementation Shortfall: Paper Versus Reality," Journal of Portfolio Management 14(3), 4-9). Запускаем два портфеля параллельно. Бумажный портфель исполняет каждое решение мгновенно, в неограниченном объеме, без издержек, по цене, действующей в момент принятия решения. Реальный портфель — это то, что бот сделал на самом деле: частичные исполнения, погоня за котировками, отмена остатков, комиссии. Implementation shortfall — это разница в их доходности.
Это определение важно именно тем, что оно отказывается что-либо прятать. Отчет по комиссиям показывает комиссии. Отчет "исполнение против лимитной цены" не показывает вообще ничего (вы никогда не исполняетесь хуже своего лимита — по построению). Бумажный портфель выставляет счет за все: дрейф между решением и выходом на рынок, пересеченный спред, вызванный импакт и — что критично — заявки, которые так и не исполнились, пока цена уходила. Вагнер и Эдвардс (1993, "Best Execution," Financial Analysts Journal 49(1), 65-71) назвали видимые комиссии верхушкой айсберга; для всего, что имеет оборот, доминирует подводная часть.
Зафиксируем обозначения. Родительская заявка: сторона (покупка/продажа), объем . Цена решения — это mid, который стратегия видела в момент срабатывания сигнала. Исполнения приходят как с суммарным исполненным объемом . На горизонте (родительская заявка завершена, отменена или истек таймаут) mid равен . Явные комиссии — . Implementation shortfall в денежном выражении:
нормализуется в базисные пункты делением на бумажный номинал . Положительное значение означает, что вы заплатили. Первое слагаемое — сколько стоили ваши исполнения относительно бумажного портфеля; второе — издержки упущенной возможности по Перольду: неисполненный остаток, оцененный по цене, до которой она убежала; третье — единственная часть, которую признает выписка биржи.
Замечание про терминологию: индустрия говорит "arrival price" (цена выхода на рынок), и в большинстве equity TCA arrival означает mid в момент, когда заявка достигла рынка. Для бота момент принятия решения и момент выхода на рынок различаются на величину собственной внутренней задержки плюс ожидание в очереди из-за rate limit — это реальная, измеримая издержка. Поэтому мы держим оба таймстампа и обе цены и позволяем декомпозиции разделить их.
Декомпозиция: задержка, импакт, тайминг, упущенная возможность, комиссии
Одно число IS говорит вам, что исполнение дорогое. Оно не говорит, почему, а способы исправления для разных компонентов совершенно разные — задержку и издержки импакта не решить одним и тем же изменением. Расширенный implementation shortfall Роберта Кисселла (Kissell, 2006, "The Expanded Implementation Shortfall: Understanding Transaction Cost Components," Journal of Trading 1(3), 6-16; развернуто изложено в The Science of Algorithmic Trading and Portfolio Management, Academic Press, 2013) разбивает итог на компоненты, каждый из которых относится к отдельной стадии жизненного цикла заявки. Практическая версия с использованием arrival mid (mid в момент первого подтверждения биржей):
Тождество раскрывается точно до определения Перольда — раскройте слагаемые, и сократится. У каждой части свой владелец:
Издержки задержки : дрейф цены между срабатыванием сигнала и выходом первой дочерней заявки на биржу в статус live. Это ваша инфраструктура — сериализация, сеть, очереди rate limit, риск-проверки. Если у сигнала есть настоящее альфа на коротком горизонте, издержки задержки — это то место, где оно утекает первым; устойчиво положительное среднее говорит о том, что рынок движется в вашу сторону еще до того, как вы выходите на рынок — затухающее моментум-альфа, либо кто-то более быстрый торгует тот же сигнал.
Издержки торговли : во что обошлись исполнения относительно arrival — пересеченный спред плюс рыночный импакт плюс дрейф внутри расписания исполнения. Это оценка работы алгоритма исполнения, и именно эта величина реально моделируется в исследованиях по execution. Альмгрен, Тум, Хауптманн и Ли (2005, "Direct Estimation of Equity Market Impact," Risk 18(7), 58-62) измерили ее примерно на 700 000 заявках по американским акциям с десков Citigroup (декабрь 2001 - июнь 2003) и обнаружили, что издержки торговли масштабируются с дневной волатильностью и долей участия в объеме — временный импакт следует степенному закону от темпа торговли с показателем около 3/5, постоянный импакт близок к линейному по размеру. Мы повторно используем эту функциональную форму при калибровке.
Тайминг-риск: не слагаемое в декомпозиции среднего, а дисперсия вокруг него. Растягивание родительской заявки во времени снижает ожидаемый импакт, но подвергает вас волатильности; для расписания с оставшейся позицией стандартное отклонение издержек масштабируется как . Это ровно тот компромисс, который оптимизирует фреймворк Альмгрена-Криса. В вашем TCA-отчете это проявляется как дисперсия IS по родительским заявкам — приводите стандартное отклонение рядом с каждым средним, иначе все внимание достанется среднему, а все ваши деньги — хвостам.
Издержки упущенной возможности : неисполненный объем, оцененный по терминальной цене. Для пассивных стратегий это регулярно самый крупный и наименее изученный компонент, и именно это слагаемое делает весь фреймворк честным — подробнее в разделе про ловушки, потому что его пропуск — самый распространенный способ обмануть себя с помощью TCA.
Комиссии : явная часть. В крипте логируйте комиссии после скидок (VIP-уровни, токен-ребейты, конвертированные по цене на момент исполнения) и сохраняйте знак мейкер-ребейтов — отрицательная комиссия это данные, а не шум.
Разобранный пример
Покупка BTC. Сигнал срабатывает при ; бумажный номинал $600,000. Первая дочерняя заявка подтверждена при mid . За следующие две минуты 8 BTC исполняются по VWAP 60,072; цена уходит в тренд, алгоритм соблюдает свой лимит, оставшиеся 2 BTC отменяются при mid . Смешанная комиссия 2.5 базисных пункта от исполненного номинала.
| Компонент | Формула | USD | б.п. от бумажного |
|---|---|---|---|
| Задержка | 120 | 2.0 | |
| Издержки торговли | 480 | 8.0 | |
| Упущенная возможность | 456 | 7.6 | |
| Комиссии | 120 | 2.0 | |
| Итого IS | 1,176 | 19.6 |
Выписка биржи показывает $120. Реальная стоимость исполнения решения составила $1,176 — в десять раз больше, причем два крупнейших компонента невидимы для учета на основе комиссий. Примерно 40% этой суммы пришлось на объем, который так и не был торгован. TCA-отчет, анализирующий только исполненные сделки, оценил бы эту родительскую заявку в 8 б.п. и назвал бы все в порядке.

Markout: оценка неблагоприятного отбора
Implementation shortfall оценивает родительскую заявку. Он ничего не говорит о качестве отдельных исполнений — конкретно о том, систематически ли вы торгуете с контрагентами, которые знают что-то, чего не знаете вы. Это измеряется markout: каждое исполнение оценивается по mid на фиксированном горизонте после самого события.
где — цена исполнения в момент времени , а — mid в момент времени . Это удельный mark-to-market PnL исполнения на горизонте , и у него чистая анатомия при : мейкерское исполнение начинается с половины спреда (вы купили по бид, mid выше вас); тейкерское исполнение начинается с половины спреда. То, что происходит по мере роста , — это информационное содержание сделки:
- с выделяет снайпинг и подбор устаревших котировок (stale-quote pickoff). Если ваши мейкерские исполнения уже в минусе через секунду после исполнения, значит более быстрые участники бьют по вашим котировкам в момент, когда те становятся неверно оцененными — ваш цикл обновления котировок медленнее их цикла триггера. Этот markout — диагностика задержки, а не диагностика стратегии.
- с измеряет классический неблагоприятный отбор: исполнения, за которыми следует продолжение движения через вашу цену. Для маркет-мейкера это издержки, которые должен перекрыть захват спреда; микроструктура акций называет родственную величину realized spread, институционализированную в отчетности по SEC Rule 605 на горизонте 5 минут. Крипта движется быстрее; эквивалентный диапазон — 10-60с.
- с показывает, несут ли исполнения моментум против вас за пределами горизонта микроструктуры — для тейкерских стратегий это вопрос, превышает ли альфа сигнала на 60с спред плюс импакт, заплаченные при входе. Тейкерская markout-кривая, начинающаяся с б.п. и никогда не пересекающая ноль, — это стратегия, которая платит за входы, которые ее альфа не способна окупить.
Асимметрия мейкер/тейкер — это вся экономика пассивной торговли, уместившаяся в две кривые. Мейкерская кривая, начинающаяся с б.п. (половина спреда) и затухающая до б.п. к 60с, говорит: вы захватываете спред и отдаете больше, и никакой уровень ребейта это не исправит. Та же кривая, устанавливающаяся на б.п., говорит, что котировочный движок окупает себя. Это же ровно то, что наивные бэктесты с исполнением по касанию (touch-fill) просто игнорируют — симулятор, исполняющий вас всякий раз, когда цена касается лимита, игнорирует тот факт, что быть исполненным коррелирует с тем, что вы оказались неправы, поэтому уровню с учетом очереди в лестнице симуляции исполнения нужны измеренные markout в качестве входных данных, а не как допущение.

Вычисление — это merge_asof поверх собственного потока mid:
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)
Разрежьте отчет дальше по символу, часу дня и дистанции котировки от mid. Самый практически полезный срез для мейкерской стратегии — markout по позиции в очереди на момент исполнения: исполнения в начале свежей очереди ценятся совершенно иначе, чем исполнения, где уровень был пробит насквозь через вас.
Пайплайн: что логировать
TCA умирает на уровне логирования, а не на уровне математики. Вышеописанной математике нужны числа, которые большинство ботов выбрасывает, и ни одно из них потом не восстановить из истории биржи. Обязательные требования:
- Mid на момент решения из собственного потока данных, в момент срабатывания сигнала. Не свеча биржи, не последующая реконструкция. Бенчмарк — это "цена, в которую верила стратегия, когда принимала решение" — только собственный процесс в этот момент это знает.
- Оба таймстампа: момент решения и момент первого подтверждения, иначе издержки задержки неизмеримы и незаметно сливаются с издержками торговли.
- Каждое исполнение с биржевым таймстампом, комиссией и флагом мейкер/тейкер — время получения на вашей стороне загрязнено собственной входящей задержкой.
- Отмененные и истекшие родительские заявки, логируемые наравне со всем остальным. Заявки с нулевым исполнением — самые дорогие строки в таблице.
- Постоянный поток mid (или L1-поток) с гранулярностью 100-250мс, хранящийся достаточно долго для вычисления markout. Если вы уже записываете стаканы для симулятора исполнения, это бесплатно.
Достаточно двух плоских таблиц:
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",
}
Функция атрибуции — это прямая транскрипция декомпозиции:
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")
И запросы атрибуции — это то место, где TCA перестает быть бухгалтерией и становится исследованием:
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)}))
Запрос 4 — это входная таблица, которую оптимизирует умный роутер заявок — маршрутизация без TCA по площадкам — это маршрутизация по расписанию комиссий, то есть по наименьшему компоненту издержек (Умная маршрутизация заявок в крипте берет эту таблицу как отправную точку). Вместе с модулем markout это и есть обещанные ~200 строк.
Замыкание контура: калибровка модели издержек бэктеста
Вот здесь пайплайн окупает себя. Ваш бэктест содержит утверждения: slippage_bps = 5, кривую вероятности исполнения, коэффициент импакта. Каждое из них — это утверждение о реальном исполнении, а TCA-таблица и есть реальное исполнение. Контур: измерить с помощью TCA, подобрать модель издержек, прогнать бэктест с подобранной моделью, торговать, измерить заново.

Для тейкерских издержек позаимствуем функциональную форму у Альмгрена и др. (2005), а не изобретаем свою. Их результат — издержки пропорциональны дневной волатильности, умноженной на степень доли участия в объеме — дает двухпараметрическую модель:
с оценкой для акций как априорным значением. Подбирайте ее на усредненных по бинам данных, а не на сырых родительских заявках — издержки отдельных родительских заявок доминируются шумом (это и есть слагаемое тайминг-риска), и лог-логарифмическая регрессия на сырых данных с удовольствием подгонится под шум:
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
А затем будьте честны насчет доверительных интервалов. У команды Альмгрена было 700 000 заявок, и они все равно указали показатель временного импакта как ; бот с 2 000 родительскими заявками не может позволить себе свободно оценивать по каждому символу отдельно. Практичный режим: объединяйте данные по символам после нормализации на , сильно стягивайте к 0.6 (или просто зафиксируйте его и подбирайте только ), пересчитывайте ежемесячно. Если подобранное дрейфует вверх, значит ваш след на рынке вырос или рынок обмелел — в любом случае бэктесту нужно было об этом знать.
Для мейкерских стратегий цели калибровки другие и приходят из двух других модулей:
- Вероятность исполнения: модель очереди симулятора исполнения предсказывает по ячейкам (дистанция от спреда, позиция в очереди); лог родительских заявок дает реализованные доли исполнения по этим же ячейкам. Расхождение — это баг симулятора с привязкой к конкретной ячейке сетки.
- Неблагоприятный отбор: замените неявное допущение симулятора "исполнения взаимозаменяемы" на измеренную таблицу markout — симулированное мейкерское исполнение на дистанции в час несет измеренный как немедленную mark-to-market скидку. Это единственное изменение и есть разница между мейкерским бэктестом, который галлюцинирует, и тем, который отслеживает реальность; это был недостающий вход калибровки, отмеченный в статье про симуляцию исполнения.
- Дисперсия издержек: подавайте в бэктест распределение IS, а не его среднее. Модель издержек, сдвигающая только среднее, не способна воспроизвести просадки, которые создает тайминг-риск; даже логнормальное распределение, подобранное по IS отдельных родительских заявок, лучше константы.
Полное рассмотрение кривых slippage — функциональные формы, обусловленность режимом, где ломается степенной закон — это отдельная статья: Кривые slippage и модели издержек для бэктестов. Мысль здесь архитектурная: модели из той статьи невозможно подобрать без таблиц из этой.
Ловушки
Литература по TCA существует десятилетиями, и столько же существуют способы ее обмануть. Три режима отказа объясняют большинство случаев самообмана.
Игра с бенчмарком. Любой бенчмарк, кроме arrival price, можно "обнять". Классика — VWAP: алгоритм, оцениваемый против интервального VWAP, может отслеживать его с точностью до базисного пункта, пока позиция теряет двадцать пунктов относительно arrival, потому что бенчмарк дрейфует вместе с ценой, которую вы же и толкаете — а при значимой доле участия ваши же принты и есть VWAP, так что отслеживание его — это самопроверка домашнего задания. Политику бенчмарков мы разобрали в статье TWAP против VWAP против POV; правило со стороны TCA проще: бенчмарки выбираются до начала торговли, и IS относительно arrival вычисляется всегда, даже когда планировщик оценивается по своему собственному бенчмарку расписания. Более тонкий вариант — игра с arrival: если компонент, устанавливающий decision_ts, может видеть краткосрочный моментум, он может выбирать момент "решения" так, чтобы приукрасить слагаемое задержки. Таймстампы решений принадлежат слою сигнала и логируются до запуска какой-либо логики исполнения.
Смещение выжившего в анализе только исполненных сделок. Обусловьте анализ издержек только исполнениями, и пассивное исполнение начнет казаться бесплатным. Конкретно: 100 пассивных заявок на покупку, на один тик ниже mid. Шестьдесят исполняются и, будучи пассивными, исполняются по ценам в среднем на 3 б.п. лучше arrival: измеренная "издержка" б.п., отчет, которым можно гордиться. Сорок неисполненных — это как раз те, где цена ушла вверх; оцените их на отмене как 25 б.п. неблагоприятного движения, и честное число — б.п. Отчет только по исполненным и честный отчет различаются на 11 б.п. и по знаку. Это не краевой случай — это сам механизм пассивной торговли: получение исполнения коррелирует с тем, что цена проходит сквозь вас, и это то же самое обусловливание, которое превращает бэктесты с исполнением по касанию в фантазию. Издержки упущенной возможности — не опциональное уточнение IS; это слагаемое, которое защищает все измерение от смещения отбора. У этого же смещения есть тейкерский вариант: IOC-заявки, которые промахнулись, заявки, отклоненные rate limit или риск-проверками — если промахи не логируются, издержки промаха неизмеримы, а они наибольшие именно на быстрых рынках, где стратегия больше всего хотела эту сделку.
Разные мелкие ловушки, коротко: arrival mid, реконструированный из биржевых свечей (ваш поток и свеча расходятся именно тогда, когда это важно); усреднение б.п. по родительским заявкам без весов по номиналу (тысяча пылевых исполнений перевешивает одну настоящую заявку); комиссии, залогированные до скидки; markout-mid, взятый с другой площадки, чем исполнение (кросс-венью базис, маскирующийся под неблагоприятный отбор); а на перпетуалах — начисление funding, просачивающееся в окно исполнения — funding это издержка, но не издержка исполнения, и их смешивание отравляет оба анализа.
Что сделать на этой неделе
Добавьте две таблицы логов в своего бота — схема выше это список колонок, а не проект. Ничего не восстанавливайте задним числом; две недели честных логов лучше года реконструкций. Запустите декомпозицию и три горизонта markout. Вы узнаете, какой компонент доминирует (почти никогда не комиссии), подвергаются ли ваши пассивные исполнения неблагоприятному отбору сверх захвата спреда, и насколько далеко константа издержек в бэктесте отстоит от измеренной кривой. Затем встройте подобранную кривую обратно в симулятор и перезапустите бэктест, который изначально сказал вам запускать эту стратегию. Бумажный портфель Перольда спустя тридцать восемь лет остается единственным честным противником, который есть у вашего реального.
Авторы
Инженер торговых систем
Разработка торговых ботов с 2017 года: межбиржевой арбитраж (подключал до 30 бирж), парный арбитраж на коинтеграции между спотом и фьючерсами, скальпинг, фронтраннинг, торговля по новостям, сентиментный анализ, трендовые алгоритмы, а также алгоритмы управления и балансировки портфелей. Делает выставление ордеров до 1 мс, warehouse для big data, бэктестинг-движки, AI-агентов и интерфейсы для ботов (в т.ч. open-source profitmaker.cc). Стек: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, архитектура.