Un LLM peut-il trouver des facteurs alpha ?
Un LLM peut créer une centaine de facteurs alpha en une heure. Découvrez le score de 240 facteurs pile ou face sur dix ans de cours réels et testez les survivants.
Un LLM peut proposer des facteurs alpha toute la journée. Donnez à un modèle performant un dictionnaire de données et un moteur de scoring, et il écrira une centaine d’expressions de facteurs plausibles avant midi. La question la plus difficile est sous-jacente : comment savoir si l’une d’elles est réelle, lorsque la recherche qui l’a produite est une machine à fabriquer des gagnants à partir du bruit ?
Qu’est-ce qu’un facteur alpha ?
Un facteur est une règle qui transforme les données de marché en un nombre pour chaque action à chaque date. La variation du cours sur douze mois est un facteur. Le ratio dette sur capitaux propres en est un autre. Un facteur devient une stratégie lorsque vous classez un univers selon ce facteur, achetez la tranche supérieure, vendez la tranche inférieure et rééquilibrez le portefeuille selon une fréquence définie. L’alpha correspond au rendement restant après déduction de ce qu’une simple exposition au marché aurait de toute façon rapporté.
Les candidats sont évalués avec le ratio de Sharpe : le rendement moyen divisé par l’écart-type de ce rendement, annualisé. Il mesure le rendement par unité de volatilité. Un ratio de Sharpe proche de 1 à long terme pour une stratégie en réel est respectable. Gardez-le à l’esprit la prochaine fois qu’un backtest affiche 3.
Comment fonctionne réellement la recherche de facteurs avec un LLM
Chaque projet dans ce domaine applique une variante de la même boucle.
- Le modèle écrit des expressions de facteurs dans un langage limité que le moteur peut évaluer.
- Un backtester évalue chaque expression sur un historique fixe de cours et de données fondamentales.
- Les expressions dont le score dépasse un seuil sont conservées. Les autres sont écartées.
- Les expressions conservées sont réinjectées dans le contexte du modèle comme exemples fonctionnels, puis la boucle recommence.
Les systèmes de trading multi-agents répartissent ces tâches entre plusieurs rôles, l’un proposant les expressions et l’autre les testant. L’infrastructure est réellement utile, et les compétences en données de marché dont un agent IA a besoin sont les mêmes que celles nécessaires à une personne.
Rien dans cette boucle n’est malhonnête. La recherche est une méthode normale de travail. Le problème vient de l’arithmétique, dès que l’étape 2 est exécutée plus d’une poignée de fois.
Pourquoi une recherche de facteurs alpha avec un LLM fabrique des gagnants
Un historique de cours. Des milliers d’hypothèses peu coûteuses. Chaque hypothèse est évaluée sur le même échantillon limité, qui contient une grande part de hasard. Testez suffisamment de règles et certaines s’ajusteront étroitement à ce hasard. Le score ne permet pas de distinguer les types d’ajustement : une règle qui correspond au bruit et une règle qui correspond au marché produisent le même chiffre.
Voici l’hypothèse nulle, tirée 240 fois. Chaque « facteur » ci-dessous est un pile ou face : un hash du ticker, du mois et d’un numéro d’essai répartit 40 grandes valeurs américaines en deux moitiés chaque mois. La stratégie est acheteuse sur une moitié et vendeuse sur l’autre. Elle ne contient aucune information, par construction. Évalués sur les rendements réels de fin de mois de janvier 2016 à juin 2021, les 240 essais se répartissent ainsi.
Le SQL exact derrière chaque chiffre
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)Le spread est tout l’intérêt de l’exercice. Rien sur ce graphique ne prédit quoi que ce soit, et pourtant 1 essais se trouvent dans la tranche supérieure (1.2 and above), 0.4% de la recherche, tandis que 1 se trouvent dans la tranche inférieure (below -1.2). Un chercheur qui aurait exécuté un seul essai chanceux avant de s’arrêter disposerait d’un graphique et d’un ratio de Sharpe, sans moyen de distinguer l’un ou l’autre d’une véritable découverte. Les rendements utilisés ici vont de clôture de fin de mois à clôture de fin de mois ; la mesure des rendements mensuels détaille ce calcul.
Le chiffre important est le nombre d’essais
Un backtest présenté seul ne fournit pas son dénominateur. Voici les mêmes 240 essais, lus comme une recherche qui s’élargit progressivement : à chaque étape, le meilleur score affiché à côté de la moyenne de tous les essais réalisés jusque-là.
Le SQL exact derrière chaque chiffre
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_triedUn maximum cumulé ne peut qu’augmenter. C’est précisément le piège. La première règle testée a obtenu 0.44. Après 240 essais, le meilleur score affiché est 1.59, tandis que la moyenne de l’ensemble atteint 0.01. Le chiffre mis en avant s’est amélioré sans qu’une seule règle ne se soit améliorée. Un moteur qui évalue dix mille expressions prolonge cette courbe bien au-delà de ce qui est représenté ici, et le chiffre qu’il publie correspond à son sommet.
Ce qu’une période hors échantillon fait aux gagnants
La défense habituelle consiste à utiliser un échantillon de validation : évaluer une période, puis réévaluer les survivants sur une période ultérieure que la recherche n’a jamais utilisée. Prenons les douze meilleurs piles ou faces de la fenêtre d’apprentissage et appliquons exactement les mêmes règles aux cinq années suivantes, de juillet 2021 à juin 2026.
Le SQL exact derrière chaque chiffre
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 12Chaque paire de barres correspond à une règle. La barre de gauche représente le score qui lui a permis d’être retenue dans le rapport. Celle de droite représente le score de la même règle sur les cinq années suivantes. L’essai classé premier a obtenu 1.59 pendant l’apprentissage et -0.51 ensuite ; le douzième a obtenu 0.74, puis 0.49.
Douze règles constituent un petit échantillon à elles seules. Classez les 240 essais en cinq groupes selon leur score d’apprentissage, puis calculez le score moyen de chaque groupe sur l’échantillon de validation. Vous obtenez une vue plus fiable.
Le SQL exact derrière chaque chiffre
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)Pendant l’apprentissage, les groupes vont de 0.66 pour le groupe supérieur à -0.63 pour le groupe inférieur. L’échelle est large et parfaitement ordonnée, ce qui est garanti puisque les groupes ont été formés selon ce score. Sur l’échantillon de validation, les moyennes des deux extrémités sont respectivement de 0.01 et 0.13. L’échelle s’aplatit. Un échantillon de validation est la seule partie du processus qui n’a pas été optimisée, ce qui justifie de l’utiliser avec parcimonie.
Les protections qui fonctionnent réellement
Un échantillon de validation utilisé une seule fois. Chaque consultation le transforme en données d’apprentissage. Le walk-forward testing, dans lequel la fenêtre se déplace et chaque score provient de données postérieures à l’ajustement, est la version qui résiste aux utilisations répétées.
Un ajustement pour tests multiples. Le ratio de Sharpe défalqué, introduit par Bailey et López de Prado en 2014, corrige un ratio de Sharpe observé en fonction du nombre d’essais effectués, de la durée de l’échantillon, de l’asymétrie des rendements et de l’épaisseur de leurs queues de distribution. Avec un nombre d’essais déclaré honnêtement, un ratio de Sharpe mis en avant après une recherche portant sur dix mille expressions est souvent ramené à zéro.
Une piste d’audit couvrant toutes les expressions testées, y compris celles qui ont été écartées. C’est l’élément essentiel. C’est pourquoi le terme « auditable » est important dans la description d’un projet de recherche factorielle. La correction nécessite un nombre d’essais. Un processus qui n’enregistre que les gagnants a détruit la donnée nécessaire à sa propre correction. Les versions abandonnées, les balayages de paramètres interrompus, les redémarrages du chercheur et toutes les versions précédentes du code de scoring doivent être comptabilisés.
Des contrôles des coûts et du biais d’anticipation avant de croire au score. Un facteur classé selon une donnée fondamentale portant la date à laquelle le fournisseur l’a chargée, plutôt que la date à laquelle le marché pouvait y avoir accès, produit un excellent backtest mais de mauvais résultats en trading.
Comprendre le terme « auditable »
De nouveaux dépôts apparaissent presque chaque semaine dans ce domaine. Un projet comptant quelques dizaines d’étoiles est un prototype, pas un historique de performance. Le nombre d’étoiles évolue aussi plus vite que le code. C’est pourquoi cette page évalue le schéma général plutôt qu’un projet précis. Voici ce que vous devez examiner en premier dans celui qui se présente à vous.
- Enregistre-t-il chaque candidat avec son expression et son score, horodatés, ou uniquement les expressions conservées ?
- L’échantillon de validation est-il imposé par le moteur, ou dépend-il de la discipline du chercheur ?
- Chaque score publié est-il accompagné d’un nombre d’essais ?
- Pour quel marché le projet a-t-il été conçu ? Une bibliothèque réglée sur les actions chinoises de catégorie A intègre des limites de variation quotidiennes et l’interdiction de vendre des actions achetées au cours de la même séance. Le comportement d’un facteur dans le cadre de ces règles ne se transpose pas aux actions américaines.
- Pouvez-vous le réexécuter et reproduire les chiffres ? Épinglez le commit exact que vous avez consulté, car un projet à ce stade réécrit son code de scoring d’un week-end à l’autre.
Rien de tout cela ne rend un LLM inutile pour la recherche factorielle. La génération d’hypothèses constitue un véritable goulot d’étranglement, et les modèles y sont performants. Ce qui change, c’est l’endroit où se situe la charge de preuve : dans le suivi du nombre d’hypothèses testées. Avant toute confrontation avec un carnet d’ordres réel, le paper trading permet de mesurer l’écart entre un backtest et une exécution.
FAQ sur les facteurs alpha générés par les LLM
Un LLM peut-il trouver des facteurs alpha ?
Il peut en proposer par milliers, mais une proposition n’est pas une découverte. L’affirmation intervient au stade du scoring, et un score issu d’une recherche étendue comporte un problème de sélection que le score lui-même ne peut pas détecter. Évaluez d’abord la discipline appliquée à l’échantillon de validation et le nombre d’essais enregistré, avant de vous intéresser à l’expression.
Qu’est-ce que le ratio de Sharpe défalqué ?
Il s’agit d’une correction qui transforme un ratio de Sharpe observé en une probabilité qu’une recherche de cette ampleur l’aurait produit en l’absence de véritable avantage. Bailey et López de Prado l’ont publié en 2014. Son principal paramètre d’entrée est le nombre d’essais, précisément le nombre qu’un processus de recherche non audité ne peut pas fournir.
Combien de backtests sont-ils excessifs ?
Il n’existe pas de seuil. Seul un ajustement doit être appliqué. Un backtest affichant un score de 1,0 et dix mille backtests dont le meilleur score est de 1,0 ne constituent pas la même affirmation sur le monde. Les piles ou faces ci-dessus ont atteint 1.59 sur 240 essais, alors que les données ne contenaient aucune information.
Pourquoi les facteurs publiés s’affaiblissent-ils après leur publication ?
Les travaux universitaires ont suivi l’érosion des anomalies publiées au cours des années qui suivent leur présentation. L’encombrement des positions est l’un des mécanismes avancés, et le surajustement d’un résultat original à son propre échantillon en est un autre. Les deux produisent la même forme sur un graphique. L’hypothèse des marchés efficients décrit le premier mécanisme, tandis que les essais ci-dessus démontrent le second.
Chaque panneau présenté ici correspond à une requête stockée sur des cours réels de fin de mois, avec le SQL visible en dessous. Copiez-en une, augmentez le nombre d’essais et observez le meilleur chiffre progresser sur le terminal Strasmore.