OASIS BLOG
← 回到七日冲刺← D2 笔记D4 笔记 →
写给没碰过股票、期货、合约的 Java 程序员 · 已读完 D1、D2

D3 零基础速成笔记

今天只讲一件事:交易所不停地往外播报"刚刚发生了什么",你的程序怎么接住这些消息,在自己内存里维护一份和交易所一模一样的订单簿,而且一旦对不上,要能马上发现。页面里带下划虚线的词,点一下就能看解释。

怎么读按顺序读,一共 11 节,大约 2 小时。第 5 到第 8 节是今天的重点。
每个概念都分四层大白话 → 生活例子 → 算一遍 → Java 类比。前两层看懂就够了。
读完以后回到七日冲刺的 D3,看同步流程图、玩丢包动画,再做 applyDeltas 那道编程题。
1

行情是什么

本节新词行情 · 成交流 · 最新价 · 24 小时涨跌

行情就是交易所不停往外播报的"刚刚发生了什么":谁和谁成交了、成交价是多少、订单簿上哪一档变了。你的机器人所有的判断,都建立在行情上。

行情 market data

大白话
交易所对外公开的实时数据。不需要账户,谁都能看。
生活例子
像股票软件上那个不停跳动的价格,或者体育比赛的文字直播:"第 32 分钟,张三进球"。行情就是交易所的文字直播。
Java 类比
一个只读的事件流。你是消费者,交易所是生产者,你只能听,不能改。

成交流 trades

大白话
每成交一笔,就播报一条:什么时间、什么价格、成交了多少个,以及这一笔是"主动买"还是"主动卖"。
主动买 / 主动卖
看是谁"撞上来"促成了这笔成交。买方下单直接吃掉了挂着的卖单,叫主动买;卖方下单直接吃掉了挂着的买单,叫主动卖。也就是 D1 讲的吃单那一方是买还是卖。今天用不到,知道有这个字段就行。
例子
10:00:03 · 60,010 · 0.2 个,意思是 10 点 0 分 3 秒,有 0.2 个 BTC 以 60010 U 成交。一个热门交易对,一秒钟可能有几十上百条。

最新价 和 24 小时涨跌 last price / 24h change

大白话
最新价:最后一笔成交的价格。24 小时涨跌:和 24 小时前的价格比,涨了还是跌了多少。这两个加上 24 小时最高、最低、成交量,通常打包成一条"行情摘要"(英文叫 ticker)一起推送。
算一遍
24 小时前价格 58000,现在最新价 60010:涨了 2010 U,涨幅 2010 ÷ 58000 ≈ +3.47%。

除了成交流和行情摘要,还有两种行情:K 线(下一节)和订单簿(D1 讲过,第 4 节起重点讲怎么在本地维护它)。

2

K 线:把一段时间压成四个数

本节新词K 线 · 开盘价 · 最高价 · 最低价 · 收盘价 · 成交量 · 阳线 · 阴线 · 实体 · 上影线 · 下影线 · K 线周期

一分钟里可能有几百笔成交,人看不过来。K 线就是把这一分钟的所有成交,压缩成四个价格加一个数量:开、高、低、收、量。

K 线的五个数

开盘价
这段时间里第一笔成交的价格。
最高价
这段时间里成交过的最高价格。
最低价
这段时间里成交过的最低价格。
收盘价
这段时间里最后一笔成交的价格。
成交量
这段时间里一共成交了多少个(所有成交数量加起来)。
算一遍
10:00 这一分钟有 4 笔成交:60010、60040、59980、59995。
开 = 60010,高 = 60040,低 = 59980,收 = 59995。收比开低,这根 K 线是"跌"的。
Java 类比
一个按时间窗口分组的聚合:trades.stream().collect(groupingBy(t -> t.time() / 60, ...)),每组取 first、max、min、last、sum。
        |        ← high  最高价
     +--+--+     ← close 收盘价 (green: close > open, price went up)
     |     |
     |     |
     +--+--+     ← open  开盘价
        |        ← low   最低价

 red candle: open on top, close at bottom  ← 收比开低,这段时间跌了

