| 本篇位置 | 番外。正文三十五篇已经讲完,这一篇只做一件事:补第 28 篇留下的那个洞 |
| 用到的数据 | BTCUSDT 永续的盘口深度快照(2023-01-01 起,1,336 天)+ 第 28 篇的最优买卖报价 + 第 22 篇的 1 分钟线 |
| 动手 | 新模块 talab.book:sides、imbalance、relative、squeeze、recovery、slope,附 8 个测试 |
| 读完你能 | 回答「我这张单子在崩盘的时候会打穿多少」,并且知道盘口数据答不了哪些问题 |
⚠️ 先把边界说清楚,免得读到一半失望。
这一篇用的是 Binance 公开的 bookDepth:每 30 秒一张快照,每张只有 12 个数——买卖两侧在 ±0.2%、1%、2%、3%、4%、5% 六个距离上的累计挂单金额。
它不是逐笔订单流,也不是完整的 L2 盘口。所以:
| 能答 | 答不了 |
|---|---|
| 现价上下 1% 以内还剩多少钱 | 大单墙在哪一档 |
| 两侧对不对称 | 有没有撤单动作 |
| 崩盘时掉多少、多久回来 | 是不是冰山单 |
| 我的单子会打穿多少 | 美股的盘口(免费源一个都没有) |
右边那一列,正好是大多数人说「盘口」时想的那几样。它们要逐笔订单流,公开数据站没有,只能自己开 WebSocket 录,而且录的是未来。
这一篇只回答左边那一列。而左边第一行,就是第 28 篇欠下的那个问题。
这一篇的比方是银行挤兑:
| 挤兑 | 盘口 |
|---|---|
| 柜台前排多长的队 | 买卖价差 |
| 金库里还有多少现金 | 深度 |
| 「我随时能取钱」 | 「我随时能卖掉」 |
| 钱不是被取光的,是被搬走藏起来的 | 深度不是被吃掉的,是被撤走的 |
第 28 篇量的是排队的长度。这一篇量金库。
一、2023 年 8 月 17 日 21:44
time
2023-08-17 21:40:00+00:00 0.037 0.218 10.541 14.147 27581.180
2023-08-17 21:41:00+00:00 1.133 15.802 21.707 5.068 27453.379
2023-08-17 21:42:00+00:00 3.611 53.154 17.220 1.296 27128.116
2023-08-17 21:43:00+00:00 3.815 72.694 28.055 11.318 26007.042
2023-08-17 21:44:00+00:00 1.813 234.590 63.360 9.963 25251.355
2023-08-17 21:45:00+00:00 0.802 317.462 24.662 9.905 25488.011
2023-08-17 21:46:00+00:00 0.206 16.940 3.163 10.272 25883.355
2023-08-17 21:47:00+00:00 0.243 47.047 15.578 3.583 25939.502
2023-08-17 21:48:00+00:00 0.277 16.249 2.326 2.939 25974.965
2023-08-17 21:49:00+00:00 0.266 77.075 5.736 3.480 26197.570
2023-08-17 21:50:00+00:00 0.369 46.107 3.322 2.841 25929.871
当天价差中位 0.0353 个基点(第 28 篇量到的全样本中位是 0.0328),而 21:45 那一分钟最宽到过 317.46 个基点
⚠️ 顺带和第 28 篇那个 161.5 对一下账(它是「那一分钟里**最宽的一次报价**」,不是分钟平均):
2023-08-17:60 分钟最大跌幅 -10.52%,最宽的一次报价 317.46 基点,而分钟平均价差最大只有 3.81
2023-10-23:60 分钟最大跌幅 -5.45%,最宽的一次报价 161.48 基点,而分钟平均价差最大只有 7.47
两天比下来,光看价差连哪一天更糟都排不出来。
第 28 篇到这里就没有下文了:价差只是柜台前排队的长度。柜台后面还有多少钱?

这是第 28 篇那套数据能看到的全部:
- 当天价差中位 0.0353 个基点(第 28 篇全样本中位是 0.0328,对得上)
- 21:45 那一分钟,最宽的一次报价差到过 317.46 个基点——是平常的九千倍
- 但分钟平均的价差只有 0.802 个基点
第 28 篇的结论是「价差平时可以忽略,只在崩盘那几分钟要命」。看上去已经说完了。
可是价差只告诉你「买一和卖一差多远」,没告诉你「买一后面还有多少钱」。
一个要卖掉一百万美元的人,关心的从来不是第一档差几个基点,是现价下方一共还有多少人在等着接。
那三分钟里,柜台后面还有多少钱?
二、揭晓
2023-08-17 21:39:02+00:00 227360442.0 72072935.0 299433377.0 0.519
2023-08-17 21:39:31+00:00 231911293.0 73425936.0 305337229.0 0.519
2023-08-17 21:40:06+00:00 240758312.0 74564963.0 315323275.0 0.527
2023-08-17 21:40:31+00:00 226148720.0 84450000.0 310598720.0 0.456
2023-08-17 21:41:02+00:00 236117119.0 84608003.0 320725122.0 0.472
2023-08-17 21:41:31+00:00 231036770.0 73793572.0 304830342.0 0.516
2023-08-17 21:42:03+00:00 138187840.0 49987391.0 188175231.0 0.469
2023-08-17 21:42:31+00:00 121438884.0 50878573.0 172317457.0 0.409
2023-08-17 21:43:02+00:00 43411851.0 36004127.0 79415978.0 0.093
2023-08-17 21:43:30+00:00 40716145.0 21899860.0 62616005.0 0.301
2023-08-17 21:44:04+00:00 21020342.0 11356709.0 32377051.0 0.298
2023-08-17 21:44:31+00:00 19293926.0 11133606.0 30427532.0 0.268
2023-08-17 21:45:05+00:00 10904628.0 8526646.0 19431274.0 0.122
2023-08-17 21:45:31+00:00 16249321.0 6123267.0 22372588.0 0.453
崩盘前 24 小时,买侧深度中位 128,337,883 美元
21:41 那一张快照 240,758,312(比平常还厚 +88%)
21:45:05 只剩 10,904,628——229,853,684 美元在 3 分钟里消失了(-95.5%)

崩盘前 24 小时,现价下方 1% 以内的挂单中位是 1.28 亿美元。
21:41 那一张快照:2.41 亿——比平常还厚 88%。
21:45:05:只剩 1,090 万。三分钟里少了 95.5%。
价差那一栏最宽的时候是平常的九千倍,听上去很吓人;但它是一瞬间的数,分钟平均只放大到一两倍。深度不是——它是存量,少掉的那 2.3 亿不会在下一个报价里回来。
⚠️ 注意 21:41 那一行:崩盘前三分钟,盘口看起来是「买盘特别厚」的。这件事第十节再回来算账。
三、那三分钟砸下来多少钱
2023-08-17 21:41:00+00:00 27565.0 27315.0 27330.0 314466870.0 56700139.0 257766731.0
2023-08-17 21:42:00+00:00 27333.0 26676.0 26677.0 508378479.0 126442661.0 381935818.0
2023-08-17 21:43:00+00:00 26676.0 25322.0 25479.0 601220785.0 222410145.0 378810641.0
2023-08-17 21:44:00+00:00 25479.0 24581.0 24804.0 561369741.0 238243854.0 323125886.0
2023-08-17 21:45:00+00:00 24803.0 24731.0 25787.0 279132246.0 181502994.0 97629252.0
2023-08-17 21:46:00+00:00 25787.0 25779.0 26050.0 138925874.0 86274952.0 52650922.0
21:42–21:44 三分钟,主动卖出合计 1,083,872,345 美元
而崩盘前现价下方 1% 以内一共只有 128,337,883——**8.4 倍**
单是 21:44 这一分钟的 323,125,886,就已经是它的 2.52 倍

