每一家机构交易台都有一套交易成本分析(TCA)流水线。而几乎没有哪个跑加密货币交易机器人的人有。典型的做法是:记录成交,把手续费加总,然后把回测PnL和实盘PnL之间的差额一股脑称为"滑点"——一个未经拆解的残差项,把延迟、点差、冲击成本、逆向选择,以及所有跑单未成交的损失全部吞了进去。如果你把一项成本衡量成一个数字,你就没法真正修正它。真正把这件事量清楚的方法论早在1988年就已经存在,它并非什么专有秘技,而且套用在你自己的成交日志上,大约只需要200行Python代码。本文就来把它搭建出来。
这套方法带来的回报并不只是一块更漂亮的仪表盘。你的回测里包含一个成本模型——一个滑点常数、一条成交概率曲线、一个冲击系数——而其中的每一个参数目前都只是猜出来的。基于实盘成交的TCA,是唯一能为这些参数提供真实校准依据的东西。我们在《成交模拟:从收盘价幻想到感知队列的现实》一文中搭建了这个闭环的模拟侧;本文要搭建的是它的测量侧。
纸面与现实:Perold究竟衡量了什么
这套方法论的核心技巧出自André Perold(1988年,《The Implementation Shortfall: Paper Versus Reality》,《Journal of Portfolio Management》14(3),4–9页)。同时并行运行两个组合。纸面组合在决策发生的瞬间,以决策时刻的价格、无限规模、零成本地立即执行每一个决定。真实组合则是你的机器人实际做到的事:部分成交、追价、撤单剩余、手续费。执行落差就是两者收益之间的差额。
这个定义之所以重要,恰恰在于它拒绝隐藏什么。一张手续费账单只会显示佣金。一份"成交价对比限价"的报告则什么都显示不出来(按定义,你永远不会以比限价更差的价格成交)。而纸面组合会向你收取一切代价:决策与到达之间的价格漂移、你跨越的点差、你造成的冲击,以及——关键的一点——那些价格已经跑远却始终没有成交的订单。Wagner与Edwards(1993年,《Best Execution》,《Financial Analysts Journal》49(1),65–71页)把可见的手续费称为冰山的一角;对任何有周转率的交易而言,水面之下的部分才是主导。
先固定符号。一笔母单:方向 (买/卖),规模 。决策价 是策略信号触发那一刻你观察到的中间价。成交以 的形式陆续到达,累计成交量 。到时间跨度 (母单完成、被撤销或超时)结束时,中间价为 。显性手续费为 。以货币计价的执行落差为:
除以纸面名义金额 即可归一化为基点。正值意味着你付出了成本。第一项是你的成交相对纸面组合所付出的代价;第二项是Perold所说的机会成本——未成交剩余部分按其"跑掉"时的价格计价;第三项是唯一会体现在交易所账单上的部分。
有一点关于惯例的说明:业界所说的"到达价",在大多数股票TCA语境中指的是订单到达市场那一刻的中间价。而对一个机器人来说,决策时刻与到达时刻之间存在着由你自身内部延迟加上限速排队所导致的差异——这是一项真实的、可测量的成本。因此我们同时保留这两个时间戳与两个价格,让分解方法把它们区分开来。
分解:延迟、冲击、择时、机会与手续费
单一的IS数字只会告诉你执行成本很高,但不会告诉你为什么高,而针对不同成分的修正手段完全不同——你不可能用同一个改动同时解决延迟成本和冲击成本。Robert Kissell提出的扩展执行落差(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年)一书中有更详尽的展开)把总量拆解为多个成分,每一项都可归因于订单生命周期中的某个特定阶段。以下是实务版本,使用到达中间价 (交易所首次确认时的中间价):
这个恒等式经过展开可以精确套回Perold的原始定义——把各项展开后 会相互抵消。每一部分都有明确的归属:
延迟成本 :信号触发到你的第一笔子单在交易所挂出之间的价格漂移。这属于你的基础设施——序列化、网络、限速队列、风控检查。如果你的信号确实带有短期alpha,延迟成本正是它最先流失的地方;持续为正的均值说明市场在你到达之前就已朝有利方向移动——这可能是动量alpha在衰减,也可能是有人比你更快地在交易同一个信号。
交易成本 :你的成交相对到达价所付出的代价——跨越的点差加上市场冲击再加上执行时段内的价格漂移。这是执行算法的成绩单,也是执行研究真正建模的对象。Almgren、Thum、Hauptmann与Li(2005年,《Direct Estimation of Equity Market Impact》,《Risk》18(7),58–62页)基于花旗集团交易台约70万笔美股订单(2001年12月至2003年6月)进行了测算,发现交易成本随日波动率与参与率而变化——暂时性冲击在交易速率上呈幂律关系,指数接近3/5;永久性冲击则近似与规模呈线性关系。在我们做校准时会重新用到这个函数形式。
择时风险:这不是均值分解中的一项,而是围绕均值的方差。把一笔母单拆散到一段时间内执行,会降低预期冲击,但会让你暴露在波动率之下;对于剩余仓位为 的执行计划,成本的标准差按 的规律变化。这正是Almgren-Chriss框架所要优化的权衡关系。在你的TCA报告里,它会体现为各母单IS的离散度——在每一个均值旁边都报告标准差,否则均值会占据全部注意力,而尾部风险则会吞掉你全部的钱。
机会成本 :未成交部分按终止价计价。对被动策略而言,这通常是最大也最少被审视的成分,也正是这个使整套框架保持诚实的关键项——关于这一点,在"陷阱"一节还会详述,因为忽略它是人们用TCA自欺欺人最常见的方式。
手续费 :显性的部分。在加密货币交易中,要记录折扣后的手续费(VIP等级、按成交时价格折算的代币返利),并且要保留挂单返佣的正负号——负的手续费是数据,不是噪声。
一个实例演算
买入 BTC。信号触发时 ;纸面名义金额60万美元。第一笔子单确认时中间价 。接下来两分钟内,8 BTC以VWAP 60,072成交;价格正在走远,算法遵守了限价,剩余2 BTC被撤销,此时中间价 。成交名义金额的综合手续费为2.5个基点。
| 成分 | 公式 | 美元 | 纸面名义的基点 |
|---|---|---|---|
| 延迟 | 120 | 2.0 | |
| 交易成本 | 480 | 8.0 | |
| 机会成本 | 456 | 7.6 | |
| 手续费 | 120 | 2.0 | |
| 总执行落差 | 1,176 | 19.6 |
交易所账单上显示的是120美元。而这项决策实际执行的真实成本是1,176美元——差了十倍,其中两项最大的成分在以手续费为基础的核算体系中根本不可见。其中约40%来自从未成交的那部分数量。一份只分析成交记录的TCA报告会把这笔母单打8个基点的分数,判定它"没问题"。

