Um LLM consegue encontrar fatores de alpha?
Um LLM pode criar cem fatores de alpha em uma hora. Veja como 240 fatores de cara ou coroa se saem em dez anos de preços reais e como testar os sobreviventes.
Um LLM pode propor fatores de alpha o dia inteiro. Dê a um modelo capaz um dicionário de dados e um mecanismo de pontuação, e ele escreverá cem expressões de fatores plausíveis antes do almoço. A questão mais difícil vem depois: como alguém saberia se algum deles é real, quando a busca que o produziu é uma máquina de fabricar vencedores a partir de ruído?
O que é um fator de alpha?
Um fator é uma regra que transforma dados de mercado em um número para cada ação em cada data. A variação de preço em doze meses é um fator. A relação entre dívida e patrimônio também. Um fator se torna uma estratégia quando você ordena um universo por ele, compra a faixa superior, vende a faixa inferior e rebalanceia em uma periodicidade definida. Alpha é o retorno que sobra depois de subtrair o que uma exposição simples ao mercado teria proporcionado de qualquer forma.
Os candidatos recebem uma pontuação pelo índice de Sharpe: retorno médio dividido pelo desvio-padrão desse retorno, anualizado. É o retorno por unidade de oscilação. Um Sharpe de longo prazo próximo de 1 em uma estratégia ao vivo é respeitável. Vale lembrar disso na próxima vez que um backtest apresentar 3.
Como a pesquisa de fatores com LLM realmente funciona
Todo projeto nessa área executa uma versão do mesmo ciclo.
- O modelo escreve expressões de fatores em uma linguagem pequena que o mecanismo consegue avaliar.
- Um backtester atribui uma pontuação a cada expressão usando um histórico fixo de preços e fundamentos.
- As expressões acima de um limite de pontuação são mantidas. As demais são descartadas.
- As expressões mantidas retornam ao contexto do modelo como exemplos resolvidos, e o ciclo recomeça.
Sistemas de trading multiagente distribuem essas tarefas entre funções separadas: uma propõe e outra testa. A infraestrutura é realmente útil, e as habilidades de dados de mercado de que um agente de IA precisa são as mesmas de que uma pessoa precisa.
Nada nesse ciclo é desonesto. A pesquisa é feita por meio de buscas. O problema é aritmético e surge no momento em que a etapa 2 é executada mais do que algumas vezes.
Por que uma busca de fatores de alpha com LLM fabrica vencedores
Um histórico de preços. Milhares de hipóteses baratas. Cada hipótese é avaliada contra a mesma amostra finita, e essa amostra contém muita aleatoriedade. Teste regras suficientes e algumas se ajustarão bem à aleatoriedade. A pontuação não consegue dizer qual tipo de ajuste ocorreu, porque uma regra que capturou ruído e uma regra que capturou o mercado produzem o mesmo número.
Eis a hipótese nula, extraída 240 vezes. Cada “fator” abaixo é um lançamento de moeda: um hash do ticker, do mês e do número do teste divide 40 ações americanas de grande porte em duas metades a cada mês, e a estratégia mantém uma metade comprada e a outra vendida. Por construção, não há informação alguma nesse processo. Avaliados com retornos reais de fechamento mensal entre janeiro de 2016 e junho de 2021, os 240 testes se distribuem assim.
O SQL exato por trás de cada número
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)O spread é o ponto central. Nada nesse gráfico prevê coisa alguma, mas 1 testes ficaram na faixa superior (1.2 and above), 0.4% da busca, e 1 ficaram na faixa inferior (below -1.2). Um pesquisador que executasse um teste sortudo e parasse teria um gráfico e um índice de Sharpe, sem nenhuma forma de distinguir qualquer um dos dois de uma descoberta. Os retornos aqui vão de um fechamento mensal ao fechamento mensal seguinte; como os retornos mensais são calculados explica essa aritmética.
O número que importa é quantos testes você fez
Um backtest apresentado isoladamente não informa seu denominador. Os mesmos 240 testes, vistos como uma busca que continua se ampliando: a cada etapa, a melhor pontuação registrada ao lado da média de tudo o que foi testado até então.
O SQL exato por trás de cada número
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_triedUm máximo acumulado só pode subir, e essa é exatamente a armadilha. A primeira regra testada marcou 0.44. Depois de 240 testes, a melhor pontuação registrada era 1.59, enquanto a média de todos eles estava em 0.01. A manchete melhorou sem que uma única regra tivesse melhorado. Um mecanismo que avalia dez mil expressões percorre essa curva muito mais à direita do que qualquer ponto mostrado aqui, e o número que ele reporta é o topo da curva.
O que um período fora da amostra faz com os vencedores
A defesa padrão é um período de holdout: avaliar um período e depois reavaliar os sobreviventes em um período posterior que a busca nunca utilizou. Pegue os doze lançamentos de moeda com melhor desempenho na janela de treinamento e execute exatamente as mesmas regras nos cinco anos seguintes, de julho de 2021 a junho de 2026.
O SQL exato por trás de cada número
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 12Cada par de barras representa uma regra. A barra da esquerda é a pontuação que garantiu sua inclusão no relatório. A barra da direita é a mesma regra nos cinco anos seguintes. O teste que ficou em primeiro marcou 1.59 no treinamento e -0.51 depois; o décimo segundo marcou 0.74 e depois 0.49.
Doze regras também constituem uma amostra pequena. Ordenar os 240 testes em quintos pela pontuação de treinamento e calcular a média da pontuação de holdout de cada quinto oferece uma visão mais clara.
O SQL exato por trás de cada número
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)No treinamento, os grupos vão de 0.66 no topo a -0.63 na base: uma escada ampla e perfeitamente ordenada, algo garantido porque os grupos foram formados por essa mesma pontuação. No holdout, as mesmas duas extremidades têm médias de 0.01 e 0.13. A escada fica mais plana. O holdout é a única parte do processo que não foi otimizada, e é isso que torna importante usá-lo com parcimônia.
Defesas que realmente funcionam
Um holdout usado uma única vez. Cada consulta a ele o transforma em dados de treinamento. O teste walk-forward, em que a janela avança e cada pontuação vem de dados posteriores ao ajuste, é a versão que resiste ao uso repetido.
Um ajuste para múltiplos testes. O índice de Sharpe deflacionado, introduzido por Bailey e López de Prado em 2014, desconta um Sharpe observado de acordo com o número de testes realizados, a duração da amostra, a assimetria dos retornos e a espessura de suas caudas. Alimente-o com uma contagem honesta de testes, e um Sharpe de destaque vindo de uma busca com dez mil expressões frequentemente se reduz a nada.
Um registro de auditoria que cubra todas as expressões testadas, inclusive as descartadas. Este é o elemento estrutural, e é por isso que “auditável” é a palavra interessante na descrição de um projeto de pesquisa de fatores. A deflação precisa de uma contagem de testes. Um pipeline que registra apenas os vencedores destruiu a entrada necessária para sua própria correção. Rascunhos descartados, varreduras de parâmetros abandonadas, reinícios do próprio pesquisador e todas as versões anteriores do código de pontuação entram nessa contagem.
Verificações de custos e de viés de antecipação antes de acreditar na pontuação. Um fator ordenado por um campo de fundamentos com a data em que o fornecedor o carregou, em vez da data em que o mercado poderia ter acesso a ele, produz um backtest excelente e uma negociação ruim.
Como interpretar a palavra “auditável”
Novos repositórios nessa área aparecem quase toda semana, e um projeto com algumas dezenas de estrelas é um protótipo, não um histórico de resultados. A contagem de estrelas também muda mais rápido do que o código, razão pela qual esta página avalia o padrão, e não um projeto específico. Eis o que abrir primeiro em qualquer projeto que aparecer à sua frente.
- Ele registra todos os candidatos com sua expressão e sua pontuação, com carimbo de data e hora, ou apenas os que foram mantidos?
- O holdout é imposto pelo mecanismo ou depende da disciplina do pesquisador?
- Toda pontuação divulgada vem acompanhada de uma contagem de testes?
- Para qual mercado ele foi desenvolvido? Uma biblioteca ajustada para ações A da China incorpora limites diários de preço e a restrição de vender ações compradas na mesma sessão. O comportamento de um fator sob essas regras não se transfere para ações americanas.
- É possível executá-lo novamente e reproduzir os números? Fixe o commit exato que você leu, pois um projeto nesse estágio reescreve seu código de pontuação de um fim de semana para o outro.
Nada disso torna um LLM inútil na pesquisa de fatores. Gerar hipóteses é um gargalo real, e os modelos são bons nisso. O que muda é onde recai o trabalho: na contabilidade de quantas hipóteses foram consumidas. Antes de qualquer uma delas chegar a um livro de ordens real, o paper trading é onde fica visível a distância entre um backtest e uma execução.
Perguntas frequentes sobre fatores de alpha com LLM
Um LLM consegue encontrar fatores de alpha?
Ele pode propor milhares deles, mas uma proposta não é uma descoberta. A afirmação é feita na etapa de pontuação, e uma pontuação obtida em uma busca ampla carrega um problema de seleção que a própria pontuação não consegue enxergar. Avalie a disciplina do holdout e a contagem registrada de testes antes da expressão.
O que é o índice de Sharpe deflacionado?
É uma correção que transforma um índice de Sharpe observado na probabilidade de que uma busca desse tamanho o tivesse produzido sem a presença de uma vantagem real. Bailey e López de Prado o publicaram em 2014. Sua principal entrada é o número de testes, exatamente o número que um ciclo de pesquisa não auditado não consegue fornecer.
Quantos backtests são demais?
Não existe um limite fixo. Existe apenas um ajuste que precisa ser aplicado. Um backtest com pontuação de 1.0 e dez mil backtests cujo melhor resultado é 1.0 representam afirmações diferentes sobre o mundo. Os lançamentos de moeda acima chegaram a 1.59 em 240 testes, sem qualquer informação nos dados.
Por que os fatores publicados enfraquecem depois da publicação?
Pesquisas acadêmicas acompanharam a deterioração de anomalias publicadas nos anos seguintes à sua divulgação. O excesso de concentração de posições é um mecanismo proposto, e um resultado original superajustado à própria amostra é outro. Ambos produzem o mesmo formato em um gráfico. A hipótese dos mercados eficientes enquadra o primeiro, e os testes acima demonstram o segundo.
Cada painel aqui é uma consulta armazenada sobre preços reais de fechamento mensal, com o SQL disponível logo abaixo. Copie uma delas, aumente a contagem de testes e observe o melhor número subir no terminal da Strasmore.