土法炼钢兴趣小组的算法知识备份

【量化交易】订单类型与执行语义:限价、市价、IOC、FOK、冰山

文章导航

分类入口
quant
标签入口
#order-types#ioc#fok#iceberg#execution-semantics

目录

一笔订单从策略发出到撮合系统落地,中间被压缩成一个紧凑的二进制报文,但这条报文承载的语义比绝大多数工程师以为的要复杂。同样写着「买入十手」,在 A 股是「最多以现价高 2% 成交、剩余撤销」,在 CME iLink 协议里是 Tag 40=2, Tag 59=3,在 Binance 现货是 type=LIMIT, timeInForce=IOC,三者落到撮合引擎里的行为互不兼容。把这些差异糊在一起,就会出现策略在回测里完美、上线后撤单率超标被交易所警告,或者 IOC 当成 FOK 用、风控滑点假设被打穿这样的事故。

本文不打算把订单类型当成一份术语表来罗列。我会把订单拆成五个互相正交的语义维度——价格、数量、时效、可见性、触发,再把市面上看似纷繁的订单类型映射回这五个维度的组合;接着对照 A 股、港股、美股、CME 和 Binance 五个具体市场,把同一个名词在不同地方意义不同的地方挑出来;最后给出一个可以直接抄到生产代码里的订单工厂、状态机和回报对账框架,配上 IOC 与 FOK 在同一份簿上的撮合差异模拟。

范围与版本说明:本文涉及的市场规则口径以 2024 年至 2025 年公开发布的交易所规则为准,包括上交所《交易规则》(2024 年 8 月修订)、深交所《交易规则》(2024 年版)、港交所《第二上市证券交易细则》、Nasdaq 和 NYSE 的官方规则手册、CME Rulebook 与 iLink 3 SBE 文档(v9)、以及 Binance Spot REST API(2024-12 版本)。代码示例使用 Python 3.11,仅依赖标准库;SVG 图位于本文同目录 images/ 下。

风险提示:本文出现的所有交易示例只用于阐释订单语义,不构成任何投资建议。订单类型的滥用在监管层面会被认定为异常交易(A 股「频繁撤单」、港股「禁止性交易行为」、美股 Reg NMS 与 SEC Rule 15c3-5、CME Rule 575)。把本文示例搬到生产环境前,请先确认所在市场的规则与券商接入限制。


一、订单的语义维度

把市场上五花八门的订单类型理顺,第一步是不要按名字记,而要按维度拆。一笔订单本质上是一份契约,告诉撮合引擎如何处理它。这份契约可以分解为五个独立维度。

五个维度

维度 回答的问题 典型取值
价格(Price) 我愿意以什么价格成交? Limit、Market、Pegged、Stop、Stop-Limit
数量(Quantity) 我要交易多少?最少接受多少? Total Qty、MinQty、Display Qty
时效(Time-in-Force, TIF) 这笔订单可以在簿上挂多久? DAY、GTC、GTD、IOC、FOK
可见性(Visibility) 交易对手能在簿上看到我吗? Visible、Iceberg、Hidden、Dark
触发(Trigger) 什么条件下这笔订单才被激活? None、Stop Price、Trailing

这五个维度在大多数交易所都是正交的。换句话说,「IOC 限价冰山买单」是一个合法组合;「FOK 市价冰山」在 CME 上同样合法(虽然语义古怪)。当你看到一个订单类型名字时,先在脑子里把它拆成这五个分量。例如:

为什么要这样拆

实际工程里有三个理由值得强调。

第一,上层抽象不爆炸。如果按订单类型列举,券商接入层会出现 submit_limitsubmit_marketsubmit_iocsubmit_foksubmit_icebergsubmit_stop_limitsubmit_ioc_limit_iceberg 这种笛卡尔积。把维度独立出来后,策略层只需要构造一个 Order 对象,把每个维度填到对应字段。

第二,跨市场映射只动叶子节点。不同市场的差异其实集中在某些维度的取值上。A 股没有 GTC,但 GTC 是「时效」维度的一种取值;港股的 ALO 是「时效=DAY 且仅挂单」的特例;CME 的 Display Qty 是「可见性」维度的具体参数。维度框架不变,只是叶子节点要做适配层。

第三,风控前置校验更易写。下面会看到,风控规则几乎全部都对应到某一两个维度上:撤单频率限制对应「时效+操作历史」;自成交检测对应「价格+方向」;冰山下限对应「可见性+数量」。维度独立的好处是规则之间可以独立组合。

一个看起来矛盾的细节

把 IOC 和 FOK 都归到「时效」维度,初看是反直觉的——FOK 不是关于「数量必须全部成交」吗?怎么也是时效?

把它放到撮合引擎的视角去看就顺了。FOK 的判定时点和 IOC 是一样的:报单到达撮合引擎、扫一遍簿、得到一个潜在的成交序列。差别只在于,IOC 的策略是「能成多少成多少,不能成的剩余撤销」,FOK 的策略是「如果不能全部成交则一笔都不成」。两者都是「报单进来后没有挂在簿上的机会」,所以本质上仍然是 TIF 的两种取值,加上一个隐含的 MinQty 约束(FOK 等价于 MinQty=Total Qty 的 IOC)。把这层关系想清楚,下面 IOC vs FOK 的撮合模拟才会写得干净。


二、价格类型:限价 vs 市价

价格维度是最被低估的维度,也是国内外语义差异最大的维度。

限价单

限价单(Limit Order) 的契约是:买单不高于限价 \(p^\*\) 成交,卖单不低于 \(p^\*\) 成交,没有立即对手盘则挂在簿上等待。这是连续竞价市场的基本订单,几乎所有交易所都支持。

限价单的关键性质是「价格保护」:你的成交均价不会超出 \(p^\*\),但成交数量没有保证,未成交部分会停在簿上直到撤销、改单或到期。挂在簿上还提供流动性,绝大多数 maker rebate 制度都奖励这种行为。

写成等式更直观。设买单限价为 \(p^\*\)、数量 \(q\),簿上卖一价 \(a_1\)、量 \(v_1\)、卖二价 \(a_2\)、量 \(v_2\),依此类推。则成交序列为:

fills = []
remaining = q
for k in 1..K:
    if a_k <= p* and remaining > 0:
        take = min(v_k, remaining)
        fills.append((a_k, take))
        remaining -= take
    else: break
# remaining 部分以 p* 挂在买盘

策略层最常踩的坑是把「成交均价 \(\bar p\) 一定等于 \(p^\*\)」当成事实。事实上 \(\bar p \le p^\*\)(买单),并且当对手簿存在多档优于 \(p^\*\) 的报价时,\(\bar p\) 严格小于 \(p^\*\)。回测里如果统一假设 \(\bar p = p^\*\),会高估买入成本、低估卖出收入,做空策略尤其容易得出虚假利润。

市价单

市价单(Market Order) 的契约是:以当前市场最优价格成交,不限定价格。其优点是成交几乎确定,缺点是滑点完全暴露——尤其在薄盘或波动剧烈的瞬间。

工程上要意识到一个细节:纯市价单在不同市场被实现成不同的内部形式。