Markout:为逆向选择定价
执行落差衡量的是母单整体的表现。它对单笔成交的质量——具体来说,你是否系统性地在跟那些比你更懂行情的对手方成交——只字未提。这需要用markout来衡量:把每一笔成交在成交后固定的时间跨度上对标中间价。
其中 是时间 时的成交价, 是时间 时的中间价。这是该笔成交在时间跨度 处的单位盯市盈亏,在 时有一个清晰的结构:挂单成交起点是 半点差(你以买一价成交,而中间价在你之上);吃单成交起点是 半点差。随着 增大所发生的变化,就是这笔交易所携带的信息含量:
- 秒用于识别狙击式交易与陈旧报价被"捡漏"的情况。如果你的挂单成交在成交一秒后就已经浮亏,说明更快的参与者是在你的报价刚变得错误定价的瞬间就击中了它——你的报价更新循环比他们的触发循环慢。这一markout衡量的是延迟问题,而非策略问题。
- 秒衡量的是经典意义上的逆向选择:成交之后价格朝着对你不利的方向持续运行。对做市商而言,这是点差捕获必须战胜的成本;股票微观结构领域把相关的量称为已实现点差,并被制度化写入SEC规则605、以5分钟为时间跨度的报告要求中。加密货币的节奏更快;10到60秒是相应的区间。
- 秒告诉你,成交是否在超出微观结构时间跨度之后依然带着不利于你的动量——对吃单策略而言,就是你信号在60秒处的alpha,是否超过了你为入场所付出的点差加冲击成本。一条从个基点起步、始终没有越过零轴的吃单markout曲线,说明这个策略入场所付出的代价,是它的alpha根本负担不起的。
挂单/吃单之间的不对称性,用两条曲线就概括了被动交易的全部经济学。一条从个基点(半点差)起步、到60秒衰减至个基点的挂单曲线说明:你捕获了点差,但回吐得更多,而任何返佣等级都解决不了这个问题。同样的曲线若稳定在个基点,则说明报价引擎确实在创造价值。这也恰恰是那种朴素的"触价即成交"回测所悄悄假设掉的东西——一个只要价格触及你的限价就给你成交的模拟器,忽略了"能成交"本身就与"判断错误"相关这一事实,这正是为什么成交模拟阶梯中感知队列的那一档,需要把实测的markout作为输入,而不是凭空假设。

