交易机器人容易,交易引擎很难:订单生命周期与重启恢复

信号不是订单,接受不是成交,重连会重复推送成交,而最危险的失败是"不知道成没成"。一份 Java 交易引擎的 12 个架构问题清单。

交易机器人容易,交易引擎很难:订单生命周期与重启恢复

一句话结论:搭一个交易机器人很简单——拿行情、算指标、出 BUY/SELL。真正难的是信号之后发生什么:券商接受了但一直没回怎么办?部分成交怎么记账?同一笔成交推送两次怎么办?更新持仓时进程崩了怎么办?重启后还有挂单怎么办?NERV Trade 的作者把这些当成架构问题而不是边角情况,逐条给出解法:信号与订单分离、状态与经济效益分离、成交身份持久化、未知结果挂起并对账、数据库进入执行引擎、恢复成为一种架构职责。最后他补了一个更根本的前提:先保证同一份行情能复现同一个决策(确定性),再谈可靠执行。

披露:本文编译自 czetsuya(NERV Trade 系列第 2 篇)的技术文(dev.to,2026-10-17),并参考其第 1 篇。NERV Trade 是作者的 Java/Spring 开源工程实践,文中的代码为示意模型,不是其精确领域 API。本文只讨论工程技术,不构成投资建议。


一、每个人都是从这张图开始的

Market Data
     |
     v
Technical Indicators
     |
     v
Trading Strategy
     |
     v
BUY / SELL / HOLD

一个最简单的 Java 策略可能长这样:

public enum Signal { BUY, SELL, HOLD }

public Signal evaluate(BigDecimal fastMovingAverage,
                       BigDecimal slowMovingAverage) {
    int comparison = fastMovingAverage.compareTo(slowMovingAverage);
    if (comparison > 0) return Signal.BUY;
    if (comparison < 0) return Signal.SELL;
    return Signal.HOLD;
}

恭喜,你有一个交易机器人了。

但作者用一个问题改变了整个设计方向:信号之后会发生什么?

  • 券商接受了订单,但一直不响应?
  • 订单部分成交?
  • 同一笔成交到达两次?
  • 更新持仓时应用崩溃?
  • 应用重启时,订单还是活的?

这些不是可以忽略的假设性边角情况,它们是架构问题。 解决它们,才是"简单机器人"和"可靠引擎"的分界线。

二、BUY 信号不是订单

假设策略输出 BUY BTCUSDT。它到底是什么意思?

  • 策略想买比特币?
  • 已经创建订单?
  • 交易所接受了订单?
  • 订单成交了?

这是四个不同的状态。 更现实的架构把它们分开:

Market Data → Analysis → Strategy → Risk Evaluation
     → Trade Intent → Local Order → Broker Submission → Execution Reports
组件 职责
Analysis 解释市场条件
Strategy 决定提议什么动作
Risk 判断动作是否被允许
Trade Intent 拥有交易生命周期
Local Order 表示请求的券商动作
Broker Adapter 与外部场所通信
Execution Processor 接受并核算真实成交

为什么要拆? 因为一个交易决策可以产生多个订单,一个订单可以产生多笔成交。策略不应该理解这些执行细节——它的职责是表达意图;执行引擎负责确定到底发生了什么。

三、被接受 ≠ 被执行

考虑买入 10 个单位。最简单的实现假设:

BUY 10 → FILLED 10

但券商可能给出:

BUY 10 → ACKNOWLEDGED → PARTIALLY FILLED: 3
       → PARTIALLY FILLED: 7 → FILLED: 10

订单有生命周期,状态随时间变化。一个简化的状态模型:

public enum OrderStatus {
    CREATED,
    SUBMITTED,
    ACKNOWLEDGED,
    PARTIALLY_FILLED,
    FILLED,
    CANCELLED,
    REJECTED
}

关键区别:订单状态不等于它的经济效果。

  • 可能被确认但没有任何成交;
  • 可能部分成交后被取消;
  • 可能通过多笔独立成交完全成交。

引擎必须同时跟踪两件事:券商对订单说了什么,以及实际发生了哪些成交。两者相关,但责任不同。

四、部分成交带来会计问题

Order: BUY 10
Execution 1: BUY 3 @ 100
Execution 2: BUY 4 @ 101
Execution 3: BUY 3 @ 102

引擎不能简单记成 BUY 10 @ 100——那会歪曲真实执行。它必须逐笔核算:

Total quantity = 10
Total cost = (3 × 100) + (4 × 101) + (3 × 102) = 1,010
Average price = 101

这还没算手续费。Java 里算术不难:

BigDecimal totalCost = executions.stream()
        .map(e -> e.price().multiply(e.quantity()))
        .reduce(BigDecimal.ZERO, BigDecimal::add);