「主动卖出」是 成交额 − 主动买入额——taker 的卖单,也就是打在买盘上的那些钱。
| 金额 | |
|---|---|
| 21:42–21:44 三分钟,主动卖出合计 | 10.84 亿美元 |
| 崩盘前现价下方 1% 以内的全部承接力 | 1.28 亿美元 |
| 倍数 | 8.4 倍 |
| 单是 21:44 这一分钟 | 3.23 亿 = 2.52 倍 |
价格为什么要跌 10%?因为砸下来的钱是那一层承接力的八倍,它必须往下走到能接得住的地方。
这就是第 22 篇那些止损滑点的来源。那一篇量到「止损触发后 99% 分位滑点 +1.17%」,当时只能说「滑点是尾部问题」;现在知道尾部长什么样了——尾部就是你的单子掉进一个只有平时二十分之一的盘口。
四、这份数据有多少
盘口深度快照:3,768,503 张,2023-01-01 → 2026-08-31,1309 天
有值的快照比例 中位金额(美元)
-5% 1.000000 522491520.0
-4% 1.000000 455070351.0
-3% 1.000000 375156317.0
-2% 1.000000 277026095.0
-1% 1.000000 133883011.0
-0.2% 0.167522 33812766.0
+0.2% 0.167522 33916502.0
+1% 1.000000 128564243.0
+2% 1.000000 261541748.0
+3% 1.000000 343061411.0
+4% 1.000000 404098536.0
+5% 1.000000 453893371.0
⚠️ ±0.2% 那两档只从 2026-01-15 才开始有值,219 天——所以全篇用 ±1%。
⚠️ 第 28 篇那套价差数据只有 2023-07-19 → 2024-03-30 里的 11 天(Binance 之后不再公开 bookTicker),所以价差和深度**配得上对的只有这几天**。
三点要记住:
- 1,336 天、376 万张快照,2023-01-01 到 2026-08-31。⚠️ 2023 年之前 Binance 没有公开过。
- ±0.2% 那两档只从 2026-01-15 才开始有值(219 天)。所以全篇用 ±1%——它是所有年份都有的最近的一档。
- ⚠️ 第 28 篇那套价差数据只剩 11 天。Binance 的
bookTicker只发布到 2024-03-30 就停了,而这门课下载过的是其中 11 天。所以「价差 vs 深度」的对比只能在这 11 天里做,后面所有跨样本的结论都只有深度。
第三条不是小事:**这门课在第 28 篇量到的那个价差数字,今天已经没法在新数据上复现了。**第 34 篇说「交易所的规矩要和回测结果一起存档」,这里是同一句话的另一个版本——数据源本身也会消失。
五、不是一次特例
一次崩盘说明不了什么。把 2023 年以来所有「60 分钟跌超 5%」的时刻找出来——连着的合并成一段、只取最狠的那一分钟(第 25 篇的口径):
def runs(moments: pd.DatetimeIndex, gap: str = "6h") -> np.ndarray:
"""把相邻不超过 `gap` 的时刻并成一段,返回每个时刻属于第几段。
⚠️ 合并是第 25 篇定下来的口径:相邻分钟的事件窗口几乎完全重叠,
不合并的话同一次崩盘会被数很多遍。
"""
if not len(moments):
return np.array([], dtype=int)
# ⚠️ 别用 `np.diff(moments.asi8) > pd.Timedelta(gap).value`:这里的索引是**微秒**精度,
# 而 `Timedelta.value` 永远是**纳秒**,两边差一千倍,结果是所有事件被并成一段。
apart = (moments[1:] - moments[:-1]) > pd.Timedelta(gap)
return np.concatenate([[0], np.cumsum(np.asarray(apart, dtype=int))])
def pick_per_run(moments: pd.DatetimeIndex, weight: np.ndarray | None = None,
gap: str = "6h") -> pd.DatetimeIndex:
"""每一段只留一个时刻:`weight` 最小的那个(不给就取段首)。⚠️ 用位置索引,免得丢掉时区。"""
moments = pd.DatetimeIndex(moments).sort_values()
if not len(moments):
return moments
label = runs(moments, gap)
keep = []
for value in np.unique(label):
inside = np.flatnonzero(label == value)
keep.append(inside[0] if weight is None else inside[int(np.argmin(weight[inside]))])
return moments[np.array(keep)]
def events(threshold: float = -0.05, window: int = 60, gap: str = "6h") -> pd.DatetimeIndex:
"""崩盘时刻:`window` 分钟跌幅超过 `threshold`,连着的合并成一段、只取最狠的那一分钟。"""
drop = um["close"].pct_change(window)
hit = drop[drop <= threshold]
return pick_per_run(hit.index, hit.to_numpy(float), gap)
⚠️ 里面那条注释是这一篇踩到的坑,值得单独记:Timedelta.value 永远是纳秒,而这份数据的索引是微秒精度。用 np.diff(index.asi8) > pd.Timedelta("6h").value 去比,两边差一千倍,结果是 22 次崩盘被并成 1 次。改成直接相减比 Timedelta,pandas 自己处理单位。
2023-01-01 起,60 分钟跌幅超过 5% 的崩盘共 22 次:
时刻 60 分钟跌幅 那一分钟主动卖出 崩盘前 24 小时买侧 崩盘后 10 分钟最低 掉了多少
2023-03-03 01:57 -0.0570 2.287554e+08 109807805.0 30111551.0 -0.7258
2023-03-22 19:53 -0.0565 4.236702e+07 100468940.0 61251914.0 -0.3903
2023-04-26 20:01 -0.0810 2.190031e+08 107168120.0 37397512.0 -0.6510
2023-08-17 21:44 -0.1052 3.231259e+08 128337883.0 10904628.0 -0.9150
2023-10-16 14:30 -0.0602 2.476953e+07 95810012.5 42084299.0 -0.5608
2023-10-23 23:42 -0.0545 1.728655e+07 80203968.0 39443718.0 -0.5082
2023-12-11 02:12 -0.0569 4.060089e+08 133655230.0 23423058.0 -0.8248
2024-01-03 12:09 -0.1007 3.699221e+08 78374643.0 10061244.0 -0.8716
2024-01-09 22:14 -0.0500 1.629810e+07 69079049.0 37908150.0 -0.4512
2024-02-28 18:23 -0.0581 1.732361e+08 124489542.5 36379223.0 -0.7078
2024-03-05 19:56 -0.0628 4.315565e+08 103161972.0 20452911.0 -0.8017
2024-03-15 03:28 -0.0544 1.908203e+08 121968010.0 43715971.0 -0.6416
2024-04-13 20:08 -0.0888 3.106557e+08 101717893.0 14023530.0 -0.8621
2024-08-05 01:10 -0.0845 4.182273e+08 94571430.0 30877046.0 -0.6735
2024-08-27 22:15 -0.0573 1.802994e+08 148508539.0 43161545.0 -0.7094
2024-12-05 22:28 -0.0659 9.851355e+08 172554118.0 10611606.0 -0.9385
2025-01-20 17:44 -0.0546 1.822102e+08 120344937.5 54090640.0 -0.5505
2025-02-03 02:07 -0.0525 2.409714e+08 156387715.0 50318324.0 -0.6782
2025-03-07 00:54 -0.0549 1.327852e+08 138333161.5 92002902.0 -0.3349
2025-10-10 21:19 -0.1137 5.991851e+08 208066432.0 2142547.0 -0.9897
2026-02-05 15:37 -0.0501 4.178351e+07 129251184.0 84423186.0 -0.3468
2026-02-06 00:14 -0.0672 1.810222e+08 124354406.0 46508092.0 -0.6260
22 次里,买侧深度掉幅的中位数是 -67.6%,最狠 -99.0%,最轻 -33.5%

