你的回測裡包含兩個模型:一個是關於 alpha 的模型,另一個是關於成交的模型。大多數人把 95% 的精力放在前者上,而後者則直接繼承自他們碰巧用的那個框架。這本末倒置了。一個平庸的訊號配上一個誠實的成交模型,會得到一個平庸但真實的 PnL 估計;一個出色的訊號配上 fill_price = candle.close,得到的數字則什麼都估計不了——它只是你從未審視過的某個假設的輸出。
對於流動性充裕品種上的吃單(taker)策略,成交模型只是一個修正項。而對於任何涉及掛單的場景——做市、被動入場、post-only 返傭收割——成交模型就是策略本身。你是否成交、何時成交、成交多少,以及在隨後價格如何變動的條件下成交,決定的不只是 PnL 的大小,而是它的正負號。
在回測與實盤一致性一文中,我們繪製了回測與實盤偏差的完整分類圖譜,並將執行偏差評為 5/5 的嚴重程度——最糟糕的一類。那篇文章只給了成交模擬三個粗略的精度等級便一帶而過。本文則是它的深度姊妹篇:完整的階梯,逐級拆解,把訂單生命週期建模為狀態機,給出你真正能計算的成交機率上下界,並用一個實測實驗精確地展示,一個"盈利"的做市策略究竟死在階梯的哪一級。
階梯

