04 · 交易接口与订单生命周期:系统的心脏
行情模块做错了,损失的是数据质量;交易模块做错了,损失的是真金白银。交易接口是整个对接系统里**最不容许「试错」**的部分:重复下单、撤不掉单、状态错乱、回报乱序,任何一个都在直接烧钱。
本篇从接口形态讲起,贯穿订单类型、状态机、撮合原理、生命周期管理、成交回报、对账、限频,最后给出多年对接血泪总结的 bug 清单。
1. 接口形态
| 接口形态 | 特点 | 适用场景 | 代表 |
|---|---|---|---|
| REST | 请求-应答,天然幂等(查询类);下单需靠「客户端订单号」幂等 | 低频下单、查询、对账、管理类操作 | 币安 REST、OKX REST、券商普通接口 |
| WebSocket | 长连接、全双工、回调式推送;适合订单回报与账户推送 | 加密交易所回报订阅、前端实时状态 | 币安 WS 用户数据流、OKX WS |
| FIX | 金融行业标准文本协议,字段字典固定,广泛用于机构间 | 海外市场(CME 等)、机构对接 | CME FIX/FAST |
| CTP 专有二进制 | C++ DLL、结构体消息、回调模型,国内柜台事实标准 | 国内期货主流路径 | CTP(详见 02-交易所与柜台.md) |
选择建议:
- 下单走 REST(加密)/ 柜台 API(国内)——查询、改单、撤单都有明确的请求-应答语义,便于超时重试与幂等。
- 回报必须走推送通道(WS/回调),绝不能用轮询拉回报——轮询的延迟和漏单风险在交易场景不可接受。
- FIX 只在海外机构场景出现,团队首次接触建议先做协议仿真再上。
2. 订单类型全览
| 订单类型 | 定义 | 适用场景 |
|---|---|---|
| 限价单(Limit) | 以指定价格(或更优价格)成交,不保证成交 | 绝大多数策略的默认选择;T+1/支持改价 |
| 市价单(Market) | 以当前市场最优价立即成交,不保证价格 | 快速离场、流动性好的品种;国内期货无标准市价单(部分柜台以「市价转限价」实现,以柜台规则为准) |
| **止损**单(Stop) | 价格触发后转为市价/限价单 | 突破入场、风险离场 |
| 止损限价单(Stop-Limit) | 触发后转限价单,价差有保护但可能不成交 | 既要止损又怕**滑点** |
| IOC(立即成交否则取消) | 可部分成交,未成交部分立即撤销 | 抢入场、套利腿 |
| FOK(全成否则全撤) | 要么全部成交,要么全部撤销 | 一揽子/组合单、套利腿的强约束 |
| 冰山单(Iceberg) | 只暴露部分数量,隐藏剩余部分 | 大单拆小、避免冲击 |
| 条件单(Conditional / OCO) | 一组条件单:一个触发则其余自动撤销 | 止盈+止损同时挂,离场自动化 |
| 盘口一档委托(对手方最优档) | 以对手方最优一档价格委托,未成交部分撤销/保留 | A 股常见的市价委托类型(如「对手方最优五档即时成交剩余撤销」等,以交易所规则为准) |
两点提示:① 加密交易所的订单类型名称与行为(如币安
STOP_LOSS_LIMIT、TAKE_PROFIT_MARKET等)与国内期货差别很大,按交易所文档逐一定义映射;② 「止损单」在柜台侧是否支持、触发后转什么单,以柜台实现为准——很多国内柜台不支持交易所级止损单,需要客户端自己盯价格触发(这也是风控模块的职责之一)。
💀 国内柜台不支持交易所级止损单客户端必须自己盯
很多国内柜台不支持交易所级止损单,需要客户端自己盯价格触发。 依赖柜台提供止损单等于把命交给对方——柜台风控层一旦失效,你的止损就只是「一句代码」,必须把它实现进自己系统的风控闸门里。
3. 订单状态机
无论哪家交易所/柜台,订单的底层状态都可以归纳为下面这张图(状态命名参考交易所通用枚举,具体以各交易所文档为准):
关键状态与失败路径:
| 状态 | 含义 | 常见触发 |
|---|---|---|
| NEW | 订单已受理,等待撮合 | 正常申报 |
| PARTIALLY_FILLED | 部分成交,剩余部分继续挂 | 大单分笔成交 |
| FILLED | 全部成交 | 正常 |
| CANCELED | 被撤销 | 主动撤单、超时自动撤销、部分成交后剩余撤销 |
| REJECTED | 被拒绝,没有成为订单 | 资金不足、价格超限、品种停牌、参数非法、频率超限 |
| EXPIRED(加密常见) | 有效期到期未成交被撤 | 设置了 timeInForce |
工程铁律:
- 状态只能前进、不能回退:NEW → FILLED 是合法的;FILLED 之后永远不能变成其他状态。任何「状态回退」的回报都要按异常处理(报警 + 冻结该单)。
- REJECTED 不等于撤单:被拒的单不需要再撤,也不能再撤;很多 bug 就是「收到 REJECTED 后仍然执行了撤单流程」。
💀 重复下单是超时后直接重发造成的
超时重试策略:下单请求超时 → 先查询,再决定(query-first)。 禁止「超时直接重发」——这是重复下单的第一来源。曾有团队因重试逻辑没有幂等,一次网络抖动造成同一笔单重复成交了 30 次。
- 撤单也有状态:撤单请求同样可能失败(如订单刚成交)、可能被拒,撤单的回报(
OrderCancelRejected/ 撤单失败)必须同样处理。
4. 撮合原理
4.1 价格优先、时间优先
连续竞价的核心规则:价格优先(买价高/卖价低的先成交),价格相同时先到先得(时间优先)。这决定了:
- 限价单成交的确定性取决于你在队列中的位置——同一价格,别人先挂就比你先成交。
- 市价单/止损单的成交价 = 队列中对手方最优价,可能远差于你的心理价位。
4.2 集合竞价与连续竞价
- 集合竞价(国内开盘前):集中撮合出一个开盘价,成交价唯一,所有高于开盘价的买单、低于开盘价的卖单全部按开盘价成交。
- 连续竞价(交易时段):逐笔撮合,价格实时跳动。
- 对对接的意义:集合竞价期间部分接口行为不同(如国内期货集合竞价不接受市价类委托、加密没有集合竞价概念),代码要按交易所规则区分处理。
4.3 做市商与盘口撮合差异
- 期货/股票市场:订单簿撮合,有没有对手方取决于盘口深度。
- 加密交易所:同样是订单簿撮合,但部分平台对特定品种有做市商激励、以及「止盈止损」等条件触发在交易所侧实现——触发逻辑在交易所 vs 在客户端的差异,直接决定断网时你的单能不能动。
5. 订单生命周期管理
5.1 标准流程
策略/用户提交意图(目标品种、方向、数量、价格)
│
▼
风控前置校验(见 05 篇)——不通过则拦截,永远不进下单链路
│
▼
生成本地订单(分配本地订单号 clientOrderId)
│
▼
调用交易所接口下单(携带 clientOrderId)
│
▼ 请求超时?
├── 是 → 查询订单状态(幂等确认),根据结果决定「补发/撤回请求/标记未知」
└── 否 → 等待回报
▼
回报驱动状态机更新(NEW → PARTIALLY → FILLED / CANCELED / REJECTED)
│
▼
成交回报落库 + 通知策略 + 通知前端5.2 幂等设计(最重要)
- 客户端订单号(clientOrderId / ClientID):下单请求里必须携带全局唯一的本地订单号,交易所把它与订单绑定。重试同一笔下单时使用同一个 clientOrderId——交易所据此去重(支持与否以交易所文档为准),杜绝「网络超时重发,结果下了两单」。
- 撤单幂等:对同一订单的撤单请求同样要用「撤单请求号」去重;重复撤单在部分交易所会返回错误(说明已撤/不存在),要按「幂等成功」处理而不是按异常报错。
- 超时重试策略:下单请求超时 → 先查询,再决定(query-first)。禁止「超时直接重发」——这是重复下单的第一来源。
5.3 本地订单号与交易所订单号映射
- 下单前:本地订单号(clientOrderId)本地唯一即可。
- 下单后:交易所返回交易所订单号(orderId),必须与本地订单号双向映射并持久化:以后所有查询、撤单、对账都用交易所订单号。
- 映射丢失的后果:撤不了单、对不了账——所以映射要在下单返回后第一时间落库,宁可先写库再响应用户。
6. 成交回报处理
6.1 逐笔回报与批量回报
- 逐笔回报(order + trade 分开推):先推订单状态变化,再推成交明细(可能一笔订单多次部分成交,推多条 trade)。订单回报与成交回报是两回事:
OnRtnOrder说「单子的状态」,OnRtnTrade说「真的成交了多少钱量」。 - 批量回报(加密常见):一个事件里携带多条成交/状态变更,处理时注意幂等(同一条回报可能因重连被重推)。
- 处理原则:以 trade 为准记持仓与资金,以 order 为准记状态;两者按时间戳/序号对齐,顺序错乱时以「状态只能前进」为约束自愈。
6.2 tick 级成交与「最后成交价」的区分
- tick 级成交(逐笔成交回报):每一笔成交的精确价格、数量、时间——记持仓、记成本、做滑点分析都用它。
- 「最后成交价」(行情快照里的最新价):是行情层面的聚合结果,不能当成本价,也不代表你这一笔的成交价。
- 常见错误:用行情最新价给成交单记账 → 对账永远对不上。成本只认回报,不认行情。
⚠️ 成本只认回报不认行情
用行情最新价给成交单记账 → 对账永远对不上。成本只认回报,不认行情。 tick 级成交回报才是记持仓、记成本、做滑点分析的依据,行情快照里的「最后成交价」是行情层面的聚合结果,不能当成本价。
7. 对账机制
客户端本地状态(持仓/资金/订单)
│
▼ 周期性(每日收盘 + 盘中抽样)
交易所/柜台状态(查询接口)
│
▼
逐项比对:持仓数量/均价、资金余额、当日委托数、成交数
│
▼
差异 → 分级处理:可自愈(漏回报→补拉)自动修复;
不可自愈(金额差)→ 冻结相关交易 + 人工介入| 对账场景 | 时机 | 处理 |
|---|---|---|
| 当日委托/成交对账 | 每个交易日收盘后 | 拉全部当日委托与成交,与本地逐条核对;差异按漏回报补数据 |
| 持仓对账 | 每日 + 盘中抽查 | 本地持仓 vs 柜台持仓;不一致先暂停该合约交易 |
| 资金对账 | 每日 | 余额、冻结、手续费差异逐笔排查 |
| 重启恢复 | 进程重启后 | 拉取当日全部委托/成交/持仓重建本地状态,再恢复自动交易(恢复期间禁止下单) |
对账不是「上线时做一次」的仪式,而是每天自动运行、差异自动报警的常态化机制。对账对不上时,唯一正确动作是先停相关交易,再排查。
8. 限频与并发
8.1 rate limit 规则
| 交易所 | 典型限频(以官方文档为准) |
|---|---|
| 币安 | 权重制(weight-based):每请求消耗权重,每分钟额度(如 6000 权重/分钟),查询与下单权重不同 |
| OKX | 每秒/每分钟请求数限制,按接口分档 |
| CTP 等柜台 | 通常无公开固定值,但高频报单会触发柜台/交易所「异常交易行为」监控(如撤单率、报撤比超限) |
8.2 处理策略
- 排队:全局请求队列 + 令牌桶/滑动窗口限速器,把请求速率压在线内,宁可排队不可超限。
- 退避:收到 429/限频错误 → 指数退避重试,退避期间不叠加请求。
- 分级限速:下单类请求优先级高于查询类;查询失败不影响下单路径。
- 多账户并发控制:多个交易账号并发时,每个账号独立限额,同时全局队列兜底;防止「单账户限频 → 全系统超时 → 回报堆积」。
8.3 限频与行情的关系
- 行情订阅请求与交易请求共用限频池(部分交易所如此),行情订阅失败会连带影响交易——订阅管理要复用连接、减少订阅请求次数。
9. 常见对接 bug 清单
| # | Bug | 成因 | 对策 |
|---|---|---|---|
| 1 | 重复下单 | 超时后直接重发,无幂等 | clientOrderId 幂等 + query-first 重试 |
| 2 | 重复撤单 | 撤单请求无幂等号,或撤单回报延迟期间再次撤 | 撤单请求号去重 + 按幂等成功处理 |
| 3 | 状态丢失 | 漏处理某类回报(如取消失败、过期),本地状态停在旧值 | 状态机全路径覆盖 + 每日对账兜底 |
| 4 | 回报乱序 | 多线程处理回报、或重连后旧回报与新回报交错 | 单连接单消费者;按时间戳/序号排序;状态只前进 |
| 5 | 用行情价记账 | 成本用最新价而不是成交回报 | 成本只认 trade 回报 |
| 6 | 拒单当撤单 | REJECTED 后仍执行撤单流程 | 状态机区分 REJECTED / CANCELED |
| 7 | 重启后裸奔 | 进程重启后直接恢复自动交易,本地状态是旧的 | 先重建状态(拉当日委托/持仓)再恢复 |
| 8 | 限频炸锅 | 请求超限被拒后全部重试,雪崩 | 退避 + 排队 + 分级限速 |
| 9 | 集合竞价窗口错乱 | 未按交易所时段规则区分委托行为 | 交易日历 + 时段状态机(详见 02 篇 9.3) |
| 10 | 订单号格式非法 | clientOrderId 用了不允许的字符/超长 | 按交易所文档校验字段再发送 |
风险提示
⚠️ 风险提示
交易接口环节的事故往往是「高频 + 错误叠加」:曾有团队上线时把下单参数里的价格小数位配错,在毫秒级循环里连续打出数千笔离谱价格的有效订单,等发现时已无法全部撤掉;也有团队因为重试逻辑没有幂等,一次网络抖动造成同一笔单重复成交了 30 次。请务必记住:任何自动重试都必须先查询确认;任何参数变更都必须先仿真验证再小资金试运行;上线当天禁止改代码。真实事故往往不是「某个 bug 多严重」,而是「错的代码跑得有多快」——把速度留给正确的系统,把刹车装在任何出错之前(风控详见 05-风控与资金管理.md)。