halo 的技术博客

返回

2007 年 8 月 6 日到 8 月 9 日,几乎所有量化多空基金同时巨亏。持仓没有共同的行业暴露,没有共同的宏观事件,甚至很多基金彼此从不交流。但它们在同一周亏了同样的钱。

原因后来被称为 quant quake:大家的模型太像了。当其中一家因为外部原因被迫平仓,它卖出的正是别人也持有的股票、买回的正是别人也做空的股票。价格反向移动,触发下一家的风控,下一家平仓,再触发下一家。

结论先放这:因子的危险程度,不写在它的历史 Sharpe 里,写在有多少人和你持有同样的仓位里。 而这件事——竞争对手的持仓——你看不到。本文的全部工作就是回答:只用公开的价格数据,能不能把它反推出来?

答案是能,但用途和你想的不一样。本文用 12 年日频市场模拟,把”资金流入→推高价格→更好业绩→更多资金”的正反馈完整实现,让它在第 6.1 年自然崩掉:因子组合 12 天内亏掉 49.2%。三个纯价格代理指标中,吸收率与不可观测的真实套利资本相关达 0.653。但拥挤度对未来收益的五分位完全不单调——它是尾部风险指标,不是收益预测指标。所有数字均由下文 Python 真实计算,非占位图。

因子组合净值与复合拥挤度:资金流入推高收益,去杠杆冲击时同一批持仓一起夺门而出

一、先把「拥挤」这件事建模出来#

要检验拥挤度指标,得先有一个真的会拥挤、也真的会踩踏的市场。这比想象中难,也是本文技术含量最高的部分。

核心是一个正反馈环:

因子业绩    资金流入    买入压力推高价格    因子业绩\text{因子业绩} \uparrow \;\to\; \text{资金流入} \uparrow \;\to\; \text{买入压力推高价格} \;\to\; \text{因子业绩} \uparrow

用对数资本 t=logCt\ell_t = \log C_t 写出来:

for t in range(1, T):
    perf = fac_ret[t-LOOKBACK:t].sum()                  # 过去半年业绩
    dlog = FLOW_SENS * perf - PHI * logcap[t-1] + shock_log[t]
    logcap[t] = np.clip(logcap[t-1] + dlog, -2.0, 2.0)
    capital[t] = np.exp(logcap[t])

    push       = KAPPA * (logcap[t] - logcap[t-1])      # 资金推动价格
    crowd_drag = -DRAG * (capital[t] - 1.0)             # 拥挤侵蚀未来收益
    noise_sd   = NOISE_BASE + NOISE_CROWD * capital[t]
    fac_ret[t] = TRUE_PREM + crowd_drag + push + noise_sd * rng.standard_normal()
python

三个组成部分各有对应的现实机制:

  • push:资金进出直接推动价格。这是拥挤能自我强化的原因
  • crowd_drag拥挤本身侵蚀未来收益。钱越多,同样的机会被摊薄得越厉害
  • noise_sd 随资本上升:拥挤的因子波动更大

一个值得单独说的坑:这个环会爆炸#

我的第一版参数直接把系统跑飞了。12 年里套利资本在 0.05 和 75394 之间反复爆炸,因子组合年化收益 -1817%

原因是这个反馈环有明确的稳定性条件。把环路走一圈:业绩累积 perf 影响 dlog(系数 FLOW_SENS),dlog 影响 push(系数 KAPPA),而 perf 是过去 LOOKBACK 天的累加。一圈下来的增益是:

G=KAPPA×FLOW_SENS×LOOKBACKG = \text{KAPPA} \times \text{FLOW\_SENS} \times \text{LOOKBACK}

G>1G > 1 时系统自激发散。 我第一版取到了 1.4。

KAPPA, FLOW_SENS, LOOKBACK = 0.35, 0.016, 126
print(f"环路增益 = {KAPPA * FLOW_SENS * LOOKBACK:.3f}")   # 0.706
python

调到 0.706 后系统稳定:资本在 0.59~1.40 之间波动,因子组合年化 16.2%、波动 17.3%、Sharpe 0.93 —— 一个合理的量化多空产品的样子。