在 Nasdaq、NYSE、CME 这类成熟市场,市价单确实就是「不限价」,撮合时按对手簿从优往劣吃,吃到指定数量为止。但即便如此,CME 在 2010 年闪崩(Flash Crash)之后引入了 Stop Price Limits 和 Velocity Logic,对极端市价单做截断,否则一笔大额市价卖单会把价格瞬间砸穿。

在 Binance 现货上,市价单按 quantityquoteOrderQty 两种参数发送,撮合规则相同但报价单位不同。quoteOrderQty 模式下,交易所按输入金额吃对手簿,最后一笔可能不足整数手——这是币圈现货特有的语义。

中国 A 股的「市价单」并不是真市价单

这是一类非常容易出事的语义陷阱。中国大陆 A 股市场(上海、深圳)在连续竞价时段,没有传统意义上的纯市价单,取而代之的是几种「市价订单」变体。以上交所《交易规则》(2024 修订)4.4.3 与深交所《交易规则》4.3 节为口径:

  1. 对手方最优价格申报:以买卖对手方当时最优价格作为申报价格,未成交部分按该价格挂入簿。
  2. 本方最优价格申报:以本方当时最优价格作为申报价格,未成交部分按该价格挂入簿。
  3. 最优五档即时成交剩余撤销:以对手方价格从优撮合,最多依次匹配五档,剩余未成交部分撤销。
  4. 最优五档即时成交剩余转限价:与上一种类似,但剩余未成交部分按最后一笔成交价转为限价单挂入簿。
  5. 即时成交剩余撤销:扫穿对手所有可用流动性,余量撤销。
  6. 全额成交或撤销:整笔成交或全部撤销,对应 FOK 语义。

注意没有任何一种是「不限价 + 不挂簿」。所有 A 股市价变体都隐含了价格限制(对手最优、本方最优或对手五档之内),目的是防止程序化交易在涨跌停板附近瞬间打穿价格。所以当你在国内券商柜台 API 里看到「市价」按钮时,一定要看清楚到底是哪一种变体,下游的滑点假设、风控阈值都会因此不同。

把这件事做成统一抽象时,一种干净的做法是把 A 股的这些类型视作「价格=Bounded Market」的一组特殊取值,并在 Order 对象上多一个 bound_levels: int 字段,5 表示五档、1 表示对手一档、None 表示无界。这样上层策略不用关心内部实现细节,下游适配层把这些字段映射到 SZSE/SSE 实际的报单类型代码。

一句话工程结论

价格维度上能写到代码里的判断只有一句:永远不要使用纯市价单,除非交易所或券商已经替你做了价格保护。在不知道流动性深度的情况下,IOC 限价单是市价单更安全的替代品——把限价定在当前对手价加一定容忍范围(比如 \(a_1 \cdot (1+\delta)\)\(\delta\) 取 0.5%~1%),本质上等价于「带保护价的市价」。


三、时效类型

时效维度回答一个简单的问题:这笔订单如果没立刻成交,要怎么处理。不同的回答派生出 DAY、GTC、GTD、IOC、FOK 这一系列。

DAY

DAY 是日内有效,未成交部分在收盘集合竞价结束后自动撤销。这是几乎所有股票交易所的默认 TIF,A 股没有提供其他选项,DAY 实际上是唯一选项(除了 IOC 与 FOK 的本土变体)。

DAY 看似简单,但隐含一个细节:「日」的定义在不同市场不一样。美股的 DAY 包括正常时段,不包括盘前盘后;CME 的 DAY 在结算时段(Trade Date Rollover)会被清掉,结算时间一般是芝加哥时间下午 4 点;Binance 没有 DAY 概念,连续 24 小时不收盘。

GTC

GTC(Good-Till-Cancel) 是订单一直有效直到主动撤销,跨日存在。美股、CME、加密货币都支持,A 股不支持,港股 GTC 通过券商代为管理(每日开盘自动重新挂出)。

工程上 GTC 的麻烦在于「一直有效」其实有上限。Nasdaq 的 GTC 上限是 90 天;CME 的 GTC 跨结算时仍然有效,但结算价波动会触发风控;Binance 的 GTC 没有上限。生产系统不能依赖 GTC 永远活着,必须在客户端维护到期重挂逻辑。

GTD

GTD(Good-Till-Date) 是「有效到指定日期」,本质上是 GTC 的有上限版本。CME 大量使用 GTD,到期日在订单字段 Tag 432 ExpireDate 里写明。

IOC

IOC(Immediate-Or-Cancel) 的契约:报单进入撮合引擎后立刻撮合,能成多少成多少,未成交的全部撤销,不会挂在簿上。等价于「时效=立即且不挂簿」。

IOC 是最常被算法子单使用的 TIF,因为它把「市价单的成交确定性」和「限价单的价格保护」结合到一起,又避免了被簿上挂出去暴露策略意图。VWAP、TWAP、IS 这些算法每隔几秒切一片小单,几乎都是用限价 IOC 实现:以 \(a_1+\epsilon\) 这种价格扫一刀对手簿,没成的就放弃,下一秒重新计算。

FOK

FOK(Fill-Or-Kill) 的契约:要么全部成交,要么一笔都不成。等价于 IOC 加 MinQty=Total Qty。

FOK 主要用于大额机构单和跨市场套利。比如做一笔三角套利,必须三条腿同时成交,否则全部撤销,避免出现腿断(leg risk)。但 FOK 在薄盘上几乎注定失败——对手簿上根本没有那么多对手量。所以 FOK 在权益市场用得很少,在期货、外汇、加密的大宗交易里更常见。

时效维度的语义分类树

把上面五种 TIF 画成判断树,更容易记住差异:

报单到达撮合引擎
├── 立即撮合,剩余撤销 ─→ IOC
├── 立即撮合,要么全成要么全撤 ─→ FOK
└── 立即撮合,剩余挂簿
    ├── 收盘前有效 ─→ DAY
    ├── 一直有效(上限交易所定)─→ GTC
    └── 指定日期前有效 ─→ GTD

记住这棵树,再加上「IOC 是 MinQty=1 的特例,FOK 是 MinQty=Total 的特例」这条等式,时效维度就理顺了。


四、可见性

可见性维度决定了对手能不能在公开簿上看到这笔订单。这维度直接关系到信息泄露和市场冲击成本。

显性单

显性单(Visible Order) 是默认行为,整笔订单的剩余数量在最优档显示,对手簿可见。挂在簿上提供流动性的同时,也暴露了你的真实意图。

这听起来正常,但当一笔单子的数量远超日均成交量时,显性挂单等于公开喊话。对手算法(尤其是博弈型 HFT)会把这条单子当成信号,往反方向推价格,然后等你不得不撤。这种现象在学术上叫 quote sniping,在实务里就是大单挂出去后市场迅速远离。

冰山单

冰山单(Iceberg Order) 把一笔大单切成「显示量 + 隐藏量」两部分,每次只在簿上展示显示量,每当显示量被吃掉一笔,再从隐藏量补一片到簿上。对手簿只能看到显示量这一小块,看不到水面下的本体。

冰山单的关键参数有两个:

参数 含义 典型值
Display Qty 每次显示在簿上的数量 总量的 5%~20%
Total Qty 总数量 客户端真实意图