每上升一級,都需要更多的資料和更多的程式碼,而每一級都消除了一個特定的系統性偏差。這些級別的排序依據是它們各自出的錯,而不僅僅是成本。
第 0 級:收盤價成交
fill_price = bar.close
訂單在生成訊號的那根 K 線的收盤價上瞬間、全額成交。而訊號本身就是用同一個收盤價算出來的,所以你是在用價格列印出來時還不存在的資料交易:披著執行外衣的未來函數(look-ahead bias)。任何有換手率的策略在這裡看起來都很美。
第 1 級:下一根 K 線開盤價
fill_price = next_bar.open
這是 K 線級別吃單邏輯的最低誠實模型。訊號在第 根 K 線上計算,成交發生在第 根 K 線的第一個可觀測價格上。這消除了未來函數,但仍然假設零點差、零衝擊、開盤列印處無限流動性,以及 100% 成交確定性。對於限價單,它退化成觸碰即成交(touch-fill)邏輯(至於為什麼這是毒藥,詳見下文)。
第 2 級:點差 + 固定滑點
half_spread = mid * spread_bps / 2e4
slip = mid * slippage_bps / 1e4
fill_price = mid + side * (half_spread + slip) # side: +1 buy, -1 sell
現在每一筆吃單交易都要支付半個點差外加一個校準過的常數。這是第一個高換手率策略可能在回測裡死掉的級別——而這正是重點所在。殘餘誤差在於:滑點不是常數。它隨訂單規模相對於顯示深度而縮放,並且恰恰在你的策略最想交易的時候爆炸式增大。固定的 5 bps 是跨市場狀態(regime)的平均值;而你的策略並不在平均狀態下交易。
第 3 級:市價單的 L2 深度穿行
有了訂單簿快照,你就不再猜測滑點,而是直接計算它。規模為 的市價買單會逐檔穿行賣方;成交價是被吃掉的各檔位上的成交量加權平均:
def depth_walk(levels: list[tuple[float, float]], qty: float) -> tuple[float, float]:
"""levels: [(price, size), ...] sorted best-first. Returns (vwap, filled_qty)."""
remaining, cost = qty, 0.0
for price, size in levels:
take = min(size, remaining)
cost += take * price
remaining -= take
if remaining <= 0:
break
filled = qty - remaining
return (cost / filled if filled > 0 else float("nan"), filled)
有兩個修正能讓這一切在實質上更誠實。第一,延遲:要穿行的是你決策時間戳之後 時刻的訂單簿狀態,其中 是你實測的訊號到交易所延遲——你看到的簿並不是你打進去的簿。第二,可成交部分的部分成交:如果在你的限價穿透價內的顯示深度小於 ,模型必須返回一個部分成交,並留下一個掛單餘量,這就把問題交給了下文的狀態機。
第 3 級的殘餘誤差在於衝擊與補單:你把訂單簿當成一個靜態物件來消耗,但真實的簿會部分補充(而真實的對手方會做出反應)。對於低於簿口深度百分之幾的掛單規模,這個誤差很小;對於更大的掛單,你需要在其上疊加一個衝擊模型(Almgren-Chriss 2001)。
第 4 級:限價單的機率化佇列位置成交
第 0 到 3 級回答的是"我的主動訂單以什麼價格成交"。第 4 級回答的是更難的問題:我的被動訂單到底成不成交——而它是唯一能給做市策略定價的級別。掛在價格 上的限價單,只有當 處的累計成交量超過排在它前面的佇列量時才會成交。這就要求你追蹤自己在一個無法直接觀測的 FIFO 佇列中的位置。
佇列位置估計機制——初始位置、成交與撤單時的更新規則、用於分配未觀測撤單的 機率族——是我們在牆內的佇列一文中構建的原語。這裡我不再重新推導;模擬器直接消費它。第 4 級新增的,是在這個估計之上的成交判定規則,詳見下文的成交機率一節。
這一級為何重要,文獻裡有量化的答案:Moallemi 與 Yuan(2016)的《A Model for Queue Position Valuation in a Limit Order Book》表明,對於大跳動點(large-tick)品種,隊首位置相對於隊尾位置的經濟價值,與半個點差相當——與整個做市策略的理論優勢處於同一數量級。一個忽略佇列位置的成交模型,並不是把做市商的 PnL 估錯了;它估計的是另一個策略的 PnL。
確實還存在一個第 5 級——完整的基於智慧體的模擬,其中市場會對你的訂單做出反應(佇列反應式模型,如 Huang、Lehalle 與 Rosenbaum 2015 意義上的;多智慧體框架如 ABIDES,Byrd 等 2020)。歷史回放,即便是佇列感知的,也假設你的訂單不會改變其他所有人的行為。這個假設在散戶規模下沒問題,但當你的報價成為該檔位中可見的一部分時,就越來越不成立了。第 5 級不在本文範圍內;但要知道,階梯並未止步於第 4 級。
把部分成交建模為狀態機
第 0 到 2 級可以假裝訂單就是一次函式呼叫:提交、拿到價格、結束。從第 3 級往上,訂單就是一個帶有生命週期的過程,模擬器必須把它建模成一個狀態機,否則它就會在最要緊的那些情形上悄無聲息地處理錯誤。