这个 bug 值得写进正文,因为它本身就是拥挤现象的本质。 现实中的拥挤反馈环增益确实可能逼近甚至短暂超过 1,那正是泡沫期。区别只在于现实里没有 np.clip 兜底,爆掉的是真金白银。

踩踏必须是内生触发的#

冲击时点不能手写成”第 8 年”,那等于我在偷看未来。正确做法是让它由状态触发:

if SHOCK_T < 0 and t >= SHOCK_MIN_T and capital[t-1] > SHOCK_TRIGGER_CAP:
    SHOCK_T = t
    shock_log[t:t+SHOCK_LEN] = np.log(1.0 - SHOCK_TOTAL) / SHOCK_LEN
python

资本累积到常态的 1.33 倍时,触发一次为期 12 天、累计撤出 55% 资本的强制去杠杆。模拟中它自然发生在第 6.1 年。结果:因子组合在这 12 天里亏掉 49.2%,全样本最大回撤 -54.4%

二、拥挤在价格数据里留下的三个痕迹#

关键约束:指标只能用收益数据,不能碰 capital 变量。 capital 在现实中不可观测,偷看它就是作弊。

我用三个代理:

1. 组内平均成对相关#

同一批被套利资金共同持有的股票,会因为共同的流动性冲击而同涨同跌:

def rolling_pairwise_corr(RET, w_sign, win):
    longs, shorts = np.where(w_sign > 0)[0], np.where(w_sign < 0)[0]
    out = np.full(RET.shape[0], np.nan)
    for t in range(win, RET.shape[0]):
        sl, cs = RET[t-win:t], []
        for grp in (longs, shorts):
            X = sl[:, grp]
            X = (X - X.mean(0)) / (X.std(0) + 1e-12)
            C = (X.T @ X) / win
            cs.append(C[np.triu_indices(len(grp), 1)].mean())
        out[t] = np.mean(cs)
    return out
python

2. 吸收率(Absorption Ratio)#

Kritzman 等人 2011 年提出:第一主成分解释的方差占比。系统性风险集中时它上升。

def rolling_absorption(RET, win, k=1):
    out = np.full(RET.shape[0], np.nan)
    for t in range(win, RET.shape[0]):
        X = RET[t-win:t]
        ev = np.linalg.eigvalsh(np.cov(X - X.mean(0), rowvar=False))[::-1]
        out[t] = ev[:k].sum() / ev.sum()
    return out
python

3. 多空双腿相关#

这个指标直指 quant quake 的病理。正常情况下多头腿和空头腿应该反向移动——那正是对冲的意义。但踩踏时同一批资金同时平掉两边,多头被砸、空头被轧,两腿同时亏钱

def rolling_leg_corr(RET, w_sign, win):
    L = RET[:, np.where(w_sign > 0)[0]].mean(1)
    S = RET[:, np.where(w_sign < 0)[0]].mean(1)
    out = np.full(RET.shape[0], np.nan)
    for t in range(win, RET.shape[0]):
        out[t] = np.corrcoef(L[t-win:t], S[t-win:t])[0, 1]
    return out
python

三个代理指标各自的时间序列,红色区间为去杠杆冲击窗口

与不可观测的真实资本的相关性:

指标与真实套利资本的相关
组内平均成对相关0.316
吸收率(PC1 占比)0.653
多空双腿相关0.357
三者复合(等权 z-score)0.560
复合 + EWMA 21 日平滑0.638

吸收率单独就赢过复合指标(0.653 vs 0.560)。这不是意外——它直接度量”整个截面被多少个共同因子驱动”,与拥挤的定义最接近。加权平滑后复合指标追平到 0.638。

平滑这一步值得说明:拥挤度是慢变量,日频读数噪声很大。EWMA(半衰期 21 天)把相关性从 0.560 提到 0.638。用的是单边指数加权,只看过去,不引入前视

三、最重要的发现是负面的#

现在检验预测力。拥挤度对未来 3 个月收益的信息系数:

IC(拥挤度, 未来 3 个月收益)=0.327\text{IC}(\text{拥挤度},\ \text{未来 3 个月收益}) = -0.327

负相关,符合直觉——越拥挤,未来收益越差。但别急着建仓,看五分位:

左:拥挤度五分位与未来 3 个月收益;右:与未来 3 个月最大回撤

五分位未来 3 个月收益未来 3 个月最大回撤
Q1(最不拥挤)+16.76%-4.86%
Q2+2.66%-7.91%
Q3-1.76%-11.05%
Q4+2.44%-8.84%
Q5(最拥挤)+2.75%-8.47%

完全不单调。 Q1 一枝独秀,Q3 最差,Q5 反而比 Q3 好。

-0.327 这个 IC 几乎全部由 Q1 一个桶贡献。如果你只看 IC 就去做”拥挤度多空”,你会在 Q3 到 Q5 之间反复被打脸。

这是本文最值得记住的一条方法论:IC 是线性相关系数,它对单调性一无所知。 一个由单侧极值驱动的强 IC,和一个各分位平滑过渡的弱 IC,在下游策略里是完全不同的两种东西。任何时候看到 IC 都要追问一句:分位数长什么样?

那拥挤度还有什么用?看右边那张图——回撤的单调性明显好于收益。Q1 的未来回撤 -4.86%,其余四个桶在 -7.9% 到 -11.1% 之间。信号很清楚:

拥挤度告诉你什么时候容易受伤,不告诉你什么时候能赚钱。

四、落地:三种仓位调节#

按 z-score 调仓,信号滞后 1 天生效,避免前视:

sig = np.roll(crowd, 1)          # 昨天的读数决定今天的仓位
sig[:WIN + 1] = np.nan
python
方案年化收益Sharpe最大回撤年换手
恒定满仓15.61%0.899-54.37%0
线性减仓 clip(1 − 0.45z, 0, 1.6)19.90%1.145-55.70%1.31
阈值切档 z>1 → 0.35 仓14.67%0.851-54.37%0.22

左:三种方案的净值;右:随机市场下的 Sharpe 增量零分布

两个结果需要诚实面对:

第一,阈值切档几乎没用(Sharpe 0.851 < 0.899,回撤一点没减)。原因看第三节的五分位——最拥挤的 Q5 并不是最危险的桶。设一个”高于 z=1 就撤”的硬阈值,砍掉的恰恰不是最该砍的时段。

第二,线性减仓提升了 Sharpe,但没有降低最大回撤(-55.70% vs -54.37%,反而略差)。

第二点尤其重要,因为它直接违背了我在第三节给拥挤度的定位。原因是踩踏太快了:12 天亏 49.2%,而 EWMA 平滑的拥挤度需要几周才能显著上移。等信号发出,钱已经亏完了。

拥挤度择时改善的是长期的风险调整收益(避开了那些温水煮青蛙的高拥挤低回报期),而不是极端事件当天的保护。 想要后者,你需要的是止损和杠杆上限,不是一个慢变量指标。

五、对抗性检验:这会不会是我挖出来的?#

一套指标 + 一个仓位函数 + 若干参数,在单条路径上跑出 Sharpe 提升 0.246 —— 这完全可能是噪声。

必须做零假设检验:在纯随机市场(无因子、无拥挤、无反馈)上跑完全相同的流程,看能”挖”出多少 Sharpe。

def null_test(n_trial=60):
    gains = []
    for k in range(n_trial):
        Rn = g.standard_normal((T, N_STOCK)) * IDIO_SD \
             + (MKT_SD * g.standard_normal(T))[:, None]
        pr = Rn @ w
        cz = ewma(composite_crowding(Rn, pr), HL)     # 同一套指标
        ps = np.clip(1.0 - 0.45 * np.roll(cz, 1), 0.0, 1.6)
        gains.append(sharpe(ps * pr) - sharpe(pr))
    return np.array(gains)
python

结果:

数值
随机市场 Sharpe 增量均值+0.004
标准差0.097
95 分位+0.171
增量为正的比例53.3%
真实市场增量+0.246(z = 2.5)

