因子有了正 IC,下一步最危险的动作,可能就是立刻把它的权重调高。

上一轮我刚把因子验证链路整理完:先冻结 signal-time snapshot,再看 Bucket Spread、Rank IC 和多周期 outcome。那一步解决的是“这个因子有没有排序信息”。但模型真正开始自动调权以后,问题会变成另一种——最近 20 个周期看起来更强的因子,凭什么马上改写 active model?

这两个问题很容易被混在一起。

一个候选因子可以是真的有用,同时最近这个窗口对它的估计又刚好偏高;一个 regime 也可能真的已经切换,只是你加了太多保护以后,模型反而迟迟不肯跟上。自动调权不是把研究报告里的 IC 映射成一组新 weight 就结束了,真正麻烦的是模型变更本身。

我这次没有拿旧的生产权重或 Notion 里以前记过的阈值做证明,而是做了一轮可控的 regime-switching 合成实验。因为 ground truth 是已知的,我可以准确知道什么时候“因子关系真的变了”,再比较三种 activation policy:

  • 新权重每次都立即上线;
  • 每次上线,但限制单个因子的最大权重变化;
  • 候选先在 shadow 里跑,连续赢过 active 后才允许 promotion,同时再受 delta cap 限制。

实验用了 12 个固定随机种子,每个种子 240 个连续 period、每期 200 个 synthetic assets、4 个 factor;在 period 80 和 160 明确切换 regime。每 5 个 period 生成一次 candidate,估计窗口只看过去 20 个 period,candidate 只能用之后的 5 个 period 做 out-of-sample 评价。

这个时间边界我刻意做得很死。scikit-learn 的 TimeSeriesSplit 文档本身就提醒,时间有顺序的数据不能像普通样本那样随便切,否则会出现用未来训练、再回头评价过去的问题。

如果连 candidate weight 都偷看了未来,后面讨论 shadow、regime 或 drift budget 都没有意义。

我先比较“调得快”和“调得稳”到底差多少

候选权重的估计方式没有故意做复杂:在 trailing window 里计算每个 factor 的横截面 Rank IC,负值截到 0,再把正向证据归一化成 candidate weight。

真正变化的是 activation policy。

这次我记录了几个指标:

  • future block 的 combined-score Rank IC;
  • 累计 L1 weight turnover;
  • 单次最大权重跳变;
  • promotion 次数;
  • promotion 后下一个 evaluation block 反而输给旧 active profile 的比例;
  • regime 真正切换以后,active profile 的最大权重多久才转到新 regime 的最强 factor。

最后一个指标我叫 adaptation lag。它不是金融行业通用定义,只是这次实验里为了量化“模型到底多慢才肯承认 regime 已经变了”。

12 个 seed 的均值如下:

PolicyCapShadow confirmationsMean OOS Rank ICL1 turnoverMax weight jumpFalse promotion rateAdaptation lag
Immediate——0.21997.3050.33339.0%11.67
Delta capped5%—0.20925.5910.05027.8%22.08
Shadow first5%10.20014.1970.05020.6%29.79
Shadow first5%20.18822.4090.05015.0%52.29
Shadow first5%30.18501.6590.05010.2%75.83
Delta capped10%—0.21726.8840.10036.2%14.58
Shadow first10%10.20775.0150.10027.6%22.71
Shadow first10%20.19373.4720.10013.7%37.08
Shadow first10%30.18692.4110.10011.2%47.71

这个结果没有给我一个“最安全参数”。

Immediate 反而拿到了最高的 mean OOS Rank IC,而且 regime 切换后跟得最快,平均只慢 11.67 个 period。代价也很明显:累计换手最高,44 个 checkpoint 基本每次都换,false promotion rate 接近 39%。

加一个真正的 5% delta cap 以后,mean OOS Rank IC 从 0.2199 降到 0.2092,适应速度也慢了一截;但 L1 turnover 从 7.305 降到 5.591,false promotion rate 从 39.0% 降到 27.8%。

Shadow 再往前一步。5% cap 下要求连续 3 次 shadow 胜出,false promotion rate 只剩约 10.2%,turnover 也压到 1.659。看起来很稳,可 adaptation lag 拉到了 75.83 个 period。

如果只看“错误 promotion 越少越好”,很容易把最后一组当成最佳答案。问题是一个真的 regime switch 发生以后,模型也可能被你保护到几乎不愿意动。

