每一家機構交易臺都有一套交易成本分析(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.