下游的撮合引擎在每次成交后会自动补片。CME 在 iLink SBE 协议里把它放在 MaxShow(Tag 210)字段;Nasdaq 在 OUCH 协议里叫 Display;港交所叫 Disclosed Qty;Binance 叫 icebergQty

冰山单在交易所撮合优先级上有一个细节:不同市场对「补片后这片新展示量」的优先级处理不同。CME 在补片后把新片放到该价位队尾,所以频繁补片会损失时间优先级;Nasdaq 类似;Binance 的现货 iceberg 不享有时间优先级(每次补片都是新单)。这意味着冰山单的有效成本是「显示量被吃完前能拿到的优先级时间」,显示量太小、补片太频繁,反而让冰山的隐蔽性失去价值——因为你被持续打到队尾,每片都成交在劣势档。

暗池单与隐藏单

暗池单(Dark Order) 是完全不展示的订单,对手簿看不到任何信息,只有匹配发生时双方才知道。Nasdaq、BATS、IEX 都提供暗池模式,欧洲 MIFID II 之后通过 Large-In-Scale Waiver 限制了暗池可用规模。

隐藏单(Hidden Order) 与暗池单类似,但仍然挂在公开市场簿上,只是不展示自己。Nasdaq 的 hidden order 在公开簿上看不到,但优先级低于 displayed order——也就是说同一价格上 displayed 永远排在 hidden 前面。这是为了奖励显式提供流动性的参与者。

A 股没有暗池或隐藏单,所有订单都必须在公开簿上展示,这是监管硬性要求;港股有 dark pool(暗盘),但限于一些特定的「大宗交易」与「上市后第一天暗盘交易」。

工程实现的取舍

冰山单是几乎所有量化做执行算法时的主力选项之一,但要谨慎处理两个问题:

第一,显示量不能太小。太小则补片过于频繁,每次补片都失去时间优先级,最终成交均价反而比 displayed 单还差。一个经验值是:单笔显示量不少于该价格档位平均成交量的 50%,这样在排到队头之前,对手簿看到的是一个看似「合理大小」的单子,不会立刻引起算法警觉。

第二,冰山数量本身可能被推断。一些交易所(包括早期 Eurex)在 trade tape 上会反映出补片瞬间的「同价位连续成交」模式,对手可以通过 statistical inference 反推冰山的剩余量。Eurex 后来引入了 randomized refill 来缓解这点。CME 的 MD-A 数据流没有显式标注,但补片时间间隔仍然会泄露信息。要更隐蔽,就要走暗池或者机构 RFQ。


五、触发条件

触发维度回答的是:什么条件下这笔订单才进入撮合?

止损单

止损单(Stop Order) 在触发价被达到(买单:市价 \(\ge\) 触发价;卖单:市价 \(\le\) 触发价)后激活,激活后变成市价单进入簿。常用于限制下行损失,但激活后是市价单,所以滑点不可控,在闪崩中容易把价格打穿。

止损限价单

止损限价单(Stop-Limit Order) 类似 Stop,激活后变成限价单而非市价。这就避免了滑点失控,但代价是价格穿过限价时可能完全成交不了,止损失效。这是工程上必须接受的权衡:你要么接受不可控的滑点(Stop),要么接受不可控的成交概率(Stop-Limit)。

追踪止损

追踪止损(Trailing Stop) 的触发价不是固定的,而是相对于市场最高价(卖单)或最低价(买单)维持一个固定差距。市场对你有利时触发价跟着移动,市场反向时触发价不动。

工程上 Trailing Stop 通常在客户端模拟,因为很多交易所并不原生支持。Trailing Stop 在客户端的实现要点是:

  1. 持续订阅 last trade 或 BBO(最优买卖)。
  2. 维护一个 highest watermark(卖单)或 lowest watermark(买单)。
  3. 当 BBO 突破触发价 \(\Delta\) 时,发出真正的 Stop 单。

要点是——客户端模拟的 Trailing Stop 与连接断开后会失效,没有交易所兜底。如果你的策略真的依赖止损,应该用交易所原生的 Stop 单作为最后保险。

触发条件的工程化

触发逻辑通常实现成三组字段:

trigger_type:    None | Stop | StopLimit | Trailing
trigger_price:   触发价(绝对值)
trigger_offset:  触发价相对当前价的偏移(Trailing 用)
trigger_basis:   触发价格基准 (last_trade | mark | mid)

注意 trigger_basis。期货市场里有「last trade price」「mark price」「index price」三种基准,触发逻辑大不一样。Binance 永续合约的强平触发用 mark price 而非 last,因为 last 容易被插针操纵。客户端策略如果用 last 做 Stop,遇到 stop hunt 几乎必然中招。


六、特殊订单

某些订单类型在维度上与基础组合一致,但在交易所层面有独立的报单代码,值得单独说。

MOO 与 MOC

MOO(Market-On-Open)MOC(Market-On-Close) 是参与开盘集合竞价或收盘集合竞价的市价单。在美股、CME 上是独立的订单类型;在 A 股、港股则是「在集合竞价时段提交不限价单」的隐含语义。

LOO / LOC 是对应的限价版本:参与集合竞价但带价格保护。

集合竞价的撮合规则与连续竞价完全不同:所有订单按价格优先、相同价格按时间优先排队,撮合时一次性按集合竞价价格成交,价格通过最大化成交量算法决定。这意味着限价 MOO 单可能在最终撮合价上不成交(你限的价格没在集合竞价区间内),但市价 MOO 必然成交,代价是吃下整个集合的滑点。

实务上:A 股的开盘集合竞价时段是 9:15–9:25,9:25 一次性撮合;收盘集合竞价是 14:57–15:00(深交所有完整三分钟,上交所主板从 2018 年起加入收盘集合竞价)。把策略放到这两个时段时,不能再当作连续竞价处理。

TWAP / VWAP 子单

TWAP 和 VWAP 不是交易所支持的订单类型,而是算法执行(algo execution)框架。它们在内部把一笔母单切成多笔子单,按时间或成交量加权派发。子单本身一般是限价 IOC 单或冰山单。一些券商把这类算法封装成「订单类型」(例如 BTIG 的 VWAP,Goldman Sachs 的 SOR),但底层落到交易所的还是基础订单。

写自己的 TWAP / VWAP 引擎时要避免一个常见错误:子单切片间隔太短。如果间隔小于市场反应时间(典型为几百毫秒),等于在打高频游戏,撤单率立刻飙升,触发交易所警告。一个保守值是 5–30 秒一片,足够穿过普通做市商的报价更新周期,又不至于太集中。


七、跨市场差异

把维度框架套到具体交易所,会发现差异远不止「TIF 取值少几个」这么简单。下面这张图把主要市场上的可用订单类型做成一张矩阵。

跨市场订单类型支持矩阵

这张图想传达的一个判断是:A 股是约束最严的市场。没有 GTC、没有原生 Iceberg、没有 Stop(券商可在客户端模拟)、没有原生 Post-Only。所有这些都不是「中国市场不发达」,而是监管对程序化交易的态度:尽量减少机构通过订单类型形成不对称信息优势。

下面对几个市场单独说几句最容易被误用的地方。

