交易机器人容易,交易引擎很难:订单生命周期与重启恢复
信号不是订单,接受不是成交,重连会重复推送成交,而最危险的失败是"不知道成没成"。一份 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
- czetsuya, A Trading Bot Is Easy. A Trading Engine Is Not, dev.to, 2026-10-17:https://dev.to/czetsuya/a-trading-bot-is-easy-a-trading-engine-is-not-538a
- 系列第 1 篇:I Built a Trading Engine in Java — Here's What It Took:https://www.czetsuyatech.com/2026/09/i-built-a-trading-engine-in-java.html
同主题阅读
本文属于「AI × 加密交易」专题。DefiPlot 翻译整理,转载请标明出处。
免责声明:本文仅讨论交易系统工程与架构,用于学习目的,不构成投资建议,不推荐任何交易所、标的或策略。实盘前请自行完成测试、风控与合规评估。
免责声明:本站资源仅用于学习目的,也不应被视为投资建议,读者在采取任何行动之前应自行研究并对自己的决定承担全部责任。