一根 K 线画成一个"蜡烛":粗的部分从开盘价到收盘价,细线从最高价到最低价。涨是绿色,跌是红色(有的软件反过来,国内股票软件通常红涨绿跌)。

阳线、阴线 和 影线 candle body / wick

大白话
收盘价高于开盘价叫阳线(这段时间涨了),低于开盘价叫阴线(跌了)。开盘价和收盘价之间那段粗的叫实体;实体上面的细线叫上影线,画到最高价;下面的叫下影线,画到最低价。
算一遍
用上面 10:00 这一根:开 60010、高 60040、低 59980、收 59995。收 < 开,是阴线。
实体 = 60010 − 59995 = 15;上影线 = 最高价 − 实体顶端(开、收里大的那个)= 60040 − 60010 = 30;下影线 = 实体底端(开、收里小的那个)− 最低价 = 59995 − 59980 = 15。
怎么读
影线说的是"这段时间里去过、但没站住的价格"。上影线很长:冲上去过,被卖回来了;下影线很长:跌下去过,又被买回来了。单独一根说明不了太多,要结合前后几根一起看。
周期
一根 K 线代表多长时间叫 K 线周期:1 分钟、1 小时、4 小时、1 天都很常用。同一段行情,1 分钟图上是几十根乱跳的小线,日线上可能只是一根。交易软件左上角可以切换周期。
Java 类比
实体 = abs(close − open);上影线 = high − max(open, close);下影线 = min(open, close) − low。
Tips · 看图前先确认颜色多数交易所和国际软件是绿涨红跌,国内股票软件是红涨绿跌,很多交易所 App 可以在设置里切换。先看清是哪种,别把跌看成涨。怎么看 K 线图、画支撑线和趋势线、加均线和 RSI 这类指标,放在 D11 讲。
拖一下:看 K 线怎么一笔一笔长出来10:00:00 – 10:02:59 的 24 笔成交(演示数据)
每根 K 线多长:
最近的成交(最下面是最新的一笔)
生成的 K 线

往右拖,注意最后一根 K 线是怎么变的:还没到时间的那一根叫"正在形成",它的收盘价会随着每一笔新成交变化,最高价、最低价也可能被刷新。再点"30 秒":同样这 24 笔成交会被切成 6 根更短的 K 线,每根里的成交更少,所以更能看出价格在一分钟里是怎么上下晃的。

3

两种拿数据的方式:你去问,还是它来推

本节新词REST 接口 · 轮询 · WebSocket · 订阅 · 限频

REST 接口 和 轮询

大白话
REST 接口:你发一个请求问"现在价格多少",它回答一次,就结束了。轮询:想一直知道最新情况,就只能每隔一段时间再问一次。
生活例子
打电话问快递到哪了。想知道最新位置,就得每隔 10 分钟打一次。两次电话之间发生了什么,你不知道。
Java 类比
就是你天天写的 HttpClient 调接口,加一个 ScheduledExecutorService 定时去调。

WebSocket 和 订阅

大白话
WebSocket:连上之后连接一直保持着,交易所一有变化就主动推给你。订阅:连上之后告诉它"我要 BTC-USDT 的订单簿变化",它以后就只推这个给你。
生活例子
关注了一个公众号。它一发文章你就收到,不用你天天去问"有新文章吗"。
Java 类比
一条长连接,像 Netty 的 Channel,或者 Java 11 的 HttpClient.newWebSocketBuilder()。你写一个 onMessage 回调,消息来了就被调用。

限频 rate limit

大白话
交易所规定每个人每秒(或每分钟)最多能调几次接口,超了就拒绝你,严重的会暂时封掉。具体限多少各家不同,以文档为准。
为什么重要
轮询问得越勤,越容易撞上限频;问得越慢,错过的变化越多。这是轮询的死结,所以实时行情基本都用 WebSocket。REST 只用来做"一次性"的事,比如拉一次快照(第 5 节)。
轮询 vs 推送:10 秒里价格变了 40 次演示数据
看到了这次变化错过了(还没问到就又变了)一次轮询请求
轮询:发了几次请求–
轮询:看到几次变化–
轮询:平均晚知道–
推送:看到几次变化40 / 40
推送:平均晚知道≈ 网络延迟