22 次崩盘,买侧深度掉幅的中位数是 −67.6%,最狠的一次 −99.0%。
最狠那次是 2025-10-10:崩盘前 2.08 亿,十分钟后只剩 214 万——千分之一。
六、和什么比
上面那张表少了这门课最看重的一样东西:对照组。
「崩盘的时候深度掉七成」这句话,要和什么比才有意义?和「平常」比是不够的——崩盘那几分钟本来就有巨量的卖单砸下来,深度被吃掉一些是算术,不是发现。
所以对照组要控制住卖单的大小:主动卖出额同样落在最大的 0.1% 里,但 60 分钟涨跌幅在 ±1% 以内的时刻。
对照组:主动卖出额同样在最大的 0.1% 里,但 60 分钟涨跌幅在 ±1% 以内的时刻,共 410 个
哪一组 个数 那一分钟主动卖出(中位) 买侧深度掉幅(中位) 买侧深度掉幅(最狠)
崩盘 22 2.049117e+08 -0.6759 -0.9897
对照:同样大的卖单,但价格没崩 410 1.576567e+08 -0.1926 -0.9971
| 个数 | 那一分钟主动卖出(中位) | 买侧深度掉幅(中位) | |
|---|---|---|---|
| 崩盘 | 22 | 2.05 亿 | −67.6% |
| 对照:同样大的卖单,但价格没崩 | 410 | 1.58 亿 | −19.3% |
卖单大小差不多(2.05 亿对 1.58 亿),深度掉幅差三倍半。
⚠️ 但两组的尾巴是重叠的:对照组里最狠的一次掉了 −99.7%,比崩盘组的最狠还难看。所以正确的说法是「中位数差得很远」,不是「崩盘时必然掉光、平常必然不掉」。
七、价差和深度,恢复得一样快吗
价差和深度都有数据的崩盘:6 次(bookTicker 只覆盖 11 天)
什么时候 价差(越小越好,1 = 回到崩盘前) 买侧深度(越大越好,1 = 回到崩盘前)
10 分钟后 1.826 0.755
1 小时后 1.015 0.893
4 小时后 0.580 1.227
24 小时后 0.982 1.142
同样这几次崩盘、同样的口径:价差十分钟后还宽着,一小时基本回到崩盘前;买侧深度十分钟后只剩四分之三,一小时补到九成。
⚠️ 只有 6 次事件,这张表的每个数都很吵(4 小时那一行价差反而比崩盘前还窄)——它只够说明「两者的时间常数不一样」,不够说明差多少。下一节用全部 22 次重做。
八、深度多久才补回来
什么时候 买侧深度占崩盘前 两侧合计占崩盘前 期间主动卖出(中位,美元)
10 分钟后 0.5639 0.7662 7.860757e+08
1 小时后 0.8246 0.8834 2.156238e+09
4 小时后 0.9470 0.9908 4.452416e+09
24 小时后 0.8433 0.9684 1.678530e+10
⚠️ 换成「第一次碰到崩盘前的九成是什么时候」,中位只有 3 分钟,1 次两天都没碰到——但那只是碰一下。深度抖得厉害,碰一下不等于补回来了,上面那张表才是持续水平。
最低点中位只剩崩盘前的 42.1%,最低点出现在崩盘后 2.2 分钟

| 崩盘之后 | 买侧深度占崩盘前 |
|---|---|
| 10 分钟 | 56.4% |
| 1 小时 | 82.5% |
| 4 小时 | 94.7% |
最低点中位只剩崩盘前的 42.1%,出现在崩盘后 2.2 分钟。
⚠️ 这里有一个很容易做错的地方。如果改问「第一次碰到崩盘前的九成是什么时候」,中位只有 3 分钟——听上去深度恢复得飞快。但那只是碰一下:深度抖得厉害,一张快照触到 90% 再掉回去,会把一条趴在地上的曲线记成「三分钟就回来了」。
所以 recovery 里写了两条规矩,测试把它们都钉住了:
def recovery(values: pd.Series, events, horizon: int = DAY, fraction: float = 0.9,
before: int = 60, trough_within: int | None = None) -> pd.DataFrame:
"""事件之后,多久回到事件**前**水平的 `fraction`。
「事件前水平」取事件前 `before` 张快照的中位数。
⚠️ 计时从**最低点**算起,不是从事件那一刻算起。崩盘那一分钟深度往往还没来得及掉,
从事件时刻起算会把一大半事件记成「0 分钟就回来了」——**那不是恢复快,是还没开始跌**。
⚠️ 而最低点只在事件后 `trough_within` 张快照里找(默认 120 张 = 一小时,不超过 `horizon`)。
`horizon` 动辄两天,在两天里找最低点,找到的多半是**下一次崩盘**,不是这一次的谷底。
回不到就记 NaN——别把回不到的那些当成 0,也别把它们悄悄丢掉:
**它们才是最该看的那几次。**
"""
if not 0 < fraction <= 1:
raise ValueError("fraction 要在 0 和 1 之间")
trough_within = min(120, horizon) if trough_within is None else trough_within
if trough_within < 1 or trough_within > horizon:
raise ValueError("找谷底的窗口要在 1 和 horizon 之间")
index, array = values.index, values.to_numpy(float)
rows = []
for event in pd.DatetimeIndex(events):
when = event.tz_localize(index.tz) if index.tz is not None and event.tz is None else event
position = int(index.searchsorted(when))
if position - before < 0 or position + 1 >= len(array):
continue
base = float(np.nanmedian(array[position - before:position]))
after = array[position:position + horizon]
if not len(after) or np.all(np.isnan(after)):
continue
trough = int(np.nanargmin(after[:trough_within]))
back = np.flatnonzero(after[trough:] >= base * fraction)
rows.append({"事件": index[position], "事件前水平": base,
"最低点": float(after[trough]),
"最低点占事件前": (float(after[trough]) / base) if base > 0 else np.nan,
"最低点在几分钟后": trough / 2,
"几张快照后回来": float(back[0]) if len(back) else np.nan,
"几分钟后回来": float(back[0]) / 2 if len(back) else np.nan})
return pd.DataFrame(rows)
- 计时从最低点算起,不是从事件那一刻算起(崩盘那一分钟深度往往还没来得及掉)
- 最低点只在事件后一小时里找(
horizon动辄两天,在两天里找最低点,找到的多半是下一次崩盘)
**第 28 篇那句话的深度版本是:形状一样,幅度大得多、窗口长得多。**价差在分钟平均上只放大一两倍、一小时就回去了;深度直接少掉一半,要一个小时才补到八成。
九、崩盘时盘口变陡了
「陡峭程度」就是远档深度 ÷ 近档深度。这个比值越大,说明钱越集中在远处——近处很薄,稍微一卖价格就往下走一截。
(一行是一个偏移,单位是 30 秒快照;0 就是崩盘那一刻)
平均 中位数 事件数 平常的平均 平常的中位数
-20 3.993 3.961 22 3.66 3.602
-10 3.796 3.691 22 3.66 3.602
-5 3.834 3.794 22 3.66 3.602
-2 3.653 3.590 22 3.66 3.602
-1 3.832 4.025 22 3.66 3.602
0 4.084 3.788 22 3.66 3.602
1 4.529 3.959 22 3.66 3.602
2 4.950 3.835 22 3.66 3.602
5 4.440 4.050 22 3.66 3.602
10 4.101 3.961 22 3.66 3.602
20 3.761 3.654 22 3.66 3.602
40 3.767 3.765 22 3.66 3.602
平常是 3.60,崩盘后头五张快照(两分半)升到 3.83–4.05,二十张快照(十分钟)之后回到 3.65。
⚠️ 幅度不大(中位数只动了 12%),而且平均数(4.95)远高于中位数——说明是少数几次拉高的。这一节的结论是「有这个方向,但不强」,不要拿它做规则。
十、「买盘厚所以会涨」
这是盘口里最流行的说法。这一篇有 22 次崩盘可以拿来验它。
崩盘之前那几张快照,盘口是什么样的:
平均 中位数 事件数 平常的平均 平常的中位数
-20 -0.0175 -0.0031 22 0.0127 0.0153
-10 0.0456 0.1158 22 0.0127 0.0153
-5 0.0529 0.0602 22 0.0127 0.0153
-2 0.0950 0.1005 22 0.0127 0.0153
-1 0.0324 0.0576 22 0.0127 0.0153
0 0.1109 0.1584 22 0.0127 0.0153
1 0.1154 0.1712 22 0.0127 0.0153
2 0.1535 0.1708 22 0.0127 0.0153
5 -0.1241 -0.0576 22 0.0127 0.0153
10 -0.1577 -0.1471 22 0.0127 0.0153
20 -0.1099 -0.0868 22 0.0127 0.0153
⚠️ 决策点那一天,21:41 的不对称是 +0.516——买盘比卖盘厚一半还多,三分钟之后价格跌了 10%