A 股

A 股最大的两个陷阱是市价单变体(已在第二节讲过)和频繁撤单监控。沪深交易所对「单只股票每日撤单笔数」「撤单率」「报单笔数」都有上限,超出阈值会被认定异常交易。具体阈值未公开,但行业经验是单只股票单日 500 笔报单、撤单率 80% 以上几乎必触发。要在 A 股做高频策略,撤单频率必须在策略层就被卡住。

A 股没有「改单」原语,所有改单实际是「撤单 + 重报」。这意味着改单的操作开销是两次报文,单笔失败的概率也翻倍。如果用一笔单子在簿上「跟踪 BBO」,每跟一次价就消耗两次撤单配额,频率非常容易爆。

港股

港股有几个特殊订单类型:

港交所对撤单收费(Order Cancellation Ratio Fee, OCR),如果撤单率超过阈值会按笔收费。所以策略层要把 OCR 摊到执行成本里,不能假设撤单是免费的。

美股

美股的订单类型最丰富但也最碎片化。一个名义上的「市价单」在 Nasdaq、NYSE、BATS、IEX、ARCA 上的实际行为略有差异,加上 Reg NMS Order Protection Rule 要求 NBBO(全国最优买卖)保护,每笔订单可能被路由到多个交易所,最终的成交报告(execution report)会写明被路由到了哪。

美股的 Post-Only 在 Nasdaq 叫 ALO(Add Liquidity Only),在 BATS 叫 Post-Only,在 NYSE 叫 ALO(Adding Liquidity Only),语义都是「如果会立刻吃单则拒绝」。但「拒绝」的实现方式不同:Nasdaq ALO 会被自动转换成「调整价格不立即成交」的限价单,NYSE 则直接撤回。生产代码要测两种行为。

CME 期货

CME 的协议是 iLink 3 SBE(Simple Binary Encoding),订单类型在 OrdType(Tag 40)字段里:1=Market、2=Limit、3=Stop、4=Stop-Limit、K=MarketLimit。TIF 在 TimeInForce(Tag 59):0=DAY、1=GTC、3=IOC、4=FOK、6=GTD。

CME 有一个独有的限制叫 Messaging Quota Policy(MQP),按合约组对每秒撤改报笔数设限,超出会被该 session 暂时禁言。MQP 的具体阈值在 CME 官网公开,主要合约(ES、NQ、ZN)阈值较高,小品种(KOMEX 类)则严苛得多。

CME 的 Self-Match Prevention(SMP)也是必填项。同一 trader 的买单与卖单在簿上相遇会被拒绝其中一笔(可选哪笔),避免 wash trade。

Binance

Binance 现货与合约的订单类型差异较大。现货支持 LIMIT、MARKET、STOP_LOSS、STOP_LOSS_LIMIT、TAKE_PROFIT、TAKE_PROFIT_LIMIT、LIMIT_MAKER(Post-Only)。TIF 通过 timeInForce 字段:GTC、IOC、FOK、GTX(仅合约,等价于 Post-Only)。

Binance 没有 MOO/MOC(24 小时连续交易,没有开收盘)。冰山单通过 icebergQty 字段支持,但要求订单是限价 GTC,且 icebergQty < quantity

频率限制按 weight + raw request count 双维度,每分钟 6000 weight、每 10 秒 100 个 raw 请求。撤单不计 weight 上限,但仍占 raw 请求。

撤改单费用与频率限制对比

市场 撤单收费 撤单率监控 改单原语
A 股 是(异常交易认定) 否(撤+报)
港股 OCR 阶梯收费 是(每月评估) 部分支持
美股 否(但 SEC 有 layering 监控) CancelReplace
CME 否(高于 MQP 后被截流) MQP 阈值
Binance 频次 + 权重限流

跨市场抽象层一般会暴露 modify(order_id, new_price, new_qty) 接口,但实现上要根据市场拆开:在 A 股退化成 cancel+new、在 CME 走原子 modify、在 Binance 走 cancelReplace。差异隐藏在适配层,对策略层透明。


八、量化系统中的订单工厂

把上面所有维度收敛到代码上,量化系统里有三个核心抽象:Order 数据类、订单状态机、回报对账逻辑,再加上一层风控前置校验。

Order 数据类

下面这版 Order 包含五个维度的全部参数,并预留了交易所适配的扩展字段。所有字段名遵循一个原则:维度独立——价格字段不和时效字段混合,可见性参数不和触发参数混合。

from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
from decimal import Decimal
import time
import uuid


class Side(Enum):
    BUY = "BUY"
    SELL = "SELL"


class PriceType(Enum):
    LIMIT = "LIMIT"
    MARKET = "MARKET"
    BOUNDED_MARKET = "BOUNDED_MARKET"  # A 股五档即成等
    PEGGED = "PEGGED"


class TimeInForce(Enum):
    DAY = "DAY"
    GTC = "GTC"
    GTD = "GTD"
    IOC = "IOC"
    FOK = "FOK"


class Visibility(Enum):
    VISIBLE = "VISIBLE"
    ICEBERG = "ICEBERG"
    HIDDEN = "HIDDEN"
    DARK = "DARK"


class TriggerType(Enum):
    NONE = "NONE"
    STOP = "STOP"
    STOP_LIMIT = "STOP_LIMIT"
    TRAILING = "TRAILING"


class TriggerBasis(Enum):
    LAST = "LAST"
    MARK = "MARK"
    MID = "MID"
    INDEX = "INDEX"


class OrderState(Enum):
    NEW = "NEW"
    PENDING_NEW = "PENDING_NEW"
    ACKNOWLEDGED = "ACKNOWLEDGED"
    WORKING = "WORKING"
    PARTIALLY_FILLED = "PARTIALLY_FILLED"
    FILLED = "FILLED"
    PENDING_CANCEL = "PENDING_CANCEL"
    PENDING_REPLACE = "PENDING_REPLACE"
    CANCELLED = "CANCELLED"
    REJECTED = "REJECTED"
    EXPIRED = "EXPIRED"


@dataclass
class Order:
    # 标识
    client_order_id: str = field(default_factory=lambda: str(uuid.uuid4()))
    exchange_order_id: Optional[str] = None
    symbol: str = ""
    side: Side = Side.BUY

    # 价格维度
    price_type: PriceType = PriceType.LIMIT
    limit_price: Optional[Decimal] = None
    bound_levels: Optional[int] = None  # A 股「五档即成」=5,对手一档=1

    # 数量维度
    quantity: Decimal = Decimal("0")
    min_quantity: Decimal = Decimal("0")  # MinQty;FOK 时等于 quantity
    filled_quantity: Decimal = Decimal("0")
    avg_fill_price: Optional[Decimal] = None

    # 时效维度
    tif: TimeInForce = TimeInForce.DAY
    expire_at: Optional[float] = None  # GTD 用,UNIX 秒

    # 可见性维度
    visibility: Visibility = Visibility.VISIBLE
    display_quantity: Optional[Decimal] = None  # ICEBERG 时必填

    # 触发维度
    trigger_type: TriggerType = TriggerType.NONE
    trigger_price: Optional[Decimal] = None
    trigger_offset: Optional[Decimal] = None
    trigger_basis: TriggerBasis = TriggerBasis.LAST

    # 行为
    post_only: bool = False
    reduce_only: bool = False  # 期货专用
    self_match_prevention: str = "CANCEL_NEWEST"

    # 状态
    state: OrderState = OrderState.NEW
    create_ts: float = field(default_factory=time.time)
    update_ts: float = field(default_factory=time.time)

    def remaining(self) -> Decimal:
        return self.quantity - self.filled_quantity

    def is_terminal(self) -> bool:
        return self.state in (
            OrderState.FILLED,
            OrderState.CANCELLED,
            OrderState.REJECTED,
            OrderState.EXPIRED,
        )