均值 0.004 ≈ 0,这证明方法本身不会凭空造 alpha,指标构造和仓位函数没有隐藏的前视偏差。

但同时:随机市场的 95 分位是 0.171。也就是说,纯靠运气,二十次里就有一次能挖出 0.17 的”提升”。真实市场的 0.246 对应 z = 2.5 —— 统计上显著,但只是勉强及格

如果我在真实市场上得到的是 0.15,那它连噪声都跑不赢。这个零分布应该是所有择时研究的标配,它给出的是”多好才算好”的标尺。 没有这把尺子,任何 Sharpe 提升的数字都无法解读。

六、窗口敏感性#

不同估计窗口下的择时 Sharpe,红线为恒定满仓基准

窗口与未来收益 IC择时 Sharpe
21 日-0.2891.195
63 日-0.3271.145
126 日-0.4161.187
252 日-0.3981.311

全部五个窗口都跑赢基准 0.899,没有一个窗口翻车——这是稳健性的好迹象,说明结论不依赖窗口的精细选择。

但注意 IC 最强的窗口(126 日)不是 Sharpe 最好的窗口(252 日)。又一次印证第三节:IC 和下游策略表现之间没有简单的单调映射。长窗口平滑掉了更多噪声,虽然相关性略降,但仓位信号更稳定、换手更低,净效果更好。

七、三盆冷水#

第一盆:反馈机制是我自己写进去的。 这是最根本的限制。我在 DGP 里显式实现了”资本推动价格”和”拥挤侵蚀收益”,然后发现指标能检测到它们——这在逻辑上接近循环论证。它能证明的只有一件事:如果现实中存在这样的反馈,那么这三个指标有能力捕捉到。至于现实中反馈的强度和形态,本文的模拟无法回答。要回答它,必须用真实的 13F 持仓、融券余额、ETF 资金流做交叉验证。

第二盆:纯价格代理会被误导。 吸收率上升可以是拥挤,也可以是普通的宏观风险上升(危机期所有相关性都上升,与套利资金无关)。本文的模拟里没有独立的宏观危机源,所以指标看起来很干净。真实数据里,你需要至少一个持仓类数据源来区分”资金拥挤”和”市场恐慌”这两种截然不同的高相关状态。这是本文合成实验相对现实最乐观的一处偏差,我倾向于把它明说。

第三盆:它救不了你的踩踏当天。 第四节已经量化了:线性减仓把 Sharpe 从 0.899 提到 1.145,但最大回撤纹丝不动(甚至略差)。拥挤度是慢变量,踩踏是快事件。用一个半衰期 21 天的指标去防一个 12 天完成的崩塌,时间尺度根本不匹配。真正的尾部保护来自杠杆上限、多因子分散和期权保护,不是来自任何一个监测指标。

八、结论#

拥挤度的正确定位是风险仪表盘上的一个读数,不是交易信号。

三条可落地的结论:

  1. 优先用吸收率。它最简单(一次特征值分解)、与真实拥挤相关最高(0.653)、经济含义最清楚。复合指标的边际收益不大
  2. 看分位数,不要只看 IC。本文的 -0.327 看起来很强,但五分位完全不单调,直接按 IC 建仓会亏钱
  3. 做零假设检验。随机市场的 95 分位是 0.171,这意味着任何低于 0.17 的 Sharpe 提升都不值得相信

而最实用的一条建议其实与指标无关:当你发现自己的因子最近特别好用、路演特别顺利、同行都在聊同一个信号时,那就是拥挤度最高的时刻。 价格数据里的吸收率往往只是在确认你已经知道的事。

指标的真正价值不在于告诉你新东西,而在于在你不想承认的时候,给你一个不带情绪的读数

拥挤度指标:用持仓相关性预警因子踩踏
https://blog.halo26812.eu.org/blog/factor-crowding-metric
Author halo
Published at 2026年7月31日
版权声明 CC BY-NC-SA 4.0
Comment seems to stuck. Try to refresh?✨