2. 订单生命周期与状态定义

订单状态机是交易系统的骨架。它决定了每一笔订单在什么条件下可以做什么、不能做什么,也决定了新增、撤销、成交三类事件如何被系统解释与落库。本节围绕三个问题展开:订单从创建到完结要经历哪些典型状态;新增、撤销、成交事件各自意味着什么;以及如何把这些要素组织成一个可实现的有限状态机。

一、典型状态划分

一个可落地的订单状态集合通常包含以下状态,它们覆盖了从下单到终结的完整路径:

  • 已创建(Created):订单对象已在内存或数据库中生成,但尚未通过风控与合规校验,不参与撮合。
  • 待成交(Pending / Working):已通过校验并进入撮合队列,等待对手方或等待触发条件,是订单存活期的主要状态。
  • 部分成交(PartiallyFilled):成交量大于零但小于委托数量,剩余数量继续留在队列中。
  • 已成交(Filled):累计成交量等于委托数量,订单自然终结。
  • 已撤销(Cancelled):在完全成交前被主动撤单或系统撤单,剩余数量作废。
  • 已拒绝(Rejected):校验失败(如资金不足、价格越界、标的不存在),订单从未进入撮合。
  • 已过期(Expired):带时效的订单(如当日有效、IOC/FOK)在时限到达时未成交部分自动失效。

需要强调的是,已成交、已撤销、已拒绝、已过期属于终态,进入后不再接受任何事件;已创建、待成交、部分成交属于中间态,可以继续流转。区分中间态与终态,是后续所有事件处理逻辑的前提。

二、三类事件的语义

新增事件(New / Place)表示“请求建立一笔订单”。它携带订单号、标的、方向、价格、数量、订单类型、时效策略等字段。新增事件的处理结果不是必然成功:系统要先做静态校验(字段合法性、价格档位、数量精度),再做动态校验(可用资金、可卖持仓、风控限额)。校验通过则订单进入待成交;不通过则直接进入已拒绝,并记录拒绝原因码。因此新增事件是一个“带分支的入口事件”,而不是简单的状态推进。

撤销事件(Cancel)表示“请求终止一笔尚未完全成交的订单”。它的合法前置状态只有待成交与部分成交。撤销事件需要处理两类边界:一是撤单请求与成交回报的竞态——若撤单到达撮合引擎时订单恰好已全部成交,则撤单应被拒绝并返回“订单已终结”;二是部分成交后的撤单,只作废剩余数量,已成交部分不可回滚。撤销成功后订单进入已撤销,剩余数量清零。

成交事件(Fill / Trade)表示“撮合产生了实际成交”。它携带成交价、成交数量、对手方信息与成交编号。成交事件对状态的影响取决于累计成交量:若累计成交量小于委托量,订单进入部分成交并保留剩余数量;若累计成交量等于委托量,订单进入已成交。成交事件是唯一能推动订单走向“已成交”终态的事件,也是唯一会产生资金与持仓变动的业务事件,因此必须保证幂等——同一成交编号重复到达时不得重复记账。

三、状态机建模要素

把上述内容形式化,一个订单状态机由五类要素构成:

  1. 状态集合 S:上文列出的七个状态,需明确标注哪些是终态。
  2. 事件集合 E:新增、撤销、成交,以及系统内部的校验通过、校验失败、超时触发等派生事件。
  3. 转移函数 δ: S × E → S:定义在某个状态下收到某个事件后应迁移到哪个状态;对非法组合应显式定义为“拒绝并保持原状态”,而不是静默忽略。
  4. 守卫条件 G:转移发生前必须满足的业务约束,例如“撤销仅当剩余数量大于零”“成交数量不得超过剩余数量”。
  5. 动作 A:转移发生时执行的副作用,如冻结资金、释放冻结、写入成交明细、发送回报。

下面这张状态转移图把状态、事件与守卫条件放在一起,可直接作为实现时的对照依据:

已创建 Created 已拒绝 Rejected · 终态 待成交 Pending 部分成交 PartiallyFilled 已成交 Filled · 终态 已撤销 Cancelled · 终态 已过期 Expired · 终态 新增·校验通过 新增·校验失败 成交·累计量<委托量 成交·累计量=委托量 成交·一次全成 撤销·剩余量>0 撤销·作废剩余量 超时·未成交部分失效 终态(不再接受事件) 中间态(可继续流转) 撤销/拒绝路径

四、实现要点与示例

在工程实现上,建议把状态机写成“状态 × 事件”的显式转移表,而不是散落在各处的 if-else。下面是一个简化的转移表片段,用 Python 字典表达,键为 (当前状态, 事件),值为 (目标状态, 守卫条件, 动作):

TRANSITIONS = {
    ("Created", "New"):        ("Pending",   "risk_pass",      "freeze_funds"),
    ("Created", "Reject"):     ("Rejected",  "risk_fail",      "log_reason"),
    ("Pending", "Fill"):       ("PartiallyFilled", "qty < remain", "apply_trade"),
    ("Pending", "Fill"):       ("Filled",    "qty == remain",  "apply_trade"),
    ("Pending", "Cancel"):     ("Cancelled", "remain > 0",     "release_funds"),
    ("PartiallyFilled", "Fill"):   ("Filled",    "qty == remain", "apply_trade"),
    ("PartiallyFilled", "Cancel"): ("Cancelled", "remain > 0",  "release_funds"),
    ("Pending", "Expire"):     ("Expired",   "timeout",        "release_funds"),
}

注意表中同一 (Pending, Fill) 出现了两次,实际实现时应按守卫条件顺序匹配,或把“是否全部成交”作为分支判断放在动作内部。无论哪种写法,都要保证:非法组合(如 Filled 状态收到 Cancel)必须显式返回错误码,而不是落入默认分支被忽略。

再举一个具体的教学示例(参数为假设值):某限价买单委托数量 1000 股,价格 10.00 元。新增事件通过校验后冻结资金 10000 元,订单进入待成交。随后成交事件到达,成交 400 股,累计成交量 400 < 1000,订单转入部分成交,剩余 600 股继续挂单。此时若收到撤单请求,守卫条件“剩余量 > 0”成立,订单转入已撤销,释放剩余 600 股对应的 6000 元冻结资金,已成交的 400 股不可回滚。若撤单未到达而第二笔成交 600 股到达,累计成交量等于委托量,订单转入已成交,冻结资金按实际成交金额结算。

判断状态机是否建模正确,可以用三个检查点:终态是否真的不再接受任何事件;每个中间态是否都有至少一条通往终态的路径(避免订单永久悬挂);成交事件的幂等键是否唯一且被校验。

最后要区分“状态”与“事件”的职责边界。状态描述订单此刻的客观处境,事件描述外部或系统内部发起的动作请求。把两者混为一谈,常见后果是把“撤单请求”直接当成“已撤销”状态写入,导致撤单被拒时数据与实际不符。正确做法是:事件先经过守卫条件判断,只有转移成功才更新状态,失败则记录拒绝原因并保持原状态。