永续合约撮合与仓位引擎架构¶
30 秒版(开场)¶
永续订单簿可以沿用价格/时间优先,但 fill 还要驱动仓位、保证金、已实现盈亏和 清算。关键不是强行“撮合 + 仓位同线程”,而是定义两个权威顺序域: order book 按 market 全序,账户/风险按 account 或 margin unit 串行并可恢复。 隔离保证金、单市场系统可共置状态机;跨多个 symbol 的全仓账户不能由任一 symbol 线程单独掌握完整风险。标记价通常不决定普通订单的成交价,但用于风险、 未实现盈亏和强平触发(S-EXCH-04)。
3 分钟版(精讲深度)¶
- 是什么:永续合约的订单执行、账户 clearing/仓位更新,以及保证金和清算规则。
- 为什么:只讲现货撮合(S-EXCH-01)不够;合约常见深挖 开平仓、只减仓、双向持仓、撮合与仓位一致性。
- 怎么做:下单前在账户风险单元中原子 reserve;撮合引擎只按订单规则产生
带
matchSeq的 fills。Clearing/position 状态机按可恢复顺序应用 maker/taker 两侧、费用与 reservation 变化,并发布 PositionUpdate/Journal (S-EXCH-03)。是否共进程取决于保证金模型。
10 分钟版(原理 + 图示)¶
与现货撮合的差异¶
| 维度 | 现货撮合 | 永续合约撮合 |
|---|---|---|
| 成交结果 | 资产交割 posting | fill 驱动 仓位增减、PnL、费用和保证金 reservation |
| 账户模型 | 可用余额 | 钱包余额 + 仓位 + 订单冻结保证金 |
| 订单语义 | Buy/Sell | Open/Close(双向模式)或 单向自动判断 |
| 定价来源 | 订单簿成交价 | 订单簿成交价;标记价仅用于 PnL/强平 |
| 实例隔离 | 可独立部署 | 通常独立风险/产品边界;同一本 order book 仍保持单写者 |
| 特殊单 | Post-only 等 | Reduce-Only、强平单、ADL 减仓单 |
端到端架构¶
下单、撮合与清算事实链¶
flowchart LR
subgraph Ingress[接入 Go]
API[合约交易 API]
Pre[下单预检]
end
subgraph PerpME[永续撮合域 每本订单簿单写者]
OB[订单簿 OrderBook]
Match[撮合循环]
WAL[撮合命令/结果日志]
OB <--> Match
Match --> WAL
end
subgraph Account[账户与风险域]
Reserve[保证金 Reservation]
Clear[Clearing / Position Engine]
Ledger[复式账务]
Reserve -.->|冻结/保证金约束| Clear
Clear --> Ledger
end
subgraph Event[可重放事实流]
MQ[可重放 Fill 流]
end
API --> Pre
Pre --> Reserve
Pre -->|新单| Match
Match -->|FillEvent matchSeq| MQ
MQ --> Clear
标记价、风险扫描与强平回路¶
flowchart LR
Index[现货指数/标记价服务]
MQ[可重放 Fill 流]
Clear[Clearing / Position Engine]
Risk[维持保证金扫描]
Liq[强平引擎]
Pre[下单预检]
MD[行情/标记价广播]
Index --> Risk
Index --> MD
MQ --> Clear
MQ --> MD
Clear -->|仓位/权益投影| Risk
Risk -->|账户触发强平| Liq
Liq -->|市价平仓/撤单指令| Pre
设计要点:
- order book 的 fill 一旦对外确认,必须已进入 durable log
- clearing 可以与撮合共置,也可以独立,但必须以 fill id/sequence 幂等、可重放, 并对外暴露“execution 已发生、account posting 尚在追赶”的状态
- 全仓账户跨多个 market 时,以账户/风险单元为权威边界;每个 symbol 私有
PositionMap会产生跨市场超额开仓竞态
持仓模式(必考)¶
单向持仓(One-way / 买卖模式)¶
| 用户意图 | 方向 | 有反向仓位时 | 无反向仓位时 |
|---|---|---|---|
| 开多 | BUY | 先平空再开多(净仓) | 开多 |
| 开空 | SELL | 先平多再开空 | 开空 |
- 同一 symbol 只有一个净仓位(正=多,负=空)
- 订单 不需
positionSide;交易所根据净仓自动判断开/平
双向持仓(Hedge / 对冲模式)¶
| positionSide | side | 效果 |
|---|---|---|
| LONG | BUY | 开多 / 加多 |
| LONG | SELL | 平多 / 减多 |
| SHORT | BUY | 平空 / 减空 |
| SHORT | SELL | 开空 / 加空 |
- 同一 symbol 可 同时 持有多仓与空仓(两条独立仓位记录)
- 订单必须带
positionSide;撮合/仓位引擎按 side 分桶
Reduce-Only(只减仓)¶
- 语义:订单 只能减少 指定
positionSide的仓位绝对值,不得反向开仓或加仓 - 接单预检:
- 单向模式:SELL 单不能使净多仓增加;BUY 单不能使净空仓增加
- 双向模式:LONG+SELL、SHORT+BUY 才可能通过;否则拒单
- 若当前可减仓位为 0,通常拒绝或立即取消;具体 error/cancel 语义按 venue API 定义
- 与 Close Position 区别:Close 是 UI/语义;Reduce-Only 是 硬约束标志位
只在接单时检查不够:多个 Reduce-Only 挂单可能合计超过当前仓位,仓位也会被其他 订单/强平改变。venue 必须定义 reservation、优先级和动态处理规则,例如按接受顺序 保留可减数量,或在 fill 前缩量/取消超额订单,确保任何成交都不会反向开仓。
撮合主循环(单 symbol 伪代码逻辑)¶
loop:
1. 处理已完成账户 reservation 的新单
2. 与订单簿对手盘匹配,生成 fills
3. 在执行边界再次校验 Reduce-Only/自成交保护等会随状态变化的约束
4. 剩余挂单写入 OrderBook(限价)
5. durable log: [command/order_delta/fill],分配 matchSeq
6. 发布 FillEvent;Clearing 幂等更新 maker/taker 仓位、PnL、fee 与 reservation
若产品采用单 market 隔离保证金,可把第 6 步的 position 状态与撮合共置并在同一 复制状态机提交;但这是一种部署选择,不应推广到跨市场全仓账户。
仓位更新与均价(准确性)¶
线性、以报价资产结算的合约:加仓(同向)加权均价示意
[ \text{newEntry} = \frac{\text{oldEntry} \times |\text{oldQty}| + \text{fillPrice} \times |\text{fillQty}|}{|\text{oldQty}| + |\text{fillQty}|} ]
同一线性合约:减仓已实现盈亏(多仓示意)
[ \text{realizedPnL} = (\text{fillPrice} - \text{entryPrice}) \times \text{closedQty} ]
closedQty = min(|position|, |fillQty|)在减仓方向上的分量- 仅部分减仓时,剩余同向仓位通常保留 entry price;若一次 fill 穿过 0 并形成反向 仓位,超出 closedQty 的剩余部分应以本次成交价建立新的 entry price
- 反向/币本位合约的 PnL 公式不同;费用、资金费、quanto/portfolio margin 也不能 套上面的线性公式
- 热路径优先使用带 scale 的定点整数并定义舍入;禁止
float64
合约面值:必须先定义 linear/inverse/quanto、contract multiplier、结算资产和
数量单位。baseQty = contracts × contractSize 只适用于相应产品定义,不能作为
所有合约的统一换算公式。
保证金与撮合的边界¶
| 阶段 | 检查内容 | 失败处理 |
|---|---|---|
| 下单预检 | 在 account/margin unit 内计算最坏执行后的增量风险、费用与现有 reservations | 原子 reserve 或拒单 |
| 撮合中 | 价格/数量规则、STP、Reduce-Only 动态约束 | 按公开规则拒绝、缩量或取消;不能产生后再撤销外部可见 fill |
| 成交后 | Clearing 立即应用 fill;Risk 随 mark/account 变化持续重算维持保证金 | 取消可释放订单、限制开仓、进入清算 |
| 资金费率 | 定时账务结算 | 不经撮合 |
notional / leverage只适合解释简单线性、隔离保证金示例。生产还要考虑风险档位、 全仓抵扣、现有仓位、未成交单、费用、价格带、组合风险与资产折扣- 市价单 reservation 应基于可执行深度/保护价或风险定义的 worst-case price, 不能只用当前 mark 价
- 风险检测可由 mark price 事件、账户变更队列和优先级结构驱动,周期扫描作兜底; 清算订单进入该 market 的统一命令序列,但触发与账户冻结属于风险域
强平单、ADL 与撮合优先级¶
sequenceDiagram
participant R as 风险引擎
participant Q as Symbol 撮合队列
participant ME as 撮合+仓位
R->>Q: 撤用户开仓挂单(释放保证金)
R->>Q: 按 venue 规则提交强平/接管指令
ME->>ME: 与公开订单簿成交或执行专用清算机制
alt 仍低于维持保证金
R->>Q: 保险基金接管 / ADL 对手方减仓
end
- 强平实现可能使用受保护市价/IOC、清算引擎接管、拍卖或 backstop liquidity。 是否拥有命令优先级必须是公开、可审计的 venue 规则;不能随意“插队”
- ADL 是部分平台在保险基金/清算机制不足时按规则减少对手方仓位的最后手段, 排序指标和执行价格均依产品规则;不能描述为所有永续平台的统一流程
订单类型(永续特有)¶
| 类型 | 说明 |
|---|---|
| Limit / Market | 同现货;撮合规则一致 |
| IOC / FOK / Post-only | 同现货语义 |
| Stop / Take Profit | 条件单:触发后生成 普通限价/市价单 进撮合队列 |
| Trailing Stop | 触发价随行情移动;触发后同样 转普通单 |
| Reduce-Only | 标志位,非独立订单簿类型 |
条件单引擎 独立服务 订阅标记价/最新价,触发后 写入同一 symbol 撮合队列,保证单写者。
成交事件(下游账务)¶
type PerpFillEvent struct {
TradeID string
MatchSeq uint64
ContractSpecVersion uint32
Symbol string
Price decimal.Decimal
Quantity decimal.Decimal // base 数量
TakerSide string // BUY / SELL
MakerOrderID string
TakerOrderID string
MakerPositionSide string
TakerPositionSide string
MakerReduceOnly bool
TakerReduceOnly bool
MakerUserID int64
TakerUserID int64
IsLiquidation bool
Ts int64
}
FillEvent 只陈述撮合事实,不应让撮合引擎替 maker/taker 两个账户猜测 realized PnL、
margin delta 或最终 position snapshot。Clearing 应按账户模式、合约规格和费用规则
生成独立的 PositionUpdated 与 journal,并以 tradeId + side/account 幂等
(S-ARCH-04)。
持久化与恢复¶
| 数据 | 恢复策略 |
|---|---|
| 订单簿 | WAL 重放 或 快照 + 增量 |
| 仓位/账户 | 从 clearing journal/快照 + fill offset 重放;记录已应用的 match epoch/sequence |
| 用户挂单保证金 | 以权威 reservation ledger 为准,并与 open orders 对账;不能只靠订单簿“猜回” |
恢复时分别恢复 order-book owner 与 account/clearing owner,核对:
last durable matchSeq、last published fillSeq、last applied clearingSeq、
open orders 与 reservations。确认旧主已被 fencing 后再开放接单。
与现货撮合部署关系¶
flowchart LR
subgraph Spot[现货撮合集群]
S1[BTCUSDT Spot]
end
subgraph Perp[永续撮合集群]
P1[BTCUSDT Perp]
end
API[统一交易 API] --> Spot
API --> Perp
- 不要 现货与永续共用一个 OrderBook(资产模型不同)
- 可共用 行情、用户、账务 服务;撮合 物理隔离 便于独立扩缩容与故障隔离
生产场景¶
- 极端波动:按预先公布规则进入 reduce-only/cancel-only、收紧价格带或暂停新开仓; 临时上调维持保证金会影响既有仓位,应有通知、生效时间和治理/风控流程
- 插针:强平触发与未实现 PnL 通常使用标记价,实际平仓仍受订单簿/清算机制价格 影响;条件单触发价源需明确为标记价、指数价或最新价
- 自成交:STP 模式(取消 taker/maker/两者)— 合约与现货共用规则
- 上市新合约:空订单簿启动;首批做市商 Post-only 挂单
排查与工具¶
| 现象 | 排查 |
|---|---|
| 用户称平仓仍加仓 | 查 reduceOnly 标志与 positionSide;单向模式净仓方向 |
| 仓位与 UI 不一致 | 对比 fill sequence、clearing journal、position projection 与 WS/API 版本;撮合 WAL 只权威记录执行,不单独决定跨市场账户状态 |
| 强平后仍穿仓 | 标记价延迟;保险基金/ADL 是否触发 |
| 市价单滑点过大 | 深度、保护价/价格带、错误数量单位和流动性分层;拆单也不能替代最大可接受价格 |
Metrics:perp_match_latency、position_apply_errors、liquidation_queue_depth、mark_price_staleness
架构取舍¶
| 方案 | 优点 | 代价 |
|---|---|---|
| 单 market 撮合 + 仓位共置状态机 | 隔离保证金/单市场模型可一次提交执行与仓位 | 难处理跨 symbol 全仓风险,状态量和故障域扩大 |
| 撮合与 account clearing 解耦,以 durable fill 流连接 | 适合跨市场账户、独立扩展和可重放 | 必须设计 reservation、lag 阈值、账户序号、幂等与故障可见性;不能用无状态同步 RPC 拼接 |
| 全仓保证金 | 资金利用率高 | 风险传染;预检需读全账户 |
| 逐仓保证金 | 风险隔离 | 预检仅看单仓位;实现略简单 |
深挖问答¶
- 永续和交割合约撮合有区别吗? → 撮合环相同;交割有 结算/交割日 仓位下线,永续无到期 + 有 资金费率。
- 市价单按什么价格成交? → 对手盘订单簿;保护价(市价单 max/min price)防极端滑点。
- 标记价更新频率? → 由产品和数据源 SLO 决定,携带 event time、version 与 stale 状态;通常不决定普通订单的限价匹配,但会驱动 risk/unrealized PnL, 某些条件单触发源也可配置为 mark。
- 双向模式下能否同时开多开空? → 可以;两条 position 记录独立保证金(逐仓)或共享钱包(全仓)。
- Go 能实现永续撮合吗? → 可以,但要用目标订单类型、持久化复制、GC 和 极端行情回放证明 P99/P999;不能用“中高频足够”替代容量证据。
- 与 S-EXCH-01 关系? → 01 讲通用订单簿;本题讲 仓位语义与永续专属预检/部署。
反模式与事故¶
- 成交后用无序、无持久化、无幂等的 goroutine 更新仓位 → race、丢 fill、 重复 PnL;独立异步 clearing 本身不是错误,前提是有 durable ordering 和安全 lag 策略
- 现货与永续共订单簿 → 资产结算模型混乱
- Reduce-Only 仅在 UI 限制 → API 绕过加仓
- 恢复只加载订单簿不加载仓位 → 重启后用户仓位归零或翻倍
- 用最新成交价算强平且用于撮合 → 与标记价职责混淆(见 S-EXCH-04 反模式)
代码示例¶
// 单向、线性合约示意:真实实现还需合约 multiplier、scale/rounding、fee/funding。
func (pe *PositionEngine) ApplyFillOneWay(
pos *Position, side Side, qty, price decimal.Decimal,
) {
signed := qty
if side == Sell {
signed = qty.Neg()
}
if pos.Qty.IsZero() || pos.Qty.Sign() == signed.Sign() {
// 加仓:加权均价
pos.EntryPrice = weightedEntry(pos.Qty, pos.EntryPrice, signed, price)
pos.Qty = pos.Qty.Add(signed)
return
}
// 反向成交:先平掉 existing position。
closed := minAbs(pos.Qty, signed)
pe.RealizedPnL = pe.RealizedPnL.Add(
calcLinearPnL(pos.Qty.Sign(), pos.EntryPrice, price, closed),
)
newQty := pos.Qty.Add(signed)
switch {
case newQty.IsZero():
pos.EntryPrice = decimal.Zero
case newQty.Sign() == pos.Qty.Sign():
// 仅部分减仓,剩余仓位保留原 entry price。
default:
// fill 穿过 0,超出 closed 的部分建立反向新仓。
pos.EntryPrice = price
}
pos.Qty = newQty
}