怎么看:上面一行是轮询,每个点是一次价格变化,绿点是你问到了、红点是你还没来得及问它就又变了(这个价格你永远不知道)。灰色竖线是你发出的每一次请求。"平均晚知道"是价格变了以后,平均要过多久你才问到它。
先看默认的 500 ms:红点不少。再把间隔拖到最小(100 ms):错过的少了,但 10 秒要发 100 次请求,很快撞上限频。下面一行推送只需要连一次,所有变化都能收到。

4

为什么要在自己这边存一份订单簿

本节新词盘口 · 档位 · 本地订单簿

盘口 和 档位

大白话
盘口:订单簿最靠近成交价的那几行,也就是买一、买二…卖一、卖二…。平时说"看盘口",就是看订单簿。档位:订单簿里的一行,一个价格一行。同一个价格上可能有很多人挂单,合起来算一档,只显示总数量。
例子
卖一档:60010 · 0.3 个。可能是 A 挂了 0.1、B 挂了 0.2,行情里只告诉你这一档一共 0.3。
   price       size
   60,030      1.0     ← 卖三
   60,020      0.5     ← 卖二
   60,010      0.3     ← 卖一 (best ask)
 -----------------------
   60,000      0.4     ← 买一 (best bid)
   59,990      0.8     ← 买二
   59,980      1.2     ← 买三

本地订单簿 local order book

大白话
在你自己程序的内存里,维护一份和交易所一模一样的订单簿。策略要看盘口时,直接读内存,不去问交易所。
算一遍
一个网格机器人每秒要看几百次盘口。如果每次都用 REST 去问:一次请求往返至少几十毫秒(比如 50 ms),一秒最多问 20 次,还会撞限频。读内存只要不到 1 微秒。
生活例子
记账:你不会每次想知道余额都去银行柜台查,而是自己记一本账,银行每发生一笔就发短信给你,你照着短信改自己的账本。本地订单簿就是这本账,行情推送就是这些短信。
Java 类比
本地缓存 + 变更通知。跟"MySQL 是源头,本地缓存靠 binlog 同步"是一个思路。难点也一样:怎么保证缓存和源头一致。后面几节讲的全是这件事。
5

全量快照 + 增量更新

本节新词全量快照 · 增量更新

先拿一份完整的订单簿(快照),然后只接收"哪一档变了"(增量),自己在快照上一条条改。

全量快照 snapshot

大白话
某一时刻订单簿的完整拷贝,通过 REST 接口拉一次。
生活例子
一张 Excel 表的完整文件。

增量更新 delta / update

大白话
通过 WebSocket 推过来的一条条"修改记录":哪一边(买或卖)、哪个价格、这一档现在一共多少。
三种情况
① 这个价格原来就有 → 把数量改成新的;
② 这个价格原来没有 → 新增一档;
③ 数量是 0 → 删掉这一档(挂单都被吃光或者都撤了)。
容易误会
常见做法是增量给的是"这一档现在一共有多少",不是"加了多少"。卖一原来 0.3,来一条"卖 60010 → 0.5",意思是现在变成 0.5,不是 0.8。具体以各家文档为准。
生活例子
Excel 的修改记录:"B3 单元格改成 0.5""删除第 7 行"。拿着原文件加上所有修改记录,就能还原出最新的表。
Java 类比
MySQL 的全量备份 + binlog。恢复数据库时,先导入备份,再按顺序重放 binlog。

下面的演示里会看到 这个字段:它是每份快照、每条增量身上带的编号,一直往上加 1。现在只要记住"编号越大越新",快照 seq = 100 的意思是"包含到第 100 号变化为止"。第 6 节会专门讲它怎么用。

一步一步应用增量快照 seq = 100
本地订单簿
收到的增量

