4. 订单管理模块设计:订单生命周期管理、订单状态机、撤单与改单逻辑、订单簿维护

做市系统的核心,说白了就是跟订单打交道。你策略算得再准,信号发得再快,如果订单管理这块掉链子,一切都是白搭。我见过不少团队,策略模型漂亮得很,结果上线第一天就因为订单状态没处理好,被交易所罚了个底朝天。

今天咱们就聊聊订单管理模块。这块我做了七八年,踩过的坑能写一本小册子。嗯,咱们一个一个来拆解。

4.1 订单生命周期:从生到死的完整轨迹

一个订单从诞生到消亡,经历哪些阶段?我习惯把它分成这几个关键节点:

  • 创建(Created):策略模块发出指令,订单对象刚被实例化。此时它还没离开系统。
  • 已发送(Sent):订单通过网络发往交易所。注意,这里只是“发出”,不代表交易所收到了。
  • 已确认(Acknowledged):交易所返回了确认消息。这个状态很关键——只有到了这一步,订单才算真正“活着”。
  • 部分成交(Partially Filled):订单被部分撮合,剩余数量还在挂单。
  • 全部成交(Filled):订单完全成交,生命周期结束。
  • 已撤销(Cancelled):主动撤单成功。
  • 已拒绝(Rejected):交易所拒绝了这个订单,比如价格超出范围、数量不对等。
  • 已过期(Expired):比如GTC(Good Till Cancelled)订单在收盘后被自动清除。

你可能会问:为什么要把“已发送”和“已确认”分开?

我在项目中遇到过网络闪断的情况。订单明明发出去了,但交易所的确认消息没回来。如果我把这两个状态合并,系统就会认为订单没发出去,然后重发——结果就是同一个订单被成交两次。嗯,这个教训挺深刻的。

核心原则:订单状态的变更,必须由交易所的明确消息驱动。任何基于超时或猜测的状态转换,都要慎之又慎。

4.2 订单状态机:别让状态乱飞

状态机是订单管理的骨架。我建议用有限状态机(FSM)来建模,每个状态之间的转换路径是严格定义的。乱跳状态?那是灾难。

下面是我常用的一个简化版状态机:

// 伪代码示例:订单状态机核心逻辑
enum OrderState {
    CREATED,
    SENT,
    ACKNOWLEDGED,
    PARTIALLY_FILLED,
    FILLED,
    CANCELLED,
    REJECTED,
    EXPIRED
}

class OrderStateMachine {
    private OrderState currentState;
    
    // 定义允许的转换
    private Map<OrderState, List<OrderState>> transitions = Map.of(
        CREATED, List.of(SENT, REJECTED),
        SENT, List.of(ACKNOWLEDGED, REJECTED),
        ACKNOWLEDGED, List.of(PARTIALLY_FILLED, FILLED, CANCELLED, REJECTED),
        PARTIALLY_FILLED, List.of(PARTIALLY_FILLED, FILLED, CANCELLED),
        FILLED, List.of(),  // 终态
        CANCELLED, List.of(), // 终态
        REJECTED, List.of(),  // 终态
        EXPIRED, List.of()    // 终态
    );
    
    public boolean transitionTo(OrderState newState) {
        if (transitions.get(currentState).contains(newState)) {
            currentState = newState;
            return true;
        }
        // 我曾经在这里加过告警日志,线上出问题时排查效率极高
        log.warn("非法状态转换: {} -> {}", currentState, newState);
        return false;
    }
}

你看,FILLED、CANCELLED、REJECTED、EXPIRED 都是终态。一旦进入终态,任何状态转换都不允许。为什么这么严格?

我曾经遇到过一个bug:某个订单已经全部成交了,但因为网络重传,系统又收到了一条成交回报,结果状态机把订单从FILLED又转到了PARTIALLY_FILLED。你想想看,这会导致什么后果?资金计算全乱套了。

避坑指南:状态机的转换表一定要写死,不要用动态配置。我曾经见过有人把状态转换规则放到数据库里,结果运维手抖改了一条记录,整个系统的订单状态全崩了。

4.3 撤单与改单逻辑:小心并发陷阱

