Может ли LLM находить alpha-факторы
LLM может написать сотню alpha-факторов за час. Узнайте, как 240 случайных факторов показали себя на десяти годах реальных цен и как проверять лучшие.
Большую часть дня LLM может предлагать alpha-факторы. Дайте достаточно способной модели словарь данных и инструмент оценки — и до обеда она напишет сотню правдоподобных выражений факторов. Но главный вопрос остаётся нерешённым: как понять, что один из них действительно работает, если сам поиск превращает шум в победителей?
Что такое alpha-фактор?
Фактор — это правило, которое преобразует рыночные данные в одно число для каждой акции на каждую дату. Изменение цены за двенадцать месяцев — фактор. Соотношение долга и капитала — тоже. Фактор становится стратегией, когда вы ранжируете по нему инвестиционный набор, покупаете верхнюю часть, продаёте нижнюю и регулярно ребалансируете позиции. Alpha — это доходность, которая остаётся после вычитания результата обычной рыночной экспозиции.
Кандидатов оценивают с помощью коэффициента Шарпа: средняя доходность делится на стандартное отклонение этой доходности, показатель пересчитывается на годовой период. Это доходность на единицу колебаний. Долгосрочный Sharpe около 1 для реальной стратегии считается достойным результатом. Об этом стоит помнить в следующий раз, когда backtest покажет значение 3.
Как на самом деле проходит поиск факторов с помощью LLM
Каждый проект в этой области использует один и тот же цикл в той или иной форме.
- Модель записывает выражения факторов на небольшом языке, который может выполнять инструмент оценки.
- Backtester оценивает каждое выражение на фиксированной истории цен и фундаментальных показателей.
- Выражения, превысившие порог оценки, сохраняются. Остальные отбрасываются.
- Сохранённые выражения возвращаются в контекст модели как готовые примеры, после чего цикл запускается снова.
Мультиагентные торговые системы распределяют эти задачи между отдельными ролями: одна роль предлагает идеи, другая их тестирует. Такая инфраструктура действительно полезна, а навыки работы с рыночными данными, необходимые AI-агенту нужны и человеку.
В этом цикле нет ничего нечестного. Поиск — нормальный способ проводить исследование. Проблема возникает в арифметике, как только шаг 2 выполняется больше нескольких раз.
Почему поиск alpha-факторов с помощью LLM создаёт мнимых победителей
Одна история цен. Тысячи дешёвых гипотез. Каждая гипотеза оценивается на одной и той же конечной выборке, в которой много случайности. Если протестировать достаточно правил, некоторые из них хорошо подстроятся под эту случайность. Оценка не показывает, какой именно это тип подгонки: правило, совпавшее с шумом, и правило, совпавшее с движением рынка, получают одинаковое число.
Рассмотрим нулевую гипотезу, проверенную 240 раз. Каждый приведённый ниже «фактор» — это подбрасывание монеты: хеш тикера, месяца и номера испытания каждый месяц делит 40 крупных американских компаний на две группы, после чего стратегия открывает длинные позиции по одной половине и короткие — по другой. По конструкции в этом факторе нет информации. При оценке на реальной доходности от конца месяца к концу месяца с января 2016 года по июнь 2021 года 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). Исследователь, который провёл одно удачное испытание и остановился, получил бы график и коэффициент Шарпа, но не смог бы отличить их от результата настоящего открытия. Здесь доходность рассчитана от конца одного месяца до конца следующего; как рассчитывается месячная доходность описывает эту арифметику.
Важнее всего количество проведённых испытаний
Результат backtest без знаменателя неполон. Рассмотрим те же 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 года по июнь 2026 года.
Точный 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. Ряд выравнивается. Отложенный период — единственная часть процесса, против которой не проводилась оптимизация. Поэтому его следует использовать экономно.
Методы защиты, которые действительно работают
Отложенный период, который используется один раз. Каждый просмотр превращает его в обучающие данные. Walk-forward-тестирование, при котором окно сдвигается, а каждая оценка строится на данных после периода подгонки, — это вариант, выдерживающий повторное использование.
Корректировка на множественное тестирование. Deflated Sharpe ratio, представленный Bailey и López de Prado в 2014 году, корректирует наблюдаемый Sharpe с учётом количества проведённых испытаний, длины выборки, асимметрии доходности и толщины хвостов распределения. Если указать честное количество испытаний, заявленный Sharpe из поиска по десяти тысячам выражений часто снизится практически до нуля.
Журнал всех проверенных выражений, включая отброшенные. Это ключевой элемент. Именно поэтому слово «аудируемый» имеет такое значение в описании проекта по исследованию факторов. Для корректировки необходимо знать количество испытаний. Если pipeline сохраняет только победителей, он уничтожает данные, необходимые для собственной корректировки. Отброшенные варианты, незавершённые переборы параметров, перезапуски исследователя и каждая предыдущая версия кода оценки — всё это учитывается в данном числе.
Проверки издержек и смещения из-за заглядывания вперёд до того, как результату начнут доверять. Если фактор ранжируется по фундаментальному показателю с датой загрузки поставщиком, а не с датой, когда этот показатель стал доступен рынку, backtest будет выглядеть отлично, а торговля даст плохой результат.
Как понимать слово «аудируемый»
Новые репозитории в этой области появляются почти каждую неделю, а проект с несколькими десятками звёзд — это прототип, а не подтверждённая история результатов. Количество звёзд также меняется быстрее, чем код. Поэтому на этой странице оценивается сам подход, а не какой-либо отдельный проект. Вот что следует проверить в первую очередь в любом проекте, который окажется перед вами.
- Сохраняет ли он каждого кандидата вместе с выражением и оценкой, с отметкой времени, или только прошедшие отбор правила?
- Обеспечивается ли использование отложенного периода самим инструментом, или это зависит от самодисциплины исследователя?
- Указывается ли рядом с каждой опубликованной оценкой количество испытаний?
- Для какого рынка создан проект? Библиотека, настроенная на китайские акции класса A, учитывает ежедневные ценовые лимиты и запрет на продажу акций, купленных в ту же торговую сессию. Поведение фактора в таких условиях нельзя автоматически переносить на акции США.
- Можно ли повторно запустить проект и воспроизвести результаты? Зафиксируйте точный commit, который вы изучаете: на этом этапе проект может менять код оценки от выходных к выходным.
Всё это не делает LLM бесполезной для исследования факторов. Генерация гипотез — реальное узкое место, и модели хорошо с ним справляются. Меняется лишь место, где возникает основная нагрузка: теперь необходимо учитывать, сколько гипотез было проверено и отброшено. До выхода на реальный рынок бумажная торговля показывает, насколько результат backtest отличается от фактического исполнения заявки.
Часто задаваемые вопросы об alpha-факторах LLM
Может ли LLM находить alpha-факторы?
Модель может предложить их тысячами, но предложение ещё не является открытием. Утверждение о наличии фактора появляется на этапе оценки, а оценка после широкого поиска несёт проблему отбора, которую сама оценка не видит. В первую очередь проверяйте дисциплину работы с отложенным периодом и зафиксированное количество испытаний, а уже затем — само выражение.
Что такое deflated Sharpe ratio?
Это корректировка, которая преобразует наблюдаемый коэффициент Шарпа в вероятность того, что поиск такого размера дал бы этот результат при отсутствии реального преимущества. Bailey и López de Prado опубликовали этот показатель в 2014 году. Его основной входной параметр — количество испытаний. Именно это число не может предоставить неаудируемый исследовательский цикл.
Сколько backtest слишком много?
Порогового значения нет. Есть только корректировка, которую необходимо применять. Один backtest с результатом 1,0 и десять тысяч backtest, среди которых лучший также показал 1,0, — это разные утверждения о мире. В приведённом выше эксперименте со случайными факторами значение 1.59 было достигнуто в ходе 240 испытаний, хотя в данных вообще не было информации.
Почему опубликованные факторы ослабевают после публикации?
Академические исследования отслеживали снижение эффективности опубликованных аномалий в годы после их появления. Одним из объяснений называется переполненность сделками, другим — подгонка исходного результата под собственную выборку. На графике оба механизма выглядят одинаково. Гипотеза эффективного рынка описывает первый механизм, а приведённые выше испытания демонстрируют второй.
Каждая панель здесь представляет собой сохранённый запрос к реальным ценам на конец месяца, а SQL-код доступен под ней. Скопируйте запрос, увеличьте количество испытаний и наблюдайте, как лучший результат растёт в терминале Strasmore.