LLMはアルファ・ファクターを見つけられるか
LLMは一時間でアルファ・ファクターを一百個書けます。実際の十年分の価格で二百四十個のコイン投げファクターを評価し、生き残りを検証する方法を紹介します。
LLMは、アルファ・ファクターを一日中提案できます。高性能なモデルにデータ辞書とスコアリング・ハーネスを渡せば、昼前にはもっともらしいファクター式を一百個書き出すでしょう。より難しい問題は、その下にあります。検索自体がノイズから勝者を作り出す装置であるとき、そのうちの一つが本物かどうかを、いったいどう判断すればよいのでしょうか。
アルファ・ファクターとは何か
ファクターとは、市場データを各銘柄・各日に一つの数値へ変換するルールです。十二カ月の株価変化率はファクターです。負債比率も同様です。ファクターで投資対象銘柄を順位付けし、上位を買い、下位を売り、一定のスケジュールでリバランスすると、ファクターは戦略になります。アルファとは、単純な市場エクスポージャーから得られたはずのリターンを差し引いた後に残るリターンです。
候補はシャープレシオで評価します。平均リターンをその標準偏差で割り、年率換算した値です。変動一単位当たりのリターンを示します。実運用戦略で長期的にシャープレシオが1前後なら評価できます。だからこそ、バックテストで3という数値が出たときは注意が必要です。
LLMによるファクター研究の実際
この分野のプロジェクトは、いずれも同じループを何らかの形で実行します。
- モデルが、ハーネスで評価できる小規模言語でファクター式を書きます。
- バックテスターが、固定された価格・ファンダメンタルズの履歴で各式を評価します。
- スコアが閾値を上回る式を残し、それ以外を破棄します。
- 残った式を実例としてモデルのコンテキストに戻し、再びループを実行します。
マルチエージェント取引システムでは、これらの作業を別々の役割に分担させます。一方が提案し、もう一方が検証します。基盤部分は実際に有用であり、AIエージェントに必要な市場データのスキルは人間にも必要です。
このループに不正はありません。検索は研究の進め方です。問題は算術にあります。ステップ2を数回以上実行した時点で、その問題が現れます。
なぜLLMのアルファ・ファクター検索は勝者を生み出すのか
一つの株価履歴に対して、安価な仮説を数千個試します。すべての仮説を同じ有限標本で評価するため、その標本には多くの偶然が含まれます。ルールを十分に試せば、偶然に強く適合するものが出てきます。スコアだけでは、どの種類の適合かを判別できません。ノイズに適合したルールも、市場の値動きに適合したルールも、同じ数値を返すからです。
帰無仮説を二百四十回抽出しました。以下の各「ファクター」はコイン投げです。ティッカー、月、試行番号のハッシュ値で、米国の大型銘柄四十銘柄を毎月二つのグループに分け、一方をロング、もう一方をショートします。構造上、そこに情報はありません。2016年1月から2021年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('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)に入りました。研究者が幸運な試行を一回だけ実行して終了すれば、チャートとシャープレシオは得られます。しかし、それが発見なのかどうかを判別する方法はありません。ここでのリターンは月末終値から次の月末終値までです。月次リターンの測定方法で、その計算を説明しています。
重要なのは試した数
単独で報告されたバックテストには、分母がありません。同じ二百四十回の試行を、検索が段階的に拡大するものとして読み直します。各段階で、それまで試したすべての平均と、その時点の最高スコアを比較します。
各数値の背後にある正確な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でした。
十二個のルール自体も小さな標本です。二百四十個すべてを学習スコアで五分位に分け、各五分位のホールドアウト・スコアを平均すると、より明確な姿が見えます。
各数値の背後にある正確な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アルファ・ファクターFAQ
LLMはアルファ・ファクターを見つけられますか
LLMはアルファ・ファクターを数千個単位で提案できます。しかし、提案は発見ではありません。主張が生まれるのはスコアリングの段階です。広範な検索から得たスコアには、スコア自体では見えない選択問題があります。式より先に、ホールドアウトの運用方法と記録された試行数を確認してください。
デフレーテッド・シャープレシオとは何ですか
観測されたシャープレシオを、実際の優位性がない場合でも、その規模の検索によって得られた確率へ変換する補正です。BaileyとLópez de Pradoが2014年に発表しました。中心となる入力は試行数です。これは、監査されていない研究ループでは提供できない数値です。
バックテストは何回行うと多すぎますか
閾値はありません。必要なのは、調整を適用することです。シャープレシオ1.0のバックテストが一回ある場合と、最良スコアが1.0のバックテストを一万回行った場合では、世界について異なる主張をしています。上のコイン投げは、データに何の情報もないのに、1.59へ到達しました。試行数は240です。
公表されたファクターは、なぜ公表後に弱くなるのですか
学術研究では、公表されたアノマリーが登場後の数年間で弱まることが確認されています。その原因として、取引の混雑が挙げられています。また、元の結果が自身の標本に過剰適合していた可能性もあります。どちらもチャート上では同じ形になります。効率的市場仮説は前者を説明し、上の試行は後者を示しています。
ここにある各パネルは、実際の月末価格を対象とした保存済みクエリです。SQLもその下で公開されています。一つをコピーして試行数を増やし、Strasmoreターミナル上で最高値が上昇する様子を確認してください。