难的是算术之外的生命周期:

  • 一笔成交被投递两次怎么办?
  • 部分成交后订单被取消怎么办?
  • 应用在会计提交之前崩溃怎么办?
  • 对账发现了一笔实时流从未收到的成交怎么办?

结论:执行会计必须在重复投递、失败与恢复之下仍然正确。

五、同一笔成交可能到达多次

交易系统在这里开始像分布式系统。假设券商报告:

Execution ID: 12345
BUY 0.001 BTC

应用处理了这笔成交,敞口增加 0.001 BTC。然后连接断了。重连后,应用再次收到同一笔成交;或者,它通过一次对账请求发现了这笔成交。

如果引擎再处理一遍:

Actual purchased: 0.001 BTC
Recorded exposure: 0.002 BTC

内部状态就错了。所以"成交身份"很重要。

一个简化的处理契约:

public interface ExecutionProcessor {
    ExecutionResult accept(ExecutionReport report);
}

实现必须先判断这笔成交是否已经被接受过:

Receive Execution
       |
       v
Check Durable Identity
   +---+---+
   |       |
 Already   New
 Accepted  Execution
   |       |
   v       v
No New    Apply Accounting
Effect    Atomically

"durable(持久)"这个词很重要。 内存里的 Set 可以在运行期间去重,但它活不过重启;而重启恰恰是你会再次遇到旧成交的时候。在 NERV Trade 里,已接受的成交身份被持久化,所以重复投递不会产生重复的经济效果。

原则一句话:同一笔成交可以被观察到多次,但它的会计效果只能应用一次。

六、最危险的失败是"结果未知"

一个常见的后端写法:

try {
    broker.submit(order);
} catch (Exception e) {
    retry(order);
}

对普通读操作,瞬时失败后重试是合理的;对下单提交,这个模式很危险:

Trading Engine                 Broker
      |------ Submit BUY -------->|
      |                     Order Accepted
      X                           |
 Connection Lost                 |

券商接受了订单,但引擎从未收到响应。从应用的角度看请求失败了;从券商的角度看订单已经存在。如果引擎用一个新身份盲目重试,就可能创建第二个订单——账户敞口超过预期。

这是一个不确定的外部副作用(uncertain external side effect):系统知道请求发出过,但不知道外部操作是否成功。更安全的流程是:

Submit Order
     |
     v
Response Received?
  +--+--+
 YES   NO
  |     |
  v     v
Record  Preserve Uncertain
Result  Submission State
            |
            v
     Reconcile With Broker
            |
            v
     Establish Actual Outcome

引擎需要稳定的订单关联标识与恢复机制,并且不能假设"没有响应"等于"券商拒绝"。

作者特别指出,这很像支付网关的问题:支付请求可能在客户端超时后成功;交易订单可能在应用断连后被接受。 两种情况下,盲目重复操作都可能比暂时保留不确定性更糟。

七、数据库成为执行引擎的一部分

应用最终要活过重启,所以必须持久化。但只保存订单不够。当一笔成交被接受时,可能需要一起改变:

Execution Identity + Execution Details + Order Accounting
+ Trade Exposure + Cost Basis + Realized P&L
+ Lifecycle State + Journal Outcome

这些变化代表一个逻辑上的经济操作。如果应用处理到一半崩溃:

Execution recorded: YES
Exposure updated:  NO

数据库里就出现了一笔没有反映到持仓的成交。反过来同样糟:

Execution recorded: NO
Exposure updated:  YES

重启后,引擎可能再次接受同一笔成交。NERV Trade 最终转向用 PostgreSQL 事务做原子化执行处理,要求是:

一笔已接受成交的所有会计效果,要么全部提交,要么全部不提交。

这不只是数据库实现细节,它影响领域模型与执行处理边界的划分:事务必须覆盖完整的状态转换。否则,持久化可以"完美地"保存一个不一致的结果。

八、重启恢复改变了"状态"的定义

假设应用已经跑了几小时,它有:

  • 活跃的交易意图;
  • 已提交订单;
  • 部分成交订单;
  • 已接受成交;
  • 未平敞口;
  • 已完成的生命周期。

然后进程停止。重启时,它不能凭空创建一个空状态:券商还记得它的订单,账户还有资产,市场在应用离线期间可能继续成交。

引擎需要重建持久状态,并与券商对账未解决的活动。这意味着要恢复的不只是"最新持仓汇总",还要知道:

  • 哪些交易意图拥有这些敞口;
  • 哪些成交已经被接受;
  • 哪些订单仍未解决。

重启不能把一笔已有交易变成一笔新交易;恢复也不能悄悄发明一个尚未确认的结果。 这就是为什么在 NERV Trade 里,恢复是架构职责,而不只是一个启动例程。

九、行情数据有同样的问题