| 离崩盘(括号里是快照数) | 买卖不对称(中位) |
|---|---|
| 平常 | +0.0153 |
| 崩盘前 5 分钟(−10) | +0.1158 |
| 崩盘前 1 分钟(−2) | +0.1005 |
| 崩盘前 30 秒(−1) | +0.0576 |
| 崩盘那一刻(0) | +0.1584 |
| 崩盘后 2.5 分钟(+5) | −0.0576 |
| 崩盘后 5 分钟(+10) | −0.1471 |
⚠️ 一张快照是 30 秒,所以表里的偏移要除以二才是分钟。
崩盘那一刻,买盘看起来比平常厚十倍。
而决策点那一天更夸张:21:41 的不对称是 +0.516——买盘比卖盘厚一半还多。三分钟之后价格跌了 10%。
这不是巧合,机制也不神秘:价格往下走的时候,还没被撤掉的买单相对中间价越来越近,而卖单相对中间价越来越远。于是「现价下方 1% 以内的钱」在下跌的头几十秒里会变多,直到它们被吃光或者撤掉。
在这 22 次里,「买盘厚」是一个精确的反向指标。
十一、显著,和有用
那么反过来用行不行?崩盘是尾部,平常呢?
用上一分钟收盘时的盘口不对称,去预测之后 1 / 5 / 30 / 240 分钟的涨跌,和打乱 200 次的对照比:
往后看几分钟 样本量 秩相关 打乱 200 次的 95% 分位 比打乱的极端吗 秩相关的平方
1 1895529 0.01444 0.00155 True 0.00021
5 1895525 0.03123 0.00131 True 0.00098
30 1895500 0.04783 0.00157 True 0.00229
240 1895290 0.03590 0.00144 True 0.00129
⚠️ 四个窗口全部「比打乱的极端」——但样本有 190 万分钟,秩相关 0.048 的平方只有 0.00229。**显著和有用是两件事**(第 19 篇量过同一件事的另一面:不显著也不等于没有)。
| 往后看 | 秩相关 | 打乱的 95% 分位 | 平方 |
|---|---|---|---|
| 1 分钟 | 0.0144 | 0.0016 | 0.00021 |
| 5 分钟 | 0.0312 | 0.0013 | 0.00098 |
| 30 分钟 | 0.0478 | 0.0016 | 0.00229 |
| 240 分钟 | 0.0359 | 0.0014 | 0.00129 |
四个窗口全部远远超过打乱的对照——统计上毫无疑问是显著的。
而秩相关最大只有 0.048,平方之后 0.0023。它解释了千分之二的变化。
⚠️ 这两句话都对,而且是这一篇最该带走的一条:样本有 190 万分钟,任何一点点系统性都会显著。显著回答的是「是不是零」,有用回答的是「够不够覆盖成本」——第 28 篇量过 taker 费率是 10 个基点,而这点相关性连价差都盖不住。
而且方向还得反着记:平常是弱正相关(买盘厚,之后微微涨),崩盘前是强反向(买盘看起来最厚的那一刻,价格正要塌)。两个都真,只是后者发生在尾部——而尾部才是会要你命的地方。
十二、价差和深度量的不是同一件事
两套数据都有的分钟:15,785
价差 买侧深度 两侧合计 主动卖出
价差 1.000 -0.189 -0.214 0.245
买侧深度 -0.189 1.000 0.915 -0.370
两侧合计 -0.214 0.915 1.000 -0.361
主动卖出 0.245 -0.370 -0.361 1.000
价差中位 0.0335 基点、99% 分位 0.197、最大 7.5
深度中位 99,515,026 美元、1% 分位 44,158,346、最小 7,025,894
价差从中位到最大放大了 223 倍,深度从中位到最小缩小到 7.060%
价差和买侧深度的秩相关只有 −0.189。
| 中位 | 极端 | 放大/缩小 | |
|---|---|---|---|
| 价差 | 0.0335 基点 | 最大 7.5 基点 | 223 倍 |
| 买侧深度 | 9,952 万美元 | 最小 703 万 | 缩到 7.06% |
**它们几乎是两个独立的量。**用价差衡量流动性,你会得到「平时好得不得了、偶尔有一瞬间很糟」;用深度衡量,你会得到「平时也就那样、崩盘时少一半、要一小时才补回来」。
对一个要平掉仓位的人,后面那个才是他真正面对的东西。
十三、你的单子有多大
这一篇唯一能直接拿去用的结论。
一张市价卖单(美元) 占现价下方 1% 承接力的比例(平常中位) 崩盘那十分钟的中位 平常打穿 1% 的比例
100000 0.0007 0.0027 0.0000
1000000 0.0075 0.0266 0.0000
10000000 0.0747 0.2656 0.0037
50000000 0.3735 1.3280 0.0058

