量化因子怎麼判斷有沒有用?用 Bucket Spread 和 Rank IC 做多週期驗證
做選股或打分模型時,最容易的事情其實是繼續加因子。
動量、RSI、相對強弱、距離 52 週高點、波動率、成交量……只要回測裡看起來有一點關係,很快就能再加一欄、再配一個權重。真正困難的是下一步:這個因子是真的在排序未來報酬,還是剛好在某個視窗裡表現不錯?
我最近在整理自己的量化研究流程時,發現以前最容易被「勝率」「單一週期報酬」這類數字帶偏。一個因子 5 天後看起來很好,不代表 1 天、3 天也一致;同一批訊號每天都統計,樣本數會快速增加,但 forward return 又大量重疊,看起來很穩定的結果,未必真的增加了那麼多獨立資訊。
所以我沒有直接拿內部系統以前記過的 IC 或 spread 來寫文章,而是重新做了一次小型公開復現。資料來自一個公開倉庫裡 5 個 ETF 的調整後收盤價:sector_prices.csv。我把資料檔固定在 blob SHA 0a442a9260c2ad64e6cd8c01af49328eb7619367,用 SPY、XLK、XLF、XLE、XLV 測幾個很簡單的 20 日價格因子,再分別看 1D / 3D / 5D 的 forward return。
結果沒有得到一個漂亮的「最佳因子」。這反而更有用。
20 日動量在稀疏錨點樣本裡,1D 和 5D 的 High-Low median spread 是正的,3D 卻略微轉負;低波動因子在三個週期的方向也不穩定。更有意思的是,我做了一個「20 日相對 SPY 強弱」之後,最後發現它和原始 20 日動量的橫截面排名完全一樣。
這些不整齊的地方,比一份全綠的因子報告更接近真實研究。
先把訊號發生當下的因子值凍結
做因子驗證時,我現在最看重的不是公式,而是時間邊界。
假設某一天系統替一組股票打分,其中有一個 momentum_20d。真正應該保存的是做出決策當下看到的值,然後等 1D、3D、5D outcome 結算後,再回頭看它有沒有排序能力。
也就是:
decision / snapshot -> factor value at t -> realized return at t + N
而不是等三天之後,再用今天的資料、今天的程式碼重新計算一次「當時的因子」。
這兩種做法看起來很像,實際差很多。資料供應商可能修訂歷史資料,還權方式可能改變,股票池會變,因子定義會改,標準化的橫截面也可能不是當時那批資產。只要事後重算,驗證對象就可能悄悄變成「今天理解的過去」,而不是系統當時真正看到的過去。
Alphalens 的因子分析本質上也是把 factor value 和之後的 forward returns 對齊,再按 quantile 看報酬、算 Information Coefficient。它的文件還特別提醒一個很典型的 look-ahead 風險:如果你用未來報酬本身算 z-score 再過濾異常值,那麼資料清洗這一步就已經知道了未來。
如果之後真的要讓我的生產研究系統自動調整權重,我會把這條邊界做得比一般研究 notebook 更嚴格:factor snapshot 一旦進入 outcome tracking,就不允許後續模型版本回頭覆蓋它。
這次公開復現沒有使用生產資料庫,所以我是用同一份固定歷史價格資料,構造 signal-time factor 和 forward return。它能驗證計算流程,但不能拿來證明某個生產模型沒有 point-in-time 資料問題。這兩件事必須分開。
Bucket Spread 和 Rank IC 看的是兩件不同的事
如果因子主要是用來排序,我不太喜歡一開始就問「勝率多少」。
勝率會把很多資訊壓成一個布林值。漲 0.01% 和漲 8% 都算贏,排第一和排第五也可能都算贏。它適合描述策略結果,但不夠直接地回答「因子值越高,未來結果是不是整體更好」。
我這次用了兩個很基礎的量。
第一個是 Bucket Spread。每個 signal date,把橫截面的因子值按排名切成 Low / Mid / High 三組,再比較 High 和 Low 組之後的報酬差:
Bucket Spread = median(return_high) - median(return_low)
如果一個「越高越好」的因子真的包含排序資訊,理想情況下 High 組應該比 Low 組更強。但 spread 為正只代表這批樣本裡頭尾兩組拉開了,它不會自動等於統計顯著,也不代表可以覆蓋交易成本,更不代表中間組一定單調。
第二個是 Rank IC。這裡我用 Spearman rank correlation,把因子排名和未來報酬排名直接做相關:
Rank IC = Spearman(factor_rank, forward_return_rank)
SciPy 的 spearmanr 是非參數的單調相關衡量,範圍從 -1 到 1。對很多因子來說,我更在意「排序方向有沒有資訊」,而不是假設因子值和未來報酬一定是線性關係,所以 Spearman 很自然。
這次實際執行的核心計算很小,主要邏輯就是:
mom20 = px / px.shift(20) - 1
fwd = px.shift(-horizon) / px - 1
x = mom20.loc[dt].dropna()
y = fwd.loc[dt].dropna()
common = x.index.intersection(y.index)
bucket = pd.qcut(
x.loc[common].rank(method="first"),
3,
labels=["low", "mid", "high"],
)
rho, _ = spearmanr(
x.loc[common].values,
y.loc[common].values,
)
spread = (
y.loc[common][bucket == "high"].median()
- y.loc[common][bucket == "low"].median()
)
實際實驗腳本已經固定在 Content Lab 裡,執行命令退出碼為 0。這裡沒有再做一個新的「量化框架」,只是把最核心的驗證流程壓到足夠小,避免結果被複雜策略邏輯包住。
同一個因子,換個 horizon 結論就開始改變
為了降低 forward return 重疊,我把主要展示結果設定成「每 5 個交易日取一個 anchor date」。總共 14 個日期,每個日期 5 個 ETF,所以每個 factor × horizon 有 70 筆橫截面觀察。
下面的 spread 用 High bucket median - Low bucket median,單位是百分點;Rank IC 是各 anchor date 橫截面 Spearman IC 的平均值。
| 因子 | Horizon | N | Median Spread | Mean Rank IC |
|---|---|---|---|---|
| 20D Momentum | 1D | 70 | +0.583 pp | +0.179 |
| 20D Momentum | 3D | 70 | -0.071 pp | +0.014 |
| 20D Momentum | 5D | 70 | +0.667 pp | +0.214 |
| Distance to 20D High | 1D | 70 | +0.249 pp | +0.089 |
| Distance to 20D High | 3D | 70 | +0.259 pp | +0.151 |
| Distance to 20D High | 5D | 70 | +0.529 pp | +0.252 |
| Low Volatility 20D | 1D | 70 | -0.029 pp | +0.029 |
| Low Volatility 20D | 3D | 70 | +0.296 pp | +0.014 |
| Low Volatility 20D | 5D | 70 | -0.224 pp | -0.207 |
| Relative Strength 20D vs SPY | 1D | 70 | +0.583 pp | +0.179 |
| Relative Strength 20D vs SPY | 3D | 70 | -0.071 pp | +0.014 |
| Relative Strength 20D vs SPY | 5D | 70 | +0.667 pp | +0.214 |
這張表裡我最不想做的事,就是圈出最大值然後宣布「有效」。
先看 20D Momentum。只看 5D,spread +0.667 pp、mean Rank IC +0.214,真的很容易讓人產生加權衝動。可是 3D spread 變成 -0.071 pp,Rank IC 也幾乎貼近 0。它沒有形成一個很乾淨的「週期越長越有效」結構。
Low Volatility 更明顯。3D median spread 是正的,5D 卻轉成 -0.224 pp,mean Rank IC 也到了 -0.207。如果我只截 3D 那一欄,它會看起來像一個值得繼續研究的正向因子;換成 5D,故事完全不同。
這就是多週期 outcome tracking 最實際的價值。它不是為了把 1D、3D、5D 加權成一個更漂亮的綜合分,而是先暴露:你的結論到底對 horizon 有多敏感。
我還保留了一份每天都取樣的結果。那份樣本有 66 個 signal dates、330 筆 factor/outcome 記錄,數量一下大很多。
問題是 3D、5D forward returns 會高度重疊。
結果也真的變了:20D Momentum 的 3D median spread 在每日重疊樣本裡約為 +0.578 pp,到了每 5 個交易日的錨點樣本卻變成 -0.071 pp。
我不會拿這個差異去判斷哪一邊才是「真相」。14 個錨點仍然很小,每 5 日取樣也沒有完全消掉所有相依性。它說明的是另一個更重要的問題:如果一個因子的結論連合理的抽樣方式一換都會翻,研究報告就不該只給一個漂亮的總樣本數字。
因子數量變多之前,先檢查是不是同一份排序資訊
這次還有一個結果,一開始讓我以為程式寫錯了。
我同時做了:
momentum_20d
relative_strength_20d_vs_spy
後者定義為:
asset_20d_return - SPY_20d_return
跑完以後,兩者所有 bucket、spread、Rank IC 結果完全一樣。
檢查程式後發現這不是 bug。
在同一個日期,SPY 的 20 日報酬對所有資產都是同一個常數:
RS_i = Momentum_i - C_t
給橫截面裡每個資產都減去同一個 C_t,不會改變排序。所以只要 Bucket 和 Rank IC 只依賴橫截面 rank,這兩種寫法在這裡就是同一份排序資訊。
這個例子對我來說,比「某個因子 IC 是 0.21」更有價值。
多因子模型很容易出現一種假複雜:指標名字不同、公式看起來不同,最後其實在重複表達同一個方向。兩個高度冗餘的因子如果都拿到獨立權重,不是模型得到兩份證據,而是同一份證據被重複投票。
這裡不能推廣成「relative strength 等於 momentum」。如果相對強弱是相對產業、動態 benchmark、殘差報酬、rolling beta 調整,或使用其他視窗,排序都可能改變。這次等價只來自一個很具體的構造:同一天給所有資產減去相同的 SPY 20D return。
但它給了我一個很實用的檢查習慣:在調權之前,先比較 factor rank、相關性和 bucket assignment,看看新因子到底增加了新資訊,還是只換了名字。
我不會讓一次正 IC 直接改線上權重
如果研究系統最後要做自動調優,我現在更願意把因子從「被發現」到「影響模型」之間拉開一點距離。
一個候選因子可以先進入 observe:記錄訊號時點值,但不參與決策;等 outcome 結算後,再看不同 horizon 的 bucket spread、Rank IC、覆蓋率和樣本分布。
結果有點意思,再進 shadow。Shadow 可以產生「如果啟用它,權重會怎樣變化」的候選結果,但仍然不修改 active model。
我至少會看這幾件事:
- 樣本量和非空覆蓋是不是夠;
- High / Mid / Low 有沒有基本排序結構,而不只是頭尾偶然拉開;
- 1D / 3D / 5D 的方向能不能解釋,還是互相打架;
- 換一段時間、換一個 regime,方向還在不在;
- 是否和既有因子高度重複;
- 抽樣方式、去重規則一改,結論會不會翻;
- 研究過程中試了多少因子、視窗和參數。
最後一點尤其容易被忽略。因子研究本質上常常是在做大量假設檢驗。你試 50 個指標、6 個視窗、4 個 horizon,再挑一個最好看的,出現「漂亮結果」的機率當然會越來越高。Harvey、Liu 和 Zhu 在資產定價的多重檢驗研究裡專門討論過這個問題:… and the Cross-Section of Expected Returns。
所以 Bucket Spread 和 Rank IC 對我來說不是「因子准入許可證」,更像兩種基礎體檢。它們能幫我快速看出排序有沒有資訊、方向是否穩定,也很容易暴露 horizon 翻轉和因子冗餘;但離「這個因子可以拿真金白銀交易」中間還隔著穩定性、交易成本、樣本外驗證、regime、組合曝險和風險控制。
這次公開實驗本身也只用了 5 個 ETF 和 14 個稀疏錨點日期。我不會拿它證明任何生產 alpha。它真正證明的是,這條驗證鏈可以用一個很小的形式跑起來;而且只要把 horizon、抽樣和 rank redundancy 攤開來看,很多原本很乾淨的因子結論會立刻變得沒那麼確定。
這正是我想保留的狀態。
一個因子如果值得進入模型,最好不是因為某次研究報告給了它一個漂亮數字,而是它經過一段時間的 outcome tracking 之後,仍然有足夠多不太好看的檢查沒有把它推翻。