这个数据类是策略层与执行层之间的契约。策略层只构造它,执行层把它翻译成具体交易所的报文。下面一些订单类型的构造范式:

# 限价 DAY 单
o1 = Order(symbol="AAPL", side=Side.BUY, price_type=PriceType.LIMIT,
           limit_price=Decimal("180.50"), quantity=Decimal("100"),
           tif=TimeInForce.DAY)

# IOC 限价单(VWAP 子单常用)
o2 = Order(symbol="ES_FUT", side=Side.BUY, price_type=PriceType.LIMIT,
           limit_price=Decimal("4500.25"), quantity=Decimal("10"),
           tif=TimeInForce.IOC)

# FOK:MinQty 等于 Quantity
o3 = Order(symbol="BTCUSDT", side=Side.SELL, price_type=PriceType.LIMIT,
           limit_price=Decimal("65000"), quantity=Decimal("2.5"),
           min_quantity=Decimal("2.5"), tif=TimeInForce.FOK)

# 冰山单
o4 = Order(symbol="0700.HK", side=Side.BUY, price_type=PriceType.LIMIT,
           limit_price=Decimal("400.00"), quantity=Decimal("100000"),
           visibility=Visibility.ICEBERG, display_quantity=Decimal("10000"),
           tif=TimeInForce.DAY)

# 止损限价
o5 = Order(symbol="SPY", side=Side.SELL, price_type=PriceType.LIMIT,
           limit_price=Decimal("440.00"), quantity=Decimal("500"),
           trigger_type=TriggerType.STOP_LIMIT,
           trigger_price=Decimal("442.00"),
           trigger_basis=TriggerBasis.LAST,
           tif=TimeInForce.GTC)

# A 股五档即成
o6 = Order(symbol="600519.SH", side=Side.BUY,
           price_type=PriceType.BOUNDED_MARKET, bound_levels=5,
           quantity=Decimal("100"), tif=TimeInForce.IOC)

每一行就对应一个具体业务场景,没有歧义。

订单状态机

订单状态机是订单生命周期的骨架。下面这张图整理了所有合法状态转移。

订单状态机:从 New 到终态的所有合法转移

图里有几个关键点要在正文里强调:

代码层把状态转移写成一张转移表,运行时校验:

ALLOWED_TRANSITIONS = {
    OrderState.NEW: {OrderState.PENDING_NEW, OrderState.REJECTED},
    OrderState.PENDING_NEW: {
        OrderState.ACKNOWLEDGED, OrderState.REJECTED,
        OrderState.PARTIALLY_FILLED, OrderState.FILLED,
        OrderState.CANCELLED, OrderState.EXPIRED,
    },
    OrderState.ACKNOWLEDGED: {
        OrderState.WORKING, OrderState.PARTIALLY_FILLED,
        OrderState.FILLED, OrderState.CANCELLED,
        OrderState.REJECTED, OrderState.EXPIRED,
        OrderState.PENDING_CANCEL, OrderState.PENDING_REPLACE,
    },
    OrderState.WORKING: {
        OrderState.PARTIALLY_FILLED, OrderState.FILLED,
        OrderState.PENDING_CANCEL, OrderState.PENDING_REPLACE,
        OrderState.CANCELLED, OrderState.EXPIRED, OrderState.REJECTED,
    },
    OrderState.PARTIALLY_FILLED: {
        OrderState.PARTIALLY_FILLED, OrderState.FILLED,
        OrderState.PENDING_CANCEL, OrderState.PENDING_REPLACE,
        OrderState.CANCELLED, OrderState.EXPIRED,
    },
    OrderState.PENDING_CANCEL: {
        OrderState.CANCELLED, OrderState.PARTIALLY_FILLED,
        OrderState.FILLED, OrderState.WORKING,
    },
    OrderState.PENDING_REPLACE: {
        OrderState.WORKING, OrderState.PARTIALLY_FILLED,
        OrderState.REJECTED,
    },
    OrderState.FILLED: set(),
    OrderState.CANCELLED: set(),
    OrderState.REJECTED: set(),
    OrderState.EXPIRED: set(),
}


class IllegalStateTransition(Exception):
    pass


def transition(order: Order, new_state: OrderState) -> None:
    allowed = ALLOWED_TRANSITIONS.get(order.state, set())
    if new_state not in allowed and new_state != order.state:
        raise IllegalStateTransition(
            f"order {order.client_order_id}: {order.state.value} -> {new_state.value} 非法"
        )
    order.state = new_state
    order.update_ts = time.time()

注意 PENDING_CANCEL 可以回到 PARTIALLY_FILLEDFILLED:你发出撤单请求时,订单可能已经在撮合通道里被部分或全部成交。这种 race 是分布式系统的标准问题,状态机必须能表达。

回报对账

所有交易所都通过执行回报(execution report)告诉客户端订单状态变化。在 FIX 协议里这条消息是 35=8(ExecutionReport),关键字段:

Tag 名称 含义
11 ClOrdID 客户端订单 ID
37 OrderID 交易所订单 ID
39 OrdStatus 0=New, 1=Partial, 2=Filled, 4=Cancelled, 8=Rejected
150 ExecType 执行事件类型
14 CumQty 累计成交量
32 LastQty 本次成交量
31 LastPx 本次成交价
6 AvgPx 平均成交价
60 TransactTime 交易所时间

回报对账的核心逻辑是把每一条 ExecutionReport 应用到本地 Order 上:

@dataclass
class ExecutionReport:
    client_order_id: str
    exchange_order_id: str
    exec_type: str  # NEW, PARTIAL_FILL, FILL, CANCELED, REJECTED, EXPIRED
    cum_qty: Decimal
    last_qty: Decimal
    last_price: Optional[Decimal]
    avg_price: Optional[Decimal]
    timestamp: float
    seq_no: int  # 单调递增序号,去重用