这次实验让我更确定一件事:guardrail 不是免费的安全层,它本身就是模型的一部分。

AWS SageMaker 的 shadow tests 用的是类似的生产思想:新 variant 可以接收复制过来的真实请求,但 response 不直接返回给调用方,先和 production variant 对比,再决定要不要 promotion。这个工程模式很适合借到量化系统里,但 SageMaker 文档并没有告诉你“连续赢几次就应该上线”,更不会替交易系统决定 weight cap。

这些阈值必须从自己的 outcome、换手成本和对 regime 变化的容忍度里找。

Regime 不是一个标签,它决定你该拿哪些历史去相信

动态权重最容易出现的一个错觉,是样本一多就觉得估计会更稳。

如果市场结构一直没变,这句话没什么问题。但一个 factor 的作用本来就可能有周期性,把很久以前的另一种环境全部揉进同一个 trailing window,样本数变多的同时,也可能把当前关系稀释掉。

Shu 和 Mulvey 2025 年发表在 Journal of Portfolio Management 的 Dynamic Factor Allocation Leveraging Regime-Switching Signals 就是直接把 factor-specific regime inference 放进动态 allocation。我的实验没有复现他们的模型,也不能拿他们的结果替自己的系统背书,但至少说明“因子分配需要考虑 regime”不是我项目里凭空造出来的概念。

对自动调权系统来说,我更愿意把 market / regime 看成评价上下文,而不是一个最后才加上的标签。

比如同样是 momentum factor:

momentum evidence
    + market
    + regime
    + session / universe
    + data quality
        ↓
candidate evidence

你可以选择完全分开统计,也可以做层级 shrinkage,不一定要切成很多小桶。重点是别把“过去整体有效”直接翻译成“现在应该继续加权”。

美国联邦储备当前的 model risk management guidance 讲的是银行模型治理,不是交易策略教程;但它里面有一条很通用:ongoing monitoring 要看数据相关性、exposure、activity 和 market conditions 是否已经变化,如果模型不再按预期工作,就需要 adjustment 或 redevelopment。

我觉得这层思想放在量化系统里很实用——权重不是一次训练后的常数,也不该是每个窗口随手重算的临时变量。它需要版本、上下文和变更条件。

这也是为什么我会把上一篇 因子有效性验证 放在这篇前面。先确认 factor evidence 值不值得信,再讨论 candidate weight 怎么变;顺序倒过来,就会变成模型不断对噪声做出精确反应。

一个 5% 上限,差点被 normalize 悄悄改成 9.1%

这次最值得留下来的不是结果表,而是中间一个实现错误。

第一版 delta cap 我写得很自然:

target weight
    ↓
逐因子 clip 到 old ± 5%
    ↓
normalize,让权重和重新等于 1

直觉上看,已经 clip 到 5% 了,最后 normalize 只是把总和修正回 1。

实际一跑,名义 5% cap 的 observed max weight jump 平均到了大约 9.1%。

原因也不复杂:clip 只约束了 normalize 之前的坐标。所有权重一起重新缩放以后,其中某些维度又被推得更远。

也就是说,这段看起来没问题的逻辑:

clipped = np.clip(target, old - cap, old + cap)
weights = clipped / clipped.sum()

并不能保证:

abs(weights[i] - old[i]) <= cap

这类 bug 比“某个 IC 算错一位小数”危险得多,因为监控页面上可能仍然写着 cap=0.05,系统实际执行的权重漂移却已经超过了它。

最后我改成 bounded simplex projection:新的 weight 同时满足三件事——非负、总和等于 1、每个维度仍然在 old ± cap 里。

核心约束就是:

sum(w) = 1
0 <= w_i <= 1
old_i - cap <= w_i <= old_i + cap

实现里我用一个很小的二分过程去找投影偏移量:

lower = np.maximum(0.0, old - cap)
upper = np.minimum(1.0, old + cap)

lo = np.min(target - upper) - 1.0
hi = np.max(target - lower) + 1.0

for _ in range(80):
    lam = (lo + hi) / 2.0
    w = np.clip(target - lam, lower, upper)

    if w.sum() > 1.0:
        lo = lam
    else:
        hi = lam

修完以后,最终结果里 5% cap 的最大 observed jump 就是 0.05,10% cap 就是 0.10,只有浮点 epsilon 的误差。

