加密货币交易所十:订单处理流程
一笔订单从提交到终态要走完校验、预冻结保证金、撮合、成交后处理四段。这篇拆订单状态机的合法迁移(部分成交之后仍然可以撤单,已成交不行)、撮合引擎为什么必须单线程无 IO,以及吃穿三档的成交均价怎么算。
你点了「买入」,到订单变成一个终态为止,中间有四段互不相同的处理:入口校验、预冻结保证金、撮合、成交后处理。这四段跑在不同的进程里,用不同的一致性要求,出错的方式也完全不同。
先看终点。一笔订单最后只会停在四个状态之一:
| 终态 | 含义 |
|---|---|
FILLED |
全部成交 |
CANCELED |
被撤掉(可能已经部分成交过) |
REJECTED |
校验没过,从来没进过订单簿 |
EXPIRED |
到期作废(IOC 没吃完的部分、GTD 到时间) |
EXPIRED_IN_MATCH |
撞上自成交拦截,被撮合引擎当场作废 |
状态机里没有「成交之后又被撤销」这条边
合法的迁移只有这几条:
| 从 | 到 | 什么时候 |
|---|---|---|
| (入口) | REJECTED |
参数、权限、保证金、风控任一项没过 |
| (入口) | NEW |
校验通过,挂进订单簿 |
NEW |
PARTIALLY_FILLED |
吃到了一部分 |
NEW |
FILLED |
一次吃完 |
NEW |
CANCELED |
用户撤单,或者系统撤单 |
NEW |
EXPIRED |
FOK 没能全成、GTD 到时间 |
PARTIALLY_FILLED |
FILLED |
剩下的也成交了 |
PARTIALLY_FILLED |
CANCELED |
撤掉剩余部分,已成交的那些不受影响 |
PARTIALLY_FILLED |
EXPIRED |
IOC 吃了一部分,剩下的作废 |
四个终态都是死胡同,出不去。这一条看着显然,但它排除掉一整类错误:已经成交的量不可能因为撤单而回滚。 撤单撤的永远只是「还没成交的那部分」,成交是不可逆的 —— 对手方已经拿到了他的仓位,你没法单方面把它收回来。
REJECTED 和 CANCELED 的区别也值得说清楚:前者的订单从来没有存在于订单簿上,因此没有订单号意义上的历史,也不占用过任何保证金;后者曾经真实挂在簿子上,占用过额度,可能还成交过一部分。把两者合成一个「失败」状态,是对账时最容易埋雷的简化。
保证金在下单那一刻冻结,不是成交时
这是顺序上最关键的一步。如果等成交才扣保证金,那么在挂单期间,同一笔可用余额可以被无数个挂单重复承诺出去 —— 十个单同时被吃,账户瞬间穿仓。
所以校验通过之后、进撮合引擎之前,先把这笔单需要的起始保证金从可用余额挪到挂单冻结(第八篇 讲过这次移动为什么必须在同一个账户内部完成)。撤单就原路退回,成交就从挂单冻结转成仓位占用保证金。
图里两条分支的差别在于拿什么去比:全仓算的是账户级的可用保证金(所有仓位共用一个池子),逐仓算的是这个仓位单独划到的那一份。杠杆检查在保证金检查之后 —— 顺序不能反,因为杠杆是用「持仓价值 ÷ 账户权益」算出来的,而账户权益要先确定下来。
撮合引擎是单线程的,而且不许碰 IO
一个交易对一条线程、整本订单簿常驻内存、一条输入日志按顺序写盘 —— 这是撮合引擎的标准形状,理由不是「快」,是确定性。
同一串输入必须产出完全相同的一串输出。有了这一条,崩溃恢复就变成重放日志;对账就变成拿同一份输入在另一台机器上跑一遍比对;线上出了争议,可以精确复现那一毫秒发生了什么。一旦引擎里出现多线程竞争、或者一次「顺便查一下数据库」,这些能力全部失效 —— 因为重放不再保证得到同样的结果。
代价是引擎里做不了任何需要外部数据的事。风控、账户、持仓全部在引擎外面完成,引擎只认「已经被批准的订单」。这就是为什么保证金检查必须发生在进引擎之前:引擎没有能力拒绝一笔资金不足的单,它只会老老实实把它撮合掉。
撮合规则本身是价格优先、时间优先:买单按价格从高到低排,卖单从低到高,同价按进入订单簿的先后。由此推出一条对交易者有实际影响的结论 —— 改单等于撤单加下单,会丢掉排队位置。想把价格往有利方向挪一点,代价是回到那个价位队伍的最后。
主动吃单的一方是 taker(吃单方),被吃的挂单方是 maker。两者的费率通常不同,maker 更低,有的交易所在高等级上给 maker 负费率,也就是挂单反而拿钱 —— 这不是慈善,是在花钱买盘口深度。
自成交(同一个用户的买单和卖单撞上)必须主动拦截:成交量是假的,打出去的价格却是真的,不拦就等于给了每个人一个免费的画线工具。
拦截之后作废谁,是一个要让用户自己选的参数。Binance 把它做成了订单上的一个 STP 模式,四个取值:NONE(不拦)、EXPIRE_TAKER(作废主动那一单)、EXPIRE_MAKER(作废挂在簿子上那一单)、EXPIRE_BOTH(两边都作废)。被作废的单子走的就是上面那个 EXPIRED_IN_MATCH 状态。
有一条容易踩的规则:按哪个模式执行,取决于 taker 那一单的设置,maker 单上写的 STP 模式不作数。做市策略如果只在挂单上设了 EXPIRE_TAKER,以为自己的挂单永远安全,那是误解 —— 真正决定的是后来那笔主动单。
吃穿三档:成交均价和滑点算一遍
限价单成交在对手方挂单的价格上,不是你写的价格。市价单则会一路往下吃。假设卖方盘口是这样:
卖一 50000.0 × 0.5卖二 50000.5 × 1.2卖三 50001.0 × 2.0你市价买 2 个 BTC,成交会被拆成三笔:
0.5 × 50000.0 = 25000.01.2 × 50000.5 = 60000.60.3 × 50001.0 = 15000.3 ─────────总额 100000.9成交均价 100000.9 ÷ 2 = 50000.45相对卖一价滑了 0.45 USDT,也就是 0.0009%。这个数看着小,但它随成交量非线性增长 —— 盘口越薄、单子越大,吃得越深,多付的越多。「市价单没有手续费之外的成本」是错的,滑点通常比手续费大。
手续费按成交额算,不是按你下单时想的那个价格。假设 taker 费率 0.05%:
100000.9 × 0.0005 = 50.00 USDT注意分母是 100000.9 而不是 2 × 50000.0 = 100000。这两个数差 0.9,费率乘下去差不到半分钱,但在对账脚本里它是一个会累积的系统性偏差。
成交之后的五件事,顺序不能乱
顺序是有讲究的:
- 更新持仓 —— 数量、方向、开仓均价。加仓时均价按数量加权重算(第七篇 里有反向合约要用调和平均的例子)。
- 调整保证金 —— 挂单冻结的那一份转成仓位占用;平仓则相反,释放回可用余额。
- 算费用并扣除 —— 必须在这一步,因为费用直接减少钱包余额,而下一步的风险评估要用到它。
- 落成交记录 —— 用户账单、对账、报税都靠它,这条记录一旦写下就不能改。
- 触发风险评估 —— 拿更新后的保证金余额重算一次保证金率。
第 3 步在第 5 步之前,这一点容易被当成无关紧要而调换。它不是无关紧要的:手续费从钱包余额里扣,扣完之后保证金率会变低一点。一笔恰好卡在强平线附近的成交,费用算不算进去决定了这个账户当场会不会被强平。
最后一步的输出可能是一笔强平单,它会被送回撮合引擎 —— 于是一次成交触发了下一次成交。这条回路是行情剧烈时连锁清算的机制来源:价格下跌 → 一批强平单 → 这些单子是市价卖出 → 价格进一步下跌。第七篇 讲了这条链走到尽头会发生什么。
三种时效,决定了没吃完的部分去哪
| 时效 | 没能立刻全部成交时 |
|---|---|
GTC |
剩下的挂在订单簿上等着,直到成交或被撤 |
IOC |
能吃多少吃多少,剩下的立刻作废 |
FOK |
不能一次全部成交就整单作废,一手都不成交 |
还有一个 post-only(只做 maker):如果这笔单会立刻和对手方成交,直接拒绝而不是成交。做市策略靠它保证自己永远拿 maker 费率 —— 宁可不成交,也不要变成 taker。
这四个选项的区别只在「没能立刻成交的部分怎么办」,但它们对应四种完全不同的交易意图,而且是下单参数里最容易填错、填错了后果最直接的一个。
这一层的代价
单线程换来确定性,上限就是一颗核。 一个交易对的吞吐不能靠加机器提升,只能靠把不同交易对拆到不同引擎上。所以交易所的容量是「多少个交易对 × 每个多少」,不是一个总数 —— 单个热门交易对的峰值才是真正的天花板。
预冻结保证金会误伤。 挂单占用的额度在成交前是死的,一个用挂单铺满盘口的做市商,大部分资金都冻在那儿用不了。这是挂单方在费率优惠之外要付的隐性成本。
撤单请求和成交请求会赛跑。 你点撤单的同一瞬间对手方吃掉了它,这时候撤单必然失败,而且必须失败得干净 —— 返回「订单已成交」,不能返回「撤单成功」再回滚。客户端要能处理这个结果,把它当成正常路径而不是异常。
改单丢排队位置这件事没有解。 有些交易所提供「改数量但不改价格」的接口来保住优先级,但只在数量减少时有效 —— 增加数量等于插队,任何一家都不会允许。
这篇是 加密货币交易所的第 10 篇。前一篇是 加密货币交易所九:市场数据系统,后一篇是 加密货币交易所十一:持仓管理。