计算方式是在你自己的中间价数据流上做一次merge_asof:
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)
进一步按交易对、一天中的小时、以及报价相对中间价的距离来切分报告。对做市策略而言,最具行动指导意义的切分方式是"成交时按队列位置划分的markout":排在新鲜队列最前端的成交,与那种整个价位被扫穿而你被动带过的成交,定价表现截然不同。
数据流水线:该记录什么
TCA的失败往往发生在日志层,而不是数学层。上面的数学公式需要的那些数字,大多数机器人根本没保留,而且事后也无法从交易所历史记录中重建。以下是不可妥协的几项:
- 决策中间价,来自你自己的行情,记录于信号触发时刻。 不是交易所的K线收盘价,也不是事后重建的结果。这个基准是"我的策略在做决策那一刻所相信的价格"——只有你的进程在那一刻才知道它是多少。
- 两个时间戳:决策时刻与首次确认时刻,否则延迟成本无法测量,会悄悄并入交易成本之中。
- 每一笔成交都要记录交易所时间戳、手续费与挂单/吃单标志——你本地的接收时间已经被你自身的入站延迟污染了。
- 被撤销和过期的母单,和其他一切一样被记录下来。 那些零成交的母单,恰恰是这张表里成本最高的行。
- 一条持久化的中间价数据流(或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测量,拟合成本模型,用拟合出的模型跑回测,实盘交易,再重新测量。

对于吃单成本,借用Almgren等人(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
然后要对误差范围保持诚实。Almgren团队拥有70万笔订单的数据,依然把暂时性冲击指数报告为 ;一个只有2,000笔母单的机器人,是没有资格为每个交易对自由估计 的。实务上的做法是:按 归一化之后跨交易对合并数据,把 大幅向0.6收缩(或者干脆固定它,只拟合 ),并每月重新拟合。如果你拟合出的 持续走高,说明你自己的交易足迹在扩大,或者市场深度在变薄——无论哪种情况,回测都需要知道这件事。
对做市策略而言,校准目标不同,来自另外两个模块:
- 成交概率:成交模拟器的队列模型按(点差距离、队列位置)网格单元预测 ;你的母单日志则提供每个网格单元的实际成交比例。两者不一致,就是一个带有具体网格坐标的模拟器bug。
- 逆向选择:用实测的markout表,替换掉模拟器里"成交彼此可互换"这一隐含假设——一次在距离 、第 小时发生的模拟挂单成交,应当携带实测的 作为即时盯市折价。这一处改动,就是"做市回测在自欺欺人"和"做市回测能跟踪现实"之间的分水岭;这正是成交模拟一文中指出的缺失校准输入。
- 成本离散度:把IS的分布而不是均值输入回测。一个只会平移均值的成本模型,无法复现出择时风险所导致的回撤;哪怕只是给单笔母单的IS拟合一个对数正态分布,也比用一个常数强。
关于滑点曲线的完整处理——函数形式、状态条件化、幂律何时失效——是另一篇文章的内容:《回测的滑点曲线与成本模型》。这里要说明的是架构上的一点:那篇文章里的模型,离开本文所构建的这些数据表是根本无法拟合的。
陷阱
TCA这套文献已经存在了几十年,钻它空子的手法同样存在了几十年。三种失败模式囊括了大多数自我欺骗的情形。
基准操纵。 除到达价之外的任何基准都可以被"贴着走"。最经典的例子是VWAP:一个按区间VWAP打分的算法,可以把跟踪误差控制在一个基点以内,而仓位相对到达价却在流血亏损20个基点,因为这个基准本身就随着你正在推动的价格漂移——而在有意义的参与率下,你自己的成交记录本身就是VWAP的一部分,因此跟踪它就相当于自己给自己判作业。我们在《TWAP对比VWAP对比POV》一文中剖析了基准选择背后的博弈;TCA一侧的规则更简单:基准要在交易开始之前就选定,并且即便某个调度器是按其自身的调度基准打分的,也始终要计算IS相对到达价的表现。更隐蔽的一种变体是到达价操纵:如果设定decision_ts的那个组件能够看到短期动量,它就可以掐算"决策"发生的时刻,让延迟项显得更好看。决策时间戳应当属于信号层,在任何执行逻辑运行之前就已记录下来。
只分析成交记录带来的幸存者偏差。 如果你把成本分析的条件设定为"仅限已成交部分",被动执行看起来就会像是免费的。具体来说:100笔被动买入母单,报价比中间价低一个tick。其中60笔成交,而且因为是被动成交,平均成交价格比到达价好3个基点:测得的"成本"是个基点,一份值得骄傲的报告。而剩下未成交的那40笔,恰恰是价格向上跑掉的那些;若在撤单时将其标记为25个基点的不利影响,诚实的数字就是 个基点。只看成交记录的报告和真实报告之间,差了11个基点,而且符号都反了。这不是什么边缘情形——它正是被动交易的运作机制本身:能成交这件事,本身就与价格朝你冲过来相关,而这与让"触价即成交"回测沦为幻想的,是同一种条件相关性。机会成本不是IS的一个可有可无的补充精细项;它是唯一能捍卫整套测量方法免于选择偏差的那一项。同样的偏差在吃单一侧也有对应版本:未成交的IOC订单、被限速或风控拒绝的订单——如果这些"没成交"的情况没有被记录下来,那么"错失"这件事的成本就无法被测量,而恰恰在你的策略最想成交的快市里,这项成本是最大的。
其他各类隐患,简要提及:用交易所K线重建出来的到达中间价(你自己的行情和K线恰恰会在关键时刻出现分歧);在不使用名义金额加权的情况下对各母单基点数取平均(一千笔粉尘级成交能压倒一笔真实订单的投票权);手续费按折扣前记录;markout所用的中间价来自与成交不同的交易场所(跨场所基差被误认成逆向选择);以及在永续合约上,让资金费率的累积渗入执行窗口——资金费率是一项成本,但不是执行成本,把两者混在一起会同时污染两项分析。
这一周该做什么
把这两张日志表加到你的机器人里——上面的表结构只是一份字段清单,不是一个大项目。不用回填历史数据;两周诚实的日志胜过一年的事后重建。跑一遍分解计算和三个markout时间跨度。你会了解到哪一项成分占据主导(几乎从来都不是手续费)、你的被动成交是否遭受了超出其点差捕获收益的逆向选择,以及你回测里的成本常数与实测曲线之间到底差了多远。然后把拟合出的曲线接回模拟器,重新跑一遍最初告诉你该运行这个策略的那次回测。三十八年过去了,Perold的纸面组合依然是你的真实组合唯一诚实的对手。
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.