撤单和改单,是订单管理里最容易出bug的地方。说白了,就是“你撤单的同时,交易所正在成交它”。

我个人的处理原则是:先确认,再操作

具体来说:

  • 撤单流程:先向交易所发送撤单请求,然后等待撤单确认。在收到确认之前,订单状态保持不变。如果撤单确认和成交回报同时到达,以成交回报为准——因为成交是事实,撤单只是请求。
  • 改单流程:改单本质上是“先撤单,再下新单”。但要注意,有些交易所支持“改单”这个原子操作,有些则不支持。我建议统一用“撤单+下新单”的方式实现,这样兼容性最好。

这里有个细节:撤单请求发出后,如果超时没收到确认,怎么办?

嗯,这个问题没有标准答案。我自己的做法是:重查。主动向交易所查询该订单的状态,而不是盲目重发撤单请求。盲目重发可能导致重复撤单,有些交易所会报错,有些则不会——但后果都一样,你失去了对订单的控制。

小技巧:在撤单请求中带上一个唯一的请求ID。这样即使撤单请求重复发送,交易所也能通过ID去重。这个ID我一般用“订单ID + 时间戳 + 随机数”生成。

4.4 订单簿维护:本地与交易所的同步艺术

订单簿(Order Book)是做市系统的命根子。你的策略决策,全部基于订单簿的数据。如果订单簿不准,策略就是瞎搞。

订单簿维护的核心挑战是:如何保证本地订单簿与交易所的订单簿实时一致?

我常用的方案是“增量更新 + 全量快照”双保险:

  • 增量更新:监听交易所的行情推送,每收到一条成交或挂单变更,就更新本地订单簿。这种方式延迟低,但容易累积误差。
  • 全量快照:每隔一段时间(比如30秒),从交易所拉一次完整的订单簿快照,用快照覆盖本地数据。这样可以把增量更新的误差清零。

你可能会问:增量更新和全量快照之间如果有冲突怎么办?

我建议以全量快照为准。增量更新只负责“快”,全量快照负责“准”。两者结合,才能又快又准。

下面是一个简化的订单簿维护逻辑:

class OrderBook {
    // 买单:价格从高到低排序
    private TreeMap<Double, Level> bids = new TreeMap<>(Comparator.reverseOrder());
    // 卖单:价格从低到高排序
    private TreeMap<Double, Level> asks = new TreeMap<>();
    
    // 增量更新
    public void applyIncrementalUpdate(OrderBookUpdate update) {
        // 更新买单或卖单的某个价格档位
        // 如果数量为0,则移除该档位
        // 我曾经因为忘记处理数量为0的情况,导致订单簿里残留了空档位
    }
    
    // 全量快照覆盖
    public void applySnapshot(OrderBookSnapshot snapshot) {
        // 直接清空本地数据,用快照替换
        this.bids.clear();
        this.asks.clear();
        // 填充快照数据
    }
}
重要提醒:订单簿的深度信息(比如前10档、前20档)一定要和策略模块对齐。策略用第5档的价格做决策,但你的订单簿只维护了前3档——这种错位会导致策略失效。

4.5 整体架构图

说了这么多,咱们用一张图把整个订单管理模块串起来。这张图是我自己项目里用的简化版,但核心逻辑是一样的。

订单管理模块核心架构 策略模块 生成订单指令 订单管理器 状态机 + 生命周期管理 交易所网关 网络通信 + 协议转换 交易所 本地订单簿 增量更新 + 全量快照 风控模块 检查订单合法性 订单日志 全量记录 + 审计追踪 图例说明: 策略模块 订单管理器 交易所网关 本地订单簿 风控模块 订单日志 数据流 指令流

这张图里,订单管理器是核心枢纽。它接收策略模块的指令,经过风控检查后,通过交易所网关发往交易所。同时,它维护着本地订单簿,并把订单簿数据反馈给策略模块。所有操作都会被记录到订单日志中,方便事后审计。

嗯,订单管理模块的设计,说白了就是“状态清晰、转换可控、数据一致”。把这三点做好,系统就稳了一大半。

专注资料整理