enum OrderState {
PendingNew, // sent, not yet acked (latency window)
Resting { remaining: f64, q_ahead: f64 }, // in book, queue position estimated
PartialFill { remaining: f64, q_ahead: f64 }, // some qty done, rest still queued
PendingAmend, // amend in flight
PendingCancel, // cancel in flight
Filled,
Canceled { filled_qty: f64 }, // may be partially filled at cancel time
Rejected,
}
這些狀態轉移承載著經濟含義:
PendingNew→Resting:訂單排到的是回執(ack)時刻已存在的一切之後,而不是決策時刻。你的佇列位置是用 時刻的檔位成交量來播種的。在決策時刻播種的模擬器會系統性地高估佇列優先順序——高估的量恰好就是在你的延遲視窗內到達的成交量,而在爆發行情中這就是大部分成交量。Resting→PartialFill:當你所在檔位的一筆成交大於你前面的佇列時,你會被部分成交。剩餘部分保持它(此時已排到隊首)的位置。部分成交不是噪聲——它是資訊:1.0 裡被成交了 0.3、然後眼看價格彈開,這是和全額成交截然不同的 PnL 事件,而做市商的庫存過程正是由這些碎片拼出來的。Resting→PendingAmend→Resting:這是個陷阱。在幾乎所有加密交易場所,改單(amend)都是撤單/重掛——幣安的cancelReplace對於避免重複執行是原子的,但會返回一個排到隊尾的新訂單 ID。即便是有原生 modify 語義的場所(CME Globex),也只在數量減少時保留時間優先順序;改價或數量增加都會喪失它。所以在模擬器裡:任何改價操作都會把q_ahead重置為當前檔位的全部成交量。一個每 500ms 就重新盯盤(re-peg)的報價引擎並不是在"維持報價"——它是在永無止境地從隊尾重新入場,其真實的成交畫像幾乎是純粹的逆向選擇。結合 Moallemi-Yuan 的結論:重新報價是有價格的,而這個價格就是你的佇列位置。PendingCancel→PartialFill→Canceled:撤單也有延遲。在你決定撤掉報價、到撤單指令抵達撮合引擎之間的這個視窗裡,你仍然可能成交——而這些成交是你能收到的最糟糕的成交,因為你之所以要撤報價,正是因為市場即將碾過你。一個沒有撤單延遲的模擬器,會恰好把你歷史中毒性最強的那些成交刪掉。
狀態機也是讓模擬器的賬目誠實的東西:手續費按每一次成交事件計提,庫存按每一次成交事件更新,而在各狀態中停留時長的統計(訂單在成交前掛多久 vs. 在被撤前掛多久)則可以直接與實盤日誌做比較——而這正是文末校準閉環所依賴的原料。
限價單成交機率:三個模型和一個區間
給定一個掛在價格 上的訂單(比方說一個買單),模擬器什麼時候判定它成交?有三條判定規則,誠實程度遞增:
**1. 觸碰即成交(naive)。**只要價格觸碰到你的檔位就成交:。這是一條首次穿越時間(first-passage-time)規則,而它的失敗在四分之一個世紀前就被實測過了:Lo、MacKinlay 與 Zhang(2002)的《Econometric Models of Limit-Order Executions》(Journal of Financial Economics 65),用真實的限價單資料擬合生存模型,得出的結論是,由首次穿越時間構造出的假想成交"是真實限價單成交非常糟糕的替代品"。失敗的機理是結構性的:當價格觸碰到你的檔位又彈開時,這次觸碰消耗的是佇列的前端——那些比你更早報價的交易者。觸碰即成交把他們的成交判給了你。更糟的是,它判給你的恰恰是好的成交(觸碰即彈開是做市商的盈利情形),而你真實的成交集合卻偏向觸碰即穿透——那些被逆向選擇的成交。
**2. 穿透即成交(保守下界)。**只有當價格嚴格穿透你的檔位才成交:,或者在逐筆(tick)資料上, 處的累計成交量超過整個檔位。如果價格穿透了, 處的整個佇列都被消耗了,所以不管你排在什麼位置都成交了。這條規則絕不會判給你一筆本來拿不到的成交。它的偏差恰好是觸碰即成交的映象:它會拒絕你每一筆"佇列耗盡到你的位置但未完全穿透"的成交,而它確實判給你的那些成交,又不成比例地偏向穿透(被逆向選擇)的那種。穿透即成交下的做市回測是一次壓力測試,而不是一個估計。
**3. 佇列耗盡估計。**從成交流(trade tape)追蹤你所在價格處的累計成交量 ,並從 L2 增量推斷出撤單量 。你的估計佇列前量為:
其中 是(回執時刻)下單時的檔位成交量, 是假設來自你前方的撤單佔比——這個旋鈕的原理化形式( 族)是在佇列位置一文中推導的。當 時開始成交;你的成交數量是超出它的那部分成交量,這自然而然地就給出了部分成交:
設 (所有撤單都在你前方)給出這個模型的樂觀邊界; 給出悲觀邊界。當你只有稀疏的 L2 快照、且檔位上沒有成交流時——這在 100ms 節流的加密資料來源中很常見——你可以退而求其次,用一個基於模型的先驗:Cont、Stoikov 與 Talreja(2010)的《A Stochastic Model for Order Book Dynamics》(Operations Research 58),把每一個價格檔位建模為一個生滅佇列(birth-death queue),並透過拉普拉斯變換計算,在給定當前佇列規模的條件下,一個買單在中間價移動之前成交的機率。這是一個解析式的成交機率神諭(oracle):相對於成交流回放是粗糙的,但遠好於觸碰即成交,而且便宜到足以在一個高頻回測迴圈內部求值。
區間紀律
這三條規則不是相互競爭的——它們構成一個排序:
這誘匯出一個 PnL 區間。每一次做市回測都跑三遍,並報告這個區間:
(上界這個不等式是近似的——觸碰即成交不只是會拔高策略,還會把策略的排名弄錯,因為它把反事實的好成交塞給了你)。由此得出的決策規則是:**一個做市策略只有在能挺過區間的保守邊緣、且區間窄到讓點估計有意義時,才是可部署的。**一個顯示 +3k(穿透)的策略,區間寬達 2.1k / +$0.4k 的策略,才是在告訴你一些真實的東西。
實驗:讓一個做市策略沿階梯逐級下探