这件事让我觉得“drift budget”最好不要只存在配置层。

如果系统真有:

max_weight_delta: 0.05

那运行时还应该直接 assert:

max(abs(new_weight - old_weight)) <= max_weight_delta + epsilon

否则你有的是一个参数,不是一个约束。

Shadow 的意义不是预测更准,而是把“研究结论”和“线上状态”隔开

我现在更愿意把自适应因子的生命周期拆成四层:

observe
   ↓
candidate
   ↓
shadow
   ↓
active

Observe 只收集证据,不影响任何线上评分。

Candidate 表示研究层已经给出一个可以比较的新 weight profile,但它还不是生产状态。

Shadow 开始用未来 outcome 和 active 做并行比较。这里最重要的是,shadow 不能因为“自己用了更近的数据”就天然拿到上线资格;它仍然需要和 active 在同一段 future window 里比。

Promotion 才是真正的状态变更。

这四层分开以后,很多东西会突然清楚:

factor IC 变好
≠ candidate 一定更好

candidate 最近赢了一次
≠ 应该 promotion

promotion 被允许
≠ 可以无限幅度改权重

我这次实验里 false promotion 的定义很窄:如果一个新 profile 被 promoted,下一段 future evaluation 反而输给 promotion 前的 active,我就算一次 false promotion。

它不是唯一合理定义,但足够暴露一个问题:最近窗口里看着更好的 candidate,真的上线以后,有相当一部分马上就会输。

Shadow confirmation 可以把这种次数压下去,可 confirmation 越多,真正 regime 变化以后的响应就越慢。

所以如果让我现在设计生产链路,我不会直接写死:

连续 3 次赢 → 上线

我会先保存:

candidate_version
active_version
market / regime
evidence_window
future_eval_window
weight_delta
turnover
shadow_outcome
promotion_decision

然后再从真实 outcome 里看,不同确认次数和 drift budget 到底在交换什么。

Notion 里我以前记过 2 次、3 次确认,以及 5%、8%、12%-15% 这种漂移阈值。这次实验没有证明其中任何一个数字是正确的。它只证明这些参数确实会改变模型行为,而且变化方向很明显。

我现在会把自动调权做成一个“受限的模型变更流程”

如果把这次实验压回系统设计,最小闭环其实不复杂:

1. 冻结当前 active weights
2. 只用过去窗口估计 factor evidence
3. 带上 market / regime 上下文
4. 生成 candidate weights
5. 做 hard constraint / drift check
6. candidate 进入 shadow
7. 等未来 outcome 结算
8. 对比 active 与 shadow
9. 满足 promotion rule 才生成新 active version
10. 保存整个 decision trail

真正重要的是第 9 步不要只写成一个 if ic > threshold。

至少应该同时知道:

  • candidate 赢了多久;
  • 这些胜利是不是来自同一个 regime;
  • promotion 会造成多大的 weight jump;
  • 最近累计 turnover 已经多高;
  • candidate 和 active 的差异到底来自一个 factor,还是整个 profile;
  • 如果不 promotion,真正的 regime change 会让你慢多久。

这几个问题没有一个可以从“最近 Rank IC = 0.08”直接推出答案。

更实际一点,我甚至会把 promotion decision 本身也当成一种 outcome tracking 对象。每次系统说“这次应该换权重”,以后都回来问一句:如果当时不换,会怎样?如果当时晚一个窗口换,会怎样?

时间久了以后,你才有资格讨论自己的 shadow confirmation 是 1、2 还是 3,delta cap 是 5% 还是 10%。

这和单纯调参的感觉已经不太一样了。

自动调权系统真正需要学习的不只是“哪个 factor 最近更有效”,还包括“我的模型变更规则,过去到底有没有帮忙”。

这次合成实验给我的答案也刚好停在这里:Immediate 很敏捷,但 churn 大;Shadow 很克制,但可能慢到错过 regime;delta cap 能收住动作,却不能替代 outcome validation。

我不会从这张表里挑一个数字塞进生产配置。

更合理的下一步,是让这些 guardrail 先跟着真实 outcome 跑一段 shadow,记录每次本来想 promotion 的候选,以及它之后到底有没有继续赢。等真实数据足够,再让系统决定自己能承受多少错误 promotion,又愿意为稳定付出多少 adaptation lag。