怎么看:每点一次"应用下一条",右边列表里高亮的那条就被改到左边的订单簿上,左边被改动的那一档会变色,下面的文字解释这条增量属于"改数量、新增一档、删除一档"里的哪一种。点完 6 条,左边就是交易所 seq = 106 时的样子。

6

序列号:怎么知道有没有漏

本节新词序列号 · 跳号

每条增量都带一个一直加 1 的编号。你只要检查"这条的编号是不是上一条加 1",就知道中间有没有漏掉。

序列号 sequence number

大白话
交易所给每一次订单簿变化编的号,101、102、103……一直往上加。快照也带一个编号,表示"这份快照包含到第几号为止的所有变化"。
叫法
各家叫法不同,有的叫 sequence,有的叫 u / U。有的交易所一条增量里会打包好几次变化,于是给一个"这条从第几号到第几号"的起止编号,判断规则会稍微变一下。思路都一样:检查新来的这条能不能和上一条首尾接上。具体规则以各家文档为准。
生活例子
银行排队的号码。你手里是 102 号,下一个叫到的应该是 103。如果直接叫到了 105,说明 103、104 你没听到。
Java 类比
TCP 的序号、MySQL binlog 的位点、Kafka 的 offset。都是用一个递增编号来保证"不漏、不乱"。

跳号 gap

大白话
收到的编号不是上一条加 1,中间缺了。
算一遍
本地订单簿当前是 104。收到 105,正常,应用,变成 105。下一条收到 107:期望 106,实际 107,跳号了,106 丢了。
收到 103 呢?它比 105 还小,是旧消息(比如网络重发的),直接扔掉,不算跳号。
三条规则
seq ≤ 当前 → 旧的,扔掉
seq = 当前 + 1 → 正常,应用
seq > 当前 + 1 → 跳号,本地订单簿不能再用了(第 8 节讲怎么办)
7

正确的同步顺序:先缓冲,再拉快照

本节新词缓冲

直觉上应该"先拉快照,再订阅增量",但这样一定会漏掉中间那一段,而且你还不知道漏了。正确做法反过来:先订阅,把收到的增量先存起来,再去拉快照。

缓冲 buffer

大白话
收到的增量先不用,放进一个队列里存着,等快照到了再处理。
生活例子
搬家期间先让快递放到驿站,等新家收拾好了一次性取回来,一件都不会丢。
Java 类比
一个 ArrayDeque<Delta>,快照没到之前只 offer 不 poll。
还有一个检查
快照到了以后,要确认它和缓冲区接得上:缓冲区里最早那条的编号,必须 ≤ 快照编号 + 1。比如快照 seq = 104,缓冲区从 103 开始:103、104 扔掉,105 接着用,接得上。但如果快照 seq = 100,缓冲区却从 103 才开始(订阅建立得太晚),101、102 两边都没有,接不上,只能再拉一次快照。
 1. open WebSocket, subscribe, BUFFER every delta   ← 先订阅,收到的先存着
 2. GET snapshot via REST (it says: seq = 104)      ← 再拉快照
 3. drop buffered deltas with seq <= 104            ← 快照里已经包含了,扔掉
 4. apply 105, 106, ... one by one                  ← 按顺序补上
 5. from now on: every new delta must be last + 1   ← 之后每条都检查
    gap?  ->  throw the book away, go back to 1     ← 跳号就从头来
两种顺序对比:一步一步点每个时刻交易所发生一次变化:101、102、103……
还没发生 收到了,先缓冲 已经包含在快照里 104 划掉:缓冲过,但快照里已经有了,扔掉 已应用到本地 漏掉了

滚到这里会自动一格一格播放;点任何按钮就停下,换你自己点。怎么用:先在"正确"模式下一直点"下一步"点到时刻 12,看 101–112 每一格是什么颜色(没有红色);再切到"错误"模式点一遍,看 102–105 变成红色的"漏掉了",而且时刻 6 以后程序照样在应用,自己并不知道。

