因子出現正 IC 之後,下一步最危險的動作,可能就是立刻把它的權重往上調。

上一輪我先整理了因子驗證鏈路:凍結 signal-time snapshot,再看 Bucket Spread、Rank IC 與多週期 outcome。那一步回答的是「這個因子到底有沒有排序資訊」。但模型真的開始自動調權之後,問題會變成另一種——最近 20 個 period 看起來比較強的因子,憑什麼立刻改寫 active model?

這兩個問題很容易混在一起。

一個候選因子可以真的有用,但最近這個視窗剛好把它估得太高;regime 也可能真的切換了,只是保護機制太重,模型反而遲遲跟不上。自動調權不是把研究報告裡的 IC 映射成一組新 weight 就結束,真正麻煩的是模型變更本身。

這次我沒有拿舊的 production 權重或 Notion 裡過去記過的門檻來當證據,而是做了一輪可控制的 regime-switching 合成實驗。因為 ground truth 已知,我可以準確知道什麼時候「因子關係真的改變」,再比較三種 activation policy:

  • 每次新權重都立刻啟用;
  • 每次啟用,但限制單一因子的最大權重變化;
  • candidate 先在 shadow 裡跑,連續贏過 active 後才允許 promotion,而且 promotion 時仍受 delta cap 限制。

實驗用了 12 個固定 random seed。每個 seed 有 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 都沒有意義。

先看「調得快」和「調得穩」到底差多少

candidate 權重的估計方式我刻意做得很簡單:在 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 切換之後跟得最快,平均 adaptation lag 只有 11.67 個 period。代價也很明顯:累積 turnover 最高,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 真的改變以後,你也可能把模型保護到幾乎不肯動。

這次實驗讓我更確定一件事:guardrail 不是免費的安全層,它本身就是模型的一部分。

AWS SageMaker 的 shadow tests 用了很接近的 production 模式:新的 variant 可以收到鏡像過來的 production request,但 response 不直接回給 caller,先拿來和 production variant 比較,再決定要不要 promotion。這個工程模式很適合借到量化系統裡,但 SageMaker 文件並沒有告訴你「連續贏幾次就該上線」,更不可能替交易系統決定 weight cap。

這些門檻只能從自己的 outcome、換手成本,以及對 regime 變化反應速度的容忍度裡找。

Regime 不是一個標籤,它決定哪些歷史值得相信

動態權重最容易出現的一個錯覺,是樣本越多就覺得估計一定越穩。

如果底層關係真的穩定,多一點 observation 當然有幫助。但 factor 行為本來就可能隨 regime 改變。把很久以前、完全不同環境的資料都揉進同一個 trailing window,樣本數增加的同時,也可能把現在真正重要的關係稀釋掉。

Shu 和 Mulvey 2025 年在 Journal of Portfolio Management 發表的 Dynamic Factor Allocation Leveraging Regime-Switching Signals 就直接研究 factor-specific regime inference 下的動態 allocation。我的實驗沒有重現他們的模型,也不能拿他們的結果替我的系統背書;但至少可以確認,把 regime 納入 factor allocation 並不是專案裡憑空造出的概念。

對自動調權系統來說,我比較願意把 market / regime 視為評估上下文,而不是最後才補上的標籤。

同一個 momentum factor,可以把證據上下文想成:

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

你不一定要把所有東西切成很多獨立小桶,也可以用 hierarchical shrinkage 或其他 pooling 方法。重點是不要把「過去整體有效」直接翻譯成「現在就該加權」。

美國聯準會目前的 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

runtime 還應該直接 assert:

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

否則你有的只是一個參數,不是一個約束。

Shadow 的意義不是預測更準,而是把研究結論和 production state 隔開

我現在比較願意把自適應因子的生命週期拆成四層:

observe
   ↓
candidate
   ↓
shadow
   ↓
active

Observe 只收集證據,不改任何線上評分。

Candidate 表示研究層已經產生值得比較的新 weight profile,但它仍不是 production state。

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 可以把這類事件壓低,但確認次數越多,真正 regime 變化後的反應就越慢。

所以如果現在要設計 production pipeline,我不會直接寫死:

連續 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 物件。每次系統說「現在應該換權重」,之後都回頭問:如果當時不換會怎樣?如果再晚一個 window 才換會怎樣?

累積足夠多紀錄後,才有資格用真實證據討論 shadow confirmation 應該是 1、2 還是 3,delta cap 應該是 5% 還是 10%。

到這裡,它已經不太像一般的調參。

自適應調權系統要學的不只是「哪個 factor 最近有效」,還包括自己的模型變更規則,過去到底有沒有幫忙。

這次合成實驗剛好停在這裡:Immediate 很敏捷,但 churn 大;Shadow 很克制,但可能慢到錯過 regime;delta cap 能限制每次動作大小,卻不能取代 future outcome validation。

我不會從這張表挑一個數字直接塞進 production 設定。

更合理的下一步,是讓這些 guardrail 先跟真實 outcome 跑一段 shadow:記錄每一次系統本來想 promotion 的 candidate,再看它後來是否持續勝出。等真實資料夠多,系統才有機會學會自己能承受多少錯誤 promotion,又願意為穩定付出多少 adaptation lag。