取一個刻意設計得平平無奇的做市商——在最優買/賣價上對稱報價、固定 0.05 BTC 的掛單、庫存上限 ±0.5 BTC 且觸及上限時用吃單拉平。用一個月的 BTCUSDT 永續資料:低階用 1m K 線,高階用 100ms L2 增量外加成交流;maker 手續費 1.0 bps,taker 4.0 bps。每一級都用同一套訊號程式碼(共享核心,所以唯一的變數就是成交模型)。下面的數字是我們一次有代表性的執行結果——你的量級會因交易場所、月份和規模而異;但形態不會變:
| 級別 | 成交模型 | 報價成交率 | 成交筆數 | 當月 PnL | 結論 |
|---|---|---|---|---|---|
| 0 | close-fill | 98% | 41,200 | +$14,800 | 幻想 |
| 1 | next-bar-open / 1m K 線上觸碰即成交 | 89% | 37,400 | +$9,600 | 帶延遲的幻想 |
| 2 | 觸碰即成交 + 吃單拉平的點差與固定滑點 | 89% | 37,400 | +$7,100 | 成本建模了,成交仍屬虛構 |
| 3 | + 吃單拉平的 L2 深度穿行 | 89% | 37,400 | +$6,400 | 出場誠實了,入場仍屬虛構 |
| 4a | 穿透即成交(保守) | 21% | 8,900 | -$3,900 | 壓力下界 |
| 4b | 佇列耗盡, 已校準 | 37% | 15,600 | -$700 | 最佳估計 |
| 4c | 佇列耗盡,(樂觀) | 44% | 18,700 | +$1,900 | 上邊緣 |
| — | 實盤影子執行,同一月份 | 35% | 14,100 | -$1,150 | 現實 |
從上往下讀這張表,看這個策略在哪裡死掉。它不是死在第 2 級——手續費和滑點砍掉了 26%,策略看起來仍然穩健盈利。它死在第 3 級和第 4 級之間,而它死的原因是任何成本模型都無法捕捉的:成交篩選(fill selection)。觸碰即成交判給了 37,400 筆成交,其中大多數是觸碰即彈開——純粹的點差捕獲。佇列感知模型刪掉了其中 58% 的成交,而它刪掉的那些成交不成比例地正是盈利的那些:當檔位被輕輕觸碰時,排在一個重新報價、帶散戶延遲的做市商前面的佇列會把一切都吸收掉。挺進第 4 級的那些成交,偏向於清掃檔位的掃單(sweep)——那些價格已經在穿過你的成交。成交率下降到 1/2.4;PnL 翻了符號。這種不對稱——丟掉好的成交、留下壞的——就是被機械地顯式化了的逆向選擇,而它在第 4 級以下的每一級都是不可見的。
還要注意這個區間起了什麼作用:[-1,900] 跨越了零點,校準後的估計在 -1,150。模擬器並沒有把實盤 PnL 精確到美元——但它算對了符號、量級,以及誤差在 2 個百分點以內的成交率。這正是成交模型的用處。第 1 級的回測在一個整月毛優勢僅幾千美元的策略上,把實盤 PnL 算錯了 $10,750:成交模型的誤差大約是 alpha 的 3 倍。由此得出本文的論點:你的成交模型是一個比你的 alpha 更大的假設。
對低階有一個提醒:如果你不得不用 K 線資料(第 0 到 2 級),至少要用自適應下鑽來解決 K 線內的歧義——在止損(SL)、止盈(TP)或報價檔位落在 K 線區間內的地方,從 1m 下鑽到 1s/100ms/逐筆。下鑽能修正排序誤差(哪個檔位先被觸及),但無法修正佇列誤差;它是本文在資料解析度上的姊妹篇,而不是第 4 級的替代品。
校準閉環:對照實盤成交收斂
一個第 4 級的模擬器有自由引數——、延遲 、撤單延遲、檔位補單假設。不加校準的話,它只是一個形狀不同的猜測。把它變成一臺儀器的閉環如下:
**1. 實盤記錄一切。**每一個訂單事件都帶上交易所時間戳:提交、回執、每一筆部分成交、改單回執、撤單回執。外加提交時刻的 L2 狀態。這就是回測與實盤一致性一文為其 DivergenceMonitor 所要求的那套記錄紀律——成交模型的校準是那個監視器最深的一層。
**2. 把同樣的訂單在模擬器裡回放。**把錄下來的市場資料和錄下來的訂單指令(而不是成交結果)喂進模擬器。現在你就有了成對的結果:對每一個實盤訂單,都有一個模擬命運。
**3. 分桶比較分佈,而不是均值。**單一的全域性成交率匹配可以掩蓋相互抵消的誤差(在平靜狀態下過於樂觀、在爆發行情下過於悲觀——淨出來一個"校準好了")。按驅動因素分桶:
import numpy as np
from scipy.stats import ks_2samp
def calibration_report(pairs, bucket_key):
"""pairs: [{'bucket':…, 'live_filled':bool, 'sim_filled':bool,
'live_ttf':float|None, 'sim_ttf':float|None}, …]"""
out = {}
for b in sorted({p['bucket'] for p in pairs}):
grp = [p for p in pairs if p['bucket'] == b]
live_fr = np.mean([p['live_filled'] for p in grp])
sim_fr = np.mean([p['sim_filled'] for p in grp])
live_ttf = [p['live_ttf'] for p in grp if p['live_ttf'] is not None]
sim_ttf = [p['sim_ttf'] for p in grp if p['sim_ttf'] is not None]
ks = ks_2samp(live_ttf, sim_ttf) if len(live_ttf) > 20 and len(sim_ttf) > 20 else None
out[b] = {
'n': len(grp),
'fill_rate_live': live_fr,
'fill_rate_sim': sim_fr,
'fill_rate_gap': sim_fr - live_fr, # signed: + means sim optimistic
'ttf_ks_pvalue': ks.pvalue if ks else None,
}
return out
每個桶兩個統計量:帶符號的成交率差(模擬器減實盤),以及在已成交訂單上對成交時間(time-to-fill)分佈做的 KS 檢驗。成交時間的比較是最鋒利的那個——一個模擬器可以匹配成交率,卻在系統性錯誤的時刻成交,這會汙染下游所有的庫存和逆向選擇統計。這恰恰是 Lo-MacKinlay-Zhang 生存分析視角的教益:執行是一個事件發生時間(time-to-event)問題,所以要以此來驗證它。
**4. 按可辨識性順序擬合旋鈕。**延遲優先(直接從回執時間戳測量——不是擬合的)。然後透過在各佇列深度桶上最小化成交率差來擬合 。接著檢查波動率狀態桶:一個集中在爆發行情桶裡、持續存在的樂觀差,通常意味著你的模擬器對撤單延遲的毒性成交或檔位補單建模不足,而不是 的問題。
**5. 重跑區間。**校準之後,佇列耗盡估計應當落在區間內、靠近實盤,而且——真正的驗收測試——在模擬器下各策略變體的排名,應當與它們在影子模式下的排名一致。然後凍結引數,並按計劃定期重新校準;成交動態會隨交易場所的手續費檔位、跳動點變化和 HFT 群體而漂移,一個在三月校準的 到了七月就只是個假設了。
我們執行中的收斂預期:一個未校準的第 4 級模擬器通常落在實盤成交率的 ±10-15 個百分點內;經過一輪校準後,±3-5 個百分點,且成交時間的 KS 檢驗 p 值不再一律拒絕。用歷史回放你做不到比這更好——殘差就是市場對你的反應,那是第 5 級的問題。
要點回顧
- **說出你的級別。**每一次回測都坐落在這個階梯上,不管是你選的級別還是你的框架替你選的。如果你說不出自己成交模型的級別及其已知偏差,你的 PnL 數字就帶著你沒看見的誤差棒。
- **吃單可以止步於第 3 級。**深度穿行加上實測延遲,能在散戶規模下誠實地為主動執行定價。把省下來的精力花在資料質量上。
- **做市從第 4 級起步。**在它之下,限價單成交邏輯不是"近似"——它篩選出的是一個反事實的成交集合,帶著相反的逆向選擇偏斜。做市策略的觸碰即成交回測,是最可靠的"一上生產就死"策略生成器。
- **建模生命週期,而不是成交。**部分成交、改單重置佇列、撤單延遲——毒性成交就住在狀態機裡,而毒性成交正是做市 PnL 去送死的地方。
- **報告區間。**保守和樂觀的上下界只多花兩次回測執行,卻把"我的回測說 +3.9k, +$1.9k] 之間的某處"——這是一個不同、也更好的決策。
- **分桶對照實盤成交做校準。**一個只在總量層面驗證過的成交模型,是一個帶著隱藏的相互抵消誤差的成交模型。分桶的成交率差加上成交時間 KS 檢驗,每季度重新校準。
階梯的各級不是學院派的分級——每一級都是你的回測停止對你說的一個特定謊言。一直往上爬,直到這些謊言比你的優勢更小。
有用的連結
- Moallemi, C., Yuan, K. — A Model for Queue Position Valuation in a Limit Order Book (2016)
- Cont, R., Stoikov, S., Talreja, R. — A Stochastic Model for Order Book Dynamics, Operations Research 58(3), 549-563 (2010)
- Lo, A., MacKinlay, C., Zhang, J. — Econometric Models of Limit-Order Executions, Journal of Financial Economics 65(1), 31-71 (2002)
- Huang, W., Lehalle, C.-A., Rosenbaum, M. — Simulating and Analyzing Order Book Data: The Queue-Reactive Model, JASA 110(509), 107-122 (2015)
- Almgren, R., Chriss, N. — Optimal Execution of Portfolio Transactions (2001)
- Byrd, D., Hybinette, M., Balch, T. — ABIDES: Towards High-Fidelity Multi-Agent Market Simulation (2020)
- Binance API — Cancel-Replace order semantics
- CME Globex — Order modification and time priority rules
Citation
@article{soloviov2026fillsimulation,
author = {Soloviov, Eugen},
title = {Fill simulation: the ladder from close-price fantasy to queue-aware reality},
year = {2026},
url = {https://marketmaker.cc/blog/fill-simulation-partial-fills-backtest},
description = {Five rungs of fill simulation fidelity — from close-price fills to probabilistic queue-position models. Partial fills as a state machine, limit-fill probability bounds as a PnL bracket, and a calibration loop against live fills.}
}
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.