Tips · 同步顺序口诀:订阅、缓冲、快照、丢旧、接上、应用① 先订阅增量;② 收到的先缓冲;③ 再拉全量快照;④ 缓冲里编号 ≤ 快照编号的丢掉;⑤ 检查剩下第一条能不能和快照接上;⑥ 按顺序应用。任何一步对不上,就回到第 ① 步。进组后读行情模块的代码,就按这 6 步去找每一步写在哪。
8

跳号了怎么办:整本扔掉重来

本节新词重新同步 · 盘口不一致

为什么不能"只补那一条"

大白话
交易所一般不提供"把第 106 号单独再发我一次"的功能。丢了就是丢了。
唯一的办法
重新同步:把本地订单簿整本扔掉,回到第 7 节的第 1 步,重新缓冲、重新拉快照。这期间本地没有可信的盘口,策略必须暂停下单。
生活例子
对账时发现流水缺了一页,你不能凭感觉把余额补上。只能去银行打一份最新的完整对账单重新开始。

盘口不一致:忽略跳号的后果

大白话
如果发现跳号但假装没看见,继续应用后面的增量,本地订单簿和交易所的就对不上了,而且会一直错下去,直到那一档再次被更新。
算一遍
丢的那条是"卖 60010 → 0"(卖一被吃光了)。本地没收到,还以为卖一是 60010。策略想"以卖一价买入",按 60010 下了单,可实际上最便宜的卖单已经是 60020 了:你的限价单成交不了,挂在那里干等;或者策略基于错误的价差做了错误的判断。
原则
宁可暂停几百毫秒,也不能拿着错的盘口做决策。这就像数据库主从同步断了,宁可只读主库,也不能读一个不知道错在哪的从库。
动手:丢一条试试左边是交易所的真实订单簿,右边是你的本地订单簿
处理方式:
交易所(真实)
本地

建议顺序:先点几次"来一条增量",看两边同步变化;再点"让下一条丢掉",然后"来一条增量";最后切到"忽略跳号"再丢一次,看右边标红的不一致档位。

9

行情延迟:你看到的是过去

本节新词行情延迟 · 只做挂单

行情延迟 latency

大白话
交易所那边发生变化,到你的程序收到并更新完本地订单簿,中间花的时间。网络慢、程序处理不过来、垃圾回收卡顿,都会让延迟变大。
怎么量
很多行情消息带有交易所生成它的时间。用你收到的时间减去它,就大致知道延迟多少(前提是两边时钟对得准)。
生活例子
看直播比现场晚了 5 秒。现场已经进球了,你还在看带球。

只做挂单 post-only

大白话
D2 第 3 节讲过的 post-only,这里再说一遍。下单时加一个要求:"我只想当挂单(D1 讲过,手续费更低)。如果我这张单一进去就会马上成交变成吃单,那就直接拒绝,别成交。"
为什么网格常用
网格每一轮只赚一点差价,手续费高一点就可能白干,所以很多网格单会用只做挂单来保证拿到低费率。

延迟会造成什么

算一遍
你看到买一 60000、卖一 60010,决定在 60000 挂一张买单。但行情延迟了 500 ms,这期间价格其实已经跌了 15 U,真实的卖一变成了 59995。
你的买单出价 60000 ≥ 真实卖一 59995 → 一进去就成交,变成吃单,付了更高的手续费;如果用的是只做挂单,这张单直接被拒,还白白消耗了一次下单机会。
对策
一直监控行情延迟,超过阈值(比如 200 ms,具体由团队定)就暂停下单,等延迟恢复再继续。
拖一拖:延迟多少会出事价格正在往下走 · 数字只为演示
行情有多剧烈:
你看到的 买一 / 卖一60,000 / 60,010
真实的 买一 / 卖一–
期间价格变了–
延迟监控(阈值 200 ms)–

怎么用:默认是延迟 500 ms、行情普通,真实卖一已经跌到 59995,比你的出价 60000 还低,你的买单一进去就会成交(和第 9 节"算一遍"是同一个例子)。把延迟往左拖到 100 ms 左右,看它什么时候变回正常的挂单;再点"剧烈",看同样的延迟会差出多少钱。