class OrderBookManager:
    def __init__(self):
        self.orders: dict[str, Order] = {}
        self.last_seq: dict[str, int] = {}

    def on_report(self, rpt: ExecutionReport) -> None:
        order = self.orders.get(rpt.client_order_id)
        if order is None:
            # 孤儿回报:可能客户端重启或 ID 表丢失,需要进入「未知订单」队列
            self._handle_orphan(rpt)
            return

        # 序号校验:晚到的回报丢弃
        last = self.last_seq.get(rpt.client_order_id, -1)
        if rpt.seq_no <= last:
            return
        self.last_seq[rpt.client_order_id] = rpt.seq_no

        order.exchange_order_id = rpt.exchange_order_id
        order.filled_quantity = rpt.cum_qty
        if rpt.avg_price is not None:
            order.avg_fill_price = rpt.avg_price

        # 状态映射
        if rpt.exec_type == "NEW":
            transition(order, OrderState.ACKNOWLEDGED)
        elif rpt.exec_type == "PARTIAL_FILL":
            transition(order, OrderState.PARTIALLY_FILLED)
        elif rpt.exec_type == "FILL":
            transition(order, OrderState.FILLED)
        elif rpt.exec_type == "CANCELED":
            transition(order, OrderState.CANCELLED)
        elif rpt.exec_type == "REJECTED":
            transition(order, OrderState.REJECTED)
        elif rpt.exec_type == "EXPIRED":
            transition(order, OrderState.EXPIRED)

        order.update_ts = rpt.timestamp

    def _handle_orphan(self, rpt: ExecutionReport) -> None:
        # 写入孤儿表,等待对账或人工干预
        pass

回报对账的几个工程要点:

第一,回报可能乱序。FIX 上序号是 single-stream 的,但实际网络抖动后乱序仍可能。seq_no 严格递增是去重的最低保险。

第二,孤儿回报必须处理。客户端重启、订单表持久化丢失、跨进程接力都会让 client_order_id 在内存中消失。这些回报不能直接丢,要进入孤儿表,做后台对账。

第三,累计成交量是权威。每次 fill 上报有 LastQty 也有 CumQty。在乱序场景下,应当用 CumQty 替换本地 filled_quantity,而不是累加 LastQty——后者乱序会出错。

风控前置校验

在订单出门前,必须经过一组规则校验。我把这些规则按维度组织:

@dataclass
class RiskLimits:
    max_order_qty: Decimal
    max_order_notional: Decimal
    max_position: Decimal
    max_daily_orders: int
    max_daily_cancel_ratio: float
    min_iceberg_display_ratio: float = Decimal("0.05")
    allowed_symbols: set[str] = field(default_factory=set)
    block_self_match: bool = True


class RiskCheckFailed(Exception):
    pass


def pre_trade_check(
    order: Order,
    market_state: dict,
    limits: RiskLimits,
    daily_stats: dict,
) -> None:
    # 1. 标的合法
    if limits.allowed_symbols and order.symbol not in limits.allowed_symbols:
        raise RiskCheckFailed(f"symbol {order.symbol} 未在白名单")

    # 2. 数量上限
    if order.quantity > limits.max_order_qty:
        raise RiskCheckFailed(f"单笔数量 {order.quantity} 超过上限")

    # 3. 价格合理性(防止打错小数点)
    if order.price_type == PriceType.LIMIT and order.limit_price is not None:
        last = market_state.get("last_price")
        if last is not None:
            deviation = abs(order.limit_price - last) / last
            if deviation > Decimal("0.10"):
                raise RiskCheckFailed(f"限价 {order.limit_price} 偏离最新价 >10%")

    # 4. 名义金额
    notional = (order.limit_price or market_state.get("last_price", Decimal("0"))) * order.quantity
    if notional > limits.max_order_notional:
        raise RiskCheckFailed(f"名义金额 {notional} 超过上限")

    # 5. 时效与价格类型一致
    if order.price_type == PriceType.MARKET and order.tif in (TimeInForce.GTC, TimeInForce.GTD):
        raise RiskCheckFailed("市价单不支持 GTC/GTD")

    # 6. FOK 必须 MinQty=Quantity
    if order.tif == TimeInForce.FOK and order.min_quantity != order.quantity:
        raise RiskCheckFailed("FOK 订单 MinQty 必须等于总量")

    # 7. 冰山单显示量比例
    if order.visibility == Visibility.ICEBERG:
        if order.display_quantity is None or order.display_quantity <= 0:
            raise RiskCheckFailed("冰山单未指定显示量")
        ratio = Decimal(order.display_quantity) / Decimal(order.quantity)
        if ratio < limits.min_iceberg_display_ratio:
            raise RiskCheckFailed(f"冰山显示比例 {ratio:.2%} 低于下限")

    # 8. 撤单率
    sent = daily_stats.get("orders_sent", 0)
    cancelled = daily_stats.get("orders_cancelled", 0)
    if sent > 100:
        ratio = cancelled / sent
        if ratio > limits.max_daily_cancel_ratio:
            raise RiskCheckFailed(f"日撤单率 {ratio:.2%} 超过上限")

    # 9. 当日报单笔数
    if sent >= limits.max_daily_orders:
        raise RiskCheckFailed("当日报单笔数已达上限")

    # 10. 自成交检测(简化版)
    if limits.block_self_match:
        own_orders = market_state.get("own_orders_on_book", [])
        for o in own_orders:
            if o.symbol == order.symbol and o.side != order.side:
                if order.side == Side.BUY and order.limit_price is not None and o.limit_price is not None:
                    if order.limit_price >= o.limit_price:
                        raise RiskCheckFailed(f"自成交风险:与本方挂单 {o.client_order_id} 冲突")
                elif order.side == Side.SELL and order.limit_price is not None and o.limit_price is not None:
                    if order.limit_price <= o.limit_price:
                        raise RiskCheckFailed(f"自成交风险:与本方挂单 {o.client_order_id} 冲突")

这些规则不是穷尽列表。生产系统会再加上:仓位上限、保证金率、交易时段、合约到期日、API 频率配额、券商资金可用性、监管预警阈值。但维度独立的好处是每条规则只看自己关心的字段,互不干扰。

IOC vs FOK 撮合差异模拟

最后用一段可运行代码把 IOC 和 FOK 在同一份簿上的撮合差异展示出来,作为验证状态机和数据结构的小测试。

from dataclasses import dataclass, field
from decimal import Decimal
from typing import List, Tuple


@dataclass
class BookLevel:
    price: Decimal
    qty: Decimal


@dataclass
class OrderBook:
    bids: List[BookLevel] = field(default_factory=list)  # 降序
    asks: List[BookLevel] = field(default_factory=list)  # 升序


def match_aggressive(
    book: OrderBook, side: Side, price: Decimal, qty: Decimal,
    min_qty: Decimal,
) -> Tuple[List[Tuple[Decimal, Decimal]], Decimal]:
    """模拟一笔进取性订单的撮合,返回 (fills, executed_qty)。
    min_qty 控制 IOC(=0)/ FOK(=qty)/ MinQty 单(中间值)。
    """
    fills: List[Tuple[Decimal, Decimal]] = []
    remaining = qty

    levels = book.asks if side == Side.BUY else book.bids
    cmp = (lambda lvl: lvl.price <= price) if side == Side.BUY else (lambda lvl: lvl.price >= price)

    # 第一遍:探查可成交量
    available = Decimal("0")
    for lvl in levels:
        if not cmp(lvl):
            break
        available += lvl.qty
        if available >= remaining:
            break

    # FOK 或 MinQty 检查
    if min_qty > 0 and available < min_qty:
        # 不满足最小成交量,全单撤销
        return [], Decimal("0")

    # 第二遍:实际撮合
    for lvl in levels:
        if remaining <= 0 or not cmp(lvl):
            break
        take = min(lvl.qty, remaining)
        fills.append((lvl.price, take))
        remaining -= take

    executed = qty - remaining
    return fills, executed