| 一张市价卖单 | 占 1% 承接力(平常) | 占 1% 承接力(崩盘那十分钟) |
|---|---|---|
| 10 万美元 | 0.07% | 0.27% |
| 100 万 | 0.75% | 2.66% |
| 1,000 万 | 7.47% | 26.56% |
| 5,000 万 | 37.35% | 132.80% |
一张 5,000 万美元的市价卖单,平常只用掉三分之一的承接力;崩盘那十分钟,它会把现价下方 1% 全部吃光还不够。
接到第 34 篇:
账户(美元) 一笔仓位 占平常 1% 承接力 占崩盘时 1% 承接力(中位)
100000.0 6.666667e+04 0.0005 0.0018
1000000.0 6.666667e+05 0.0050 0.0177
10000000.0 6.666667e+06 0.0498 0.1771
100000000.0 6.666667e+07 0.4979 1.7706
第 34 篇算过主线 v4 的仓位是账户的 66.67%,并且指出账户超过 1,430 万美元时 MARKET_LOT_SIZE 会让一张市价单发不完。现在有了更早、更软的一条线:
| 账户 | 一笔仓位 | 占平常 1% 承接力 | 占崩盘时 |
|---|---|---|---|
| 10 万 | 6.7 万 | 0.05% | 0.18% |
| 1,000 万 | 667 万 | 4.98% | 17.71% |
| 1 亿 | 6,667 万 | 49.79% | 177.06% |
交易所的硬门槛在 1,430 万美元,盘口的软门槛比它早得多——账户到一千万的时候,你的一笔仓位在崩盘时已经要吃掉六分之一的承接力了。
十四、三年下来,市场变深了吗
2023-12-31 00:00:00+00:00 101354054.0 98343678.0 202.4
2024-12-31 00:00:00+00:00 116107998.0 110414818.0 232.4
2025-12-31 00:00:00+00:00 191388186.0 185622199.0 381.0
2026-12-31 00:00:00+00:00 157745134.0 155701594.0 328.8
2023 7
2024 9
2025 4
2026 2
| 年份 | 两侧合计(中位) | 崩盘次数 |
|---|---|---|
| 2023 | 2.02 亿 | 7 |
| 2024 | 2.32 亿 | 9 |
| 2025 | 3.81 亿 | 4 |
| 2026(到 8 月) | 3.29 亿 | 2 |
深度三年长了差不多一倍。⚠️ 但这不能直接读成「市场更稳了」:BTC 的价格在同一段时间也涨了两三倍,按币计价的深度未必变厚;崩盘次数从 9 降到 2,样本太小,而且 2026 年只统计到 8 月。
**这张表能说的只有一句:绝对金额的深度,不能跨年份直接比。**这也是 relative 那个函数存在的理由——它拿滚动中位数做分母,就是为了避开这件事。
十五、talab.book 的完整代码
"""talab.book:盘口深度。番外篇——补第 28 篇留下的那个洞。
第 28 篇量了**价差**:BTC 永续平时的真实买卖价差中位数只有 0.0328 个基点,
基本就是一个最小变动价位;但振幅 20.4% 那天,有一分钟飙到 **161.5 个基点**。
那一篇留了半个问题没答:**价差只是柜台前排队的长度,柜台后面还有多少钱,它没量。**
这一篇用银行挤兑做比方:
| 挤兑 | 盘口 |
|---|---|
| 柜台前排多长的队 | 买卖价差 |
| **金库里还有多少现金** | **深度** |
| 「我随时能取钱」 | 「我随时能卖掉」 |
| 钱不是被取光的,是**被搬走藏起来的** | 深度不是被吃掉的,是**被撤走的** |
⚠️ **先说清楚这份数据不是什么。**Binance 公开的 `bookDepth` 是**每 30 秒一张快照**,
每张只有 12 个数:买卖两侧在 ±0.2%、1%、2%、3%、4%、5% 六个距离上的**累计**挂单金额。
它不是逐笔订单流,也不是完整的 L2 盘口。所以:
- **能答**:现价上下 1% 以内还剩多少钱、两侧对不对称、崩盘时掉多少、多久回来
- **答不了**:大单墙在哪一档、有没有撤单动作、是不是冰山单——
这些要逐笔订单流,公开数据站没有,只能自己开 WebSocket 录,而且录的是未来
⚠️ 还有两条边界,正文第三节会展开:这份数据**只有 BTC 永续、只从 2023-01-01 开始**,
而第 28 篇那套价差数据**只有 2023-05-16 到 2024-03-30**——
**两者配得上对的只有 320 天。**
"""
from __future__ import annotations
import numpy as np
import pandas as pd
LEVELS = (0.2, 1.0, 2.0, 3.0, 4.0, 5.0)
DAY = 2 * 60 * 24 # 一天有多少张 30 秒快照
def _columns(level: float) -> tuple[str, str]:
if level not in LEVELS:
raise ValueError(f"只有 {LEVELS} 这几档,收到 {level}")
return f"{-level:+g}%", f"{level:+g}%"
def sides(depth: pd.DataFrame, level: float = 1.0) -> pd.DataFrame:
"""把某一档拆成买侧、卖侧、合计和不对称。
⚠️ 负号那一侧是**买盘**(挂在中间价下方)。「买侧深度」回答的是
**「现价下方 1% 以内,一共还有多少钱在等着接」**——这正是你想卖出时唯一关心的数。
"""
bid, ask = _columns(level)
if bid not in depth or ask not in depth:
raise ValueError(f"表里没有 {bid} 或 {ask} 这两列")
out = pd.DataFrame({"买侧": depth[bid], "卖侧": depth[ask]})
out["合计"] = out["买侧"] + out["卖侧"]
out["不对称"] = (out["买侧"] - out["卖侧"]) / out["合计"].where(out["合计"] > 0)
return out
def imbalance(depth: pd.DataFrame, level: float = 1.0) -> pd.Series:
"""买卖不对称:(买 − 卖) ÷ (买 + 卖),范围 −1 到 +1。
正数表示买盘比卖盘厚。⚠️ 「买盘厚所以会涨」是盘口里最流行的说法之一,
正文第七节会把它和打乱后的对照组放在一起量——**先别信它**。
"""
return sides(depth, level)["不对称"].rename(f"不对称({level:g}%)")
def relative(values: pd.Series, window: int = DAY) -> pd.Series:
"""相对「最近 `window` 张快照的中位数」是几倍。
深度的绝对金额在三年里长了好几倍(市场变深了),直接比 2023 年和 2026 年的美元数没有意义。
⚠️ 这里用的是**滚动中位数**而不是全样本中位数,也不分 UTC 小时——
第 20 篇量过 BTC 有明显的小时效应,正文第五节会检查它够不够小。
"""
if window < 2:
raise ValueError("窗口至少是 2")
base = values.rolling(window, min_periods=window // 4).median().shift(1)
return (values / base.where(base > 0)).rename("相对平常")
def squeeze(depth: pd.DataFrame, taker_sell: pd.Series, level: float = 1.0,
span: int = 30) -> pd.DataFrame:
"""**深度少掉的那些钱,有多少能被成交解释。**这是这一篇的核心计算。
每个时刻回看 `span` 张快照(默认 30 张 = 15 分钟):
- `少掉多少`:买侧深度从那时到现在减少了多少美元
- `吃掉多少`:同一段时间里主动卖出的成交额(taker 卖单打在买盘上)
- `吃掉的占比`:后者 ÷ 前者
占比接近 1,说明买盘是**被吃掉**的;占比很小,说明那些钱**自己走了**——
要么撤单,要么挂到更远的地方去了。⚠️ 对一个想卖出的人来说这两件事没有区别:
**它不在现价下方 1% 以内了。**
⚠️ 这个比值是一个**下界**:深度只统计 ±1% 以内,而成交可能发生在更远的地方,
所以「吃掉多少」会被高估一点点。结论是「撤走的比吃掉的多得多」时,这个方向的误差不影响结论。
"""
if span < 1:
raise ValueError("回看的快照数至少是 1")
bid = sides(depth, level)["买侧"]
lost = (bid.shift(span) - bid).rename("少掉多少")
eaten = taker_sell.reindex(bid.index).fillna(0.0).rolling(span).sum().rename("吃掉多少")
share = (eaten / lost.where(lost > 0)).rename("吃掉的占比")
return pd.concat([bid.rename("买侧深度"), lost, eaten, share], axis=1)
def recovery(values: pd.Series, events, horizon: int = DAY, fraction: float = 0.9,
before: int = 60, trough_within: int | None = None) -> pd.DataFrame:
"""事件之后,多久回到事件**前**水平的 `fraction`。
「事件前水平」取事件前 `before` 张快照的中位数。
⚠️ 计时从**最低点**算起,不是从事件那一刻算起。崩盘那一分钟深度往往还没来得及掉,
从事件时刻起算会把一大半事件记成「0 分钟就回来了」——**那不是恢复快,是还没开始跌**。
⚠️ 而最低点只在事件后 `trough_within` 张快照里找(默认 120 张 = 一小时,不超过 `horizon`)。
`horizon` 动辄两天,在两天里找最低点,找到的多半是**下一次崩盘**,不是这一次的谷底。
回不到就记 NaN——别把回不到的那些当成 0,也别把它们悄悄丢掉:
**它们才是最该看的那几次。**
"""
if not 0 < fraction <= 1:
raise ValueError("fraction 要在 0 和 1 之间")
trough_within = min(120, horizon) if trough_within is None else trough_within
if trough_within < 1 or trough_within > horizon:
raise ValueError("找谷底的窗口要在 1 和 horizon 之间")
index, array = values.index, values.to_numpy(float)
rows = []
for event in pd.DatetimeIndex(events):
when = event.tz_localize(index.tz) if index.tz is not None and event.tz is None else event
position = int(index.searchsorted(when))
if position - before < 0 or position + 1 >= len(array):
continue
base = float(np.nanmedian(array[position - before:position]))
after = array[position:position + horizon]
if not len(after) or np.all(np.isnan(after)):
continue
trough = int(np.nanargmin(after[:trough_within]))
back = np.flatnonzero(after[trough:] >= base * fraction)
rows.append({"事件": index[position], "事件前水平": base,
"最低点": float(after[trough]),
"最低点占事件前": (float(after[trough]) / base) if base > 0 else np.nan,
"最低点在几分钟后": trough / 2,
"几张快照后回来": float(back[0]) if len(back) else np.nan,
"几分钟后回来": float(back[0]) / 2 if len(back) else np.nan})
return pd.DataFrame(rows)
def slope(depth: pd.DataFrame, near: float = 1.0, far: float = 5.0) -> pd.Series:
"""盘口的陡峭程度:远档深度 ÷ 近档深度(两侧合计)。
这个比值越大,说明钱越集中在**远处**——近处很薄,稍微一卖价格就往下走一截。
崩盘的时候它会怎么变,正文第六节会量。
"""
if not near < far:
raise ValueError("near 要小于 far")
close_in = sides(depth, near)["合计"]
far_out = sides(depth, far)["合计"]
return (far_out / close_in.where(close_in > 0)).rename(f"{far:g}% ÷ {near:g}%")
下载脚本:
"""番外篇的数据:BTCUSDT 永续的盘口深度快照(在 talab 项目根目录运行)。
两样东西:
1. `bookDepth`:每 30 秒一张快照,买卖两侧在 ±0.2/1/2/3/4/5% 上的累计挂单金额。
**2023-01-01 起**,一天约 550 KB,转成宽表之后更小。
2. `bookTicker`:最优买卖报价,第 28 篇已经下过 8 天,这里补上几个崩盘日。
⚠️ 这套数据**只有 2023-05-16 到 2024-03-30**,之后 Binance 不再公开。
所以**只有 2023-05-16 到 2024-03-30 这 320 天,价差和深度才配得上对**;
再往后只有深度。正文第三节会专门交代这件事。
"""
from concurrent.futures import ThreadPoolExecutor
import pandas as pd
from talab import data as D
DEPTH_DAYS = [day.strftime("%Y-%m-%d")
for day in pd.date_range("2023-01-01", "2026-08-31", freq="D")]
# 第 28 篇下过的 8 天,外加价差与深度都有的那几个崩盘日
TICKER_DAYS = ["2023-07-19", "2023-08-12", "2023-08-17", "2023-10-16", "2023-10-23",
"2023-12-11", "2024-01-03", "2024-01-11", "2024-02-28", "2024-03-05",
"2024-03-30"]
# 串行一天一天下要十几分钟,用线程池按天并行(和第 19 篇的下载脚本同一个写法)
with ThreadPoolExecutor(max_workers=8) as pool:
got = list(pool.map(lambda day: D.download_binance_book_depth("BTCUSDT", [day]), DEPTH_DAYS))
depth = [path for batch in got for path in batch]
print(f"bookDepth:{len(depth)} 天({DEPTH_DAYS[0]} → {DEPTH_DAYS[-1]})")
ticker = D.download_binance_book_ticker("BTCUSDT", TICKER_DAYS)
have_depth = {path.name.split("-30s-")[-1].split(".")[0] for path in depth}
print(f"bookTicker:{len(ticker)} 天,其中价差与深度都有的 "
f"{len(set(TICKER_DAYS) & have_depth)} 天")
十六、测试
"""talab.book 的测试(番外篇)。深度全部手工构造,每个数都能自己算一遍。"""
import numpy as np
import pandas as pd
import pytest
from talab import book as B
def snapshots(bid_1, ask_1, bid_5=None, ask_5=None) -> pd.DataFrame:
"""一张最小的深度表:只填 ±1% 和 ±5% 两档,列名和 `data.load_binance_book_depth` 一致。"""
n = len(bid_1)
index = pd.date_range("2024-01-01", periods=n, freq="30s", tz="UTC")
data = {"-1%": np.asarray(bid_1, float), "+1%": np.asarray(ask_1, float)}
if bid_5 is not None:
data["-5%"] = np.asarray(bid_5, float)
data["+5%"] = np.asarray(ask_5, float)
return pd.DataFrame(data, index=index)
def test_sides_splits_the_level_and_knows_which_sign_is_the_bid():
depth = snapshots([300.0, 100.0], [100.0, 100.0])
out = B.sides(depth, 1.0)
assert list(out["买侧"]) == [300.0, 100.0] # 负号那一侧是买盘
assert list(out["合计"]) == [400.0, 200.0]
assert out["不对称"].iloc[0] == pytest.approx(0.5) # (300-100)/400
assert out["不对称"].iloc[1] == pytest.approx(0.0)
with pytest.raises(ValueError, match="0.2"):
B.sides(depth, 1.5) # 不存在的档位
with pytest.raises(ValueError, match="没有"):
B.sides(depth, 5.0) # 这张表没填 ±5%
def test_imbalance_stays_between_minus_one_and_one():
depth = snapshots([100.0, 0.0, 50.0], [0.0, 100.0, 50.0])
out = B.imbalance(depth)
assert list(out) == [1.0, -1.0, 0.0]
assert out.name == "不对称(1%)"
empty = B.imbalance(snapshots([0.0], [0.0]))
assert np.isnan(empty.iloc[0]) # 两侧都是 0,除不出来
def test_relative_never_uses_the_current_snapshot():
"""这一篇的时钟规矩:`relative` 的分母只能用**之前**的快照。"""
values = pd.Series([10.0] * 8 + [1000.0],
index=pd.date_range("2024-01-01", periods=9, freq="30s", tz="UTC"))
out = B.relative(values, window=8)
# 最后一根暴涨到 1000,分母仍然是前八根的中位数 10 → 100 倍
assert out.iloc[-1] == pytest.approx(100.0)
# 把最后一根改成 5,分母一个字不变——说明它没看当根
changed = values.copy()
changed.iloc[-1] = 5.0
assert B.relative(changed, window=8).iloc[-1] == pytest.approx(0.5)
with pytest.raises(ValueError):
B.relative(values, window=1)
def test_squeeze_splits_what_vanished_into_eaten_and_gone():
# 买侧从 1000 掉到 200:少了 800
bid = [1000.0, 1000.0, 1000.0, 200.0]
depth = snapshots(bid, [1000.0] * 4)
sells = pd.Series([0.0, 0.0, 0.0, 100.0], index=depth.index) # 同期只吃掉 100
out = B.squeeze(depth, sells, level=1.0, span=3)
assert out["少掉多少"].iloc[-1] == pytest.approx(800.0)
assert out["吃掉多少"].iloc[-1] == pytest.approx(100.0)
# 八百块钱消失,只有一百是被吃掉的——剩下七百自己走了
assert out["吃掉的占比"].iloc[-1] == pytest.approx(0.125)
with pytest.raises(ValueError):
B.squeeze(depth, sells, span=0)
def test_squeeze_leaves_the_ratio_undefined_when_depth_grew():
depth = snapshots([100.0, 100.0, 500.0], [100.0] * 3)
sells = pd.Series([10.0] * 3, index=depth.index)
out = B.squeeze(depth, sells, span=2)
assert out["少掉多少"].iloc[-1] < 0 # 深度反而变厚了
assert np.isnan(out["吃掉的占比"].iloc[-1]) # 这时候「占比」没有意义
def test_recovery_finds_when_it_came_back_and_admits_when_it_did_not():
base = [100.0] * 10
back = pd.Series(base + [20.0, 30.0, 60.0, 95.0, 100.0],
index=pd.date_range("2024-01-01", periods=15, freq="30s", tz="UTC"))
event = back.index[10]
out = B.recovery(back, [event], horizon=10, fraction=0.9, before=10)
assert out["事件前水平"].iloc[0] == pytest.approx(100.0)
assert out["最低点"].iloc[0] == pytest.approx(20.0)
assert out["最低点占事件前"].iloc[0] == pytest.approx(0.2)
assert out["最低点在几分钟后"].iloc[0] == pytest.approx(0.0) # 事件那一张就是谷底
assert out["几张快照后回来"].iloc[0] == 3 # ⚠️ 从**谷底**往后数第 3 张(95 ≥ 90)
assert out["几分钟后回来"].iloc[0] == pytest.approx(1.5)
# 一直没回来的那几次记 NaN,不能当成 0,也不能悄悄丢掉
never = pd.Series(base + [20.0] * 5, index=back.index)
assert np.isnan(B.recovery(never, [event], horizon=10, before=10)["几张快照后回来"].iloc[0])
# ⚠️ 崩盘那一刻还没跌的那些:从事件时刻起算会记成「0 分钟就回来了」,从谷底起算才对
late = pd.Series(base + [100.0, 100.0, 20.0, 95.0, 100.0], index=back.index)
slow = B.recovery(late, [event], horizon=10, fraction=0.9, before=10)
assert slow["最低点在几分钟后"].iloc[0] == pytest.approx(1.0)
assert slow["几张快照后回来"].iloc[0] == 1
# ⚠️ 谷底只在 `trough_within` 之内找:把窗口收到 2 张,就看不到第 3 张那个 20
near = B.recovery(late, [event], horizon=10, fraction=0.9, before=10, trough_within=2)
assert near["最低点"].iloc[0] == pytest.approx(100.0)
with pytest.raises(ValueError):
B.recovery(late, [event], horizon=10, before=10, trough_within=99)
assert len(B.recovery(back, [back.index[2]], before=10)) == 0 # 窗口伸出数据范围,丢掉
with pytest.raises(ValueError):
B.recovery(back, [event], fraction=1.5)
def test_slope_says_where_the_money_sits():
# 近档薄、远档厚 → 比值大 → 稍微一卖价格就往下走一截
thin = snapshots([10.0], [10.0], bid_5=[500.0], ask_5=[500.0])
fat = snapshots([200.0], [200.0], bid_5=[500.0], ask_5=[500.0])
assert B.slope(thin).iloc[0] == pytest.approx(50.0)
assert B.slope(fat).iloc[0] == pytest.approx(2.5)
assert B.slope(thin).name == "5% ÷ 1%"
with pytest.raises(ValueError):
B.slope(thin, near=5.0, far=1.0)
def test_the_levels_binance_publishes_are_the_only_ones_that_exist():
assert B.LEVELS == (0.2, 1.0, 2.0, 3.0, 4.0, 5.0)
assert B.DAY == 2880 # 30 秒一张,一天 2,880 张
depth = snapshots([1.0], [1.0])
for level in (0.5, 1.5, 10.0):
with pytest.raises(ValueError, match="只有"):
B.sides(depth, level)
8 passed in 0.15s
387 passed in 1.29s
没装 TA-Lib 的环境里:
355 passed, 32 skipped in 1.97s
十七、小检查
- 第 28 篇量到 2023-10-23 那天有一分钟里最宽的一次报价差到 161.5 个基点,这一篇量到 2023-08-17 那天是 317.46 个。为什么这两个数都不足以说明「崩盘时流动性有多糟」?
- 崩盘前三分钟,现价下方 1% 以内的挂单比平常厚 88%,买卖不对称 +0.516。这是「买盘很强」吗?
- 22 次崩盘买侧深度掉幅中位 −67.6%,对照组只掉 −19.3%。对照组是按什么挑的?为什么不能直接拿「平常的深度」当对照?
- 盘口不对称和之后 30 分钟涨跌的秩相关是 0.0478,而打乱 200 次的 95% 分位只有 0.0016。能拿它做交易吗?
- 这份数据答不了「某一档有没有大单墙」。为什么?换成什么数据才能答?
十八、常见误用
**用价差当流动性的唯一度量。**第十二节实测:价差和买侧深度的秩相关只有 −0.189,它们几乎是两个独立的量。价差从中位到最极端放大 223 倍,深度从中位到最极端缩到 7.06%——用价差看,市场「平时好得不得了、偶尔一瞬间很糟」;用深度看,是「平时也就那样、崩盘少一半、要一小时补回来」。对一个要平仓的人,后面那个才是他面对的东西。
把「第一次碰到崩盘前的九成」当成恢复。第八节:按这个口径算,中位只要 3 分钟;但按「那一段的持续中位水平」算,崩盘后 10 分钟只有 56.4%、1 小时 82.5%、4 小时 94.7%。深度抖得厉害,碰一下不等于补回来了。⚠️ recovery 里还有一条:最低点只在事件后一小时里找,否则在两天的窗口里找到的多半是下一次崩盘。
信「买盘厚所以会涨」。第十节:22 次崩盘那一刻的买卖不对称中位是 +0.1584,而平常只有 +0.0153——买盘看起来比平常厚十倍;五分钟之后翻成 −0.1471。决策点那天 21:41 的不对称是 +0.516,三分钟后价格跌了 10%。机制不神秘:价格往下走时,没被撤掉的买单相对中间价越来越近。
**把显著当成有用。**第十一节:四个窗口的秩相关全部远超打乱的对照,统计上毫无疑问显著;而最大的那个是 0.0478,平方之后 0.0023——解释了千分之二。样本有 190 万分钟,任何一点点系统性都会显著。⚠️ 显著回答「是不是零」,有用回答「够不够覆盖成本」,而第 28 篇量过 taker 费率是 10 个基点。
直接拿「平常的深度」当对照。第六节:崩盘那几分钟本来就有巨量卖单砸下来,深度被吃掉一些是算术不是发现。对照组必须控制住卖单大小——「主动卖出额同样在最大的 0.1% 里,但价格没崩」的 410 次,掉幅中位只有 −19.3%。⚠️ 而且两组的尾巴是重叠的(对照组最狠一次 −99.7%),所以只能说中位数差得远。
**跨年份直接比绝对深度。**第十四节:两侧合计的中位从 2023 年的 2.02 亿长到 2025 年的 3.81 亿,但同期 BTC 的价格也涨了两三倍。绝对金额不能跨年份比——relative 用滚动中位数做分母,就是为了避开这件事。
以为公开数据能看到大单墙。⚠️ bookDepth 是每 30 秒一张快照、每张 12 个累计金额。它没有单个价位、没有单张挂单、没有撤单动作。「大单墙在哪一档」「是不是冰山单」这些问题,要逐笔订单流才能答,而公开数据站没有——只能自己开 WebSocket 录,录的还是未来。
**假设数据源会一直在。**第四节:第 28 篇用的 bookTicker 只发布到 2024-03-30 就停了。**这门课在第 28 篇量到的那个价差数字,今天已经没法在新数据上复现。**第 34 篇说「交易所的规矩要和回测结果一起存档」,这是同一句话的另一个版本。
十九、小结
- **第 28 篇量的是排队的长度,这一篇量金库。**价差只告诉你买一和卖一差多远,没告诉你买一后面还有多少钱——而后者才是一个要平仓的人真正面对的东西。
- 决策点那三分钟:现价下方 1% 以内从 2.41 亿(比平常厚 88%)掉到 1,090 万,少了 95.5%;同期砸下来的主动卖单 10.84 亿,是那点承接力的 8.4 倍。价格为什么跌 10%——因为它必须走到能接得住的地方。
- 22 次崩盘,买侧深度掉幅中位 −67.6%,最狠一次(2025-10-10)掉到千分之一。而「同样大的卖单但价格没崩」的 410 次对照只掉 −19.3%。⚠️ 两组的尾巴重叠。
- 恢复:最低点中位只剩 42.1%,出现在崩盘后 2.2 分钟;10 分钟后 56.4%、1 小时 82.5%、4 小时 94.7%。第 28 篇那句话的深度版本是「形状一样,幅度大得多、窗口长得多」。
- ⚠️ 「买盘厚所以会涨」在这 22 次里是精确的反向指标:崩盘那一刻的不对称是平常的十倍,五分钟后翻成负的。原因是算术——价格往下走时没被撤的买单离中间价越来越近。
- ⚠️ 显著 ≠ 有用:不对称对之后涨跌的秩相关四个窗口全部远超打乱对照,而最大的 0.0478 平方之后只有 0.0023。190 万个样本里,任何一点系统性都会显著。
- 能直接用的那条:一张 5,000 万美元的市价卖单,平常用掉 37% 的承接力,崩盘那十分钟要 133%——吃光还不够。账户到一千万时,一笔主线 v4 的仓位在崩盘时已经要吃掉 17.7%。⚠️ 交易所的硬门槛(第 34 篇的 1,430 万美元)之前,盘口的软门槛早就到了。
- 价差和深度量的不是同一件事(秩相关 −0.189)。价差是瞬间量,一小时就回去;深度是存量,少掉的不会在下一个报价里回来。
- ⚠️ 这一篇答不了的那些(大单墙、撤单、冰山、美股盘口),在正文第一段就写清楚了。一篇诚实的分析,边界要写在开头,不是写在结尾的免责声明里。
这就是番外要补的那个洞。正文三十五篇到第 35 篇为止,这一篇不改变其中任何一条结论——它只是把第 28 篇那句「价差平时可忽略,只在崩盘那几分钟要命」补成完整的一句:
要命的不是价差,是那几分钟里盘口上少掉的一半钱。
练习
- 把第六节的对照组换一种挑法:不控制卖单大小,改成控制同一个 UTC 小时(第 20 篇量过 BTC 有明显的小时效应)。两种对照给出的结论一样吗?哪一种更该信?
- 这一篇全程用 ±1% 那一档。用 ±2% 和 ±5% 重做第五、六节——深度掉幅随档位变远是变大还是变小?给出你的解释。
squeeze算的是「少掉的钱里有多少能被成交解释」,正文没有用它。用它把第八节重做一遍:崩盘后一小时里,深度补回来的那部分,有多少是新挂的单?⚠️ 想清楚这个比值的分母在价格已经跌了 10% 之后意味着什么。- 第十四节说绝对深度不能跨年份比。用
relative把 22 次崩盘的掉幅重算一遍(分母换成滚动中位数),结论变了吗? - ±0.2% 那两档只有 2026-01-15 之后的 219 天。在这 219 天里把第五、六节重做,看最近的那一档是不是掉得更狠。⚠️ 样本只有两次崩盘,先想清楚这能说明什么。
- 这一篇只有 BTC 永续。用同样的代码跑 ETHUSDT(
36_download.py改一个交易对名就行),看第五、十、十一节的结论换个标的还成不成立——这是第 31 篇立的规矩。
小检查答案
- 因为价差是一个「瞬间」的量。161.5 和 317.46 都是「那一分钟里最宽的一次报价」,而同一分钟的平均价差只有 0.802 个基点、一小时之后就回到崩盘前。⚠️ 顺带一提:2023-08-17 跌得比 2023-10-23 狠得多(−10.52% 对 −5.45%),最宽报价也更宽(317 对 161),但它那天的分钟平均价差最大只有 3.81,而 2023-10-23 是 7.47——光看价差,连哪一天更糟都排不出来。它衡量的是「买一和卖一差多远」,不是「买一后面还有多少钱」。真正决定你能不能平掉仓位的是深度:同一分钟里它少了 95.5%,而且要一个小时才补到八成。
- 不是。那是算术:价格往下走的时候,还没被撤掉的买单相对中间价越来越近,于是「现价下方 1% 以内的钱」在下跌的头几十秒里会先变多,直到被吃光或者撤掉。第十节的 22 次事件里,崩盘那一刻的不对称中位是 +0.1584(平常 +0.0153),五分钟后变成 −0.1471——它是一个反向指标。
- **按「主动卖出额同样落在最大的 0.1% 里,但 60 分钟涨跌幅在 ±1% 以内」挑的,410 次。**不能拿「平常的深度」当对照,是因为崩盘那几分钟本来就有巨量卖单砸下来,深度被吃掉一部分是算术不是发现——对照组必须控制住「卖单有多大」这个变量,剩下的差别才是「崩盘」这件事本身带来的。⚠️ 而且要说清楚两组的尾巴重叠(对照组最狠 −99.7%)。
- **不能。**秩相关 0.0478 平方之后是 0.0023,解释了千分之二的变化;而第 28 篇量过 taker 费率就有 10 个基点。它之所以「显著」,是因为样本有 190 万分钟——⚠️ **显著回答的是「是不是零」,有用回答的是「够不够覆盖成本」,这是两个问题。**而且方向还得分情况:平常是弱正相关,崩盘前是强反向。
- 因为
bookDepth只有六个百分比档位的累计金额,没有单个价位、没有单张挂单、没有撤单动作——「现价下方 1% 以内一共有 1.28 亿」这句话里,看不出这 1.28 亿是一张单还是一万张单。要答「有没有大单墙」需要完整的 L2 盘口增量或逐笔订单流,Binance 的公开数据站不提供,只能自己开 WebSocket 实时录——⚠️ 而那样录到的是未来的数据,历史补不回来。