LLM能找到真正有效的Alpha因子吗
LLM 一小时可写出一百个 alpha 因子。查看 240 个抛硬币因子在十年真实价格上的表现,并了解如何测试幸存者,识别噪声赢家。
LLM 可以全天候提出 alpha 因子。给一个能力合格的模型提供数据字典和评分框架,它午饭前就能写出一百个看似合理的因子表达式。更难的问题在于:当产生这些表达式的搜索过程本身就是一台从噪声中制造赢家的机器时,究竟如何判断其中某个因子是否真实有效?
什么是 alpha 因子?
因子是一条规则。它把市场数据转换成每只股票在每个日期对应的一个数值。十二个月价格变动率是因子,债务权益比也是因子。当你按因子对股票池排名,买入排名靠前的一组,卖出排名靠后的一组,并按固定时间表再平衡时,因子就变成了策略。Alpha 是扣除普通市场敞口本来就能带来的收益后剩余的回报。
候选因子通常用夏普比率评分:平均收益除以该收益的标准差,再年化。它衡量的是每单位波动对应的收益。长期运行的实盘策略若夏普比率接近 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)。如果研究员只运行一次幸运试验就停止,他会得到一张图表和一个夏普比率,却无法判断这两者与真正的发现有何区别。这里的收益按月末收盘价到下一个月末收盘价计算;月度收益如何计算介绍了相关算术过程。
重要的是你尝试了多少次
单独报告一项回测,就缺少分母。把同样的 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。标题数字提高了,但没有任何规则真正改善。一个评估一万个表达式的评分框架,会把这条曲线推到远超图表所示范围的位置,而它报告的数字就是曲线的最高点。
留出期如何影响赢家
标准做法是使用留出样本:在一个时期评分,然后在搜索从未接触过的后续时期,对入选规则重新评分。取训练窗口中排名最高的十二个抛硬币结果,在随后五年,即 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。
十二条规则本身仍是一个小样本。将全部 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 年提出的夏普比率折减值,会根据试验次数、样本长度、收益偏度和尾部厚度,对观察到的夏普比率进行折减。输入真实的试验次数后,一万个表达式搜索得出的高调夏普比率通常会折减至几乎为零。
覆盖每个已尝试表达式的审计轨迹,包括被丢弃的表达式。 这是承重部分,也是为什么“可审计”是因子研究项目描述中最重要的词。折减计算需要试验次数。只记录赢家的流程,已经破坏了自身修正所需的输入。被丢弃的草稿、放弃的参数扫描、研究员自行重新开始的每次尝试,以及评分代码的每个早期版本,都应计入这个数字。
在采信评分前,检查交易成本和 前视偏差。 如果因子使用的是供应商录入数据的日期,而不是市场能够看到该基本面数据的日期进行排名,回测会很漂亮,实际交易却可能表现糟糕。
如何理解“可审计”
这一领域几乎每周都会出现新的代码仓库。一个只有几十颗星标的项目更像原型,而不是业绩记录。星标数量的变化速度也可能快于代码本身,这就是本页评估方法模式而非某个具体项目的原因。无论哪个项目出现在你面前,建议先检查以下内容。
- 它是否记录每个候选因子、其表达式和评分,并附有时间戳,还是只记录保留的因子?
- 留出样本是由评分框架强制执行,还是依赖研究员自律?
- 每个报告评分旁边是否附有试验次数?
- 它针对哪个市场开发?针对中国 A 股调优的代码库会继承每日价格涨跌停限制,以及当日买入的股票不能在同一交易日卖出的限制。因子在这些规则下的表现,不能直接套用到美国股票上。
- 你能否重新运行并复现这些数字?应固定你所查看的确切提交版本,因为这一阶段的项目可能在两个周末之间重写评分代码。
这些问题并不意味着 LLM 在因子研究中没有用。生成假设确实是一个瓶颈,而模型擅长这件事。变化在于责任落在哪里:必须记录究竟消耗了多少个假设。在任何策略接触实盘订单簿之前,纸面交易可以让你看清回测结果与实际成交之间的距离。
LLM alpha 因子常见问题
LLM 能找到 alpha 因子吗?
它可以成千上万地提出因子,但提出一个因子不等于找到一个因子。真正的判断发生在评分步骤,而广泛搜索得出的评分存在自身无法识别的选择偏差。相比表达式本身,应优先评估留出样本纪律和记录的试验次数。
什么是夏普比率折减值?
这是一种修正方法。它将观察到的夏普比率转换为这样一个概率:在不存在真实优势的情况下,规模相同的搜索产生这一结果的概率有多大。Bailey 和 López de Prado 于 2014 年发表了这一方法。其核心输入是试验次数,而未经审计的研究循环恰恰无法提供这个数字。
回测多少次算太多?
没有固定阈值,只有必须执行的调整。一次回测得到 1.0,与一万次回测中的最高结果为 1.0,代表的是两种不同的市场判断。上面的抛硬币试验在 240 次试验中达到 1.59,而数据中根本没有任何信息。
为什么已发表的因子在发布后会减弱?
学术研究已经跟踪了异常收益在发表后的多年中逐渐衰减的情况。交易拥挤是其中一种解释,原始结果过度拟合自身样本是另一种解释;两者在图表上都呈现出相同的形状。有效市场假说解释了前一种情况,而上面的试验展示了后一种情况。
这里的每个面板都是基于真实月末价格的已存储查询,底层 SQL 已公开。复制其中一个查询,提高试验次数,然后在 Strasmore 终端上观察最高数字继续上升。