# 测试场景:同样的簿,同样数量,IOC vs FOK 行为差异
def demo():
    book = OrderBook(
        asks=[
            BookLevel(Decimal("100.10"), Decimal("50")),
            BookLevel(Decimal("100.20"), Decimal("80")),
            BookLevel(Decimal("100.30"), Decimal("120")),
        ]
    )

    # 案例 1:IOC 买 200 手,限价 100.25
    fills, executed = match_aggressive(
        book, Side.BUY, Decimal("100.25"), Decimal("200"), min_qty=Decimal("0")
    )
    print(f"IOC: 成交 {executed} 手,明细 {fills}")
    # 预期:吃 50@100.10 + 80@100.20 = 130 手,剩 70 撤销

    # 案例 2:FOK 买 200 手,限价 100.25
    fills, executed = match_aggressive(
        book, Side.BUY, Decimal("100.25"), Decimal("200"),
        min_qty=Decimal("200"),
    )
    print(f"FOK: 成交 {executed} 手,明细 {fills}")
    # 预期:可成交量 130 < 200,不满足 FOK,整单撤销

    # 案例 3:FOK 买 100 手,限价 100.25
    fills, executed = match_aggressive(
        book, Side.BUY, Decimal("100.25"), Decimal("100"),
        min_qty=Decimal("100"),
    )
    print(f"FOK 100: 成交 {executed} 手,明细 {fills}")
    # 预期:可成交 130 >= 100,全部成交:50@100.10 + 50@100.20

    # 案例 4:MinQty 买 200 手,最少 150 手
    fills, executed = match_aggressive(
        book, Side.BUY, Decimal("100.35"), Decimal("200"),
        min_qty=Decimal("150"),
    )
    print(f"MinQty 150: 成交 {executed} 手,明细 {fills}")
    # 预期:可成交 250 >= 150,吃 50+80+70 = 200 手


if __name__ == "__main__":
    demo()

实际运行结果(Python 3.11,标准库 Decimal):

IOC: 成交 130 手,明细 [(Decimal('100.10'), Decimal('50')), (Decimal('100.20'), Decimal('80'))]
FOK: 成交 0 手,明细 []
FOK 100: 成交 100 手,明细 [(Decimal('100.10'), Decimal('50')), (Decimal('100.20'), Decimal('50'))]
MinQty 150: 成交 200 手,明细 [(Decimal('100.10'), Decimal('50')), (Decimal('100.20'), Decimal('80')), (Decimal('100.30'), Decimal('70'))]

四个案例把 IOC、FOK 与 MinQty 的语义差异钉死。FOK 在第二个案例直接交白卷,IOC 在同样的输入下成交了 130 手,差距 130 手;这就是为什么 FOK 在薄盘上几乎不可用,也是为什么 IOC 是算法子单的主流选择。

验证撮合模拟里的两个细节

上面那段 match_aggressive 函数里有两处工程上常被忽略的细节,单独拎出来讲清楚。

第一,两遍扫描的必要性。第一遍是「探查」,只算可成交量,不真的扣减;第二遍才是「撮合」,按可成交量决定是否真的吃单。如果只写一遍循环,FOK 撤销时已经把对手簿改了一半,得回滚——回滚要么需要保存快照,要么需要事务语义。两遍扫描虽然多一次 O(K) 扫描,但代码无副作用,逻辑干净得多。生产撮合引擎也基本采用这种模型(或者用 copy-on-write 的对手簿快照)。

第二,MinQty 不等于 FOK。两者很容易混。FOK 是 MinQty=Total 的特例,但 MinQty 的取值可以是任何 0 到 Total 之间的数。CME 在 iLink 协议里有 MinQty(Tag 110)字段,允许策略指定「至少成交多少手才考虑撮合」。这种半 FOK 在大宗交易里很有用:客户愿意接受部分成交,但少于某个量就划不来(手续费高于收益)。把 MinQty 当成 TIF 维度上的连续参数处理,而不是离散枚举,抽象会更通用。

客户订单 ID 与幂等性

一个工程惯例值得专门强调。所有报单接口都应当要求客户端提供 client_order_id(CME 叫 ClOrdID,FIX Tag 11;Binance 叫 newClientOrderId),并且这个 ID 在客户端必须是确定性可重放的。理由是:

ClOrdID 的生成最常见错误是用 UUID 或时间戳。这不算错,但容易给两个特性留隐患:第一是不能重放(崩溃后产生的 UUID 与崩溃前不同);第二是不可索引(UUID 没有顺序信息,对账时只能哈希查找)。一个稍微讲究的方案是:

def generate_client_order_id(strategy_id: str, seq: int) -> str:
    # 8 字节策略 + 8 字节秒时间戳 + 8 字节序号
    ts = int(time.time())
    return f"{strategy_id:0>8}{ts:08x}{seq:08x}"

策略 ID 让多个策略并行下单不冲突;时间戳给对账时按时间分片用;序号在策略内部单调递增、可持久化、可重放。这三段加起来 24 个字符,仍然在 FIX、CME、Binance 等所有平台的字段长度限制内(FIX 标准 32 字符)。

状态机持久化

有了状态机后,下一个工程问题是怎么持久化。三种常见方案:

方案 写入路径 故障恢复 适用场景
内存 + 周期性 snapshot 每秒 dump 丢失最近 1 秒 低频策略
WAL(write-ahead log) 每次状态转移 fsync 几乎无损 中频策略
同步双写 SQL 每次状态转移 commit 无损 强一致性要求

WAL 是中频策略的常用方案:每次 transition() 调用追加一条日志,崩溃恢复时回放。问题是 fsync 引入毫秒级延迟,与策略下单链路耦合后会拖慢整体响应。生产做法是把 WAL 异步化:策略层调 transition() 时只写内存,后台线程批量 fsync 到磁盘,崩溃时丢失最后一批未刷盘的状态。这条权衡在金融系统里通常可接受,因为崩溃后无论如何都要做一次端到端对账,不能完全依赖本地日志。

如果策略涉及多机部署、跨进程接力、或者监管要求订单全生命周期可审计,那就需要走 SQL 同步双写或分布式一致协议(Raft、Paxos)。这超出本文范围,但要在架构早期就决定,否则后期改造代价巨大。

撮合优先级与队头位置

最后补一段关于「队头位置」的工程经验。同一价位上,谁先到谁先成交(time priority)是几乎所有市场的默认规则,但「先到」的口径在不同地方差异微妙。

CME 撮合引擎的时间戳基准是 Globex 内部到达时间,FIFO 的精度到微秒;Nasdaq 用 ITCH/OUCH 协议的入队时间,精度到纳秒;Binance 用撮合服务接收到 REST 或 WebSocket 报单的时间。这意味着同一笔单,到达不同交易所「在队列里的位置」由不同的延迟链路决定,跨市场套利时不能假设两边的优先级是同步的。

