LLM 能找到真正的 Alpha 因子嗎?
LLM 一小時可寫出一百個 alpha 因子。本文以十年真實價格檢驗二百四十個擲硬幣因子的表現,並說明如何辨識過度擬合與測試倖存者。
LLM 可以不斷提出 alpha 因子。只要提供模型一份資料字典和評分工具,它在午餐前就能寫出一百個看似合理的因子表達式。真正困難的問題在於:當產生這些因子的搜尋機制本身就是一台從雜訊中製造贏家候選的機器時,究竟要如何知道其中哪一個是真的?
什麼是 alpha 因子?
因子是一項規則,能將市場資料轉換成每檔股票在每個日期各自的一個數值。過去12個月的價格變化率是一種因子,負債對股東權益比也是。當你依因子為一個股票投資池排序,買進排名最高的一部分、賣出排名最低的一部分,並按固定時程再平衡時,因子就成為一項策略。Alpha 是扣除單純市場曝險原本就會帶來的報酬後,剩餘的報酬。
候選因子通常以 Sharpe ratio 評分:平均報酬除以該報酬的標準差,再年化。它代表每承擔一單位波動所取得的報酬。實盤策略的長期 Sharpe 接近 1 已屬不錯。下次回測報出 3 時,應記得這一點。
LLM 因子研究實際如何運作
這個領域的每個專案,基本上都在執行同一套迴圈。
- 模型以評分工具能夠執行的小型語言,撰寫因子表達式。
- 回測工具根據固定期間的價格與基本面資料,為每個表達式評分。
- 高於評分門檻的表達式予以保留,其餘捨棄。
- 將保留的表達式作為已驗證範例送回模型的上下文,然後再次執行迴圈。
多代理人交易系統會將這些工作分配給不同角色,例如由一個角色提出方案、另一個角色進行測試。這套基礎架構確實有用,而AI 代理人所需的市場資料技能與人類研究者所需的技能相同。
這個迴圈沒有任何不誠實之處。搜尋本來就是研究的方法。問題在於算術,而且只要第2步執行不只幾次,問題就會出現。
為何 LLM alpha 因子搜尋會製造贏家
一段價格歷史。數千個成本低廉的假設。每個假設都以同一個有限樣本進行評分,而該樣本包含大量偶然因素。測試的規則越多,其中一些就越可能緊密貼合這些偶然因素。評分無法告訴你得到的是哪一種貼合,因為符合雜訊的規則和符合市場走勢的規則,最後都會顯示同一個數字。
以下是抽樣240次的虛無假設。下方每個「因子」都是一次擲硬幣:以股票代號、月份和試驗編號的雜湊值,每月將40檔美國大型股分成兩半;策略做多其中一半,並放空另一半。這個設計本身不包含任何資訊。以2016年1月至2021年6月的實際月末報酬評分後,240次試驗的結果如下。
每個數據背後的精確 SQL 語法
WITH month_end AS (
SELECT ticker,
toStartOfMonth(toDate(toTimeZone(window_start, 'America/New_York'))) AS month_start,
argMax(toFloat64(close), window_start) AS close_px
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker IN ('AAPL','ADBE','AMZN','BA','CAT','COST','CRM','CSCO','CVX','DE',
'DUK','GE','GOOGL','HD','HON','IBM','INTC','JNJ','JPM','KO',
'LMT','MCD','MMM','MRK','MSFT','NKE','NVDA','ORCL','PEP','PFE',
'PG','QCOM','SO','T','TGT','TXN','UNP','VZ','WMT','XOM')
AND toDate(toTimeZone(window_start, 'America/New_York')) >= toDate('2015-12-01')
AND toDate(toTimeZone(window_start, 'America/New_York')) <= toDate('2021-06-30')
AND toDayOfMonth(toTimeZone(window_start, 'America/New_York')) >= 22
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959
GROUP BY ticker, month_start
),
lagged AS (
SELECT ticker,
month_start,
close_px,
lagInFrame(close_px) OVER (PARTITION BY ticker ORDER BY month_start
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS prev_px
FROM month_end
),
monthly_return AS (
SELECT ticker, month_start, close_px / prev_px - 1 AS ret
FROM lagged
WHERE prev_px > 0
AND month_start >= toDate('2016-01-01')
),
trial AS (
SELECT arrayJoin(range(1, 241)) AS n
),
factor_month AS (
SELECT t.n AS trial_id,
m.month_start AS month_start,
avgIf(m.ret, bitAnd(cityHash64(m.ticker, toString(m.month_start), t.n), 1) = 1)
- avgIf(m.ret, bitAnd(cityHash64(m.ticker, toString(m.month_start), t.n), 1) = 0) AS long_short_ret
FROM monthly_return AS m
CROSS JOIN trial AS t
GROUP BY trial_id, month_start
HAVING countIf(bitAnd(cityHash64(m.ticker, toString(m.month_start), t.n), 1) = 1) > 0
AND countIf(bitAnd(cityHash64(m.ticker, toString(m.month_start), t.n), 1) = 0) > 0
),
scored AS (
SELECT trial_id,
avg(long_short_ret) / stddevSamp(long_short_ret) * sqrt(12) AS sharpe
FROM factor_month
GROUP BY trial_id
HAVING stddevSamp(long_short_ret) > 0
)
SELECT multiIf(sharpe < -1.2, 'below -1.2',
sharpe < -0.8, '-1.2 to -0.8',
sharpe < -0.4, '-0.8 to -0.4',
sharpe < 0.0, '-0.4 to 0.0',
sharpe < 0.4, '0.0 to 0.4',
sharpe < 0.8, '0.4 to 0.8',
sharpe < 1.2, '0.8 to 1.2',
'1.2 and above') AS sharpe_bucket,
count() AS factor_count,
round(100 * count() / 240, 1) AS share_pct
FROM scored
GROUP BY sharpe_bucket
ORDER BY min(sharpe)重點就在於分布。圖中的任何內容都沒有預測能力,但仍有 1 次試驗落在最高區間(1.2 and above),占搜尋結果的 0.4%,另有 1 次落在最低區間(below -1.2)。如果研究者只執行一次幸運的試驗就停止,他會得到一張圖和一個 Sharpe ratio,卻無法分辨那是發現還是偶然。這裡的報酬是從一個月末收盤價到下一個月末收盤價;月報酬如何計算說明了相關算術。
重要的是你嘗試了多少次
單獨報告一項回測,缺少分母。將同樣的240次試驗視為持續擴大的搜尋:每一步都比較目前最佳評分與截至當時所有試驗的平均評分。
每個數據背後的精確 SQL 語法
WITH month_end AS (
SELECT ticker,
toStartOfMonth(toDate(toTimeZone(window_start, 'America/New_York'))) AS month_start,
argMax(toFloat64(close), window_start) AS close_px
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker IN ('AAPL','ADBE','AMZN','BA','CAT','COST','CRM','CSCO','CVX','DE',
'DUK','GE','GOOGL','HD','HON','IBM','INTC','JNJ','JPM','KO',
'LMT','MCD','MMM','MRK','MSFT','NKE','NVDA','ORCL','PEP','PFE',
'PG','QCOM','SO','T','TGT','TXN','UNP','VZ','WMT','XOM')
AND toDate(toTimeZone(window_start, 'America/New_York')) >= toDate('2015-12-01')
AND toDate(toTimeZone(window_start, 'America/New_York')) <= toDate('2021-06-30')
AND toDayOfMonth(toTimeZone(window_start, 'America/New_York')) >= 22
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959
GROUP BY ticker, month_start
),
lagged AS (
SELECT ticker,
month_start,
close_px,
lagInFrame(close_px) OVER (PARTITION BY ticker ORDER BY month_start
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS prev_px
FROM month_end
),
monthly_return AS (
SELECT ticker, month_start, close_px / prev_px - 1 AS ret
FROM lagged
WHERE prev_px > 0
AND month_start >= toDate('2016-01-01')
),
trial AS (
SELECT arrayJoin(range(1, 241)) AS n
),
factor_month AS (
SELECT t.n AS trial_id,
m.month_start AS month_start,
avgIf(m.ret, bitAnd(cityHash64(m.ticker, toString(m.month_start), t.n), 1) = 1)
- avgIf(m.ret, bitAnd(cityHash64(m.ticker, toString(m.month_start), t.n), 1) = 0) AS long_short_ret
FROM monthly_return AS m
CROSS JOIN trial AS t
GROUP BY trial_id, month_start
HAVING countIf(bitAnd(cityHash64(m.ticker, toString(m.month_start), t.n), 1) = 1) > 0
AND countIf(bitAnd(cityHash64(m.ticker, toString(m.month_start), t.n), 1) = 0) > 0
),
scored AS (
SELECT trial_id,
avg(long_short_ret) / stddevSamp(long_short_ret) * sqrt(12) AS sharpe
FROM factor_month
GROUP BY trial_id
HAVING stddevSamp(long_short_ret) > 0
),
ladder AS (
SELECT arrayJoin([1, 2, 5, 10, 25, 50, 100, 160, 240]) AS n
)
SELECT l.n AS factors_tried,
round(max(s.sharpe), 2) AS best_sharpe,
round(avg(s.sharpe), 2) AS average_sharpe
FROM ladder AS l
CROSS JOIN scored AS s
WHERE s.trial_id <= l.n
GROUP BY factors_tried
ORDER BY factors_tried累計最高值只能上升,這正是陷阱所在。第一個測試的規則得到 0.44。經過 240 次試驗後,排行榜上的最佳值為 1.59,而所有試驗的平均值為 0.01。標題數值改善了,但沒有任何一項規則真正改善。評估一萬個表達式的評分工具,會將這條曲線推到遠超過此圖範圍的右側,而它報告的數字就是曲線的最高點。
保留期間對贏家造成的影響
標準的防禦方式是使用保留期間:先在一個期間評分,再將通過搜尋的候選因子,放到搜尋從未接觸過的較晚期間重新評分。取出訓練期間表現最佳的12個擲硬幣結果,並在接下來五年、2021年7月至2026年6月的期間執行完全相同的規則。
每個數據背後的精確 SQL 語法
WITH month_end AS (
SELECT ticker,
toStartOfMonth(toDate(toTimeZone(window_start, 'America/New_York'))) AS month_start,
argMax(toFloat64(close), window_start) AS close_px
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker IN ('AAPL','ADBE','AMZN','BA','CAT','COST','CRM','CSCO','CVX','DE',
'DUK','GE','GOOGL','HD','HON','IBM','INTC','JNJ','JPM','KO',
'LMT','MCD','MMM','MRK','MSFT','NKE','NVDA','ORCL','PEP','PFE',
'PG','QCOM','SO','T','TGT','TXN','UNP','VZ','WMT','XOM')
AND toDate(toTimeZone(window_start, 'America/New_York')) >= toDate('2015-12-01')
AND toDate(toTimeZone(window_start, 'America/New_York')) <= toDate('2026-06-30')
AND toDayOfMonth(toTimeZone(window_start, 'America/New_York')) >= 22
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959
GROUP BY ticker, month_start
),
lagged AS (
SELECT ticker,
month_start,
close_px,
lagInFrame(close_px) OVER (PARTITION BY ticker ORDER BY month_start
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS prev_px
FROM month_end
),
monthly_return AS (
SELECT ticker, month_start, close_px / prev_px - 1 AS ret
FROM lagged
WHERE prev_px > 0
AND month_start >= toDate('2016-01-01')
),
trial AS (
SELECT arrayJoin(range(1, 241)) AS n
),
factor_month AS (
SELECT t.n AS trial_id,
m.month_start AS month_start,
avgIf(m.ret, bitAnd(cityHash64(m.ticker, toString(m.month_start), t.n), 1) = 1)
- avgIf(m.ret, bitAnd(cityHash64(m.ticker, toString(m.month_start), t.n), 1) = 0) AS long_short_ret
FROM monthly_return AS m
CROSS JOIN trial AS t
GROUP BY trial_id, month_start
HAVING countIf(bitAnd(cityHash64(m.ticker, toString(m.month_start), t.n), 1) = 1) > 0
AND countIf(bitAnd(cityHash64(m.ticker, toString(m.month_start), t.n), 1) = 0) > 0
),
scored AS (
SELECT trial_id,
avgIf(long_short_ret, month_start < toDate('2021-07-01'))
/ stddevSampIf(long_short_ret, month_start < toDate('2021-07-01')) * sqrt(12) AS in_sample_sharpe,
avgIf(long_short_ret, month_start >= toDate('2021-07-01'))
/ stddevSampIf(long_short_ret, month_start >= toDate('2021-07-01')) * sqrt(12) AS out_of_sample_sharpe
FROM factor_month
GROUP BY trial_id
HAVING countIf(month_start < toDate('2021-07-01')) >= 24
AND countIf(month_start >= toDate('2021-07-01')) >= 24
)
SELECT concat('trial ', toString(trial_id)) AS factor_label,
round(in_sample_sharpe, 2) AS in_sample_sharpe,
round(out_of_sample_sharpe, 2) AS out_of_sample_sharpe
FROM scored
ORDER BY in_sample_sharpe DESC
LIMIT 12每一組長條代表一項規則。左側長條是該規則進入報告時取得的評分,右側長條是同一規則在接下來五年的評分。排名第一的試驗在訓練期間得到 1.59,之後為 -0.51;排名第十二的試驗得到 0.74,之後為 0.49。
12項規則本身就是一個小樣本。將全部240項規則依訓練評分分成五組,再計算各組的保留期間平均評分,可以更清楚地觀察結果。
每個數據背後的精確 SQL 語法
WITH month_end AS (
SELECT ticker,
toStartOfMonth(toDate(toTimeZone(window_start, 'America/New_York'))) AS month_start,
argMax(toFloat64(close), window_start) AS close_px
FROM global_markets.delayed_stocks_minute_aggs
WHERE ticker IN ('AAPL','ADBE','AMZN','BA','CAT','COST','CRM','CSCO','CVX','DE',
'DUK','GE','GOOGL','HD','HON','IBM','INTC','JNJ','JPM','KO',
'LMT','MCD','MMM','MRK','MSFT','NKE','NVDA','ORCL','PEP','PFE',
'PG','QCOM','SO','T','TGT','TXN','UNP','VZ','WMT','XOM')
AND toDate(toTimeZone(window_start, 'America/New_York')) >= toDate('2015-12-01')
AND toDate(toTimeZone(window_start, 'America/New_York')) <= toDate('2026-06-30')
AND toDayOfMonth(toTimeZone(window_start, 'America/New_York')) >= 22
AND (toHour(toTimeZone(window_start, 'America/New_York')) * 60
+ toMinute(toTimeZone(window_start, 'America/New_York'))) BETWEEN 570 AND 959
GROUP BY ticker, month_start
),
lagged AS (
SELECT ticker,
month_start,
close_px,
lagInFrame(close_px) OVER (PARTITION BY ticker ORDER BY month_start
ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) AS prev_px
FROM month_end
),
monthly_return AS (
SELECT ticker, month_start, close_px / prev_px - 1 AS ret
FROM lagged
WHERE prev_px > 0
AND month_start >= toDate('2016-01-01')
),
trial AS (
SELECT arrayJoin(range(1, 241)) AS n
),
factor_month AS (
SELECT t.n AS trial_id,
m.month_start AS month_start,
avgIf(m.ret, bitAnd(cityHash64(m.ticker, toString(m.month_start), t.n), 1) = 1)
- avgIf(m.ret, bitAnd(cityHash64(m.ticker, toString(m.month_start), t.n), 1) = 0) AS long_short_ret
FROM monthly_return AS m
CROSS JOIN trial AS t
GROUP BY trial_id, month_start
HAVING countIf(bitAnd(cityHash64(m.ticker, toString(m.month_start), t.n), 1) = 1) > 0
AND countIf(bitAnd(cityHash64(m.ticker, toString(m.month_start), t.n), 1) = 0) > 0
),
scored AS (
SELECT trial_id,
avgIf(long_short_ret, month_start < toDate('2021-07-01'))
/ stddevSampIf(long_short_ret, month_start < toDate('2021-07-01')) * sqrt(12) AS in_sample_sharpe,
avgIf(long_short_ret, month_start >= toDate('2021-07-01'))
/ stddevSampIf(long_short_ret, month_start >= toDate('2021-07-01')) * sqrt(12) AS out_of_sample_sharpe
FROM factor_month
GROUP BY trial_id
HAVING countIf(month_start < toDate('2021-07-01')) >= 24
AND countIf(month_start >= toDate('2021-07-01')) >= 24
),
ranked AS (
SELECT trial_id,
in_sample_sharpe,
out_of_sample_sharpe,
row_number() OVER (ORDER BY in_sample_sharpe DESC) AS in_sample_rank
FROM scored
)
SELECT multiIf(in_sample_rank <= 48, 'best fifth in training',
in_sample_rank <= 96, 'second fifth',
in_sample_rank <= 144, 'middle fifth',
in_sample_rank <= 192, 'fourth fifth',
'worst fifth in training') AS training_group,
round(avg(in_sample_sharpe), 2) AS avg_in_sample_sharpe,
round(avg(out_of_sample_sharpe), 2) AS avg_out_of_sample_sharpe
FROM ranked
GROUP BY training_group
ORDER BY min(in_sample_rank)訓練期間的分組,從最高組的 0.66 到最低組的 -0.63,形成寬廣且完全有序的階梯。這是必然結果,因為分組本來就是依該評分切分的。在保留期間,兩端的平均值分別為 0.01 和 0.13。階梯變得平坦。保留期間是整條流程中唯一沒有被拿來最佳化的部分,因此值得審慎使用。
真正有效的防禦措施
只使用一次的保留期間。 每看一次保留期間,就會把它轉換成訓練資料。走勢前進測試會逐步滾動期間,且每次評分都來自擬合之後的資料,是能夠承受反覆使用的版本。
多重檢定調整。 Bailey 和 López de Prado 於2014年提出的 deflated Sharpe ratio,會依據試驗次數、樣本長度、報酬偏態,以及報酬尾端的肥厚程度,調降觀察到的 Sharpe。若提供誠實的試驗次數,一項搜尋一萬個表達式所得到的 headline Sharpe,往往會被調降至沒有意義。
涵蓋每一個嘗試過的表達式,包括被捨棄者的稽核軌跡。 這是最關鍵的一項,也正是為何「可稽核」是因子研究專案說明中最值得注意的詞。調降計算需要試驗次數。只記錄贏家的流程,等於摧毀自身修正所需的輸入。被捨棄的草稿、放棄的參數掃描、研究者自行重新開始的次數,以及評分程式碼的每一個早期版本,都應計入該數字。
在相信評分前,先檢查成本與前視偏誤。 如果因子是依據基本面欄位排序,而該欄位使用的是資料供應商載入資料的日期,而非市場能夠看到資料的日期,回測結果會非常漂亮,實際交易卻會很差。
如何理解「可稽核」
這個領域每週都會出現新的程式碼儲存庫,而一個只有幾十顆星號的專案,代表的是原型,不是績效紀錄。星號數量的變化速度也往往快於程式碼,因此本頁評估的是這種模式,而非任何單一專案。無論哪個專案出現在你面前,以下是應先檢查的內容。
- 它是否以時間戳記記錄每一個候選因子、其表達式和評分,還是只記錄保留者?
- 保留期間是由評分工具強制執行,還是依賴研究者自律?
- 每個報告的評分旁邊,是否都附有試驗次數?
- 它是為哪個市場建立的?針對中國 A 股調校的函式庫,會承襲每日價格漲跌停限制,以及不得在同一交易日賣出當日買進股票的規定;因子在這些規則下的行為,不能直接套用到美國股票。
- 你能否重新執行並重現數字?請鎖定你閱讀的確切 commit,因為這個階段的專案可能在兩個週末之間就改寫評分程式碼。
這些限制不代表 LLM 在因子研究中沒有用。產生假設確實是研究瓶頸,而模型很擅長這件事。改變的是工作重心:必須記錄究竟消耗了多少個假設。在任何結果接觸實盤委託簿之前,紙上交易能讓你看見回測與實際成交之間的差距。
LLM alpha 因子常見問答
LLM 能找到 alpha 因子嗎?
它可以提出數以千計的候選因子,但提出候選不等於找到因子。主張是在評分階段形成的,而來自廣泛搜尋的評分,帶有評分本身無法看見的選擇問題。判斷時,應優先檢視保留期間的紀律和記錄的試驗次數,而不是表達式本身。
什麼是 deflated Sharpe ratio?
這是一項修正方法,將觀察到的 Sharpe ratio 轉換為:在不存在真實優勢的情況下,具有相同規模的搜尋產生該結果的機率。Bailey 和 López de Prado 於2014年發表了這項方法。其核心輸入是試驗次數,而這正是未經稽核的研究迴圈無法提供的數字。
回測多少次算太多?
沒有固定門檻,只有必須套用的調整。一項得到 1.0 的回測,與一萬項回測中最佳值為 1.0 的情況,代表對世界的不同主張。上方的擲硬幣試驗在 1.59 次試驗中達到 240,而資料本身完全沒有任何資訊。
為什麼已發表的因子在發表後會轉弱?
學術研究已追蹤異常報酬在發表後數年間的衰減情況。市場擁擠是其中一種解釋,而原始結果過度貼合自身樣本則是另一種解釋;兩者在圖表上都會呈現相同形狀。效率市場假說說明前者,而上方的試驗展示後者。
這裡的每個面板,都是以真實月末價格執行的儲存查詢,下方公開了 SQL。複製其中一個查詢,提高試驗次數,然後觀察 Strasmore 終端上的最佳數值上升。