OASIS BLOG
← 回到七日冲刺← D7 笔记D9 笔记 →
写给做过 D1–D7 的 Java 程序员 · 换到交易所这一边看

D8 零基础速成笔记

前七天你一直站在"下单的人"这边。今天换到交易所里面看:你点一下"买入",后台经过哪几个服务,钱是怎么被冻结、扣掉、记账的;合约仓位开了以后,资金费谁来收、强平由谁来执行。页面里带下划虚线的词,点一下就能看解释。

怎么读按顺序读,一共 12 节,大约 3 小时。前 5 节讲现货这条链路,后 6 节讲合约。
每个概念都分四层大白话 → 生活例子 → 算一遍 → Java 类比。前两层看懂就够了。
读完要能做到在白纸上画出两条链路:一笔现货限价单从下单到余额变化;一个合约仓位从开仓、收资金费到被强平。
1

交易所后台全景:你点"买入"以后

本节新词网关 · 下单服务 · 下单前风控 · 定序 · 撮合引擎 · 清算 · 账户服务 · 行情推送

你在交易所页面点一下"买入",这张订单会依次经过七八个服务。每个服务只干一件事。今天后面每一节,都是在放大这张图里的某一个框。

先按顺序认识一遍,每个只记一句话:

  • 网关:所有请求的大门。检查你是谁(签名、登录态),限制你每秒能发多少请求(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

大白话
撮合要求极快、极稳,所以只让它做"配对"这一件事。慢一点的活(记账、推送、存数据库)都交给后面的服务去做。
生活例子
银行柜台:前台(网关)核对身份,填单员(下单服务)把你说的话填成标准单据,主管(下单前风控)看钱够不够,叫号机(定序)排队,柜员(撮合)办业务,后台会计(清算)记账。柜员不用自己记总账,所以能一直快速叫下一个号。
Java 类比
典型的流水线加事件驱动。撮合把结果当作事件发出去,常见做法是写进消息队列(D6 的 Kafka 就是一种),清算和行情推送各自去消费。和你熟悉的"下单 → 发 MQ → 库存、积分、通知各自消费"是一个思路。
2

资产账户:可用和冻结

本节新词可用 · 冻结 · 解冻 · 复式记账

你账户里的每一种币,都分成两个数:可用和冻结。下单时先把要花的钱冻结起来,成交时从冻结里扣,撤单时把没用上的解冻还给可用。

可用 和 冻结 available / frozen (locked)

大白话
可用是现在还能拿去下新单、能提走的钱。冻结是已经答应给某张挂单用、暂时不能动的钱。两个加起来才是你这个币的总余额。
为什么要冻结
挂单可能过一小时才成交。如果下单时不先占住这笔钱,你可以拿同一笔 1000 U 同时挂十张买单,等它们都成交时才发现钱不够。冻结就是"先占座"。
生活例子
酒店入住时刷的预授权:卡里 5000 元,预授权冻结 1000 元,你还能刷的只剩 4000。退房时按实际消费扣 600,剩下 400 解冻回卡里。
Java 类比
像库存系统里的"可售库存"和"锁定库存":下单锁库存,支付成功扣锁定库存,取消订单释放锁定。

算一遍:一张买单从下单到撤单

开始
USDT 可用 1000、冻结 0;BTC 0。
① 下单
限价买 0.01 BTC @ 60000,最多要花 0.01 × 60000 = 600 U。风控检查:可用 1000 ≥ 600,通过。
冻结 600 → 可用 400、冻结 600;BTC 0。
② 成交一半
有人按 60000 卖给你 0.005 BTC,花 0.005 × 60000 = 300 U,从冻结里扣。
可用 400、冻结 300;BTC 0.005。可用没变,因为这笔钱本来就不在可用里。
③ 撤单
剩下 0.005 BTC 不买了,对应冻结的 300 U 解冻回可用。
可用 700、冻结 0;BTC 0.005。
核对
700 U + 0.005 BTC × 60000 = 700 + 300 = 1000,一分没少(先不算手续费)。
和总页录屏的关系
总页 D8 的录屏换了一种情况:卖单簿上本来就挂着 60000 的卖单 A1 0.003(先到)和 A2 0.002(后到),你一下单就先吃 A1、再吃 A2,剩下 0.005 挂上去,最后撤单。成交拆成了两笔,但可用、冻结、BTC 三个数的变化和这里完全一样。
手续费
常见做法是从你收到的币里扣。按 0.1% 算:0.005 × 0.1% = 0.000005 BTC,实际到账 0.004995 BTC。也有交易所从 U 里扣,或者用平台自己的币抵扣,各家不同,以文档为准。
一个细节
冻结是按你的限价算的。如果你挂 60000,这 0.005 BTC 实际按更便宜的 59990 成交了,只花 0.005 × 59990 = 299.95 U,比冻结的 300 U 少 0.05 U,这 0.05 U 要退回可用。退少了,用户的钱就被"卡"在冻结里出不来。

成交那一刻,清算会同时记几条账:你 −300 U(从冻结扣)、+0.005 BTC;卖方 −0.005 BTC、+300 U;如果有手续费,再记一条"手续费账户 +多少"。同一种币,所有人加起来的变化一定是 0。这种"每笔钱都有来处和去处"的记法,会计上叫复式记账。对账时查的就是它:哪种币加起来不是 0,就说明哪里记错了。

录屏式演示:一张买单的一生,看三个数字怎么变滚到这里会自动播放,可以暂停、单步

怎么玩:先不看数字,自己按上面的算式算下一步,再点"下一步"对答案。看上面那排服务名里被点亮的那一个,它是这一步在干活的服务;再看三个数字里底色变了的那一格,它是这一步变化的数字。说明:成交只动冻结,撤单才把冻结还给可用。

USDT 可用1000
USDT 冻结0
BTC0
订单状态–
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 = 余额不足。

常见误区 · 成交了才扣钱下单那一刻就冻结:挂一张 0.01 BTC @ 60,000 的买单,可用余额马上少 600 U,哪怕一笔都还没成交。所以遇到"余额明明够却下不了单",先去当前委托里找有没有忘了撤的挂单。撤单才解冻;成交是从冻结里扣,不再动可用。
3

撮合引擎:价格优先、时间优先

本节新词价格优先 · 时间优先 · 成交价

一张买单进来,撮合引擎只按两条规则找对手:先找价格最好的卖单;价格一样,找最早挂上去的那张。成交价用挂在簿上的那张单的价格。

价格优先 price priority

大白话
买的人先和最便宜的卖单成交,卖的人先和出价最高的买单成交。
生活例子
菜市场三家卖白菜,一家 2 块、一家 2 块 5、一家 3 块。你要买 10 斤,肯定先把 2 块那家的买光,不够再去 2 块 5 那家。

时间优先 time priority / FIFO

大白话
同一个价格上挂着好几张单,谁先挂上去,谁先成交。
生活例子
同样 2 块钱的白菜,有两个摊主。先来摆摊的那个先卖。这就是为什么做挂单的机器人在乎"排队位置":同价位排得靠后,可能轮不到你。
Java 类比
D3 用 TreeMap 存盘口,价格是 key。撮合引擎一样,只是 value 从"这一档一共多少"换成了"这一档的订单队列":TreeMap<BigDecimal, ArrayDeque<订单>>。TreeMap 负责价格优先,队列的先进先出负责时间优先。

成交价 trade price

大白话
成交价用挂单那一方(先挂在簿上的那张)的价格,不用新进来那张的价格。
算一遍
卖单簿上:S1 60010 × 0.3(第 1 个到)、S3 60020 × 0.5(第 2 个到)、S2 60010 × 0.2(第 3 个到)、S4 60050 × 1.0(第 4 个到)。
你进来一张"限价买 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 被丢掉。看的地方:底色亮起来的那一档是正在吃的价位,实心高亮的小框是正在成交的那张挂单。

卖单簿(最便宜的在最上面;小框里是 编号 · 剩余数量 · 第几个到)
已成交0
花了多少 U0
成交均价–
还差多少–

总页 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 一样。

Tips · 成交价是挂单方的价格你出 60,050 买,卖一挂在 60,000,成交价是 60,000,不是 60,050,你捡了 50 U 的便宜。反过来你是挂单方被别人吃,就按你挂的价成交。所以限价单只会以你的价格或更好的价格成交,"限价买单被人立刻买走、少卖了"这种说法是不对的。
4

撮合为什么是单线程、放在内存里

本节新词定序 · 事件日志 · 回放

同一个交易对的撮合,常见做法是只用一个线程、订单簿整个放在内存里。这听起来很"不高并发",但正是它又快又不出错的原因。

定序 sequencing

大白话
在进撮合之前,把同一个交易对的所有订单排成一队,编上 1、2、3…… 的连续号码。撮合只认这个顺序。
为什么必须排好
时间优先要求"谁先到"必须有唯一答案。如果两个线程同时处理两张买单,谁先吃到最便宜的那张卖单就看运气,同样的输入可能得到不同的结果,那就没法对账了。
生活例子
银行叫号机。不管多少人同时进门,每个人拿到的号码都不一样,柜员只按号码叫。
Java 类比
一个 BlockingQueue 加一个消费线程,就是 D4 的事件循环(BotLoop)。有的交易所用类似 LMAX Disruptor 的环形队列来做,道理一样。

为什么不用数据库撮合

大白话
一张大市价单可能要连着吃掉几十张挂单。如果每吃一张都去数据库加锁、改一行、提交,一次就是几毫秒;放在内存里改一个 TreeMap,是微秒级,差了上千倍。
算一遍
假设数据库每次更新 2 毫秒,一张单吃掉 20 张挂单要 40 毫秒,这一个交易对每秒最多处理 25 张这样的单。放在内存里每次 2 微秒,同样的单只要 40 微秒,每秒能处理两万多张。(数字是示意量级,不是某家的实测。)
那不怕丢吗
怕,所以要靠 D5 讲过的那套:进撮合之前先把排好号的订单写进事件日志(只追加),程序崩了就从日志回放一遍。因为是单线程、顺序固定,回放出来的订单簿和成交,和崩溃前一模一样。
 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 = 清算和行情。
这张图说明:一个交易对内部是单线程,不同交易对之间各跑各的,这样整体仍然能并行。

5

撮合前后的保护:别让一张单打穿市场

本节新词价格保护 · 市价单保护价 · 自成交保护 · 刷量

撮合引擎本身只会机械地配对。为了防止手滑、程序 bug 或者故意操纵,交易所会在撮合前后加几道闸。策略机器人最常被这几道闸拒单,所以你必须认识它们。

价格保护 price band / price protection

大白话
限价单的价格离参考价(常见用标记价格或最新成交价)太远,直接拒绝。买单不能比参考价高太多,卖单不能比参考价低太多。
算一遍
假设参考价 60000,允许偏离 5%(示例数字,各家不同):买单最高 60000 × 1.05 = 63000,卖单最低 60000 × 0.95 = 57000。
你的程序少写了一个 0,挂"卖 @ 6000":6000 < 57000,被拒绝。没有这道闸,这张单可能把买单簿从 60000 一路往下吃,最低吃到 6000。
生活例子
银行转账大额提醒:"您确定要转 100000 元吗?"只不过交易所不问你,直接拒。

市价单保护价 market order protection

大白话
市价单不写价格,但交易所会悄悄给它加一个上限:最多吃到参考价上下百分之几,再远的挂单不吃,剩下的撤掉。
为什么
盘口很薄的时候,一张大市价单会一直往上吃,最后几笔的价格可能离谱。你在 D1 学的滑点,最坏情况就是这个。

自成交保护 STP, Self-Trade Prevention

大白话
你自己的买单碰上你自己的卖单,交易所不让它们成交,而是按规则撤掉其中一张(常见选项:撤新来的、撤原来挂着的、两张都撤,各家不同)。英文缩写 STP。
为什么
自己和自己成交,钱没变,只是白交手续费,还凭空多出成交量。故意这么做叫刷量,是市场操纵,交易所要防。对机器人来说,网格的买卖单如果价格设错互相碰上,会被 STP 撤掉,程序要能识别这种撤单原因,不然会以为是别的故障。
生活例子
左手把东西卖给右手,账面上"成交"了,东西和钱都没动过。
价格保护:你的单会不会被拒上半部分是限价单,下半部分是市价单

怎么玩:默认是"卖 @ 56000,允许偏离 5%",下限是 57000,所以被拒。把价格改成 57500 看它通过;把偏离比例拖到 8% 再看 56000。然后看下半部分:市价买 3 个,保护价是 63000,63500 那一档不吃,1.2 个被撤掉。勾上"关闭保护",看均价和最贵一笔变成多少。说明:保护价宁可少成交,也不让你吃到离谱的价格。

卖单簿:60010 × 0.3 · 60200 × 0.5 · 61000 × 1.0 · 63500 × 2.0(盘口很薄)
保护价(最多吃到)–
成交–
被撤掉–
成交均价–
最贵的一笔–
6

现货和合约,在系统里差在哪

本节新词仓位服务 · 风险引擎 · 强平引擎

现货成交,是真的把币换到你账户里。合约成交,你账户里的币没变,多出来的是一条"仓位"记录和一笔被占住的保证金。后台因此多了一整套服务。

现货合约(以 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 = 自动减仓。
这就是"做完的标志"里要你画的第二条链路。

7

U 本位和币本位

本节新词U 本位 · 币本位 · 面值 · 反向合约

合约按"押金和盈亏用什么币算"分两种。押 U、赚亏也是 U,叫 U 本位;押 BTC、赚亏也是 BTC,叫币本位。D1–D7 讲的都是 U 本位。

U 本位 USDT-margined / linear

大白话
数量按 BTC 算,押金和盈亏都是 U。最直观。
公式
做多盈亏(U)= (平仓价 − 开仓价) × 数量(BTC)
算一遍
60000 做多 1 BTC,涨到 66000:(66000 − 60000) × 1 = +6000 U。10 倍杠杆押金 = 60000 × 1 ÷ 10 = 6000 U。

币本位 coin-margined / inverse

大白话
押金和盈亏都是 BTC。数量不按"几个 BTC"算,而是按"几张"算,每张代表固定多少美元,叫面值。BTC 合约常见 1 张 = 100 美元,各家不同,以文档为准。
公式
做多盈亏(BTC)= 张数 × 面值 × (1 ÷ 开仓价 − 1 ÷ 平仓价)
算一遍
想做多价值 60000 美元的 BTC,就是 60000 ÷ 100 = 600 张。
涨到 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。
为什么叫反向
公式里是价格的倒数(1 ÷ 价格),所以也叫反向合约。后果是不对称:同样涨 10% 和跌 10%,按 BTC 算,赚的是 0.0909,亏的是 0.1111。
谁在用
手里本来就拿着很多 BTC、不想换成 U 的人(比如矿工),用 BTC 当押金做空,可以把手里 BTC 的美元价值锁住。
Java 类比
同一个 calcPnl() 接口的两种实现,放在策略模式里按合约类型选。清算系统里最怕把两种公式混用,所以合约配置里一定有一个"合约类型"字段。
U 本位和币本位,同一笔交易并排算都是做多

怎么玩:默认是 60000 → 66000,看两边的盈亏:币本位赚 0.0909 BTC,折合也是 6000 U。点"跌到 54000",看币本位亏 0.1111 BTC,比涨的时候赚的多。说明:币本位按 BTC 算不对称,而且押金本身是 BTC,BTC 跌的时候押金也在缩水。

U 本位押金 –盈亏 –
币本位张数 – · 押金 –盈亏 –按平仓价折合 –
8

单向持仓和双向持仓

本节新词单向持仓 · 双向持仓 · positionSide

同一个合约,你能不能同时拿着一个多仓和一个空仓?交易所给两种模式让你选:单向持仓只能有一个净仓位,双向持仓多仓和空仓分开记。

单向持仓 one-way mode

大白话
一个合约只有一个数:正数是多,负数是空。买就加,卖就减。
算一遍
先买 1 个 → 多 1。再卖 1.5 个 → 1 − 1.5 = −0.5,变成空 0.5。D2 讲 reduce-only 时说的"卖多了反过来开空",就是单向持仓下发生的。

双向持仓 hedge mode

大白话
多仓和空仓是两个独立的仓位,各算各的。多 1 个和空 0.5 个可以同时存在,不会互相抵消。
下单要多说一句
光说"买"不够,还要说是对哪个仓位操作。有的交易所接口里这个字段叫 positionSide,取值 LONG(多仓)或 SHORT(空仓);单向模式下常填 BOTH 或者不填,各家不同。
生活例子
单向像一个钱包,收入支出直接相抵;双向像两个钱包,一个只进一个只出,月底各看各的。
你想做的事方向(side)positionSide
开多(新开或加多仓)BUYLONG
平多(减多仓)SELLLONG
开空(新开或加空仓)SELLSHORT
平空(减空仓)BUYSHORT

最容易写错的是"平多":方向是卖,但 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 节的资金费结算会用到它。

9

资金费是怎么结算的

本节新词资金费结算 · 仓位快照 · 唯一键

D2 讲过资金费是多空之间定期转账。在交易所后台,它是一个定时批量任务:到点了,拿当时所有人的仓位,一个一个算钱、记账。难点不在公式,而在"几百万个仓位,跑到一半挂了怎么办"。

资金费结算 funding settlement

大白话
每到一个结算时刻(常见每 8 小时一次,各家不同),按"仓位价值 × 资金费率"给每个有仓位的人记一笔账。费率为正,多仓付、空仓收;为负反过来。
算一遍
标记价格 60000,费率 +0.01%:
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。用户之间互相转账,交易所不拿(常见规则)。
加起来为 0
多仓总量一定等于空仓总量(每一张合约都是一个人多、一个人空),所以每一期所有人的资金费加起来必须是 0。这是结算完最简单的对账方法。

仓位快照 position snapshot

大白话
结算时刻那一瞬间,把所有人的仓位"拍张照"存下来,后面的计算只用这张照片。
为什么
几百万个仓位,任务可能要跑几分钟。如果边跑边读实时仓位,A 在 08:00:05 平了仓,任务 08:03 才轮到 A,就会漏收 A 的钱;反过来 08:01 才开仓的人却被收了钱。结算时刻是 08:00:00,就只能按 08:00:00 的仓位算。

唯一键 idempotency key

大白话
每条资金费流水都带一个"不能重复"的编号:结算周期 + 账户 + 交易对 + 仓位方向。数据库里给它建唯一索引。
为什么
任务跑到一半挂了,重跑时前半部分的人已经扣过钱。有唯一键,重跑到他们时插入流水会失败,于是跳过;没有唯一键,他们就被扣两次。这就是 D5 讲的幂等,只不过对象从"下单"换成了"记账"。
Java 类比
和支付回调防重复入账一模一样:INSERT 流水表碰到唯一索引冲突就当作"已处理",并且"写流水"和"改余额"放在同一个数据库事务里。
结算跑到一半挂了,重跑会怎样标记价格 60000 · 费率 +0.01%

怎么玩:先保持"不用唯一键",点 ①,任务处理完 A、B 就崩了;再点 ②,看 A 被扣了两次、B 被加了两次,最下面"所有人加起来"不等于 0,对账报警。点"重来",勾上"用唯一键"再做一遍:重跑时 A、B 被跳过,加起来正好是 0。说明:批量记账必须能安全重跑。

流水条数0
所有人资金费加起来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 = 给保证金加(或减)钱。仓位价值用标记价格算是常见做法,各家不同。

常见误区 · 结算任务挂了,重跑一遍就好资金费结算是批量任务,跑到一半机器重启很常见。没有"结算周期 + 账户"唯一键,重跑会把已经扣过的账户再扣一次:1 万个仓位每个 0.06 U,多扣就是约 600 U 的资损,而且要等对账才发现。正确做法:先占唯一键、再扣钱,放在同一个事务里(D5 的幂等)。
10

风险引擎、强平引擎和阶梯保证金

本节新词风险率 · 破产价 · 阶梯保证金 · 速算扣除数

D2 教你算强平价,今天看交易所是怎么"执行"强平的:风险引擎盯着每个仓位,发现不安全就交给强平引擎,强平引擎接管仓位、去市场上平掉,亏穿了再找保险基金和自动减仓。

风险率 margin ratio

大白话
一个衡量"离强平还有多远"的比例。这里用一种常见定义:风险率 = 维持保证金 ÷ (押金 + 浮动盈亏),到 100% 就强平。有的交易所界面叫"保证金率",定义也可能反过来,各家不同。
算一遍
接 D2:60000 做多 1 BTC,10 倍,押金 6000,维持保证金率 0.5%。
标记价格 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。强平价在它前面一点,中间那段距离就是维持保证金留出来的余量。
算一遍
同一个仓位:6000 + (P − 60000) × 1 = 0 → P = 54000。
强平引擎在 54271.36 接管,去市场上卖:
· 卖在 54100:比破产价好 100 × 1 = 100 U,这 100 U 常见是进保险基金(D2 第 11 节同一个例子)
· 卖在 53800:比破产价差 200 U,这就是穿仓,由保险基金补 200 U;保险基金不够,就触发自动减仓

强平引擎做了什么

步骤
下面是常见流程,细节各家不同:
① 先撤掉这个人在这个合约上的挂单,把挂单占用的保证金释放出来,也许就不用强平了
② 还不够,就接管仓位:用户的仓位转给强平引擎,按破产价结清
③ 强平引擎以"只减仓、能成多少成多少"的方式把仓位卖到市场上
④ 卖得比破产价好,差额进保险基金;卖得更差,保险基金补;保险基金也不够,按排名对盈利的反方向仓位自动减仓
为什么你要知道
你的机器人收到的"成交"里,可能有一笔是强平单造成的;你的挂单也可能被强平流程先撤掉。程序要能认出这些撤单和成交的原因,不然本地状态就和交易所对不上了。

几百万个仓位,怎么盯得过来

大白话
标记价格每秒都在变,不可能每次把所有仓位都重算一遍。常见做法是按强平价给仓位建索引:标记价格跌到 54200,只需要看"强平价 ≥ 54200 的多仓",其他的肯定还安全。
Java 类比
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 倍
生活例子
像个人所得税的累进税率:超过的那一段才按高税率算。为了算得快,还有一个速算扣除数:直接用"总额 × 所在档税率 − 速算扣除数",结果和一段一段算完全一样。
算一遍
仓位价值 100 万 U:
一段一段算: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,所以强平来得更早。
强平价
60000 做多 100 万 U(约 16.67 BTC),10 倍,押金 10 万 U。设强平价 P、数量 q = 100 万 ÷ 60000:
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 倍,大仓位的强平价离开仓价更近。
阶梯保证金计算器做多 · 开仓价 60000 · 逐仓 · 档位是示例

怎么玩:默认 100 万 U、10 倍,看表格里哪几档被用到(底色亮起的行),以及阶梯和统一 0.5% 两个强平价差多少。把仓位改成 1 万,两个强平价一样;改成 300 万,再把杠杆改成 10,看"杠杆检查"报警:这么大的仓位最高只允许 5 倍。说明:仓位越大,交易所要你留的余量越多。

维持保证金(阶梯)–
平均维持保证金率–
统一 0.5% 时–
强平价(阶梯)–
强平价(统一 0.5%)–
常见误区 · 逐仓和全仓,亏的是哪部分钱逐仓:最多亏光这个仓位自己的保证金,比如 120 U,账户里另外 880 U 不受影响。全仓:整个账户的可用余额都在给仓位垫着,强平价离得更远,但真被强平时,亏掉的可能是整个账户。练习和新策略先用逐仓。强平时是否预留平仓手续费、全仓怎么分摊,各家细节不同,以产品文档为准。
11

手续费、VIP 等级和挂单返佣

本节新词VIP 等级 · 挂单返佣 · 做市商 · 流动性

手续费不是一个固定数。交易越多费率越低,挂单比吃单便宜,顶级的挂单大户甚至是交易所倒贴钱给他。这一节是 D9 讲做市策略的铺垫。

VIP 等级 fee tier

大白话
按最近 30 天的成交额(有的也看持有多少平台币)分等级,等级越高费率越低。具体档位各家不同,以文档为准。
示例
下面只是示意量级:普通用户合约挂单 0.02%、吃单 0.05%;高等级用户挂单 0.01%、吃单 0.03%;最高几档挂单费率可能是负数。

挂单返佣 maker rebate

大白话
挂单费率是负数:你的挂单被人成交了,交易所反过来给你钱。
为什么交易所愿意倒贴
订单簿上挂的单越多越厚,别人下单的滑点就越小,越愿意来这里交易。这种"随时能买到、卖到"的程度叫流动性。专门长期在买卖两边挂单、提供流动性的机构叫做市商,交易所用返佣吸引他们。
算一遍
做市商一天挂单成交 5000 万 U,返佣 −0.005%:交易所付给他 5000 万 × 0.005% = 2500 U。
同样成交量如果全是吃单、费率 0.03%:要付 5000 万 × 0.03% = 15000 U。
一正一负差了 17500 U 一天。做市策略能不能赚钱,常常就看这一项。
Java 类比
费率是一张配置表:(用户等级, 挂单或吃单) → 费率。清算每记一笔成交,就按这张表多记一条"手续费账户"的账,第 2 节的复式记账里那一条就是它。

现在把第 1 节的全景图再看一遍:网关限频、下单服务检查精度、下单前风控冻结钱、定序排队、撮合配对、清算按费率记账、账户服务改可用和冻结、行情推送广播出去。合约再多出仓位服务、资金费结算、风险引擎和强平引擎。这两条链路能在白纸上画出来,今天就过关了。

12

词典和自测

今天和前几天用到的词,这里都能查到。

自测 8 题

读完以后

回到 七日冲刺 D8,按这个顺序过一遍:先看撮合和冻结的录屏式演示,边看边说出每一步是哪个服务在干活;再玩强平引擎沙盘,拖动标记价格,看风险率、强平、保险基金和自动减仓依次出现;然后做编程题 match(规则就是第 3 节的价格优先、时间优先);最后在白纸上画出两条链路,打卡。