更现实的工程问题是:改单几乎一定让你失去优先级。CME 的 modify-in-place 只在数量减少时保留优先级;价格修改、数量增加都会重新入队。Nasdaq 的 CancelReplace 等价于撤+报,优先级重置。所以「跟踪 BBO 不停改价」这种策略,每次改价都意味着排到队尾。在簿很深的合约上(十年期国债期货 ZN),队尾排到队头可能要几分钟,这段时间里你的子单基本不可能成交。这是「为什么做市策略要慎用改单」的核心原因,比规则手册讲的「OCR 收费」更本质。

正确的做法是:要么挂在队头不动(同价不变就保持优先级),要么直接撤了用 IOC 扫一刀。中间路线(频繁改价跟踪 BBO)在工程上几乎全是输的——你既损失优先级,又增加撤改频率上的风险敞口。


九、深入思考:订单类型的设计哲学

把上面所有维度和市场对照看,会浮现一个判断:订单类型的丰富度反映了市场结构的设计哲学

成熟期货市场(CME、ICE、Eurex)订单类型最多,因为期货市场默认是机构博弈场,监管允许参与者用更细粒度的工具表达意图。MinQty、MaxShow、Self-Match Prevention、Stop-with-Protection——这些都是为了让大资金更顺畅地进出市场,因此交易所提供更多工具。

成熟股票市场(美股)紧随其后,但因为零售参与者多,部分订单类型(暗池、隐藏单)必须做信息隔离,避免大资金过度割韭菜。Reg NMS Order Protection 强制了 NBBO 保护,相当于在订单语义之上加了一层全国性的最优价约束。

新兴市场和监管谨慎市场(A 股)选择了反方向:限制订单类型,把可用工具压缩到几种基础形式。这不是技术不足,而是有意为之。监管层认为机构通过订单类型形成的不对称优势会损害零售投资者,所以宁可让连续竞价更接近「公平公开」的简单模型。代价是机构的执行成本上升、流动性提供方式受限,反映在国内做市商生态的相对匮乏上。

我个人的判断是:在可预见的几年内,A 股不太会引入冰山、暗池、Post-Only 这类工具,但会逐步放开 IOC、FOK 与改单原语,因为这些与公平性无直接冲突。港股则会继续向美股靠拢,逐步丰富 algo order 的支持。CME 与 Binance 之间的差距正在收敛,加密货币交易所学习传统市场的速度比反过来快得多。

另一个值得点出的争议:冰山单到底有没有用? 学术界(Bessembinder & Venkataraman, 2010;Hautsch & Huang, 2012)一系列实证研究表明,冰山单的实际隐蔽效果在 HFT 主导的市场上很有限,对手算法可以从补片节奏反推。所以冰山的真实价值不在「完全隐藏」,而在「不主动暴露」——你不会在挂单瞬间就被算法盯上,但持续成交后仍会被识别。把冰山当成「静态隐身衣」是过时的认知。把它当成「降低挂单瞬间冲击」的工具,才是合理预期。

最后一个观点:不要为「订单类型」本身而设计策略。我见过策略从「我要用 FOK 跨市场套利」开始反推交易逻辑,结果是策略服务于工具,而不是工具服务于策略。正确的顺序是:先确定信号、再确定执行约束(紧迫程度、冲击成本、撤单率预算)、最后选订单类型。FOK 不是因为「FOK 高级」才用,而是因为「腿断风险不可接受」才用。从工具反推业务,方向就错了。


十、参考资料

规范与官方文档

学术论文

书籍

工具与实测


十一、本文小结

把订单类型理解成五个独立维度(价格、数量、时效、可见性、触发)的组合,是把一切看似复杂的订单语义压缩到可工程化抽象的关键。在这个框架下:

把这套框架落到代码里,需要一个维度独立的 Order 数据类、一个明确所有合法状态转移的状态机、一套基于回报序号的对账逻辑、以及一组按维度组织的风控前置校验规则。配合一段可运行的 IOC 与 FOK 撮合差异模拟,整套抽象就有了端到端的验证。

订单类型本身不复杂,复杂的是不同市场对同一名词的不同实现,以及每种工具背后隐含的执行成本与监管约束。把这些差异隐藏在适配层、把维度抽象暴露给策略层,是量化执行系统设计的第一原则。


导航:上一篇 【量化交易】市场微观结构与订单簿 | 下一篇 【量化交易】行情数据管线


附录:术语速查表

缩写 全称 维度 一句话定义
TIF Time-in-Force 时效 订单在簿上的有效期策略
DAY Day Order 时效 当日有效,收盘自动撤销
GTC Good-Till-Cancel 时效 直到主动撤销前一直有效
GTD Good-Till-Date 时效 有效到指定日期
IOC Immediate-Or-Cancel 时效 立即撮合,剩余撤销
FOK Fill-Or-Kill 时效 全成或全撤,等价 MinQty=Total 的 IOC
MOO Market-On-Open 价格+触发 参与开盘集合竞价的市价单
MOC Market-On-Close 价格+触发 参与收盘集合竞价的市价单
ALO Add Liquidity Only 行为 Post-Only,吃单则拒绝
OCR Order Cancellation Ratio 监控 港交所撤单率收费指标
MQP Messaging Quota Policy 监控 CME 报文配额限制
SMP Self-Match Prevention 风控 自成交保护
BBO Best Bid and Offer 行情 最优买卖一档
NBBO National Best Bid and Offer 行情 全国最优买卖(美股 Reg NMS 概念)

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-05-01 · quant

【量化交易】量化交易全景:从信号到订单的工程链路

量化交易不是策略写得好就能赚钱,更难的是把数据、特征、因子、信号、组合、执行、风控、复盘这八段链路在工程上连成一条不漏数据、不串时间、不丢订单的流水线。本文是【量化交易】系列的总目录与读图,给出八段链路的输入输出、失败模式、不变量清单,并用研究流程图把从一个想法到一笔实盘订单之间所有该过的卡点串起来。

2026-05-01 · quant

【量化交易】市场结构:交易所、做市商、暗池、ECN

系统梳理全球市场结构(Market Structure)的工程图景:从证券交易所、衍生品交易所、加密交易所,到做市商、暗池、ECN/ATS,再到 Maker-Taker 收费、PFOF、Reg NMS 与 MiFID II 的监管影响;给出量化策略选择交易场所的判断框架与基于 ccxt 的多交易所行情聚合代码。

2026-05-01 · quant

【量化交易】市场微结构:订单簿、价差、流动性、冲击

系统讲解市场微结构的核心概念与可计算工具:限价订单簿的数据模型、报价/有效/已实现价差、Roll 模型、四维流动性度量、Kyle's lambda、订单流不平衡(OFI)、Almgren-Chriss 框架下的临时与永久冲击、PIN 与 VPIN、Hawkes 过程,并给出基于 polars 的 L2 增量处理与系数估计代码。

2026-05-01 · quant

【量化交易】行情与基本面数据管线:tick、bar、因子库

把量化系统里最容易藏雷的数据层从 tick 写到因子库走一遍:行情源接入与质量评估、tick 到 dollar bar 的 de Prado 式重采样、Parquet/Arrow/DuckDB/ClickHouse 列存选型、增量回填与断点续传、公司行动与前后复权、PIT 因子库与版本化查询、缺失监控与漂移检测;附 polars + pyarrow + duckdb 的可运行实现。


By .