执行不是唯一会把简单设计变复杂的地方。行情数据有自己的一致性要求。

假设策略分析 5 分钟 K 线,然后引入更高周期上下文:M5、M15、H1、H4。现在引擎必须回答:

  • 在某个决策时刻,哪些高周期 K 线是可用的?
  • 当前 K 线收盘了吗?
  • 可以用未完成的 K 线吗?
  • 行情事件迟到怎么办?
  • 如何检测缺失区间?
  • 同一段历史输入能否复现同一份分析?

最危险的错误,是意外使用了历史决策时还不存在的信息——这会产生前视偏差(look-ahead bias):回测看起来很漂亮,因为它依赖未来信息。

对 NERV Trade 来说,确定性分析与多周期一致性成了重要基础。

十、架构最终变成了什么样

原点是"行情 → 分析 → BUY/SELL",但每发现一个新问题,就多一份职责:

Market Data → Multi-Timeframe State → Analysis → Strategy
   → Risk → Trade Intent → Order Lifecycle → BrokerPort
        ├── Simulator
        └── Binance Spot
   → Execution Reports → Execution Accounting
   → PostgreSQL → Recovery / Reconciliation

作者强调:这些组件不是为了让系统显得更高级,而是因为早先的设计无法可靠回答越来越难的问题。复杂度本身不是成就;每个架构组件都应该因为它保护了某个重要职责或不变量而存在。

十一、机器人做决策,引擎承担后果

作者不认为存在一个放之四海皆准的定义,但对 NERV Trade 来说,分界线很清楚:

交易机器人主要回答 交易引擎还必须回答
我该交易什么? 实际发生了什么?
我现在持有什么?
哪个策略拥有这份敞口?
哪些已经处理过?
还有哪些不确定?
失败后我能恢复吗?
我的内部状态还与券商一致吗?

这也改变了"进度"的衡量方式。早期进度是"产生信号、改进分析";后期进度是证明:

  • 重复成交不会带来重复敞口;
  • 部分成交产生正确的会计;
  • 失败的事务不会留下半提交状态;
  • 不确定的提交不会被盲目重复;
  • 重启恢复保留生命周期身份;
  • 券商对账不会发明新交易。

这些改进通常不会出现在交易曲线上,但它们是系统可靠运行的基础。

十二、在一切之前:确定性

还有一个更根本的问题:同一段行情给两次,引擎应该产生同样的分析与交易决策吗?

听起来理所当然,但时间戳、可变状态、事件顺序、K 线边界、未完成行情,都可能引入细微差异。如果分析不可复现:

  • 策略在回放时可能给出不同决策;
  • 回测可能意外使用未来信息;
  • 一次失败可能无法复现。

所以 NERV Trade 最早的设计原则之一是 确定性:在担心"能否可靠执行一个决策"之前,先确认"能否可靠复现一个决策"。

十三、映射到 AI 交易 Agent:哪些层可以交给模型

这篇文章虽然是 Java 工程实践,但对"LLM/Agent 做交易"同样适用。一个合理的分工:

层 谁来做 原因
市场解读、假设生成 LLM/Agent 擅长非结构化信息与解释
策略提议 LLM/Agent + 规则约束 必须带理由、可回放
风控/额度/白名单 确定性代码 不能依赖模型发挥
订单生命周期 确定性代码 状态机与幂等
成交会计/对账 确定性代码 + 数据库事务 重复投递与崩溃恢复
未知结果处理 挂起 + 对账,不自动重试 避免重复下单
审计与凭证 不可变日志 事后可交代

"Agent 说它买了"不等于成交。 模型可以决定"想买",但"买没买到、买了多少、成本多少、有没有重复"必须由确定性执行层回答。

十四、上线前检查清单

# 检查 通过标准
1 信号与订单分离 策略不直接调券商接口
2 订单状态机完整 覆盖确认、部分成交、取消、拒绝
3 成交身份持久化 重复投递不重复计敞口
4 会计原子性 成交/敞口/成本/PnL 同事务
5 不确定提交不重试 挂起并对账,用稳定关联标识
6 重启恢复 不把旧交易变成新交易
7 行情多周期一致性 决策时刻只使用已收盘数据
8 确定性回放 同一输入产生同一决策
9 对账机制 能发现超时/断连期间的成交
10 成本模型 手续费、资金费、滑点进入损益

Sources

同主题阅读

本文属于「AI × 加密交易」专题。DefiPlot 翻译整理,转载请标明出处。

免责声明:本文仅讨论交易系统工程与架构,用于学习目的,不构成投资建议,不推荐任何交易所、标的或策略。实盘前请自行完成测试、风控与合规评估。

免责声明:本站资源仅用于学习目的,也不应被视为投资建议,读者在采取任何行动之前应自行研究并对自己的决定承担全部责任。