交易所后台全景:你点"买入"以后
你在交易所页面点一下"买入",这张订单会依次经过七八个服务。每个服务只干一件事。今天后面每一节,都是在放大这张图里的某一个框。
先按顺序认识一遍,每个只记一句话:
- 网关:所有请求的大门。检查你是谁(签名、登录态),限制你每秒能发多少请求(D3 讲过的限频就在这里)。
- 下单服务:把请求整理成一张标准订单,检查价格和数量的格式(D2 讲的 tickSize、lotSize)。
- 下单前风控:钱够不够、价格离谱不离谱、有没有超过持仓上限。不过关就直接拒绝,订单不会进订单簿。钱够的话,这一步会把下单要用的钱先"冻结"起来(第 2 节)。
- 定序:同一个交易对(比如 BTCUSDT)的所有订单,在这里排成一队,每张编上一个连续的号。后面按号码顺序处理(第 4 节)。
- 撮合引擎:按顺序拿订单,和订单簿里已有的单子配对成交(第 3 节)。它只负责"谁和谁成交、成交多少、什么价格",不直接改任何人的余额。
- 清算:拿到撮合的成交结果,算清楚每个人该加多少、该减多少、手续费多少,写成一条条账。
- 账户服务:真正改余额的地方。你看到的"可用""冻结"两个数字都存在这里。
- 行情推送:把成交和订单簿的变化推给所有人。D3 里你订阅的成交流和盘口增量,就是从这里出来的。
user / bot
| REST / WebSocket
v
+---------+ +-----------+ +-----------+ +-----------+
| gateway |--->| order svc |--->| pre-trade |--->| sequencer |
+---------+ +-----------+ | risk | +-----+-----+
+-----------+ |
v
+-------------+ +----------+ +------------------------+
| account svc |<---| clearing |<---| matching engine |
| avail/frozen| | (ledger) | | (in-memory order book) |
+-------------+ +----------+ +-----------+------------+
| trades / book changes
v
+------------------------+
| market data push |---> D3 里你订阅的行情
+------------------------+
图里的英文:user / bot = 用户或机器人;gateway = 网关;order svc = 下单服务;pre-trade risk = 下单前风控;sequencer = 定序;matching engine = 撮合引擎;in-memory order book = 放在内存里的订单簿;clearing (ledger) = 清算(记账);account svc = 账户服务;avail / frozen = 可用 / 冻结;trades / book changes = 成交和订单簿变化;market data push = 行情推送。
这张图说明:撮合只管配对,改余额的是清算和账户服务,两件事分开做。
为什么要拆这么多服务 why split
资产账户:可用和冻结
你账户里的每一种币,都分成两个数:可用和冻结。下单时先把要花的钱冻结起来,成交时从冻结里扣,撤单时把没用上的解冻还给可用。
可用 和 冻结 available / frozen (locked)
算一遍:一张买单从下单到撤单
冻结 600 → 可用 400、冻结 600;BTC 0。
可用 400、冻结 300;BTC 0.005。可用没变,因为这笔钱本来就不在可用里。
可用 700、冻结 0;BTC 0.005。
成交那一刻,清算会同时记几条账:你 −300 U(从冻结扣)、+0.005 BTC;卖方 −0.005 BTC、+300 U;如果有手续费,再记一条"手续费账户 +多少"。同一种币,所有人加起来的变化一定是 0。这种"每笔钱都有来处和去处"的记法,会计上叫复式记账。对账时查的就是它:哪种币加起来不是 0,就说明哪里记错了。
怎么玩:先不看数字,自己按上面的算式算下一步,再点"下一步"对答案。看上面那排服务名里被点亮的那一个,它是这一步在干活的服务;再看三个数字里底色变了的那一格,它是这一步变化的数字。说明:成交只动冻结,撤单才把冻结还给可用。
import java.math.BigDecimal;
/** 一个用户的一种币。真实系统里这是数据库的一行,更新要加版本号或放进单线程处理 */
public class AssetBalance {
private BigDecimal available = new BigDecimal("0");
private BigDecimal frozen = new BigDecimal("0");
/** 下单:可用 → 冻结。钱不够就拒绝 */
public void freeze(BigDecimal amt) {
if (available.compareTo(amt) < 0) throw new IllegalStateException("insufficient");
available = available.subtract(amt);
frozen = frozen.add(amt);
}
/** 成交:从冻结里扣掉真正花掉的钱 */
public void deductFrozen(BigDecimal amt) { frozen = frozen.subtract(amt); }
/** 撤单,或实际成交比限价便宜时退差价:冻结 → 可用 */
public void unfreeze(BigDecimal amt) {
frozen = frozen.subtract(amt);
available = available.add(amt);
}
}
三个方法对应演示里的三种变化。金额一律用字符串构造 BigDecimal(D2 讲过为什么不能用 double)。英文:insufficient = 余额不足。
撮合引擎:价格优先、时间优先
一张买单进来,撮合引擎只按两条规则找对手:先找价格最好的卖单;价格一样,找最早挂上去的那张。成交价用挂在簿上的那张单的价格。
价格优先 price priority
时间优先 time priority / FIFO
TreeMap 存盘口,价格是 key。撮合引擎一样,只是 value 从"这一档一共多少"换成了"这一档的订单队列":TreeMap<BigDecimal, ArrayDeque<订单>>。TreeMap 负责价格优先,队列的先进先出负责时间优先。成交价 trade price
你进来一张"限价买 0.8 @ 60020":
① 最便宜的是 60010,这一档有 S1 和 S2,S1 先到 → 和 S1 成交 0.3 @ 60010
② 60010 还剩 S2 → 成交 0.2 @ 60010
③ 下一档 60020 ≤ 你的限价 60020 → 和 S3 成交 0.3 @ 60020,S3 还剩 0.2 挂着
一共花 18003 + 12002 + 18006 = 48011 U,成交均价 48011 ÷ 0.8 = 60013.75。你出价 60020,但大部分是按更便宜的 60010 成交的。
市价单:不看价格一路往上吃,簿上的卖单吃光了还没买够,常见做法是剩下的直接撤掉(第 5 节会讲交易所怎么防它吃到离谱的价格)。
怎么玩:先选"限价买 0.8 @ 60020"看完一遍,对照上面的算式。再换"限价买 1.2":吃完 60020 这一档后,60050 比限价贵,剩下的 0.2 变成一张新买单挂上簿(虚线框)。最后换"市价买 2.5":簿上一共只有 2.0,剩下 0.5 被丢掉。看的地方:底色亮起来的那一档是正在吃的价位,实心高亮的小框是正在成交的那张挂单。
总页 D8 的编程题 match 就是让你把这套规则写出来:输入订单簿和一张新订单,返回成交列表和"剩下挂上簿的那一截"。它返回的每一笔成交带三个字段:挂单编号、成交价、成交数量,和下面 Java 代码里的 MatchFill 一一对应。
import java.math.BigDecimal;
import java.util.*;
/** 挂在簿上的单。remaining 会随成交减少,所以用普通类,不用 record */
final class Resting {
final String id; final BigDecimal price; final long ts;
BigDecimal remaining;
Resting(String id, BigDecimal price, BigDecimal qty, long ts) {
this.id = id; this.price = price; this.remaining = qty; this.ts = ts;
}
}
/** 撮合引擎内部产生的一笔成交:和哪张挂单、什么价、多少 */
record MatchFill(String makerId, BigDecimal price, BigDecimal qty) {}
public class SimpleBook {
// 卖单:价格从低到高,每个价位一个先进先出队列
private final TreeMap<BigDecimal, ArrayDeque<Resting>> asks = new TreeMap<>();
// 买单:价格从高到低
private final TreeMap<BigDecimal, ArrayDeque<Resting>> bids = new TreeMap<>(Comparator.reverseOrder());
/** 限价买单进来(卖单方向对称,这里省略) */
public List<MatchFill> buyLimit(String id, BigDecimal limit, BigDecimal qty, long ts) {
List<MatchFill> fills = new ArrayList<>();
BigDecimal left = qty;
while (left.signum() > 0 && !asks.isEmpty()) {
Map.Entry<BigDecimal, ArrayDeque<Resting>> best = asks.firstEntry(); // 价格优先:最便宜的一档
if (best.getKey().compareTo(limit) > 0) break; // 比我的限价贵,不吃
ArrayDeque<Resting> queue = best.getValue();
Resting maker = queue.peekFirst(); // 时间优先:最早挂的
BigDecimal q = left.min(maker.remaining);
fills.add(new MatchFill(maker.id, maker.price, q)); // 成交价 = 挂单的价格
left = left.subtract(q);
maker.remaining = maker.remaining.subtract(q);
if (maker.remaining.signum() == 0) queue.pollFirst(); // 这张挂单吃完了
if (queue.isEmpty()) asks.pollFirstEntry(); // 这一档空了
}
if (left.signum() > 0) { // 没吃完:剩下的挂到买方
bids.computeIfAbsent(limit, k -> new ArrayDeque<>()).addLast(new Resting(id, limit, left, ts));
}
return fills;
}
}
Resting = 挂在簿上的单;remaining = 剩余数量;ts = 到达顺序(timestamp 的缩写,这里用序号代替时间);MatchFill 的 makerId = 挂单编号。注意它和 D4 的 Fill 不是一回事:D4 的 Fill 是机器人收到的成交回报,MatchFill 是撮合引擎自己内部产生的。Side 沿用 D4 的 enum Side { BUY, SELL }。每个类型放一个单独的文件,和 D4 一样。
撮合为什么是单线程、放在内存里
同一个交易对的撮合,常见做法是只用一个线程、订单簿整个放在内存里。这听起来很"不高并发",但正是它又快又不出错的原因。
定序 sequencing
BlockingQueue 加一个消费线程,就是 D4 的事件循环(BotLoop)。有的交易所用类似 LMAX Disruptor 的环形队列来做,道理一样。为什么不用数据库撮合
orders ---> [ sequencer ] ---> seq 1,2,3,4 ... ---> append to event log ← 先落日志
|
v
+-------------------------+
| matching engine BTCUSDT | one thread, in memory
+-------------------------+
| matching engine ETHUSDT | another thread
+-------------------------+
|
v trades (with seq)
clearing / market data ← 下游按序号消费
图里的英文:orders = 订单;sequencer = 定序;seq = 序号;append to event log = 追加写进事件日志;one thread, in memory = 一个线程、在内存里;another thread = 另一个线程;trades (with seq) = 带序号的成交结果;clearing / market data = 清算和行情。
这张图说明:一个交易对内部是单线程,不同交易对之间各跑各的,这样整体仍然能并行。
撮合前后的保护:别让一张单打穿市场
撮合引擎本身只会机械地配对。为了防止手滑、程序 bug 或者故意操纵,交易所会在撮合前后加几道闸。策略机器人最常被这几道闸拒单,所以你必须认识它们。
价格保护 price band / price protection
你的程序少写了一个 0,挂"卖 @ 6000":6000 < 57000,被拒绝。没有这道闸,这张单可能把买单簿从 60000 一路往下吃,最低吃到 6000。
市价单保护价 market order protection
自成交保护 STP, Self-Trade Prevention
怎么玩:默认是"卖 @ 56000,允许偏离 5%",下限是 57000,所以被拒。把价格改成 57500 看它通过;把偏离比例拖到 8% 再看 56000。然后看下半部分:市价买 3 个,保护价是 63000,63500 那一档不吃,1.2 个被撤掉。勾上"关闭保护",看均价和最贵一笔变成多少。说明:保护价宁可少成交,也不让你吃到离谱的价格。
现货和合约,在系统里差在哪
现货成交,是真的把币换到你账户里。合约成交,你账户里的币没变,多出来的是一条"仓位"记录和一笔被占住的保证金。后台因此多了一整套服务。
| 现货 | 合约(以 U 本位永续合约为例) | |
|---|---|---|
| 买入后账户里多了什么 | 真的 BTC | 一条仓位记录:方向、数量、开仓均价、保证金、杠杆 |
| 下单时冻结什么 | 买:要花的 U;卖:要卖的 BTC | 开仓要用的保证金(仓位价值 ÷ 杠杆)加上预估手续费 |
| 最多亏多少 | 买的币跌到 0 | 押进去的保证金(逐仓)或整个账户(全仓) |
| 会不会被强平 | 不会 | 会,由风险引擎和强平引擎执行 |
| 定期收付钱 | 没有 | 资金费,由定时任务结算 |
| 对手是谁 | 另一个用户 | 也是另一个用户,交易所只负责撮合,不跟你对赌 |
合约比现货多出来的几个服务,今天后面每节讲一个:
- 仓位服务:记每个人每个合约的仓位,成交后更新数量和开仓均价(第 8 节讲它有两种记法)。
- 资金费结算:每隔一个周期,按仓位给多空双方记一笔资金费(第 9 节)。
- 风险引擎:标记价格一变,就重算每个仓位还安不安全(第 10 节)。
- 强平引擎:风险引擎判定某个仓位不安全以后,由它接管这个仓位,去市场上平掉,亏穿了找保险基金,保险基金不够再触发自动减仓(ADL)。
open long --> freeze margin --> match --> position svc: qty +, entry price
|
every funding period (e.g. 8h) --------------+--> funding settle: margin +/-
|
mark price svc --> risk engine: still safe? -+
| no
v
liquidation engine takes over --> insurance fund --> ADL
图里的英文:open long = 开多;freeze margin = 冻结保证金;match = 撮合;position svc = 仓位服务;qty + = 数量增加;entry price = 开仓均价;every funding period (e.g. 8h) = 每个资金费周期(比如 8 小时);funding settle = 资金费结算;margin +/- = 保证金加或减;mark price svc = 标记价格服务;risk engine: still safe? = 风险引擎判断还安全吗;no = 不安全;liquidation engine takes over = 强平引擎接管;insurance fund = 保险基金;ADL = 自动减仓。
这就是"做完的标志"里要你画的第二条链路。
U 本位和币本位
合约按"押金和盈亏用什么币算"分两种。押 U、赚亏也是 U,叫 U 本位;押 BTC、赚亏也是 BTC,叫币本位。D1–D7 讲的都是 U 本位。
U 本位 USDT-margined / linear
币本位 coin-margined / inverse
涨到 66000:600 × 100 × (1/60000 − 1/66000) = 60000 × 0.00000151515… ≈ +0.0909 BTC,按 66000 折合 ≈ 6000 U。
跌到 54000:60000 × (1/60000 − 1/54000) ≈ −0.1111 BTC,按 54000 折合 ≈ −6000 U。
10 倍押金 = 60000 ÷ 60000 ÷ 10 = 0.1 BTC。
calcPnl() 接口的两种实现,放在策略模式里按合约类型选。清算系统里最怕把两种公式混用,所以合约配置里一定有一个"合约类型"字段。怎么玩:默认是 60000 → 66000,看两边的盈亏:币本位赚 0.0909 BTC,折合也是 6000 U。点"跌到 54000",看币本位亏 0.1111 BTC,比涨的时候赚的多。说明:币本位按 BTC 算不对称,而且押金本身是 BTC,BTC 跌的时候押金也在缩水。
单向持仓和双向持仓
同一个合约,你能不能同时拿着一个多仓和一个空仓?交易所给两种模式让你选:单向持仓只能有一个净仓位,双向持仓多仓和空仓分开记。
单向持仓 one-way mode
双向持仓 hedge mode
| 你想做的事 | 方向(side) | positionSide |
|---|---|---|
| 开多(新开或加多仓) | BUY | LONG |
| 平多(减多仓) | SELL | LONG |
| 开空(新开或加空仓) | SELL | SHORT |
| 平空(减空仓) | BUY | SHORT |
最容易写错的是"平多":方向是卖,但 positionSide 仍然是 LONG,因为你操作的是多仓。写成 SHORT 就变成了开一个新空仓。
为什么策略的人要在乎:合约网格在双向模式下,可以把"低买"挂在多仓、"高卖"挂在空仓,两边独立;在单向模式下,所有买卖都在一个净仓位上加减。同一套策略,两种模式的下单参数完全不同。进组后第一件事,是问清楚你们的账户用哪种模式。
import java.math.BigDecimal;
public enum PositionSide { LONG, SHORT }
/** 双向持仓下的一个仓位。单向持仓只需要一个带正负号的 qty */
public record Position(String accountId, String symbol, PositionSide positionSide,
BigDecimal qty, BigDecimal entryPrice) {}
accountId = 账户编号;symbol = 交易对,比如 BTCUSDT;qty = 数量;entryPrice = 开仓均价。第 9 节的资金费结算会用到它。
资金费是怎么结算的
D2 讲过资金费是多空之间定期转账。在交易所后台,它是一个定时批量任务:到点了,拿当时所有人的仓位,一个一个算钱、记账。难点不在公式,而在"几百万个仓位,跑到一半挂了怎么办"。
资金费结算 funding settlement
A 多 1 BTC:仓位价值 60000,付 60000 × 0.01% = 6 U
B 空 0.5 BTC:仓位价值 30000,收 3 U
C 多 0.2 BTC:付 1.2 U
D 空 0.7 BTC:收 4.2 U
加起来:−6 + 3 − 1.2 + 4.2 = 0。用户之间互相转账,交易所不拿(常见规则)。
仓位快照 position snapshot
唯一键 idempotency key
结算周期 + 账户 + 交易对 + 仓位方向。数据库里给它建唯一索引。INSERT 流水表碰到唯一索引冲突就当作"已处理",并且"写流水"和"改余额"放在同一个数据库事务里。怎么玩:先保持"不用唯一键",点 ①,任务处理完 A、B 就崩了;再点 ②,看 A 被扣了两次、B 被加了两次,最下面"所有人加起来"不等于 0,对账报警。点"重来",勾上"用唯一键"再做一遍:重跑时 A、B 被跳过,加起来正好是 0。说明:批量记账必须能安全重跑。
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
/** 结算时刻那一瞬间的仓位,结算只用它 */
public record PositionSnapshot(String accountId, String symbol, PositionSide positionSide, BigDecimal qty) {}
public class FundingSettler {
private final FundingLedger ledger; // 资金费流水表,(periodId, accountId, symbol, positionSide) 唯一
private final Accounts accounts;
public FundingSettler(FundingLedger ledger, Accounts accounts) { this.ledger = ledger; this.accounts = accounts; }
public void settle(String periodId, List<PositionSnapshot> snapshot, BigDecimal mark, BigDecimal rate) {
for (PositionSnapshot p : snapshot) {
BigDecimal fee = p.qty().multiply(mark).multiply(rate).setScale(8, RoundingMode.HALF_UP);
// 费率为正:多付空。rate 为负时 fee 是负数,符号自动反过来
BigDecimal amount = p.positionSide() == PositionSide.LONG ? fee.negate() : fee;
// 同一个数据库事务:先插流水,插成功才改余额;唯一键冲突说明上次已经记过,跳过
if (ledger.insertIfAbsent(periodId, p.accountId(), p.symbol(), p.positionSide(), amount)) {
accounts.addMargin(p.accountId(), amount);
}
}
}
}
interface FundingLedger { boolean insertIfAbsent(String periodId, String accountId, String symbol, PositionSide side, BigDecimal amount); }
interface Accounts { void addMargin(String accountId, BigDecimal amount); }
periodId = 结算周期编号,比如 "BTCUSDT-2026-10-06T08:00Z";mark = 标记价格;rate = 资金费率;insertIfAbsent = 不存在才插入;addMargin = 给保证金加(或减)钱。仓位价值用标记价格算是常见做法,各家不同。
风险引擎、强平引擎和阶梯保证金
D2 教你算强平价,今天看交易所是怎么"执行"强平的:风险引擎盯着每个仓位,发现不安全就交给强平引擎,强平引擎接管仓位、去市场上平掉,亏穿了再找保险基金和自动减仓。
风险率 margin ratio
风险率 = 维持保证金 ÷ (押金 + 浮动盈亏),到 100% 就强平。有的交易所界面叫"保证金率",定义也可能反过来,各家不同。标记价格 57000:浮动盈亏 −3000,押金还剩 3000;维持保证金 57000 × 0.5% = 285;风险率 = 285 ÷ 3000 = 9.5%,安全。
标记价格 54271.36:押金还剩 6000 − 5728.64 = 271.36;维持保证金 = 54271.36 × 0.5% ≈ 271.36;风险率 = 100%,强平。
破产价 bankruptcy price
押金 + 浮动盈亏 = 0。强平价在它前面一点,中间那段距离就是维持保证金留出来的余量。强平引擎在 54271.36 接管,去市场上卖:
· 卖在 54100:比破产价好 100 × 1 = 100 U,这 100 U 常见是进保险基金(D2 第 11 节同一个例子)
· 卖在 53800:比破产价差 200 U,这就是穿仓,由保险基金补 200 U;保险基金不够,就触发自动减仓
强平引擎做了什么
① 先撤掉这个人在这个合约上的挂单,把挂单占用的保证金释放出来,也许就不用强平了
② 还不够,就接管仓位:用户的仓位转给强平引擎,按破产价结清
③ 强平引擎以"只减仓、能成多少成多少"的方式把仓位卖到市场上
④ 卖得比破产价好,差额进保险基金;卖得更差,保险基金补;保险基金也不够,按排名对盈利的反方向仓位自动减仓
几百万个仓位,怎么盯得过来
TreeMap<BigDecimal, List<String>> longLiq,key 是强平价,value 是仓位编号。价格下跌时 longLiq.tailMap(mark, true) 一下子拿到所有该强平的多仓。空仓反过来用 headMap。注意:全仓仓位的强平价会随账户里其他仓位变化,押金增减、资金费结算后都要更新索引。阶梯保证金 tiered maintenance margin
0 – 10 万 U:0.5%,最高 50 倍
10 万 – 50 万 U:1%,最高 20 倍
50 万 – 200 万 U:2.5%,最高 10 倍
200 万 – 1000 万 U:5%,最高 5 倍
一段一段算:10 万 × 0.5% + 40 万 × 1% + 50 万 × 2.5% = 500 + 4000 + 12500 = 17000 U
速算:100 万 × 2.5% − 8000 = 17000 U。第三档的速算扣除数 8000 = 10 万 × (2.5% − 0.5%) + 40 万 × (2.5% − 1%) = 2000 + 6000。
如果统一按 0.5%,只要 5000 U。大仓位需要多留 12000 U,所以强平来得更早。
100000 + (P − 60000) × q = 2.5% × P × q − 8000 → 0.975 × P × q = 892000 → P × q ≈ 914872 → P ≈ 54892.31,跌 8.51% 就强平。
统一 0.5% 时是 54271.36,跌 9.55%。同样 10 倍,大仓位的强平价离开仓价更近。
怎么玩:默认 100 万 U、10 倍,看表格里哪几档被用到(底色亮起的行),以及阶梯和统一 0.5% 两个强平价差多少。把仓位改成 1 万,两个强平价一样;改成 300 万,再把杠杆改成 10,看"杠杆检查"报警:这么大的仓位最高只允许 5 倍。说明:仓位越大,交易所要你留的余量越多。
手续费、VIP 等级和挂单返佣
手续费不是一个固定数。交易越多费率越低,挂单比吃单便宜,顶级的挂单大户甚至是交易所倒贴钱给他。这一节是 D9 讲做市策略的铺垫。
VIP 等级 fee tier
挂单返佣 maker rebate
同样成交量如果全是吃单、费率 0.03%:要付 5000 万 × 0.03% = 15000 U。
一正一负差了 17500 U 一天。做市策略能不能赚钱,常常就看这一项。
(用户等级, 挂单或吃单) → 费率。清算每记一笔成交,就按这张表多记一条"手续费账户"的账,第 2 节的复式记账里那一条就是它。现在把第 1 节的全景图再看一遍:网关限频、下单服务检查精度、下单前风控冻结钱、定序排队、撮合配对、清算按费率记账、账户服务改可用和冻结、行情推送广播出去。合约再多出仓位服务、资金费结算、风险引擎和强平引擎。这两条链路能在白纸上画出来,今天就过关了。
词典和自测
今天和前几天用到的词,这里都能查到。
自测 8 题
回到 七日冲刺 D8,按这个顺序过一遍:先看撮合和冻结的录屏式演示,边看边说出每一步是哪个服务在干活;再玩强平引擎沙盘,拖动标记价格,看风险率、强平、保险基金和自动减仓依次出现;然后做编程题 match(规则就是第 3 节的价格优先、时间优先);最后在白纸上画出两条链路,打卡。