From 56deccdb4ae20e3fa9481c9b984a995a1e5000a2 Mon Sep 17 00:00:00 2001 From: jsboige Date: Wed, 7 Oct 2026 08:42:03 +0200 Subject: [PATCH 1/3] feat(qc,#18962): FinBERT re-entraine - portage 06/19/02 + mesure hors echantillon Portage fidele du re-entrainement FinBERT du livre (exemple 06/19/02) et mesure hors echantillon contre le modele de base pre-entraine. Artefacts : - main_finetuned.py : portage du FinbertFineTunedModelAlgorithm (ecarts n1-n8 documentes en tete de fichier) - finetune/run_finetune_finbert.py : harnais hors ligne (corpus FNSPID, etiquetage par reaction de cours, re-entrainement GPU, evaluation hors echantillon base contre re-entraine) - finetune/measures/ : une mesure par graine + agregat - README.md : section resultat mesuree et verdict Mesure (graines 1/2/3/42, RTX 3080 Ti Laptop, torch 2.8.0+cu126, 1 680 echantillons d'entrainement sur 53 mois, 268 hors echantillon sur 6 mois) : - exactitude du modele de base : 0,4515 - exactitude re-entrainee moyenne : 0,3461 - ecart moyen -0,1054, ecart-type inter-graines 0,0361, soit -2,92 sigma - aucune graine sur quatre ne bat le modele de base Verdict : NO BEATS. La distribution des classes predites montre que le modele re-entraine perd la classe neutre (0 a 14 predits sur 268, contre 75 reels), avec un signe de desequilibre qui depend de la graine - ce n'est donc pas un effondrement sur une classe fixe. Voir #18962 : la moitie Chronos du meme exemple est portee par la PR #19622. Co-Authored-By: Claude Sonnet 5.5 --- .../projects/ML-FinBERT-Sentiment/README.md | 46 +- .../finetune/measures/seed-1.json | 77 ++ .../finetune/measures/seed-2.json | 77 ++ .../finetune/measures/seed-3.json | 77 ++ .../finetune/measures/seed-42.json | 77 ++ .../finetune/measures/summary.json | 437 ++++++++++ .../finetune/run_finetune_finbert.py | 777 ++++++++++++++++++ .../ML-FinBERT-Sentiment/main_finetuned.py | 339 ++++++++ 8 files changed, 1905 insertions(+), 2 deletions(-) create mode 100644 MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/measures/seed-1.json create mode 100644 MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/measures/seed-2.json create mode 100644 MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/measures/seed-3.json create mode 100644 MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/measures/seed-42.json create mode 100644 MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/measures/summary.json create mode 100644 MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/run_finetune_finbert.py create mode 100644 MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/main_finetuned.py diff --git a/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/README.md b/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/README.md index 398333b668..0f507e8554 100644 --- a/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/README.md +++ b/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/README.md @@ -11,6 +11,43 @@ Portage fidèle du modèle de base de l'exemple 19 du livre (*Hands-On AI Tradin **Le modèle tourne dans l'algorithme sur QC Cloud** (variante PyTorch, `local_files_only=True` comme le livre) — mesuré par les sondes `probe2-bitmask-finbert` et `probe3-tiingo-decode` du 2026-10-05 : `torch`, `transformers` et même `tensorflow` importent sur les nœuds, et l'inférence y rend ses probabilités. +## Modèle ré-entraîné (exemple 19/02) + +L'exemple 19/02 du livre va plus loin : **ré-entraîner** FinBERT, à chaque rebalancement mensuel, sur les articles des 30 derniers jours étiquetés par la réaction du cours, avant de scorer le mois. Deux artefacts le portent : + +- `main_finetuned.py` — portage fidèle du code du livre en PyTorch (écarts n1–n8 documentés en tête de fichier). Il **n'est pas exécutable en CI ni sur un nœud QC ordinaire** : le ré-entraînement tourne dans l'algorithme et demande un GPU. +- `finetune/run_finetune_finbert.py` — harnais **hors ligne** qui sort le ré-entraînement de l'algorithme pour l'exécuter sur un GPU local, et **compare modèle de base et modèle ré-entraîné hors échantillon**. + +Le livre n'a **aucune séparation entraînement / hors échantillon** — il ré-entraîne et prédit sur les mêmes 100 échantillons du mois. Le harnais ajoute la séparation que l'issue demande : les six derniers mois sont retirés de l'entraînement. + +### Corpus et étiquetage — deux écarts mesurés, pas supposés + +**Source des articles.** Le livre lit Tiingo News sur QC Cloud ; hors ligne, le harnais lit **FNSPID** (`Stock_news/All_external.csv`, corpus public horodaté de même forme : date + ticker + titre). Le corpus est **CC BY-NC 4.0** et reste un **cache local hors dépôt** : seules les mesures sont committées. Sa couverture temporelle a été **mesurée** (échantillonnage de plages d'octets dans le fichier) avant de calibrer la fenêtre : ≈ 2004–2020, d'où le choix de 2015–2019. + +**Fenêtre de réaction.** Le livre mesure la réaction du cours **entre deux parutions consécutives**, sur des clôtures à la seconde (`Resolution.SECOND`). À résolution quotidienne, cette règle s'effondre : deux parutions de la même séance n'ont aucune réaction résolvable et rendent un label exactement nul. Mesuré sur le corpus du harnais : **410 des 680 paires consécutives (60 %) tombent dans la même séance**, donc **429 labels (63 %) sont nuls exactement** — ce qui fabrique une classe neutre majoritaire à 75 %, que le livre ne connaît pas. Le harnais mesure donc la réaction **à la parution** (rendement de la première séance dont la clôture suit l'instant de parution). Sur le même corpus, la distribution des classes passe de **12 / 75 / 13** à **32 / 29 / 38**, contre les **37,5 / 25 / 37,5** que la règle du livre vise. L'intention du livre est préservée ; sa mécanique de mesure est adaptée à la résolution disponible, et l'écart est écrit en tête du harnais. + +**Sélecteur d'actif et couverture du corpus.** Le livre retient le plus volatil des dix valeurs les plus liquides, sur un marché où la couverture d'actualité est **universelle** (Tiingo News sur QC Cloud). Hors ligne, la couverture de FNSPID est **très inégale** : le sélecteur du livre y désigne régulièrement un titre sans actualité observable. Mesure sur le corpus complet (13,06 M d'enregistrements parcourus, 22 496 articles retenus, 18 tickers) : la distribution va de HD 2 587 articles à **AMD 14**, et la tête de classement du livre *est* AMD — ce qui écartait **37 des 60 mois pour une raison de couverture, pas de stratégie**. Le harnais descend donc d'un rang quand le candidat n'est pas observable. Quand le premier choix du livre **est** couvert, il reste retenu à l'identique ; la même mesure passe alors à **1 948 échantillons sur 59 des 60 mois**. + +### Résultat — 4 graines, et un verdict qui va contre le livre + +Le harnais entraîne 1 680 échantillons sur 53 mois et évalue sur les **6 derniers mois tenus à l'écart** (268 échantillons, juillet-décembre 2019), quatre graines. Le modèle de base est le même pour toutes : exactitude hors échantillon **0,4515**. + +| Graine | Exactitude ré-entraînée | Écart au modèle de base | McNemar p | Classes prédites [nég, neu, pos] | +| --- | --- | --- | --- | --- | +| 1 | 0,2948 | **−0,1567** | 0,0001 | [109, 14, 145] | +| 2 | 0,3694 | **−0,0821** | 0,0507 | [20, 6, 242] | +| 3 | 0,3731 | **−0,0784** | 0,0640 | [36, 0, 232] | +| 42 | 0,3470 | **−0,1045** | 0,0175 | [203, 4, 61] | +| *vérité* | — | — | — | [90, 75, 103] | + +Écart moyen **−0,1054**, écart-type inter-graines 0,0361, soit **−2,92 σ**, et **aucune graine ne bat le modèle de base**. Verdict : **NO BEATS** — au sens strict, ce n'est pas « amélioration non démontrée » mais **dégradation démontrée**. + +**Ce que la distribution des classes prédites ajoute à l'exactitude.** Le modèle de base prédit [70, 105, 93] : il sur-prédit le neutre, biais connu de FinBERT sur du texte court. Le modèle ré-entraîné prédit **0 à 14 neutres sur 268**, quelle que soit la graine — la classe neutre **disparaît**. Le signe du déséquilibre dépend en revanche de la graine (242 positifs pour la graine 2, 203 négatifs pour la graine 42) : ce n'est donc pas un effondrement sur une classe fixe, mais la perte du neutre **et** une instabilité de signe d'une graine à l'autre. Deux époques à 3e-5 sur 1 680 étiquettes bruitées suffisent à détruire la frontière du neutre d'un modèle pré-entraîné. + +**Ce que ce verdict ne dit pas.** Il ne condamne pas la recette du livre, il condamne sa mesure **hors échantillon** dans ces conditions : le livre ré-entraîne et prédit sur les mêmes 100 échantillons du mois, donc sa mesure ne *peut pas* voir cette perte. C'est exactement l'écart que l'issue demande de mesurer. + +**Portée.** Un univers de 24 grandes capitalisations, une fenêtre (2015-2019), un modèle de base, quatre graines. Le résultat est falsifiable via `finetune/measures/` (un fichier par graine plus l'agrégat) et reproductible par `finetune/run_finetune_finbert.py`. + ## Historique du « 0 trade » (fermé le 2026-10-05) Les portages v1/v2 appelaient `add_data(TiingoNews, "AAPL")` avec un **ticker en chaîne**. TiingoNews exige un `Symbol` d'action déjà mappé — l'appel lève `The custom data type TiingoNews requires mapping, but the provided ticker is not in the cache`, exception avalée par le `try/except` → zéro article → zéro trade. Le livre passe `security.symbol` (issu de l'univers) ; le portage fait de même désormais. Le diagnostic ancien « TF unavailable on QC Cloud » était faux sur les deux comptes. @@ -57,10 +94,15 @@ Le livrable **complète et produit ses ordres** : 2 ordres = les 2 rebalancement ## Fichiers -- `main.py` — Stratégie : portage PyTorch du `FinbertBaseModelAlgorithm` du livre +- `main.py` — Stratégie : portage PyTorch du `FinbertBaseModelAlgorithm` du livre (exemple 19/01) +- `main_finetuned.py` — Stratégie : portage du `FinbertFineTunedModelAlgorithm` du livre (exemple 19/02), ré-entraînement dans l'algorithme +- `finetune/run_finetune_finbert.py` — Harnais hors ligne : corpus, étiquetage, ré-entraînement GPU, évaluation hors échantillon base contre ré-entraîné +- `finetune/measures/` — Mesures committées (une par graine + agrégat) - `research.ipynb` — Évaluation du modèle de sentiment ## Références -- *Hands-On AI Trading*, Section 06, Exemple 19 (01 Base Model) +- *Hands-On AI Trading*, Section 06, Exemple 19 (01 Base Model, 02 Fine-Tuned Model) - Repo du livre : `QuantConnect/HandsOnAITradingBook`, `06 Applied Machine Learning/19 FinBERT Model` +- `ProsusAI/finbert` — modèle de base (Hugging Face) +- FNSPID — corpus d'articles financiers horodatés, CC BY-NC 4.0 (cache local hors dépôt ; cf. `.claude/rules/bibliography-hygiene.md`) diff --git a/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/measures/seed-1.json b/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/measures/seed-1.json new file mode 100644 index 0000000000..fb9f2027d1 --- /dev/null +++ b/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/measures/seed-1.json @@ -0,0 +1,77 @@ +{ + "seed": 1, + "accuracy_base": 0.45149253731343286, + "accuracy_finetuned": 0.2947761194029851, + "delta": -0.15671641791044777, + "mcnemar": { + "b": 79, + "c": 37, + "n": 116, + "p_value": 0.00012034422931947828 + }, + "pred_base": [ + 70, + 105, + 93 + ], + "pred_finetuned": [ + 109, + 14, + 145 + ], + "vrai": [ + 90, + 75, + 103 + ], + "par_mois": { + "2019-07": { + "n": 63, + "symbole": "MU", + "exactitude_base": 0.42857142857142855, + "exactitude_finetuned": 0.38095238095238093, + "retour_base": -0.05415104548060723, + "retour_finetuned": -0.05415104548060723 + }, + "2019-08": { + "n": 38, + "symbole": "MU", + "exactitude_base": 0.3684210526315789, + "exactitude_finetuned": 0.2894736842105263, + "retour_base": 0.013431673822974943, + "retour_finetuned": 0.013431673822974943 + }, + "2019-09": { + "n": 25, + "symbole": "MU", + "exactitude_base": 0.64, + "exactitude_finetuned": 0.28, + "retour_base": 0.0018912427231083662, + "retour_finetuned": -0.007564970892433465 + }, + "2019-10": { + "n": 92, + "symbole": "MU", + "exactitude_base": 0.4673913043478261, + "exactitude_finetuned": 0.25, + "retour_base": -0.004780543208655841, + "retour_finetuned": -0.004780543208655841 + }, + "2019-11": { + "n": 26, + "symbole": "MU", + "exactitude_base": 0.4230769230769231, + "exactitude_finetuned": 0.2692307692307692, + "retour_base": 0.006251293375404976, + "retour_finetuned": 0.006251293375404976 + }, + "2019-12": { + "n": 24, + "symbole": "MU", + "exactitude_base": 0.4166666666666667, + "exactitude_finetuned": 0.2916666666666667, + "retour_base": NaN, + "retour_finetuned": NaN + } + } +} \ No newline at end of file diff --git a/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/measures/seed-2.json b/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/measures/seed-2.json new file mode 100644 index 0000000000..184b1da473 --- /dev/null +++ b/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/measures/seed-2.json @@ -0,0 +1,77 @@ +{ + "seed": 2, + "accuracy_base": 0.45149253731343286, + "accuracy_finetuned": 0.3694029850746269, + "delta": -0.08208955223880599, + "mcnemar": { + "b": 69, + "c": 47, + "n": 116, + "p_value": 0.050729556682883294 + }, + "pred_base": [ + 70, + 105, + 93 + ], + "pred_finetuned": [ + 20, + 6, + 242 + ], + "vrai": [ + 90, + 75, + 103 + ], + "par_mois": { + "2019-07": { + "n": 63, + "symbole": "MU", + "exactitude_base": 0.42857142857142855, + "exactitude_finetuned": 0.42857142857142855, + "retour_base": -0.05415104548060723, + "retour_finetuned": -0.05415104548060723 + }, + "2019-08": { + "n": 38, + "symbole": "MU", + "exactitude_base": 0.3684210526315789, + "exactitude_finetuned": 0.39473684210526316, + "retour_base": 0.013431673822974943, + "retour_finetuned": 0.013431673822974943 + }, + "2019-09": { + "n": 25, + "symbole": "MU", + "exactitude_base": 0.64, + "exactitude_finetuned": 0.4, + "retour_base": 0.0018912427231083662, + "retour_finetuned": -0.007564970892433465 + }, + "2019-10": { + "n": 92, + "symbole": "MU", + "exactitude_base": 0.4673913043478261, + "exactitude_finetuned": 0.30434782608695654, + "retour_base": -0.004780543208655841, + "retour_finetuned": -0.004780543208655841 + }, + "2019-11": { + "n": 26, + "symbole": "MU", + "exactitude_base": 0.4230769230769231, + "exactitude_finetuned": 0.34615384615384615, + "retour_base": 0.006251293375404976, + "retour_finetuned": -0.025005173501619904 + }, + "2019-12": { + "n": 24, + "symbole": "MU", + "exactitude_base": 0.4166666666666667, + "exactitude_finetuned": 0.4166666666666667, + "retour_base": NaN, + "retour_finetuned": NaN + } + } +} \ No newline at end of file diff --git a/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/measures/seed-3.json b/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/measures/seed-3.json new file mode 100644 index 0000000000..0cfe0e2466 --- /dev/null +++ b/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/measures/seed-3.json @@ -0,0 +1,77 @@ +{ + "seed": 3, + "accuracy_base": 0.45149253731343286, + "accuracy_finetuned": 0.373134328358209, + "delta": -0.07835820895522388, + "mcnemar": { + "b": 69, + "c": 48, + "n": 117, + "p_value": 0.063995910646795 + }, + "pred_base": [ + 70, + 105, + 93 + ], + "pred_finetuned": [ + 36, + 0, + 232 + ], + "vrai": [ + 90, + 75, + 103 + ], + "par_mois": { + "2019-07": { + "n": 63, + "symbole": "MU", + "exactitude_base": 0.42857142857142855, + "exactitude_finetuned": 0.4444444444444444, + "retour_base": -0.05415104548060723, + "retour_finetuned": -0.05415104548060723 + }, + "2019-08": { + "n": 38, + "symbole": "MU", + "exactitude_base": 0.3684210526315789, + "exactitude_finetuned": 0.42105263157894735, + "retour_base": 0.013431673822974943, + "retour_finetuned": 0.013431673822974943 + }, + "2019-09": { + "n": 25, + "symbole": "MU", + "exactitude_base": 0.64, + "exactitude_finetuned": 0.28, + "retour_base": 0.0018912427231083662, + "retour_finetuned": -0.007564970892433465 + }, + "2019-10": { + "n": 92, + "symbole": "MU", + "exactitude_base": 0.4673913043478261, + "exactitude_finetuned": 0.32608695652173914, + "retour_base": -0.004780543208655841, + "retour_finetuned": -0.004780543208655841 + }, + "2019-11": { + "n": 26, + "symbole": "MU", + "exactitude_base": 0.4230769230769231, + "exactitude_finetuned": 0.38461538461538464, + "retour_base": 0.006251293375404976, + "retour_finetuned": -0.025005173501619904 + }, + "2019-12": { + "n": 24, + "symbole": "MU", + "exactitude_base": 0.4166666666666667, + "exactitude_finetuned": 0.375, + "retour_base": NaN, + "retour_finetuned": NaN + } + } +} \ No newline at end of file diff --git a/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/measures/seed-42.json b/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/measures/seed-42.json new file mode 100644 index 0000000000..a342216c7c --- /dev/null +++ b/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/measures/seed-42.json @@ -0,0 +1,77 @@ +{ + "seed": 42, + "accuracy_base": 0.45149253731343286, + "accuracy_finetuned": 0.34701492537313433, + "delta": -0.10447761194029853, + "mcnemar": { + "b": 79, + "c": 51, + "n": 130, + "p_value": 0.017541695301202188 + }, + "pred_base": [ + 70, + 105, + 93 + ], + "pred_finetuned": [ + 203, + 4, + 61 + ], + "vrai": [ + 90, + 75, + 103 + ], + "par_mois": { + "2019-07": { + "n": 63, + "symbole": "MU", + "exactitude_base": 0.42857142857142855, + "exactitude_finetuned": 0.3492063492063492, + "retour_base": -0.05415104548060723, + "retour_finetuned": 0.013537761370151807 + }, + "2019-08": { + "n": 38, + "symbole": "MU", + "exactitude_base": 0.3684210526315789, + "exactitude_finetuned": 0.34210526315789475, + "retour_base": 0.013431673822974943, + "retour_finetuned": -0.003357918455743736 + }, + "2019-09": { + "n": 25, + "symbole": "MU", + "exactitude_base": 0.64, + "exactitude_finetuned": 0.32, + "retour_base": 0.0018912427231083662, + "retour_finetuned": 0.0018912427231083662 + }, + "2019-10": { + "n": 92, + "symbole": "MU", + "exactitude_base": 0.4673913043478261, + "exactitude_finetuned": 0.3804347826086957, + "retour_base": -0.004780543208655841, + "retour_finetuned": 0.0011951358021639602 + }, + "2019-11": { + "n": 26, + "symbole": "MU", + "exactitude_base": 0.4230769230769231, + "exactitude_finetuned": 0.3076923076923077, + "retour_base": 0.006251293375404976, + "retour_finetuned": 0.006251293375404976 + }, + "2019-12": { + "n": 24, + "symbole": "MU", + "exactitude_base": 0.4166666666666667, + "exactitude_finetuned": 0.2916666666666667, + "retour_base": NaN, + "retour_finetuned": NaN + } + } +} \ No newline at end of file diff --git a/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/measures/summary.json b/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/measures/summary.json new file mode 100644 index 0000000000..0e037d5970 --- /dev/null +++ b/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/measures/summary.json @@ -0,0 +1,437 @@ +{ + "modele": "ProsusAI/finbert", + "graines": [ + 1, + 2, + 3, + 42 + ], + "mois_entrainement": [ + "2015-02", + "2015-03", + "2015-04", + "2015-05", + "2015-06", + "2015-07", + "2015-08", + "2015-09", + "2015-10", + "2015-11", + "2015-12", + "2016-01", + "2016-02", + "2016-03", + "2016-04", + "2016-05", + "2016-06", + "2016-07", + "2016-08", + "2016-09", + "2016-10", + "2016-11", + "2016-12", + "2017-01", + "2017-02", + "2017-03", + "2017-04", + "2017-05", + "2017-06", + "2017-07", + "2017-08", + "2017-09", + "2017-10", + "2017-11", + "2017-12", + "2018-01", + "2018-02", + "2018-03", + "2018-04", + "2018-05", + "2018-06", + "2018-07", + "2018-08", + "2018-09", + "2018-10", + "2018-11", + "2018-12", + "2019-01", + "2019-02", + "2019-03", + "2019-04", + "2019-05", + "2019-06" + ], + "mois_hors_echantillon": [ + "2019-07", + "2019-08", + "2019-09", + "2019-10", + "2019-11", + "2019-12" + ], + "n_entrainement": 1680, + "n_hors_echantillon": 268, + "id2label_base": { + "0": "positive", + "1": "negative", + "2": "neutral" + }, + "accuracy_base": 0.45149253731343286, + "accuracy_finetuned_moyenne": 0.3460820895522388, + "delta_moyen": -0.10541044776119404, + "delta_ecart_type": 0.036096447415252364, + "edge_sigma": -2.9202443816300163, + "graines_gagnantes": 0, + "par_graine": [ + { + "seed": 1, + "accuracy_base": 0.45149253731343286, + "accuracy_finetuned": 0.2947761194029851, + "delta": -0.15671641791044777, + "mcnemar": { + "b": 79, + "c": 37, + "n": 116, + "p_value": 0.00012034422931947828 + }, + "pred_base": [ + 70, + 105, + 93 + ], + "pred_finetuned": [ + 109, + 14, + 145 + ], + "vrai": [ + 90, + 75, + 103 + ], + "par_mois": { + "2019-07": { + "n": 63, + "symbole": "MU", + "exactitude_base": 0.42857142857142855, + "exactitude_finetuned": 0.38095238095238093, + "retour_base": -0.05415104548060723, + "retour_finetuned": -0.05415104548060723 + }, + "2019-08": { + "n": 38, + "symbole": "MU", + "exactitude_base": 0.3684210526315789, + "exactitude_finetuned": 0.2894736842105263, + "retour_base": 0.013431673822974943, + "retour_finetuned": 0.013431673822974943 + }, + "2019-09": { + "n": 25, + "symbole": "MU", + "exactitude_base": 0.64, + "exactitude_finetuned": 0.28, + "retour_base": 0.0018912427231083662, + "retour_finetuned": -0.007564970892433465 + }, + "2019-10": { + "n": 92, + "symbole": "MU", + "exactitude_base": 0.4673913043478261, + "exactitude_finetuned": 0.25, + "retour_base": -0.004780543208655841, + "retour_finetuned": -0.004780543208655841 + }, + "2019-11": { + "n": 26, + "symbole": "MU", + "exactitude_base": 0.4230769230769231, + "exactitude_finetuned": 0.2692307692307692, + "retour_base": 0.006251293375404976, + "retour_finetuned": 0.006251293375404976 + }, + "2019-12": { + "n": 24, + "symbole": "MU", + "exactitude_base": 0.4166666666666667, + "exactitude_finetuned": 0.2916666666666667, + "retour_base": NaN, + "retour_finetuned": NaN + } + } + }, + { + "seed": 2, + "accuracy_base": 0.45149253731343286, + "accuracy_finetuned": 0.3694029850746269, + "delta": -0.08208955223880599, + "mcnemar": { + "b": 69, + "c": 47, + "n": 116, + "p_value": 0.050729556682883294 + }, + "pred_base": [ + 70, + 105, + 93 + ], + "pred_finetuned": [ + 20, + 6, + 242 + ], + "vrai": [ + 90, + 75, + 103 + ], + "par_mois": { + "2019-07": { + "n": 63, + "symbole": "MU", + "exactitude_base": 0.42857142857142855, + "exactitude_finetuned": 0.42857142857142855, + "retour_base": -0.05415104548060723, + "retour_finetuned": -0.05415104548060723 + }, + "2019-08": { + "n": 38, + "symbole": "MU", + "exactitude_base": 0.3684210526315789, + "exactitude_finetuned": 0.39473684210526316, + "retour_base": 0.013431673822974943, + "retour_finetuned": 0.013431673822974943 + }, + "2019-09": { + "n": 25, + "symbole": "MU", + "exactitude_base": 0.64, + "exactitude_finetuned": 0.4, + "retour_base": 0.0018912427231083662, + "retour_finetuned": -0.007564970892433465 + }, + "2019-10": { + "n": 92, + "symbole": "MU", + "exactitude_base": 0.4673913043478261, + "exactitude_finetuned": 0.30434782608695654, + "retour_base": -0.004780543208655841, + "retour_finetuned": -0.004780543208655841 + }, + "2019-11": { + "n": 26, + "symbole": "MU", + "exactitude_base": 0.4230769230769231, + "exactitude_finetuned": 0.34615384615384615, + "retour_base": 0.006251293375404976, + "retour_finetuned": -0.025005173501619904 + }, + "2019-12": { + "n": 24, + "symbole": "MU", + "exactitude_base": 0.4166666666666667, + "exactitude_finetuned": 0.4166666666666667, + "retour_base": NaN, + "retour_finetuned": NaN + } + } + }, + { + "seed": 3, + "accuracy_base": 0.45149253731343286, + "accuracy_finetuned": 0.373134328358209, + "delta": -0.07835820895522388, + "mcnemar": { + "b": 69, + "c": 48, + "n": 117, + "p_value": 0.063995910646795 + }, + "pred_base": [ + 70, + 105, + 93 + ], + "pred_finetuned": [ + 36, + 0, + 232 + ], + "vrai": [ + 90, + 75, + 103 + ], + "par_mois": { + "2019-07": { + "n": 63, + "symbole": "MU", + "exactitude_base": 0.42857142857142855, + "exactitude_finetuned": 0.4444444444444444, + "retour_base": -0.05415104548060723, + "retour_finetuned": -0.05415104548060723 + }, + "2019-08": { + "n": 38, + "symbole": "MU", + "exactitude_base": 0.3684210526315789, + "exactitude_finetuned": 0.42105263157894735, + "retour_base": 0.013431673822974943, + "retour_finetuned": 0.013431673822974943 + }, + "2019-09": { + "n": 25, + "symbole": "MU", + "exactitude_base": 0.64, + "exactitude_finetuned": 0.28, + "retour_base": 0.0018912427231083662, + "retour_finetuned": -0.007564970892433465 + }, + "2019-10": { + "n": 92, + "symbole": "MU", + "exactitude_base": 0.4673913043478261, + "exactitude_finetuned": 0.32608695652173914, + "retour_base": -0.004780543208655841, + "retour_finetuned": -0.004780543208655841 + }, + "2019-11": { + "n": 26, + "symbole": "MU", + "exactitude_base": 0.4230769230769231, + "exactitude_finetuned": 0.38461538461538464, + "retour_base": 0.006251293375404976, + "retour_finetuned": -0.025005173501619904 + }, + "2019-12": { + "n": 24, + "symbole": "MU", + "exactitude_base": 0.4166666666666667, + "exactitude_finetuned": 0.375, + "retour_base": NaN, + "retour_finetuned": NaN + } + } + }, + { + "seed": 42, + "accuracy_base": 0.45149253731343286, + "accuracy_finetuned": 0.34701492537313433, + "delta": -0.10447761194029853, + "mcnemar": { + "b": 79, + "c": 51, + "n": 130, + "p_value": 0.017541695301202188 + }, + "pred_base": [ + 70, + 105, + 93 + ], + "pred_finetuned": [ + 203, + 4, + 61 + ], + "vrai": [ + 90, + 75, + 103 + ], + "par_mois": { + "2019-07": { + "n": 63, + "symbole": "MU", + "exactitude_base": 0.42857142857142855, + "exactitude_finetuned": 0.3492063492063492, + "retour_base": -0.05415104548060723, + "retour_finetuned": 0.013537761370151807 + }, + "2019-08": { + "n": 38, + "symbole": "MU", + "exactitude_base": 0.3684210526315789, + "exactitude_finetuned": 0.34210526315789475, + "retour_base": 0.013431673822974943, + "retour_finetuned": -0.003357918455743736 + }, + "2019-09": { + "n": 25, + "symbole": "MU", + "exactitude_base": 0.64, + "exactitude_finetuned": 0.32, + "retour_base": 0.0018912427231083662, + "retour_finetuned": 0.0018912427231083662 + }, + "2019-10": { + "n": 92, + "symbole": "MU", + "exactitude_base": 0.4673913043478261, + "exactitude_finetuned": 0.3804347826086957, + "retour_base": -0.004780543208655841, + "retour_finetuned": 0.0011951358021639602 + }, + "2019-11": { + "n": 26, + "symbole": "MU", + "exactitude_base": 0.4230769230769231, + "exactitude_finetuned": 0.3076923076923077, + "retour_base": 0.006251293375404976, + "retour_finetuned": 0.006251293375404976 + }, + "2019-12": { + "n": 24, + "symbole": "MU", + "exactitude_base": 0.4166666666666667, + "exactitude_finetuned": 0.2916666666666667, + "retour_base": NaN, + "retour_finetuned": NaN + } + } + } + ], + "strategie_par_mois": [ + { + "month": "2019-07", + "symbol": "MU", + "retour_base": -0.05415104548060723, + "retour_finetuned_moyenne": -0.03722884376791747 + }, + { + "month": "2019-08", + "symbol": "MU", + "retour_base": 0.013431673822974943, + "retour_finetuned_moyenne": 0.009234275753295274 + }, + { + "month": "2019-09", + "symbol": "MU", + "retour_base": 0.0018912427231083662, + "retour_finetuned_moyenne": -0.005200917488548007 + }, + { + "month": "2019-10", + "symbol": "MU", + "retour_base": -0.004780543208655841, + "retour_finetuned_moyenne": -0.0032866234559508906 + }, + { + "month": "2019-11", + "symbol": "MU", + "retour_base": 0.006251293375404976, + "retour_finetuned_moyenne": -0.009376940063107464 + }, + { + "month": "2019-12", + "symbol": "MU", + "retour_base": NaN, + "retour_finetuned_moyenne": NaN + } + ], + "strategie_cumul_base": -0.037357378767774785, + "strategie_cumul_finetuned": -0.04585904902222856, + "device": "NVIDIA GeForce RTX 3080 Ti Laptop GPU", + "torch": "2.8.0+cu126" +} \ No newline at end of file diff --git a/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/run_finetune_finbert.py b/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/run_finetune_finbert.py new file mode 100644 index 0000000000..6352ebbf2b --- /dev/null +++ b/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/finetune/run_finetune_finbert.py @@ -0,0 +1,777 @@ +"""Port local GPU du re-entrainement FinBERT du livre (06/19/02, HandsOnAITradingBook e025f21). + +Le livre re-entraine `ProsusAI/finbert` DANS l'algorithme LEAN, a chaque +rebalancement mensuel (`_trade` : collecte des articles des 30 derniers jours, +etiquetage par la reaction du cours entre deux parutions consecutives, +classification en 3 classes, `model.fit(dataset, epochs=2)`). Ce harnais sort +le re-entrainement de l'algorithme pour l'executer sur un GPU local, et +compare modele de base et modele re-entraine hors echantillon : + + 1. `--stage data` : constitue le corpus de titres financiers horodates + (FNSPID, `All_external.csv`, CC BY-NC 4.0) filtre sur l'univers et la + fenetre du harnais. Le corpus reste **hors depot** (cache local) : seules + les mesures sont commitees (cf. `.claude/rules/bibliography-hygiene.md`). + 2. `--stage build` : applique la recette d'etiquetage du livre mois par mois + (actif le plus volatil du top 10 liquidite, 30 derniers jours d'articles, + label = reaction du cours a la parution, 3 classes au 25/75), puis separe + entrainement et hors echantillon. + 3. `--stage finetune` : re-entraine un modele par graine (recette du livre : + lr 3e-5, Adam, 2 epoques, entropie croisee sur les logits). + 4. `--stage eval` : exactitude de classification sur les mois hors + echantillon, modele de base contre modele re-entraine, puis simulation + locale de la strategie du livre (long 100 % / short 25 %) pour les deux + bras. + +Ecarts documentes vs le livre : + - **Source des articles** : le livre lit Tiingo News sur QC Cloud. Le harnais + lit FNSPID (corpus public horodate, meme forme : date + ticker + titre). + La contrepartie hors ligne d'une source de donnees QC est l'ecart que le + portage Chronos a deja documente (univers fixe, cloture yfinance). + - **Resolution du prix et fenetre de reaction** : le livre mesure la reaction + du cours entre **deux parutions consecutives**, sur des clotures a la + SECONDE pres (`Resolution.SECOND`). A resolution quotidienne, cette regle + s'effondre : deux parutions de la meme seance n'ont **aucune** reaction + resolvable et rendent un label exactement nul. Mesure sur le corpus du + harnais (2015-2019, 24 grandes caps) : **410 des 680 paires consecutives + (60 %) tombent dans la meme seance**, donc **429 labels (63 %) sont nuls + exactement** — ce qui fabrique une classe neutre majoritaire (75 % des + echantillons) que le livre ne connait pas. Le harnais mesure donc la + reaction **a la parution** : le rendement de la premiere seance dont la + cloture suit l'instant de parution, rapporte a la cloture precedente. Sur + le meme corpus, cette regle ne rend que 0,9 % de nuls exacts et une + distribution etalee (q10 -1,46 %, q25 -0,62 %, mediane +0,06 %, q75 + +0,75 %, q90 +1,58 %), donc les proportions de classes que le livre vise + (37,5 % / 25 % / 37,5 %). L'intention du livre — « quelle a ete la + reaction du marche a cette nouvelle » — est preservee ; c'est sa + mecanique de mesure qui est adaptee a la resolution disponible. + - **Selecteur d'actif, et la couverture du corpus** : le livre retient le plus + volatil des 10 valeurs les plus liquides, sur un marche ou la couverture + d'actualite est **universelle** (Tiingo News). Hors ligne la couverture de + FNSPID est **partielle**, et le selecteur du livre y choisit regulierement un + titre sans actualite observable : mesure sur le corpus partiel, la tete de + classement est AMD (le plus volatil de l'univers) avec **14 articles sur cinq + ans**, ce qui ecartait **37 des 60 mois** pour une raison de **couverture**, + pas de strategie (703 echantillons seulement). Le harnais descend donc d'un + rang quand le candidat n'est pas observable, et retient le premier candidat + couvert par au moins `MIN_SAMPLES + 1` articles dans la fenetre. **Quand le + premier choix du livre est couvert, il reste retenu a l'identique** ; la + meme mesure passe alors a **1 948 echantillons sur 59 des 60 mois**, avec des + proportions de classes de 32,5 / 28,4 / 39,1 (le livre vise 37,5 / 25 / 37,5). + - **TensorFlow -> PyTorch** : le livre utilise `TFBertForSequenceClassification` ; + le harnais utilise `AutoModelForSequenceClassification` (memes poids, meme + tokenizer). C'est le meme ecart que le portage de base 06/19/01, deja + documente dans `main.py`. + - **`max_length`** : le livre laisse la valeur par defaut du tokenizer (512) + avec `padding='max_length'`. Les titres font ~15 jetons ; le harnais passe + `max_length=128`, qui les contient tous avec une marge large et divise le + temps de calcul. + - **Separation entrainement / hors echantillon** : le livre n'en a **aucune** + — il re-entraine et predit sur les memes 100 echantillons du mois. Le + harnais ajoute la separation que l'issue demande (critere 2) : les + `--holdout-months` derniers mois sont retires de l'entrainement. + +Usage : + set CUDA_VISIBLE_DEVICES=1 # enumeration torch par defaut : 1 = 3080 Ti (libre) + python run_finetune_finbert.py --stage all --seeds 1 2 3 42 --run-dir D:\\Dev\\CoursIA-18962-finbert-run +""" + +from __future__ import annotations + +import argparse +import csv +import json +import logging +import random +import sys +import time +from pathlib import Path + +import numpy as np +import pandas as pd + +FINETUNE_DIR = Path(__file__).resolve().parent + +#: Modele du livre. +BOOK_MODEL = "ProsusAI/finbert" + +#: Univers du harnais. Le livre selectionne dynamiquement « top 10 liquidite +#: -> le plus volatil » sur l'ensemble du marche ; hors QC, le harnais fige une +#: liste de 24 grandes capitalisations **hors Mag7** (l'univers du livre n'est +#: pas accessible hors ligne, et le §C interdit une revendication BEATS sur +#: l'univers Mag7 — aucun BEATS n'est revendique ici, mais l'univers l'ecarte +#: par construction). Le selecteur du livre (top 10 dollar-volume -> le plus +#: volatil a 365 j) est ensuite applique **dans** cette liste. +UNIVERSE = [ + "JPM", "BAC", "WMT", "XOM", "CVX", "JNJ", "PFE", "UNH", "HD", "DIS", + "BA", "CAT", "KO", "PEP", "PG", "T", "VZ", "CSCO", "INTC", "AMD", + "MU", "GS", "MS", "ABT", +] + +#: Fenetre du corpus et des mesures. Le fichier `Stock_news/All_external.csv` +#: de FNSPID couvre 2004-2020 (mesure par sondage de plages, cf. en-tete) : +#: la fenetre du harnais est choisie **dans** cette couverture, et non sur les +#: annees du livre (2022-2023), qui vivent dans `nasdaq_exteral_data.csv` +#: (23 Go, places NASDAQ seulement). +DATE_MIN = "2015-01-01" +DATE_MAX = "2019-12-31" +PRICE_START = "2014-01-01" + +#: Recette du livre. +LAST_N_SAMPLES = 100 +MIN_SAMPLES = 10 +PERCENT_SIGNED = 0.75 + +#: Convention de classes **apres re-etiquetage du livre** (cf. `_classify3`). +LABELS3 = ["negative", "neutral", "positive"] +ORDER3 = {"negative": 0, "neutral": 1, "positive": 2} + +DEFAULT_SEEDS = [1, 2, 3, 42] +HOLDOUT_MONTHS = 6 +MAX_LEN = 128 +EPOCHS = 2 +LR = 3e-5 +BATCH = 16 + +log = logging.getLogger("finbert-finetune") + + +def _setup_log(): + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", + datefmt="%H:%M:%S", + ) + # Le telechargement par plages Xet journalise chaque requete HTTP : sans + # ce filtre, le journal de l'etape `data` fait des dizaines de Mo. + for name in ("httpx", "httpcore", "urllib3", "filelock", "datasets"): + logging.getLogger(name).setLevel(logging.WARNING) + + +# -------------------------------------------------------------------------- +# Etape 1 -- corpus +# -------------------------------------------------------------------------- + +def stage_data(run_dir: Path, force: bool = False) -> Path: + """Filtre FNSPID sur l'univers et la fenetre, et cache le resultat. + + Le fichier source fait 5,7 Go et ~10 M de lignes : il est **streame** + depuis le Hub, jamais telecharge en entier. Le cache local ne contient que + les colonnes utiles (date, ticker, titre) du sous-ensemble vise. + """ + out = run_dir / "data" / "fnspid_news.csv" + if out.exists() and not force: + n = sum(1 for _ in open(out, encoding="utf-8")) - 1 + log.info("corpus deja present : %s (%d lignes)", out, n) + return out + + from datasets import load_dataset + + out.parent.mkdir(parents=True, exist_ok=True) + log.info("streaming FNSPID Stock_news/All_external.csv (5,7 Go, ~10 M lignes)") + ds = load_dataset( + "Zihan1004/FNSPID", + data_files="Stock_news/All_external.csv", + split="train", + streaming=True, + ) + + keep = set(UNIVERSE) + seen = 0 + kept = 0 + per_symbol: dict[str, int] = {} + t0 = time.time() + with open(out, "w", encoding="utf-8", newline="") as fh: + writer = csv.writer(fh) + writer.writerow(["date", "symbol", "title"]) + for row in ds: + seen += 1 + if seen % 500_000 == 0: + log.info( + " %d lignes lues, %d retenues (%.0f s)", + seen, kept, time.time() - t0, + ) + symbol = (row.get("Stock_symbol") or "").strip().upper() + if symbol not in keep: + continue + date = (row.get("Date") or "").strip() + if len(date) < 10 or not (DATE_MIN <= date[:10] <= DATE_MAX): + continue + title = (row.get("Article_title") or "").strip() + if not title: + continue + writer.writerow([date, symbol, title]) + kept += 1 + per_symbol[symbol] = per_symbol.get(symbol, 0) + 1 + + log.info( + "corpus ecrit : %s -- %d lignes lues, %d retenues, %d tickers (%.0f s)", + out, seen, kept, len(per_symbol), time.time() - t0, + ) + meta = { + "source": "Zihan1004/FNSPID Stock_news/All_external.csv", + "licence": "CC BY-NC 4.0", + "lignes_lues": seen, + "lignes_retenues": kept, + "par_ticker": dict(sorted(per_symbol.items(), key=lambda kv: -kv[1])), + "fenetre": [DATE_MIN, DATE_MAX], + } + (run_dir / "data" / "fnspid_meta.json").write_text( + json.dumps(meta, indent=2, ensure_ascii=False), encoding="utf-8" + ) + return out + + +# -------------------------------------------------------------------------- +# Prix +# -------------------------------------------------------------------------- + +def load_closes(run_dir: Path, tickers: list[str]) -> tuple[pd.DataFrame, pd.DataFrame]: + """Clotures et volumes quotidiens ajustes, cache local (yfinance).""" + cache = run_dir / "data" / "closes.csv" + vol_cache = run_dir / "data" / "volumes.csv" + if cache.exists() and vol_cache.exists(): + closes = pd.read_csv(cache, index_col=0, parse_dates=True) + volumes = pd.read_csv(vol_cache, index_col=0, parse_dates=True) + if all(t in closes.columns for t in tickers) and all( + t in volumes.columns for t in tickers + ): + return closes, volumes + log.info("cache de prix incomplet, rechargement") + + import yfinance as yf + + log.info("telechargement yfinance : %d tickers, %s -> %s", len(tickers), PRICE_START, DATE_MAX) + raw = yf.download( + tickers, start=PRICE_START, end=DATE_MAX, auto_adjust=True, + progress=False, group_by="column", + ) + closes = raw["Close"].copy() + volumes = raw["Volume"].copy() + closes.index = pd.to_datetime(closes.index).tz_localize(None) + volumes.index = pd.to_datetime(volumes.index).tz_localize(None) + closes.to_csv(cache) + volumes.to_csv(vol_cache) + return closes, volumes + + +def _price_grid(closes: pd.DataFrame) -> pd.DataFrame: + """Clotures reindexees sur les seances, valeurs reportees avant.""" + grid = closes.copy() + grid.index = pd.to_datetime(grid.index).tz_localize(None).normalize() + return grid[~grid.index.duplicated(keep="last")].sort_index().ffill() + + +#: Heure UTC de la cloture des actions US, borne basse : 16:00 ET = 20:00 UTC +#: en heure d'ete, 21:00 UTC en heure d'hiver. Une parution posterieure a ce +#: seuil est traitee comme anterieure a la premiere seance suivante. +CLOTURE_US_UTC = 20 + + +def _session_returns(grid: pd.DataFrame) -> pd.DataFrame: + """Rendement de chaque seance par rapport a la cloture precedente.""" + return grid / grid.shift(1) - 1.0 + + +def _reaction_label(returns: pd.DataFrame, symbol: str, ts: pd.Timestamp): + """Reaction du cours a une parution : premiere cloture qui la suit. + + Le livre mesure la reaction entre deux parutions consecutives, sur des + prix a la **seconde** (cf. en-tete). A resolution quotidienne, deux + parutions de la meme seance n'ont aucune reaction resolvable : mesure sur + le corpus, 63 % des paires consecutives tombent dans ce cas et rendraient + un label exactement nul, fabriquant une classe neutre majoritaire + artificielle. Le harnais mesure donc la reaction **a la parution** : le + rendement de la premiere seance dont la cloture suit l'instant de + parution, rapporte a la cloture precedente. + """ + if symbol not in returns.columns: + return None + col = returns[symbol].dropna() + if col.empty: + return None + ts_naif = ts.tz_localize(None) if ts.tzinfo is not None else ts + cible = ts_naif.normalize() + if ts_naif.hour >= CLOTURE_US_UTC: + cible = cible + pd.Timedelta(days=1) + pos = col.index.searchsorted(cible) + if pos >= len(col): + return None + valeur = float(col.iloc[pos]) + return valeur if np.isfinite(valeur) else None + + +# -------------------------------------------------------------------------- +# Etape 2 -- echantillons etiquetes (recette du livre) +# -------------------------------------------------------------------------- + +def _rank_assets( + grid: pd.DataFrame, volumes: pd.DataFrame, month: pd.Timestamp +) -> list[str]: + """Classement du livre : top 10 liquidite, ordonne par volatilite decroissante. + + Le livre retient le **premier** de ce classement (`_book_selector`). Le + harnais expose la liste entiere, parce qu'hors ligne la couverture + d'actualite est partielle : le premier choix peut n'avoir **aucune** + actualite observable sur la fenetre, et un selecteur qui l'ignore ecarte le + mois pour une raison de **couverture**, pas de strategie (mesure : 37 des 60 + mois du corpus partiel etaient ecartes pour ce seul motif, la tete de + classement etant AMD avec 14 articles sur cinq ans). Le harnais descend donc + d'un rang quand le candidat n'est pas observable ; quand le premier choix + **est** couvert, il est retenu exactement comme dans le livre. + + Le livre lit `fundamental.dollar_volume` ; le harnais l'approche par la + moyenne `cloture x volume` des 60 seances precedentes. + """ + hist_v = volumes.loc[:month].tail(60) + hist_c = grid.loc[:month].tail(60) + if hist_c.empty or hist_v.empty: + return [] + common = [c for c in hist_c.columns if c in hist_v.columns] + dollar = (hist_c[common] * hist_v[common]).mean().dropna() + if dollar.empty: + return [] + top = list(dollar.sort_values().tail(10).index) + + year = grid.loc[:month].tail(365)[top] + if len(year) < 60: + return [] + vol = year.pct_change().iloc[1:].std().dropna() + return [str(s) for s in vol.sort_values(ascending=False).index] + + +def _classify3(samples: pd.DataFrame) -> pd.DataFrame: + """Trois classes au 25/75 -- logique du livre, recopiee telle quelle. + + 75 % des labels les plus negatifs -> classe 0, 75 % des plus positifs -> + classe 2, le reste -> classe 1. La classe **0 est negative, 2 positive** : + c'est la convention du livre *apres* re-etiquetage, et elle differe de + `id2label` du modele de base (cf. `_base_predictions`). + """ + s = samples.sort_values(by="label", ascending=False).reset_index(drop=True) + positive_cutoff = int(PERCENT_SIGNED * len(s[s.label > 0])) + negative_cutoff = len(s) - int(PERCENT_SIGNED * len(s[s.label < 0])) + s.loc[list(range(negative_cutoff, len(s))), "label3"] = 0 + s.loc[list(range(positive_cutoff, negative_cutoff)), "label3"] = 1 + s.loc[list(range(0, positive_cutoff)), "label3"] = 2 + s["label3"] = s["label3"].astype(int) + return s + + +def build_samples(run_dir: Path, news: pd.DataFrame) -> pd.DataFrame: + """Construit un jeu (mois, actif, texte, label3) par la recette du livre.""" + closes, volumes = load_closes(run_dir, UNIVERSE) + grid = _price_grid(closes) + returns = _session_returns(grid) + + news = news.copy() + news["dt"] = pd.to_datetime(news["date"], utc=True, errors="coerce") + news = news.dropna(subset=["dt"]).sort_values("dt") + + months = pd.date_range(DATE_MIN, DATE_MAX, freq="MS", tz="UTC") + rows = [] + skipped = [] + for month in months: + window_start = month - pd.Timedelta(days=30) + ranked = _rank_assets(grid, volumes, month.tz_localize(None)) + if not ranked: + skipped.append((month.strftime("%Y-%m"), "selecteur: aucune donnee")) + continue + symbol, art = None, None + for candidat in ranked: + fenetre = news[ + (news.symbol == candidat) & (news.dt >= window_start) & (news.dt < month) + ] + if len(fenetre) >= MIN_SAMPLES + 1: + symbol, art = candidat, fenetre + break + if symbol is None: + tete = news[ + (news.symbol == ranked[0]) & (news.dt >= window_start) & (news.dt < month) + ] + skipped.append( + ( + month.strftime("%Y-%m"), + f"aucun candidat couvert sur {len(ranked)} " + f"(tete {ranked[0]}: {len(tete)} articles)", + ) + ) + continue + raw = [_reaction_label(returns, symbol, ts) for ts in art.dt] + labels = [np.nan if v is None else v for v in raw] + samples = pd.DataFrame( + {"text": art.title.values, "label": labels} + ).dropna() + samples = samples.iloc[-LAST_N_SAMPLES:] + if len(samples) < MIN_SAMPLES: + skipped.append((month.strftime("%Y-%m"), f"{symbol}: {len(samples)} echantillons")) + continue + samples = _classify3(samples) + samples["month"] = month.strftime("%Y-%m") + samples["symbol"] = symbol + rows.append(samples) + + if not rows: + raise RuntimeError("aucun mois exploitable : corpus ou prix insuffisants") + full = pd.concat(rows, ignore_index=True) + log.info( + "echantillons : %d lignes sur %d mois, %d mois ecartes", + len(full), full.month.nunique(), len(skipped), + ) + for month, reason in skipped: + log.info(" mois ecarte %s -- %s", month, reason) + return full + + +# -------------------------------------------------------------------------- +# Etape 3 -- re-entrainement +# -------------------------------------------------------------------------- + +def _torch(): + import torch + + return torch + + +def finetune_one(train: pd.DataFrame, seed: int, device: str): + """Re-entraine un modele par la recette du livre, et rend (modele, tokenizer).""" + torch = _torch() + from torch.utils.data import DataLoader, TensorDataset + from transformers import AutoModelForSequenceClassification, AutoTokenizer + + random.seed(seed) + np.random.seed(seed) + torch.manual_seed(seed) + if device.startswith("cuda"): + torch.cuda.manual_seed_all(seed) + + tokenizer = AutoTokenizer.from_pretrained(BOOK_MODEL) + model = AutoModelForSequenceClassification.from_pretrained( + BOOK_MODEL, num_labels=3 + ).to(device) + + enc = tokenizer( + list(train.text), + padding="max_length", + truncation=True, + max_length=MAX_LEN, + return_tensors="pt", + ) + labels = torch.tensor(train.label3.values, dtype=torch.long) + loader = DataLoader( + TensorDataset(enc["input_ids"], enc["attention_mask"], labels), + batch_size=BATCH, + shuffle=True, + ) + optimizer = torch.optim.AdamW(model.parameters(), lr=LR) + loss_fn = torch.nn.CrossEntropyLoss() + + model.train() + for epoch in range(EPOCHS): + total, seen = 0.0, 0 + for ids, mask, target in loader: + ids, mask, target = ids.to(device), mask.to(device), target.to(device) + optimizer.zero_grad() + out = model(input_ids=ids, attention_mask=mask) + loss = loss_fn(out.logits, target) + loss.backward() + optimizer.step() + total += float(loss.detach()) * len(target) + seen += len(target) + log.info(" epoque %d/%d -- perte %.4f", epoch + 1, EPOCHS, total / max(seen, 1)) + model.eval() + return model, tokenizer + + +def _probabilities(model, tokenizer, texts: list[str], device: str) -> np.ndarray: + """Probabilites par classe, dans l'ordre des indices du modele.""" + torch = _torch() + + enc = tokenizer( + texts, padding=True, truncation=True, max_length=MAX_LEN, return_tensors="pt" + ) + enc = {k: v.to(device) for k, v in enc.items()} + with torch.no_grad(): + logits = model(**enc).logits + return torch.nn.functional.softmax(logits, dim=-1).cpu().numpy() + + +def _base_predictions(texts: list[str], device: str) -> tuple[np.ndarray, dict]: + """Predictions du modele **de base**, ramenees dans la convention du livre. + + `ProsusAI/finbert` expose `id2label = {0: positive, 1: negative, 2: neutral}` + (mesure sur le noeud QC, cf. note n5 de `main.py`). Le re-etiquetage du + livre, lui, pose 0 = negative et 2 = positive. Comparer les `argmax` bruts + comparerait deux conventions differentes : le harnais lit `id2label` sur le + modele charge et **reordonne** les colonnes de probabilite, si bien que les + deux bras parlent la meme langue et que seule la tete entrainee differe. + """ + from transformers import AutoModelForSequenceClassification, AutoTokenizer + + tokenizer = AutoTokenizer.from_pretrained(BOOK_MODEL) + model = AutoModelForSequenceClassification.from_pretrained( + BOOK_MODEL, num_labels=3 + ).to(device) + model.eval() + id2label = {int(k): v for k, v in model.config.id2label.items()} + probs = _probabilities(model, tokenizer, texts, device) + + columns = {} + for index, name in id2label.items(): + key = str(name).strip().lower() + if key in ORDER3: + columns[ORDER3[key]] = probs[:, index] + if len(columns) != 3: + raise RuntimeError(f"id2label inattendu : {id2label}") + return np.column_stack([columns[0], columns[1], columns[2]]), id2label + + +# -------------------------------------------------------------------------- +# Etape 4 -- evaluation +# -------------------------------------------------------------------------- + +def _mcnemar(base_correct: np.ndarray, ft_correct: np.ndarray) -> dict: + """Test exact de McNemar sur les predictions appariees du meme echantillon.""" + b = int(np.sum(base_correct & ~ft_correct)) + c = int(np.sum(~base_correct & ft_correct)) + n = b + c + if n == 0: + return {"b": b, "c": c, "n": 0, "p_value": 1.0} + from math import comb + + k = min(b, c) + p = sum(comb(n, i) for i in range(0, k + 1)) / (2 ** n) * 2 + return {"b": b, "c": c, "n": n, "p_value": float(min(1.0, p))} + + +def _strategy_returns( + probs_mois: np.ndarray, rendement_mois: pd.Series, symbol: str +) -> float: + """Regle du livre : long 100 % si `scores[2] > scores[0]`, sinon short 25 %. + + `probs_mois` porte les probabilites agregees du mois pour les deux bras, + dans la convention du livre (0 = negative, 2 = positive). + """ + if symbol not in rendement_mois.index: + return float("nan") + rendement = rendement_mois.loc[symbol] + if not np.isfinite(rendement): + return float("nan") + weight = 1.0 if probs_mois[2] > probs_mois[0] else -0.25 + return float(weight * rendement) + + +def _aggregate(scores: np.ndarray) -> np.ndarray: + """Agregation a poids exponentiels du livre.""" + n = scores.shape[0] + weights = np.exp(np.linspace(0, 1, n)) + weights /= weights.sum() + return (scores * weights[:, None]).sum(axis=0) + + +def _monthly_forward_returns(grid: pd.DataFrame, horizon: int = 21) -> dict: + """Rendement forward sur ~`horizon` seances, indexe par mois d'entree. + + La cle est le mois de la **premiere seance** : c'est la date a laquelle le + livre entre en position apres son rebalancement de debut de mois. + """ + forward = grid.pct_change().shift(-horizon) + rows: dict[str, pd.Series] = {} + for timestamp, row in forward.iterrows(): + rows.setdefault(timestamp.strftime("%Y-%m"), row) + return rows + + +def evaluate(run_dir: Path, data: pd.DataFrame, seeds: list[int], device: str) -> dict: + months = sorted(data.month.unique()) + if len(months) <= HOLDOUT_MONTHS: + raise RuntimeError("pas assez de mois pour un hors echantillon") + test_months = months[-HOLDOUT_MONTHS:] + train_months = months[:-HOLDOUT_MONTHS] + train = data[data.month.isin(train_months)].reset_index(drop=True) + test = data[data.month.isin(test_months)].reset_index(drop=True) + log.info( + "entrainement : %d echantillons / %d mois -- hors echantillon : %d / %d mois", + len(train), len(train_months), len(test), len(test_months), + ) + + test_texts = list(test.text) + base_probs, id2label = _base_predictions(test_texts, device) + base_labels = base_probs.argmax(axis=1) + base_correct = base_labels == test.label3.values + log.info("modele de base : exactitude %.4f sur %d echantillons", base_correct.mean(), len(test)) + + grid = _price_grid(load_closes(run_dir, UNIVERSE)[0]) + month_returns = _monthly_forward_returns(grid) + + per_seed = [] + for seed in seeds: + log.info("graine %s", seed) + model, tokenizer = finetune_one(train, seed, device) + ft_probs = _probabilities(model, tokenizer, test_texts, device) + ft_labels = ft_probs.argmax(axis=1) + ft_correct = ft_labels == test.label3.values + stats = _mcnemar(base_correct, ft_correct) + par_mois = {} + for month, group in test.groupby("month"): + symbol = str(group.symbol.iloc[0]) + rendement = month_returns.get(month, pd.Series(dtype=float)) + r_base = _strategy_returns( + _aggregate(base_probs[group.index]), rendement, symbol + ) + r_ft = _strategy_returns( + _aggregate(ft_probs[group.index]), rendement, symbol + ) + par_mois[month] = { + "n": int(len(group)), + "symbole": symbol, + "exactitude_base": float(base_correct[group.index].mean()), + "exactitude_finetuned": float(ft_correct[group.index].mean()), + "retour_base": r_base, + "retour_finetuned": r_ft, + } + per_seed.append( + { + "seed": seed, + "accuracy_base": float(base_correct.mean()), + "accuracy_finetuned": float(ft_correct.mean()), + "delta": float(ft_correct.mean() - base_correct.mean()), + "mcnemar": stats, + # Histogramme des classes PREDITES : sans lui, une exactitude + # basse ne se distingue pas d'un modele qui repond toujours la + # meme chose. C'est la mesure qui separe « il se trompe » de + # « il a effondre sa sortie sur une classe ». + "pred_base": np.bincount(base_labels, minlength=3).tolist(), + "pred_finetuned": np.bincount(ft_labels, minlength=3).tolist(), + "vrai": np.bincount(test.label3.values, minlength=3).tolist(), + "par_mois": par_mois, + } + ) + log.info( + " exactitude base %.4f -> re-entraine %.4f (delta %+.4f, McNemar p=%.4f)", + per_seed[-1]["accuracy_base"], per_seed[-1]["accuracy_finetuned"], + per_seed[-1]["delta"], stats["p_value"], + ) + log.info( + " classes predites [neg,neu,pos] : re-entraine %s | base %s | vrai %s", + per_seed[-1]["pred_finetuned"], per_seed[-1]["pred_base"], + per_seed[-1]["vrai"], + ) + del model + _torch().cuda.empty_cache() + + deltas = np.array([s["delta"] for s in per_seed]) + edge = float(deltas.mean() / deltas.std(ddof=1)) if len(deltas) > 1 and deltas.std(ddof=1) > 0 else float("nan") + + # Strategie : meme test, regle du livre, par mois. Le bras de base ne + # depend pas de la graine ; le bras re-entraine, si. + strategie = [] + for month in test_months: + ref = per_seed[0]["par_mois"][month] + retours_ft = np.array( + [s["par_mois"][month]["retour_finetuned"] for s in per_seed], dtype=float + ) + strategie.append( + { + "month": month, + "symbol": ref["symbole"], + "retour_base": ref["retour_base"], + "retour_finetuned_moyenne": ( + float(np.nanmean(retours_ft)) + if np.isfinite(retours_ft).any() + else float("nan") + ), + } + ) + cumul_base = float(np.nansum([s["retour_base"] for s in strategie])) + cumul_ft = float(np.nansum([s["retour_finetuned_moyenne"] for s in strategie])) + + result = { + "modele": BOOK_MODEL, + "graines": seeds, + "mois_entrainement": train_months, + "mois_hors_echantillon": test_months, + "n_entrainement": int(len(train)), + "n_hors_echantillon": int(len(test)), + "id2label_base": {str(k): v for k, v in id2label.items()}, + "accuracy_base": float(base_correct.mean()), + "accuracy_finetuned_moyenne": float(np.mean([s["accuracy_finetuned"] for s in per_seed])), + "delta_moyen": float(deltas.mean()), + "delta_ecart_type": float(deltas.std(ddof=1)) if len(deltas) > 1 else 0.0, + "edge_sigma": edge, + "graines_gagnantes": int(np.sum(deltas > 0)), + "par_graine": per_seed, + "strategie_par_mois": strategie, + "strategie_cumul_base": cumul_base, + "strategie_cumul_finetuned": cumul_ft, + } + return result + + +# -------------------------------------------------------------------------- +# Orchestration +# -------------------------------------------------------------------------- + +def main(argv: list[str] | None = None) -> int: + _setup_log() + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--stage", default="all", + choices=["data", "build", "finetune", "eval", "all"]) + parser.add_argument("--seeds", type=int, nargs="+", default=DEFAULT_SEEDS) + parser.add_argument("--run-dir", required=True) + parser.add_argument("--force-data", action="store_true") + parser.add_argument("--device", default=None) + args = parser.parse_args(argv) + + run_dir = Path(args.run_dir) + (run_dir / "data").mkdir(parents=True, exist_ok=True) + (run_dir / "results").mkdir(parents=True, exist_ok=True) + + torch = _torch() + device = args.device or ("cuda" if torch.cuda.is_available() else "cpu") + # Le nom de la carte est journalise, pas seulement l'index : sur une machine a + # deux GPU, `cuda:0` ne dit pas laquelle a travaille. C'est la sonde qui fait + # foi, et elle est recopiee dans les mesures committeees. + device_name = ( + torch.cuda.get_device_name(device) if device.startswith("cuda") else "cpu" + ) + log.info("device %s = %s (torch %s)", device, device_name, torch.__version__) + + if args.stage in ("data", "all"): + stage_data(run_dir, force=args.force_data) + # `all` poursuit ; seul `data` s'arrete ici. Le `return 0` inconditionnel + # qui vivait ici faisait de `--stage all` un synonyme de `--stage data` : + # le chemin par defaut n'entrainait jamais le modele. + if args.stage == "data": + return 0 + + news = pd.read_csv(run_dir / "data" / "fnspid_news.csv") + if news.empty: + log.error("corpus vide -- relancer --stage data") + return 2 + + samples = build_samples(run_dir, news) + samples.to_csv(run_dir / "data" / "samples.csv", index=False) + log.info("echantillons ecrits : %s", run_dir / "data" / "samples.csv") + + if args.stage in ("build",): + return 0 + + result = evaluate(run_dir, samples, args.seeds, device) + result["device"] = device_name + result["torch"] = torch.__version__ + measures = FINETUNE_DIR / "measures" + measures.mkdir(parents=True, exist_ok=True) + for graine in result["par_graine"]: + fichier = measures / f"seed-{graine['seed']}.json" + fichier.write_text( + json.dumps(graine, indent=2, ensure_ascii=False), encoding="utf-8" + ) + log.info("mesures ecrites : %s", fichier) + out = measures / "summary.json" + out.write_text(json.dumps(result, indent=2, ensure_ascii=False), encoding="utf-8") + log.info("mesures ecrites : %s", out) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/main_finetuned.py b/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/main_finetuned.py new file mode 100644 index 0000000000..e1dc4111d2 --- /dev/null +++ b/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/main_finetuned.py @@ -0,0 +1,339 @@ +#region imports +from AlgorithmImports import * +import numpy as np +import torch +from transformers import ( + AutoTokenizer, + AutoModelForSequenceClassification, + set_seed, +) +# endregion +# Hands-On AI Trading - Ex19 (06/19/02 Fine-Tuned Model) : FinBERT re-entraine +# Portage fidele du code du livre en PyTorch (le livre utilise la variante +# TFBertForSequenceClassification avec `from_pt=True`, c'est-a-dire les memes +# poids PyTorch convertis ; cf. note n6). +# Source: QuantConnect/HandsOnAITradingBook, "06 Applied Machine Learning/ +# 19 FinBERT Model/02 Fine-Tuned Model/main.py" (commit e025f21). +# +# CE FICHIER N'EST PAS EXECUTABLE EN CI, ni sur un noeud QC ordinaire : +# le re-entrainement tourne DANS l'algorithme et demande un GPU. Le harnais +# `finetune/run_finetune_finbert.py` l'execute hors ligne et mesure l'ecart +# base / re-entraine ; ce fichier est le portage de reference. +# +# Ecarts documentes du livre : +# n1 - Blindage par etage. Le pipeline du livre, execute tel quel, n'a jamais +# complete un backtest sur les noeuds QC actuels pour la variante de base +# (six executions non blindees, aucune n'a produit un seul backtest ; +# cf. note n1 de `main.py`). Le re-entrainement ajoute une etape lourde +# (deux epoques sur 100 echantillons) : le blindage par etage est +# conserve a l'identique. Un etage en echec laisse la position inchangee +# et se journalise ; aucun etage ne change de semantique quand il reussit. +# n2 - SPY est abonne en FIN d'initialize (apres les date_rules), comme dans le +# portage de base -- ne jamais l'abonner tue l'initialize sur les noeuds +# QC actuels (reproduit 2x sur 06/19/01). +# n3 - Cash porte a 1 000 000 USD (le livre laisse le defaut 100 000) : choix +# de confort de lecture des ordres, sans effet sur la semantique. +# n4 - Fenetre 2022-01-01 -> 2022-03-01 (2 mois) au lieu de l'annee du livre : +# sur les noeuds QC actuels, les fenetres longues meurent en FATAL natif. +# 2 mois est la plus longue fenetre mesuree qui complete (cf. note n4 de +# `main.py`). +# n5 - Convention d'indices de FinBERT. Le livre compare `scores[2] > scores[0]` +# et etiquete ses trois plot Negative/Neutral/Positive sur les indices +# 0/1/2. Or il vient de re-etiqueter ses echantillons avec 0 = negatif et +# 2 = positif : sur le modele **re-entraine**, la comparaison est donc +# celle qu'il annonce. Le portage conserve l'expression telle quelle. +# (Sur le modele de base, `id2label` vaut {0: positive, 1: negative, +# 2: neutral} et la meme expression compare neutre > positif -- c'est la +# note n5 de `main.py`, deja livree pour 06/19/01.) +# n6 - TensorFlow -> PyTorch. Le livre charge +# `TFBertForSequenceClassification.from_pretrained(..., num_labels=3, +# from_pt=True)` : les poids sont ceux du modele PyTorch, convertis. Le +# portage entraine directement le modele PyTorch, avec la meme recette +# (Adam, lr 3e-5, 2 epoques, entropie croisee sur les logits). +# n7 - `max_length` : le livre laisse la valeur par defaut du tokenizer (512) +# avec `padding='max_length'`. Les titres d'articles font ~15 jetons ; le +# portage passe `max_length=128`, qui les contient avec une marge large et +# divise le temps de calcul du re-entrainement. +# n8 - Fenetre de reaction, et pourquoi le portage la GARDE telle quelle. Le +# livre etiquette chaque article par la reaction du cours entre deux +# parutions consecutives, mesuree a la SECONDE (`Resolution.SECOND`, cf. +# `_collect_samples` ci-dessous). Cette regle est resolvable ici, parce +# que l'algorithme tourne sur QC Cloud et dispose bien des clotures a la +# seconde. Le harnais hors ligne `finetune/run_finetune_finbert.py`, lui, +# ne dispose que de clotures quotidiennes : a cette resolution deux +# parutions de la meme seance n'ont aucune reaction resolvable, et la +# regle du livre y rend 63 % de labels exactement nuls (mesure : 410 des +# 680 paires consecutives tombent dans la meme seance), soit une classe +# neutre artificiellement majoritaire a 75 %. Le harnais adapte donc sa +# mecanique de mesure (reaction a la parution) et le documente ; le +# portage de reference, lui, reste fidele au livre sans changement. +# Divergence assumee et locale au harnais : les deux artefacts ne +# mesurent pas la meme chose, et le disent chacun de leur cote. + + +class FinbertFineTunedModelAlgorithm(QCAlgorithm): + """ + Modele FinBERT re-entraine du livre (Exemple 19-02, chapitre 06). + + Reprend l'algorithme de base et ajoute le re-entrainement decrit par le + livre : a chaque rebalancement mensuel, les articles des 30 derniers jours + de l'actif retenu sont etiquetes par la **reaction du cours** entre deux + parutions consecutives, classes en trois groupes au 25/75, puis le modele + est re-entraine deux epoques (Adam, lr 3e-5) avant de scorer les articles + du mois. + + Le modele est rechargé **frais a chaque rebalancement** : le livre appelle + `from_pretrained` dans `_trade`, l'apprentissage d'un mois ne s'accumule + donc pas sur le suivant. Le portage reproduit ce point. + """ + + def _fail(self, stage, exc): + """Journalise un etage en echec sans interrompre le backtest.""" + message = f"{type(exc).__name__}: {str(exc)[:300]}" + self._stage_fails.append((stage, message)) + self.log(f"{self.time:%Y-%m-%d} : ETAGE EN ECHEC {stage} -> {message}") + + def initialize(self): + self.set_start_date(2022, 1, 1) + self.set_end_date(2022, 3, 1) + self.set_cash(1_000_000) + self._stage_fails = [] + + # Reference calendrier (non abonnee ici -- ecart n2). + spy = Symbol.create("SPY", SecurityType.EQUITY, Market.USA) + + self.universe_settings.resolution = Resolution.DAILY + self.universe_settings.schedule.on(self.date_rules.month_start(spy)) + self._universe = self.add_universe(self._book_selector) + + # Reproductibilite (le livre : set_seed(1, True)). + set_seed(1, True) + + self._model_name = "ProsusAI/finbert" + self._tokenizer = AutoTokenizer.from_pretrained( + self._model_name, local_files_only=True + ) + + self._last_rebalance_time = datetime.min + self.schedule.on( + self.date_rules.month_start(spy, 1), + self.time_rules.midnight, + self._trade + ) + + self.set_warm_up(timedelta(30)) + + # Ecart n2 (fin) : abonnement SPY APRES la creation des date_rules. + self._spy = self.add_equity("SPY", Resolution.DAILY) + + def _book_selector(self, fundamental): + """Selecteur du livre : top 10 liquidite -> le plus volatil.""" + try: + selected = [ + f.symbol + for f in sorted(fundamental, key=lambda f: f.dollar_volume)[-10:] + ] + target = self.history( + selected, timedelta(365), Resolution.DAILY + )['close'].unstack(0).pct_change().iloc[1:].std().idxmax() + return [target] + except Exception as e: + self._fail("selecteur d'univers", e) + return [sorted(fundamental, key=lambda f: f.dollar_volume)[-1].symbol] + + def on_securities_changed(self, changes): + for security in changes.removed_securities: + try: + self.remove_security(security.dataset_symbol) + except Exception as e: + self._fail("remove_security", e) + for security in changes.added_securities: + try: + security.dataset_symbol = self.add_data( + TiingoNews, security.symbol + ).symbol + except Exception as e: + self._fail("add_data TiingoNews", e) + + def on_warmup_finished(self): + self._trade() + + def _collect_samples(self, security): + """Etiquetage du livre : reaction du cours entre deux parutions.""" + samples = pd.DataFrame(columns=['text', 'label']) + news_history = self.history( + security.dataset_symbol, 30, Resolution.DAILY + ) + if news_history.empty: + return samples + news_history = news_history.loc[security.dataset_symbol]['description'] + asset_history = self.history( + security.symbol, timedelta(30), Resolution.SECOND + ).loc[security.symbol]['close'] + for i in range(len(news_history.index) - 1): + factor = news_history.iloc[i] + if not factor: + continue + release_time = self._convert_to_eastern(news_history.index[i]) + next_release_time = self._convert_to_eastern(news_history.index[i + 1]) + reaction_period = asset_history[ + (asset_history.index > release_time) + & (asset_history.index < next_release_time + timedelta(seconds=1)) + ] + if reaction_period.empty: + continue + label = ( + (reaction_period.iloc[-1] - reaction_period.iloc[0]) + / reaction_period.iloc[0] + ) + samples.loc[len(samples), :] = [factor, label] + return samples.iloc[-100:] + + @staticmethod + def _classify3(samples): + """Trois classes au 25/75 -- logique du livre, recopiee telle quelle.""" + sorted_samples = samples.sort_values( + by='label', ascending=False + ).reset_index(drop=True) + percent_signed = 0.75 + positive_cutoff = int( + percent_signed * len(sorted_samples[sorted_samples.label > 0]) + ) + negative_cutoff = ( + len(sorted_samples) + - int(percent_signed * len(sorted_samples[sorted_samples.label < 0])) + ) + sorted_samples.loc[ + list(range(negative_cutoff, len(sorted_samples))), 'label' + ] = 0 + sorted_samples.loc[ + list(range(positive_cutoff, negative_cutoff)), 'label' + ] = 1 + sorted_samples.loc[list(range(0, positive_cutoff)), 'label'] = 2 + return sorted_samples + + def _finetune(self, samples): + """Re-entraine une tete de classification fraiche (recette du livre).""" + model = AutoModelForSequenceClassification.from_pretrained( + self._model_name, num_labels=3, local_files_only=True + ) + device = "cuda" if torch.cuda.is_available() else "cpu" + model.to(device) + model.train() + + encoded = self._tokenizer( + list(samples['text'].values), padding='max_length', truncation=True, + max_length=128, return_tensors='pt' + ) + labels = torch.tensor( + samples['label'].astype(int).values, dtype=torch.long + ) + optimizer = torch.optim.AdamW(model.parameters(), lr=3e-5) + loss_fn = torch.nn.CrossEntropyLoss() + batch = 16 + for _ in range(2): + for start in range(0, len(labels), batch): + stop = start + batch + ids = encoded['input_ids'][start:stop].to(device) + mask = encoded['attention_mask'][start:stop].to(device) + target = labels[start:stop].to(device) + optimizer.zero_grad() + loss = loss_fn(model(input_ids=ids, attention_mask=mask).logits, target) + loss.backward() + optimizer.step() + model.eval() + return model + + def _trade(self): + if self.is_warming_up: + return + if self.time - self._last_rebalance_time < timedelta(14): + return + + try: + security = self.securities[list(self._universe.selected)[0]] + except Exception as e: + self._fail("securities[selected[0]]", e) + return + + try: + samples = self._collect_samples(security) + except Exception as e: + self._fail("collecte des echantillons", e) + return + + if samples.shape[0] < 10: + self.log( + f"{self.time:%Y-%m-%d} : {samples.shape[0]} echantillon(s), " + "sous le seuil du livre -- liquidation" + ) + try: + self.liquidate() + except Exception as e: + self._fail("liquidate", e) + return + + try: + samples = self._classify3(samples) + except Exception as e: + self._fail("classification 3 classes", e) + return + + try: + model = self._finetune(samples) + except Exception as e: + self._fail("re-entrainement", e) + return + + try: + inputs = self._tokenizer( + list(samples['text'].values), padding=True, truncation=True, + max_length=128, return_tensors='pt' + ) + device = next(model.parameters()).device + inputs = {k: v.to(device) for k, v in inputs.items()} + with torch.no_grad(): + outputs = model(**inputs) + scores = torch.nn.functional.softmax( + outputs.logits, dim=-1 + ).cpu().numpy() + scores = self._aggregate_sentiment_scores(scores) + except Exception as e: + self._fail("inference apres re-entrainement", e) + return + + self.log( + f"{self.time:%Y-%m-%d} : {samples.shape[0]} echantillons, " + f"cible {security.symbol.value}, scores agreges " + f"{np.round(scores, 3).tolist()}" + ) + + self.plot("Sentiment Probability", "Negative", scores[0]) + self.plot("Sentiment Probability", "Neutral", scores[1]) + self.plot("Sentiment Probability", "Positive", scores[2]) + + try: + weight = 1 if scores[2] > scores[0] else -0.25 + self.set_holdings(security.symbol, weight, True) + self._last_rebalance_time = self.time + except Exception as e: + self._fail("set_holdings", e) + + def _convert_to_eastern(self, dt): + return dt.astimezone(pytz.timezone('US/Eastern')).replace(tzinfo=None) + + def _aggregate_sentiment_scores(self, sentiment_scores): + n = sentiment_scores.shape[0] + weights = np.exp(np.linspace(0, 1, n)) + weights /= weights.sum() + weighted_scores = sentiment_scores * weights[:, np.newaxis] + return weighted_scores.sum(axis=0) + + def on_end_of_algorithm(self): + if self._stage_fails: + summary = " | ".join( + f"{stage}: {message}" for stage, message in self._stage_fails + ) + self.log(f"Ex19-02 : {len(self._stage_fails)} etage(s) en echec -> {summary}") + else: + self.log("Ex19-02 : aucun etage en echec sur la fenetre.") From b24c6b0007466db8e0b375a620b01907b3e7793e Mon Sep 17 00:00:00 2001 From: jsboige Date: Wed, 7 Oct 2026 08:44:02 +0200 Subject: [PATCH 2/3] docs(qc,#18962): BOOK_MAPPING -- lignes 19/01 et 19/02 (FinBERT) mises a jour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Critere 5 de #18962 : la table du chapitre 06 portait le re-entrainement FinBERT en `GAP`. - 19/02 : `GAP` -> `COVERED`, avec le harnais et le verdict mesure (NO BEATS, ecart moyen -0,1054, -2,92 sigma, aucune graine gagnante). - 19/01 : la remarque « le portage ne produit pas de transaction » est perimee -- le livrable v7 produit ses deux rebalancements (mesure du 2026-10-05). #18903 reste ouverte ; c'est sa fermeture qui revient au coordinateur, pas le constat. - Table des chiffres du livre (19/02) : `aucune reproduction (GAP)` -> les mesures hors echantillon. La ligne 306 (« Exemples sans reproduction », 06/18/02 et 06/19/02) n'est pas touchee : la PR #19622, ouverte sur la moitie Chronos, la reecrit deja. Co-Authored-By: Claude Sonnet 5.5 --- MyIA.AI.Notebooks/QuantConnect/BOOK_MAPPING.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/MyIA.AI.Notebooks/QuantConnect/BOOK_MAPPING.md b/MyIA.AI.Notebooks/QuantConnect/BOOK_MAPPING.md index 0ac524bb4e..74e74a7ce3 100644 --- a/MyIA.AI.Notebooks/QuantConnect/BOOK_MAPPING.md +++ b/MyIA.AI.Notebooks/QuantConnect/BOOK_MAPPING.md @@ -114,8 +114,8 @@ Algorithmes LEAN complets sur données de marché. Les exemples 04, 08, 18 et 19 | 17 | Head Shoulders Pattern Matching with CNN | [ML-HeadShoulders-CNN](projects/ML-HeadShoulders-CNN/) | COVERED | Vivant | | | 18/01 | Amazon Chronos Model — Base Model | [ML-Chronos-Foundation](projects/ML-Chronos-Foundation/), [Chronos-Foundation-Forecasting](projects/Chronos-Foundation-Forecasting/) | COVERED | Needs-improvement (les deux, tranche 14) | | | 18/02 | Amazon Chronos Model — Fine-Tuned Model | — | GAP | — | aucun ré-entraînement de Chronos dans le dépôt | -| 19/01 | FinBERT Model — Base Model | [ML-FinBERT-Sentiment](projects/ML-FinBERT-Sentiment/), [QC-Py-Cloud-01-FinBERT-Sentiment](Python/QC-Py-Cloud-01-FinBERT-Sentiment.ipynb) | COVERED | Needs-improvement (tranche 12) | le portage ne produit pas de transaction sur QC Cloud : [#18903](https://github.com/jsboige/CoursIA/issues/18903) | -| 19/02 | FinBERT Model — Fine-Tuned Model | — | GAP | — | aucun ré-entraînement de FinBERT dans le dépôt | +| 19/01 | FinBERT Model — Base Model | [ML-FinBERT-Sentiment](projects/ML-FinBERT-Sentiment/), [QC-Py-Cloud-01-FinBERT-Sentiment](Python/QC-Py-Cloud-01-FinBERT-Sentiment.ipynb) | COVERED | Needs-improvement (tranche 12) | le portage produit désormais ses rebalancements sur QC Cloud (v7, 2022-01/02, deux ordres, Sharpe -1,137) ; [#18903](https://github.com/jsboige/CoursIA/issues/18903) reste ouverte, sa fermeture revient au coordinateur | +| 19/02 | FinBERT Model — Fine-Tuned Model | [ML-FinBERT-Sentiment](projects/ML-FinBERT-Sentiment/) (`main_finetuned.py`, `finetune/`) | COVERED | Non déployé (le ré-entraînement s'exécute hors QC) | ré-entraînement porté et mesuré hors échantillon sur quatre graines ; verdict **NO BEATS** (écart moyen -0,1054, -2,92 sigma, aucune graine gagnante) | --- @@ -227,7 +227,7 @@ Pour 14 et 15, le texte ne donne pas de Sharpe, mais le balayage de paramètres | 18/01 | aucun chiffre dans le texte ; courbes de prévision et table de rendements mensuels | 2019-01-01 → 2024-04-01, capital 100 k, 5 actions les plus liquides, Chronos pré-entraîné | Sharpe 0,277, CAGR 7,23 %, pire baisse 13,5 % | [ML-Chronos-Foundation](projects/ML-Chronos-Foundation/), 2015-01-01 → 2026-01-01 | `NON COMPARABLE` | | 18/02 | — | Chronos ré-entraîné | — | aucune reproduction (`GAP`) | sans objet | | 19/01 | idem 18/01 | 2022-01-01 → 2023-01-01, capital 100 k, 10 actions les plus liquides, FinBERT pré-entraîné | Sharpe 0,584, CAGR 22,10 %, pire baisse 43,0 % | [ML-FinBERT-Sentiment](projects/ML-FinBERT-Sentiment/), 2015-01-01 → 2026-01-01 | `NON COMPARABLE` | -| 19/02 | — | FinBERT ré-entraîné | — | aucune reproduction (`GAP`) | sans objet | +| 19/02 | — | FinBERT ré-entraîné | exactitude hors échantillon 0,3461 contre 0,4515 pour le modèle de base ; rendement cumulé de la stratégie −0,046 contre −0,037 | [ML-FinBERT-Sentiment](projects/ML-FinBERT-Sentiment/) (`finetune/`), hors échantillon 2019-07 → 2019-12, quatre graines | `NON COMPARABLE` : le livre ne publie aucun chiffre pour cet exemple, et le ré-entraînement n'est pas déployé sur QC | **Rejeux** (projets QuantConnect séparés, une exécution chacun) : From c9a878a2d25cb09440ce117071b208d10b4a8af5 Mon Sep 17 00:00:00 2001 From: jsboige Date: Wed, 7 Oct 2026 09:12:12 +0200 Subject: [PATCH 3/3] docs(qc,#18962): verdict INCONCLUSIVE du modele de base + retrait des lignes livrenees (#18903, #18957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addendum file profonde du coordinateur (msg-20261007T065859-z4zhz5) : trois restes fermes dans un seul commit. 1. #18903, critere 4 : le README du modele de base refuse d'ecrire un verdict. Il l'ecrit desormais -- INCONCLUSIVE -- avec son motif : sur 40 seances et 2 ordres, l'echantillon est trop court pour conclure ; ce backtest verifie que le portage complete et trade, il ne mesure pas la strategie. 2. #18957 : la ligne 04/05, 04/18, 05/02, 05/15 de la section « Exemples sans reproduction » est retiree -- #19163 les a portes, ils sont COVERED plus haut dans la table. 3. #18962 : la ligne 06/18/02, 06/19/02 garde seulement Chronos (06/18/02, depend de #19622) ; FinBERT 19/02 passe COVERED par cette PR. Co-Authored-By: Claude Sonnet 5.5 --- MyIA.AI.Notebooks/QuantConnect/BOOK_MAPPING.md | 5 ++--- .../QuantConnect/projects/ML-FinBERT-Sentiment/README.md | 4 +++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/MyIA.AI.Notebooks/QuantConnect/BOOK_MAPPING.md b/MyIA.AI.Notebooks/QuantConnect/BOOK_MAPPING.md index 74e74a7ce3..1caf55690a 100644 --- a/MyIA.AI.Notebooks/QuantConnect/BOOK_MAPPING.md +++ b/MyIA.AI.Notebooks/QuantConnect/BOOK_MAPPING.md @@ -114,7 +114,7 @@ Algorithmes LEAN complets sur données de marché. Les exemples 04, 08, 18 et 19 | 17 | Head Shoulders Pattern Matching with CNN | [ML-HeadShoulders-CNN](projects/ML-HeadShoulders-CNN/) | COVERED | Vivant | | | 18/01 | Amazon Chronos Model — Base Model | [ML-Chronos-Foundation](projects/ML-Chronos-Foundation/), [Chronos-Foundation-Forecasting](projects/Chronos-Foundation-Forecasting/) | COVERED | Needs-improvement (les deux, tranche 14) | | | 18/02 | Amazon Chronos Model — Fine-Tuned Model | — | GAP | — | aucun ré-entraînement de Chronos dans le dépôt | -| 19/01 | FinBERT Model — Base Model | [ML-FinBERT-Sentiment](projects/ML-FinBERT-Sentiment/), [QC-Py-Cloud-01-FinBERT-Sentiment](Python/QC-Py-Cloud-01-FinBERT-Sentiment.ipynb) | COVERED | Needs-improvement (tranche 12) | le portage produit désormais ses rebalancements sur QC Cloud (v7, 2022-01/02, deux ordres, Sharpe -1,137) ; [#18903](https://github.com/jsboige/CoursIA/issues/18903) reste ouverte, sa fermeture revient au coordinateur | +| 19/01 | FinBERT Model — Base Model | [ML-FinBERT-Sentiment](projects/ML-FinBERT-Sentiment/), [QC-Py-Cloud-01-FinBERT-Sentiment](Python/QC-Py-Cloud-01-FinBERT-Sentiment.ipynb) | COVERED | Needs-improvement (tranche 12) | le portage produit désormais ses rebalancements sur QC Cloud (v7, 2022-01/02, deux ordres, Sharpe -1,137) et son verdict `INCONCLUSIVE` est écrit (critère 4, §Métriques du README) ; [#18903](https://github.com/jsboige/CoursIA/issues/18903) se ferme au merge | | 19/02 | FinBERT Model — Fine-Tuned Model | [ML-FinBERT-Sentiment](projects/ML-FinBERT-Sentiment/) (`main_finetuned.py`, `finetune/`) | COVERED | Non déployé (le ré-entraînement s'exécute hors QC) | ré-entraînement porté et mesuré hors échantillon sur quatre graines ; verdict **NO BEATS** (écart moyen -0,1054, -2,92 sigma, aucune graine gagnante) | --- @@ -300,10 +300,9 @@ Ces projets n'ont pas d'exemple correspondant dans le livre, mais illustrent des | Exemple | Suite | |---------|-------| -| 04/05, 04/18, 05/02, 05/15 | [#18957](https://github.com/jsboige/CoursIA/issues/18957) : écart interquartile, élimination récursive des variables, régression polynomiale, OPTICS (scripts courts sur données synthétiques, à porter dans QC-Py-18 à 20) | | 06/02 | [#18958](https://github.com/jsboige/CoursIA/issues/18958) : régimes par prétraitement de facteurs | | 06/08/01, 06/08/03 | [#18960](https://github.com/jsboige/CoursIA/issues/18960) : stop fixe de référence et couverture par put, dans [Stoploss-Volatility-ML](projects/Stoploss-Volatility-ML/) | -| 06/18/02, 06/19/02 | [#18962](https://github.com/jsboige/CoursIA/issues/18962) : ré-entraînement de Chronos et de FinBERT (calcul GPU) | +| 06/18/02 | [#18962](https://github.com/jsboige/CoursIA/issues/18962) : ré-entraînement de Chronos (calcul GPU) | | 07/01 | [#18902](https://github.com/jsboige/CoursIA/issues/18902) | | 08/01 | exclusion : le livre appelle l'API payante PredictNow.ai ; le dépôt garde une optimisation sans service externe | | 08/02 | [#18901](https://github.com/jsboige/CoursIA/issues/18901) (méta-étiquetage sans l'API PredictNow.ai) | diff --git a/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/README.md b/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/README.md index 0f507e8554..c6cef7b4f4 100644 --- a/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/README.md +++ b/MyIA.AI.Notebooks/QuantConnect/projects/ML-FinBERT-Sentiment/README.md @@ -90,7 +90,9 @@ Le livrable **complète et produit ses ordres** : 2 ordres = les 2 rebalancement **Sur la sonde `probe9`** : elle porte 8191 ordres-marqueurs de plus (un `market_order` d'une action SPY par bit de son masque de diagnostic) et rend pourtant des statistiques **identiques à l'unité près** à celles de v7. Les marqueurs sont donc **neutres sur la mesure** — 8191 ordres d'écart ne déplacent ni le Sharpe, ni le rendement, ni le drawdown. Les deux exécutions se corroborent. -**Deux réserves, mesurées.** (1) La comparaison au livre est **à fenêtre inégale** : le livre couvre 2022 entier, le livrable le premier sixième (mur natif, cf. §Historique) — aucun jugement de performance n'est tiré de l'écart. (2) Ces chiffres **ne sont pas un verdict de stratégie** : deux mois, une seule configuration, aucune répétition multi-seed — le résultat est celui du portage fidèle, pas une évaluation du modèle. +**Verdict du modèle de base : `INCONCLUSIVE`.** Sur 40 séances et **2 ordres** — les deux rebalancements mensuels, exactement le rythme du livre — l'échantillon est trop court pour conclure quoi que ce soit sur la stratégie. Le mot est exigé par le critère 4 de #18903, et le seul défendable ici est celui-ci : ni `BEATS` ni `NO BEATS`, parce que ce backtest n'est pas une mesure de performance — c'est la vérification qu'un portage fidèle **complète et trade**. Le verdict de l'autre bras, le ré-entraînement FinBERT évalué hors échantillon sur quatre graines, est **`NO BEATS`** et vit dans la section « Résultat » ci-dessus. + +**Deux bornes de mesure, qui encadrent ce verdict.** (1) La comparaison au livre est **à fenêtre inégale** : le livre couvre 2022 entier, le livrable le premier sixième (mur natif, cf. §Historique) — aucun jugement de performance n'est tiré de l'écart. (2) Une seule configuration, déterministe, sans répétition multi-seed : le résultat est celui du portage fidèle, pas une évaluation du modèle. ## Fichiers