本篇位置 番外。正文三十五篇已经讲完,这一篇只做一件事:补第 28 篇留下的那个洞
用到的数据 BTCUSDT 永续的盘口深度快照(2023-01-01 起,1,336 天)+ 第 28 篇的最优买卖报价 + 第 22 篇的 1 分钟线
动手 新模块 talab.booksidesimbalancerelativesqueezerecoveryslope,附 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. 1,336 天、376 万张快照,2023-01-01 到 2026-08-31。⚠️ 2023 年之前 Binance 没有公开过。
  2. ±0.2% 那两档只从 2026-01-15 才开始有值(219 天)。所以全篇用 ±1%——它是所有年份都有的最近的一档。
  3. ⚠️ 第 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 次崩盘

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

十七、小检查

  1. 第 28 篇量到 2023-10-23 那天有一分钟里最宽的一次报价差到 161.5 个基点,这一篇量到 2023-08-17 那天是 317.46 个。为什么这两个数都不足以说明「崩盘时流动性有多糟」?
  2. 崩盘前三分钟,现价下方 1% 以内的挂单比平常厚 88%,买卖不对称 +0.516。这是「买盘很强」吗?
  3. 22 次崩盘买侧深度掉幅中位 −67.6%,对照组只掉 −19.3%。对照组是按什么挑的?为什么不能直接拿「平常的深度」当对照?
  4. 盘口不对称和之后 30 分钟涨跌的秩相关是 0.0478,而打乱 200 次的 95% 分位只有 0.0016。能拿它做交易吗?
  5. 这份数据答不了「某一档有没有大单墙」。为什么?换成什么数据才能答?

十八、常见误用

**用价差当流动性的唯一度量。**第十二节实测:价差和买侧深度的秩相关只有 −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 篇那句「价差平时可忽略,只在崩盘那几分钟要命」补成完整的一句:

要命的不是价差,是那几分钟里盘口上少掉的一半钱。


练习

  1. 把第六节的对照组换一种挑法:不控制卖单大小,改成控制同一个 UTC 小时(第 20 篇量过 BTC 有明显的小时效应)。两种对照给出的结论一样吗?哪一种更该信?
  2. 这一篇全程用 ±1% 那一档。用 ±2% 和 ±5% 重做第五、六节——深度掉幅随档位变远是变大还是变小?给出你的解释。
  3. squeeze 算的是「少掉的钱里有多少能被成交解释」,正文没有用它。用它把第八节重做一遍:崩盘后一小时里,深度补回来的那部分,有多少是新挂的单?⚠️ 想清楚这个比值的分母在价格已经跌了 10% 之后意味着什么。
  4. 第十四节说绝对深度不能跨年份比。用 relative 把 22 次崩盘的掉幅重算一遍(分母换成滚动中位数),结论变了吗?
  5. ±0.2% 那两档只有 2026-01-15 之后的 219 天。在这 219 天里把第五、六节重做,看最近的那一档是不是掉得更狠。⚠️ 样本只有两次崩盘,先想清楚这能说明什么。
  6. 这一篇只有 BTC 永续。用同样的代码跑 ETHUSDT(36_download.py 改一个交易对名就行),看第五、十、十一节的结论换个标的还成不成立——这是第 31 篇立的规矩。

小检查答案

  1. 因为价差是一个「瞬间」的量。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%,而且要一个小时才补到八成。
  2. 不是。那是算术:价格往下走的时候,还没被撤掉的买单相对中间价越来越近,于是「现价下方 1% 以内的钱」在下跌的头几十秒里会先变多,直到被吃光或者撤掉。第十节的 22 次事件里,崩盘那一刻的不对称中位是 +0.1584(平常 +0.0153),五分钟后变成 −0.1471——它是一个反向指标
  3. **按「主动卖出额同样落在最大的 0.1% 里,但 60 分钟涨跌幅在 ±1% 以内」挑的,410 次。**不能拿「平常的深度」当对照,是因为崩盘那几分钟本来就有巨量卖单砸下来,深度被吃掉一部分是算术不是发现——对照组必须控制住「卖单有多大」这个变量,剩下的差别才是「崩盘」这件事本身带来的。⚠️ 而且要说清楚两组的尾巴重叠(对照组最狠 −99.7%)。
  4. **不能。**秩相关 0.0478 平方之后是 0.0023,解释了千分之二的变化;而第 28 篇量过 taker 费率就有 10 个基点。它之所以「显著」,是因为样本有 190 万分钟——⚠️ **显著回答的是「是不是零」,有用回答的是「够不够覆盖成本」,这是两个问题。**而且方向还得分情况:平常是弱正相关,崩盘前是强反向。
  5. 因为 bookDepth 只有六个百分比档位的累计金额,没有单个价位、没有单张挂单、没有撤单动作——「现价下方 1% 以内一共有 1.28 亿」这句话里,看不出这 1.28 亿是一张单还是一万张单。要答「有没有大单墙」需要完整的 L2 盘口增量或逐笔订单流,Binance 的公开数据站不提供,只能自己开 WebSocket 实时录——⚠️ 而那样录到的是未来的数据,历史补不回来。