常见误区 · 直接拿本机时间减消息时间算延迟交易所的时间戳和你机器的时钟不一定对得准。本机时钟慢了几百毫秒,算出来的延迟可能是负数,或者一直偏大。服务器要开时间同步(NTP);延迟监控看的是趋势和 P99(最慢的那 1%),不要被单个数字吓到或骗到。
10

在 Java 里怎么写

本节新词TreeMap 存盘口 · 单线程处理

用 TreeMap 存盘口

为什么不用 PriorityQueue
D1 讲撮合时用的是 PriorityQueue,因为那里存的是一张张订单,只需要取队头。本地订单簿不一样:增量会说"把 59990 这一档改成 0.3",你要能按价格直接找到某一档去改、去删。PriorityQueue 删任意元素要一个个找,TreeMap 按 key 找、改、删都很快,还能直接拿到最大或最小的 key(也就是买一、卖一)。
买和卖怎么排
买单:价格从高到低排,第一个就是买一。卖单:价格从低到高排,第一个就是卖一。
容易踩的坑
价格用 BigDecimal,别用 double(D2 讲过)。但 BigDecimal 的 equals 会比较小数位数:60000.0 和 60000.00 用 equals 比较是不相等的。TreeMap 按 compareTo 比较,认为它们相等,没问题;要是用了 HashMap,同一个价格就会变成两档。

单线程处理

大白话
同一个交易对的所有增量,只在一个线程里按顺序处理。
为什么
增量必须严格按编号顺序应用。多个线程同时改,105 可能比 104 先改完,盘口就乱了,而且很难复现。单线程天然有序,也不用加锁。
Java 类比
一个交易对一个 Executors.newSingleThreadExecutor(),或者一个线程循环从队列里取消息处理。策略如果在别的线程读盘口,就读一份拷贝,不要直接读正在被修改的 TreeMap。
import java.math.BigDecimal;
import java.util.Comparator;
import java.util.TreeMap;

// 一条增量:编号、买还是卖、哪个价格、这一档现在一共多少
record Delta(long seq, boolean isBid, BigDecimal price, BigDecimal size) {}

// 买:价格从高到低;卖:价格从低到高
TreeMap<BigDecimal, BigDecimal> bids = new TreeMap<>(Comparator.reverseOrder());
TreeMap<BigDecimal, BigDecimal> asks = new TreeMap<>();
long lastSeq;          // 本地订单簿当前包含到第几号

// 只在一个线程里调用
void onDelta(Delta d) {
    if (d.seq() <= lastSeq) return;                        // 旧的,扔掉
    if (d.seq() != lastSeq + 1) { resync(); return; }      // 跳号:暂停策略,重新同步
    TreeMap<BigDecimal, BigDecimal> side = d.isBid() ? bids : asks;
    if (d.size().signum() == 0) side.remove(d.price());    // 数量为 0:删掉这一档
    else side.put(d.price(), d.size());                    // 否则:改成新数量
    lastSeq = d.seq();
}

// resync():把 bids、asks 清空,策略暂停,回到第 7 节第 1 步重新缓冲、拉快照(自己实现)

BigDecimal bestBid() { return bids.firstKey(); }   // 买一价(订单簿为空时 firstKey 会抛异常,实际要先判空)
BigDecimal bestAsk() { return asks.firstKey(); }   // 卖一价

七日冲刺 D3 那道编程题叫 applyDeltas("应用一串增量"),就是用 JavaScript 写这个 onDelta:输入一份快照和一串增量,按第 6 节的三条规则处理。

11

词典和自测

今天用到的词都在这里,包括 D1 学过的几个。

自测 8 题

读完以后

回到 七日冲刺 D3,按这个顺序过一遍:先对照第 7 节看那张同步流程图;再玩盘口动画,正确做法丢一次包、勾上"忽略跳号"再丢一次;最后做 applyDeltas 编程题,它考的就是第 6 节的三条规则。