diff --git a/MyIA.AI.Notebooks/QuantConnect/Python/QC-Py-41-PaperTrading-IBKR.ipynb b/MyIA.AI.Notebooks/QuantConnect/Python/QC-Py-41-PaperTrading-IBKR.ipynb index 293236b04f..050838da5f 100644 --- a/MyIA.AI.Notebooks/QuantConnect/Python/QC-Py-41-PaperTrading-IBKR.ipynb +++ b/MyIA.AI.Notebooks/QuantConnect/Python/QC-Py-41-PaperTrading-IBKR.ipynb @@ -42,7 +42,7 @@ "tags": [] }, "source": [ - "[<< Sommaire QC](../README.md) | [Précédent : QC-Py-40-PaperTrading-Binance <<](./QC-Py-40-PaperTrading-Binance.ipynb)\n", + "[<< Sommaire QC](../README.md) | [Précédent : QC-Py-40-PaperTrading-Binance <<](./QC-Py-40-PaperTrading-Binance.ipynb) | [Suivant : QC-Py-42-Alpha-Mining-Evolution >>](./QC-Py-42-Alpha-Mining-Evolution.ipynb)\n", "\n", "# QC-Py-41 : Paper Trading IBKR - SP500 Momentum\n", "\n", @@ -1397,4 +1397,4 @@ }, "nbformat": 4, "nbformat_minor": 5 -} \ No newline at end of file +} diff --git a/MyIA.AI.Notebooks/QuantConnect/Python/QC-Py-42-Alpha-Mining-Evolution.ipynb b/MyIA.AI.Notebooks/QuantConnect/Python/QC-Py-42-Alpha-Mining-Evolution.ipynb new file mode 100644 index 0000000000..4856f0b0ba --- /dev/null +++ b/MyIA.AI.Notebooks/QuantConnect/Python/QC-Py-42-Alpha-Mining-Evolution.ipynb @@ -0,0 +1,1737 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "6e14756c", + "metadata": {}, + "source": [ + "[<< Sommaire QC](../README.md) | [Précédent : QC-Py-41-PaperTrading-IBKR <<](./QC-Py-41-PaperTrading-IBKR.ipynb)\n", + "\n", + "# QC-Py-42 - Alpha mining par evolution\n", + "\n", + "**Boucle from-scratch d'evolution d'expressions alpha**, et pont entre la serie `Search/`\n", + "(algorithmes evolutionnaires) et la serie `QuantConnect` (validation financiere).\n", + "\n", + "## Pourquoi ce carnet\n", + "\n", + "Le depot mine ses alphas **a la main**. La litterature recente automatise cette etape par\n", + "**evolution d'expressions** : R&D-Agent(Q), Microsoft Research Asia, arXiv:2505.15155, archive au\n", + "gisement bibliographique `Trading/`. Le depot enseigne deja les algorithmes evolutionnaires\n", + "(`deap`, `Search-05-GeneticAlgorithms.ipynb`) mais ne les a **jamais** appliques a la finance.\n", + "\n", + "Mesure de l'angle mort au 2026-10-09, grep prose (`*.py` / `*.md` / `*.yml` / `*.cs`, sorties\n", + "`.ipynb` exclues) : `RD-Agent` **0**, `AlphaGen` **0**, `alpha mining` **0**.\n", + "\n", + "## Ce que ce carnet fait\n", + "\n", + "1. Construit un panier crypto depuis les donnees **locales** du depot (10 actifs) : aucune\n", + " dependance reseau, l'execution est reproductible. La plage **2018-2026** est celle des\n", + " fichiers ; la fenetre reellement exploitee, apres intersection des dates communes, est\n", + " **mesuree en §1** et vaut environ cinq ans - c'est elle qui alimente tous les chiffres.\n", + "2. Evolue des **expressions alpha** (arbres d'operateurs sur des features de base) avec `deap`.\n", + "3. Confronte le meilleur individu a une **baseline de facteurs fixes** (momentum, reversal,\n", + " volatilite, volume) - le temoin negatif sans lequel « l'evolution gagne » ne veut rien dire.\n", + "4. Mesure hors-echantillon en **walk-forward a 5 blocs**, sur **4 seeds** (0 / 1 / 7 / 42), avec un\n", + " test de **Diebold-Mariano** sur une perte de precision (`mse`) **et un critere directionnel**\n", + " explicite : une difference significative mais **defavorable** n'est pas une victoire.\n", + "5. Rend un **verdict honnete** : `BEATS` / `NO BEATS` / `INCONCLUSIVE`.\n", + "\n", + "> La jambe « mini-boucle LLM » (proposer / executer / apprendre, facon R&D-Agent reduite) est\n", + "> **hors de ce carnet** : elle demande un acces modele et un budget d'evaluation distincts.\n", + "> Elle est suivie par #20041.\n", + "\n", + "**Duree d'execution** : quelques minutes sur CPU (population et generations volontairement\n", + "bornees pour un carnet pedagogique)." + ] + }, + { + "cell_type": "markdown", + "id": "342bf6c7", + "metadata": {}, + "source": [ + "## 1. Chargement du panier\n", + "\n", + "Les donnees viennent du depot (`QuantConnect/datasets/yfinance/crypto_panier/`). On ne fait\n", + "**aucun forward-fill** : un trou de cotation n'est pas un prix. L'intersection des dates communes\n", + "est donc prise telle quelle, ce qui raccourcit la periode au plus tardif des demarrages." + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "2883d0e3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-09T11:40:22.319835Z", + "iopub.status.busy": "2026-10-09T11:40:22.319565Z", + "iopub.status.idle": "2026-10-09T11:40:23.747832Z", + "shell.execute_reply": "2026-10-09T11:40:23.746349Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "panier : D:\\dev\\CoursIA-20041-alpha\\MyIA.AI.Notebooks\\QuantConnect\\datasets\\yfinance\\crypto_panier\n", + "csv vus : 10\n" + ] + } + ], + "source": [ + "import os, glob, time, warnings\n", + "import numpy as np\n", + "import pandas as pd\n", + "import matplotlib\n", + "matplotlib.use(\"Agg\")\n", + "import matplotlib.pyplot as plt\n", + "\n", + "warnings.filterwarnings(\"ignore\")\n", + "pd.set_option(\"display.width\", 170)\n", + "\n", + "SEEDS = (0, 1, 7, 42) # plancher multi-seed de la doctrine §C\n", + "ASSETS = [\"BTC-USD\", \"ETH-USD\", \"ADA-USD\", \"AVAX-USD\", \"DOT-USD\",\n", + " \"LINK-USD\", \"LTC-USD\", \"MATIC-USD\", \"SOL-USD\", \"XRP-USD\"]\n", + "\n", + "\n", + "def find_panier_dir():\n", + " here = os.path.abspath(os.getcwd())\n", + " for _ in range(7):\n", + " cand = os.path.join(here, \"MyIA.AI.Notebooks\", \"QuantConnect\",\n", + " \"datasets\", \"yfinance\", \"crypto_panier\")\n", + " if os.path.isdir(cand):\n", + " return cand\n", + " here = os.path.dirname(here)\n", + " raise FileNotFoundError(\"panier introuvable depuis %s\" % os.getcwd())\n", + "\n", + "\n", + "PANIER = find_panier_dir()\n", + "print(\"panier :\", PANIER)\n", + "print(\"csv vus :\", len(glob.glob(os.path.join(PANIER, \"*.csv\"))))" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "213abd10", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-09T11:40:23.751581Z", + "iopub.status.busy": "2026-10-09T11:40:23.751032Z", + "iopub.status.idle": "2026-10-09T11:40:23.897410Z", + "shell.execute_reply": "2026-10-09T11:40:23.896082Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "periode : 2020-09-22 -> 2025-03-24\n", + "forme : (1645, 10) (jours x actifs)\n", + "actifs : ['BTC-USD', 'ETH-USD', 'ADA-USD', 'AVAX-USD', 'DOT-USD', 'LINK-USD', 'LTC-USD', 'MATIC-USD', 'SOL-USD', 'XRP-USD']\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
BTC-USDETH-USDADA-USDAVAX-USDDOT-USDLINK-USDLTC-USDMATIC-USDSOL-USDXRP-USD
Date
2025-03-2283832.4843751980.0378420.70094119.4201244.44477014.22672291.2575450.209941128.4849852.369824
2025-03-2386054.3750002005.2991940.70950819.8116614.49594014.43569091.4153520.212728132.5993192.435493
2025-03-2487498.9140622077.4790040.73099521.4604284.61626315.08921293.6745070.216415140.8692782.448162
\n", + "
" + ], + "text/plain": [ + " BTC-USD ETH-USD ADA-USD AVAX-USD DOT-USD LINK-USD LTC-USD MATIC-USD SOL-USD XRP-USD\n", + "Date \n", + "2025-03-22 83832.484375 1980.037842 0.700941 19.420124 4.444770 14.226722 91.257545 0.209941 128.484985 2.369824\n", + "2025-03-23 86054.375000 2005.299194 0.709508 19.811661 4.495940 14.435690 91.415352 0.212728 132.599319 2.435493\n", + "2025-03-24 87498.914062 2077.479004 0.730995 21.460428 4.616263 15.089212 93.674507 0.216415 140.869278 2.448162" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "frames = {}\n", + "for a in ASSETS:\n", + " p = os.path.join(PANIER, \"%s_2018-01-01_2026-05-01.csv\" % a)\n", + " if os.path.exists(p):\n", + " frames[a] = pd.read_csv(p, parse_dates=[\"Date\"]).set_index(\"Date\").sort_index()\n", + "\n", + "close = pd.DataFrame({a: f[\"Close\"] for a, f in frames.items()}).sort_index().dropna(how=\"any\")\n", + "volume = pd.DataFrame({a: f[\"Volume\"] for a, f in frames.items()}).sort_index().reindex(close.index)\n", + "\n", + "print(\"periode :\", close.index.min().date(), \"->\", close.index.max().date())\n", + "print(\"forme :\", close.shape, \"(jours x actifs)\")\n", + "print(\"actifs :\", list(close.columns))\n", + "close.tail(3)" + ] + }, + { + "cell_type": "markdown", + "id": "0808220d", + "metadata": {}, + "source": [ + "### Rendements et cible\n", + "\n", + "L'alpha cherche a predire le **rendement transversal du lendemain**. On construit donc :\n", + "\n", + "- `ret` : rendement quotidien simple ;\n", + "- `fwd` : cible = `ret` decale d'un jour (ce que l'on cherche a predire).\n", + "\n", + "Le decalage est la seule precaution anti-fuite : aucune feature ne doit voir le jour qu'elle predit." + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "683ab9f5", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-09T11:40:23.900861Z", + "iopub.status.busy": "2026-10-09T11:40:23.900433Z", + "iopub.status.idle": "2026-10-09T11:40:23.947325Z", + "shell.execute_reply": "2026-10-09T11:40:23.945877Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "jours exploitables : 1643\n", + "rendement quotidien moyen (transversal) : 0.00250\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
BTC-USDETH-USDADA-USDAVAX-USDDOT-USDLINK-USDLTC-USDMATIC-USDSOL-USDXRP-USD
mean0.001790.001940.002710.002830.001460.001790.001540.003530.004330.00310
std0.032100.041530.054050.064800.053510.054550.046780.066640.063640.05967
\n", + "
" + ], + "text/plain": [ + " BTC-USD ETH-USD ADA-USD AVAX-USD DOT-USD LINK-USD LTC-USD MATIC-USD SOL-USD XRP-USD\n", + "mean 0.00179 0.00194 0.00271 0.00283 0.00146 0.00179 0.00154 0.00353 0.00433 0.00310\n", + "std 0.03210 0.04153 0.05405 0.06480 0.05351 0.05455 0.04678 0.06664 0.06364 0.05967" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ret = close.pct_change().replace([np.inf, -np.inf], np.nan)\n", + "vol = volume.replace(0, np.nan)\n", + "\n", + "# Cible : rendement du lendemain. shift(-1) = anti-fuite.\n", + "fwd = ret.shift(-1)\n", + "\n", + "valid = ret.notna().all(axis=1) & fwd.notna().all(axis=1)\n", + "# close est filtre EN MEME TEMPS que ret/vol/fwd. Sans cela, les terminaux batis sur\n", + "# close (logclose) gardent les lignes que ret a retirees, et toute expression qui\n", + "# melange les deux familles est rejetee par le controle de forme d'evaluate().\n", + "close, ret, vol, fwd = close[valid], ret[valid], vol[valid], fwd[valid]\n", + "\n", + "print(\"jours exploitables :\", len(ret))\n", + "print(\"rendement quotidien moyen (transversal) : %.5f\" % ret.mean().mean())\n", + "ret.describe().loc[[\"mean\", \"std\"]].round(5)" + ] + }, + { + "cell_type": "markdown", + "id": "09038abb", + "metadata": {}, + "source": [ + "## 2. Operateurs, terminaux, et evaluation d'une expression\n", + "\n", + "Une **expression alpha** est un arbre dont :\n", + "\n", + "- les **feuilles** sont des features de base (prix, volume, rendement) ;\n", + "- les **noeuds** sont des operateurs (moyenne glissante, ecart-type glissant, delta, rang\n", + " transversal, negation, arithmetique protegee).\n", + "\n", + "`deap` fournit les operateurs evolutionnaires ; notre travail est de definir le **jeu de\n", + "primitives** et la **fonction de fitness**. C'est exactement le pont Search -> QuantConnect qui\n", + "n'existait pas.\n", + "\n", + "Les operateurs temporels prennent une **fenetre** en parametre ; on expose donc des variantes\n", + "nommees (`mean5`, `mean20`, ...) plutot qu'un argument libre, ce qui garde l'arbre typable et\n", + "l'espace de recherche interprettable." + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "29bd90b1", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-09T11:40:23.950623Z", + "iopub.status.busy": "2026-10-09T11:40:23.950376Z", + "iopub.status.idle": "2026-10-09T11:40:25.967854Z", + "shell.execute_reply": "2026-10-09T11:40:25.966646Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "primitives : 15\n", + "terminaux : ['logclose', 'ret', 'vol']\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "ts_rank : ecart max vs oracle pandas = 0 (masques identiques : True)\n" + ] + } + ], + "source": [ + "def zscore_rows(x):\n", + " mu = np.nanmean(x, axis=1, keepdims=True)\n", + " sd = np.nanstd(x, axis=1, keepdims=True)\n", + " return (x - mu) / np.where(sd == 0, np.nan, sd)\n", + "\n", + "\n", + "def rank_rows(x):\n", + " out = np.full_like(x, np.nan, dtype=float)\n", + " for i in range(x.shape[0]):\n", + " row = x[i]\n", + " ok = ~np.isnan(row)\n", + " if ok.sum() < 2:\n", + " continue\n", + " order = np.argsort(np.argsort(row[ok]))\n", + " out[i, ok] = order / (ok.sum() - 1.0)\n", + " return out\n", + "\n", + "\n", + "def ts_mean(x, w):\n", + " return pd.DataFrame(x).rolling(w, min_periods=max(2, w // 2)).mean().values\n", + "\n", + "\n", + "def ts_std(x, w):\n", + " return pd.DataFrame(x).rolling(w, min_periods=max(2, w // 2)).std().values\n", + "\n", + "\n", + "def ts_delta(x, w):\n", + " d = pd.DataFrame(x)\n", + " return (d - d.shift(w)).values\n", + "\n", + "\n", + "def ts_rank_ref(x, w):\n", + " # Reference pandas : lisible, mais 1,7 s par appel sur un panneau (1643 x 10)\n", + " # (rolling.apply passe par une boucle Python). Inutilisable dans une boucle\n", + " # evolutionnaire qui evalue des milliers d'expressions -- gardee ici comme\n", + " # oracle de la version vectorisee ci-dessous.\n", + " return pd.DataFrame(x).rolling(w, min_periods=max(2, w // 2)).apply(\n", + " lambda r: (r.iloc[-1] > r).mean(), raw=False).values\n", + "\n", + "\n", + "def ts_rank(x, w):\n", + " # Rang (0..1) de la derniere valeur dans sa fenetre glissante de w jours,\n", + " # en version vectorisee : 0,020 s au lieu de 1,7 s, meme resultat au bit.\n", + " # Deux details de la semantique pandas sont reproduits : (a) pendant\n", + " # l'amorcage, la fenetre est plus courte que w et min_periods vaut w // 2 ;\n", + " # (b) les NaN de la fenetre comptent dans le denominateur (pandas passe la\n", + " # fenetre brute, pas la fenetre nettoyee).\n", + " from numpy.lib.stride_tricks import sliding_window_view\n", + " T, N = x.shape\n", + " m = max(2, w // 2)\n", + " out = np.full((T, N), np.nan)\n", + " for i in range(m - 1, T):\n", + " lo = max(0, i - w + 1)\n", + " win = x[lo:i + 1, :]\n", + " ok = ~np.isnan(win)\n", + " cnt = ok.sum(axis=0)\n", + " gt = ((x[i, :] > win) & ok).sum(axis=0)\n", + " out[i, :] = np.where(cnt >= m, gt / np.float64(i - lo + 1), np.nan)\n", + " return out\n", + "\n", + "\n", + "def sdiv(a, b):\n", + " return np.where(np.abs(b) < 1e-12, np.nan, a / b)\n", + "\n", + "\n", + "def neg(x):\n", + " return -x\n", + "\n", + "\n", + "def slog(x):\n", + " return np.sign(x) * np.log1p(np.abs(x))\n", + "\n", + "\n", + "PRIMITIVES = {\n", + " \"add\": (np.add, 2), \"sub\": (np.subtract, 2), \"mul\": (np.multiply, 2),\n", + " \"div\": (sdiv, 2), \"neg\": (neg, 1), \"log\": (slog, 1),\n", + " \"rank\": (rank_rows, 1), \"zscore\": (zscore_rows, 1),\n", + " \"mean5\": (lambda x: ts_mean(x, 5), 1), \"mean20\": (lambda x: ts_mean(x, 20), 1),\n", + " \"std5\": (lambda x: ts_std(x, 5), 1), \"std20\": (lambda x: ts_std(x, 20), 1),\n", + " \"delta5\": (lambda x: ts_delta(x, 5), 1), \"delta20\": (lambda x: ts_delta(x, 20), 1),\n", + " \"tsrank20\": (lambda x: ts_rank(x, 20), 1),\n", + "}\n", + "\n", + "TERMINALS = {\n", + " \"logclose\": np.log(close.values),\n", + " \"ret\": ret.values,\n", + " \"vol\": np.log1p(vol.values),\n", + "}\n", + "\n", + "print(\"primitives :\", len(PRIMITIVES))\n", + "print(\"terminaux :\", list(TERMINALS))\n", + "\n", + "# ts_rank est vectorise pour tenir dans la boucle evolutionnaire. L'equivalence\n", + "# avec l'oracle pandas est MESUREE ici, sur les donnees reelles, et non supposee :\n", + "# la ligne imprimee doit afficher 0.0.\n", + "_r = ts_rank_ref(ret.values, 20)\n", + "_f = ts_rank(ret.values, 20)\n", + "_common = np.isfinite(_r) & np.isfinite(_f)\n", + "print(\"ts_rank : ecart max vs oracle pandas = %.3g (masques identiques : %s)\"\n", + " % (np.nanmax(np.abs(_r[_common] - _f[_common])),\n", + " np.array_equal(np.isfinite(_r), np.isfinite(_f))))" + ] + }, + { + "cell_type": "markdown", + "id": "0749f2cb", + "metadata": {}, + "source": [ + "### Interpretreur d'expression\n", + "\n", + "L'expression est un arbre en notation prefixee (`list`) : `['div', ['delta5', ['logclose']], ['std20', ['ret']]]`.\n", + "Un interpreteur recursif l'evalue sur le panneau complet. Evaluer l'arbre soi-meme (plutot que\n", + "`deap.gp.compile`) permet de **rejeter proprement** une expression qui produit `NaN` partout, au\n", + "lieu de propager une exception au milieu de la boucle evolutionnaire." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "9718d408", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-09T11:40:25.970797Z", + "iopub.status.busy": "2026-10-09T11:40:25.970548Z", + "iopub.status.idle": "2026-10-09T11:40:25.981852Z", + "shell.execute_reply": "2026-10-09T11:40:25.980583Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "expression : (div (delta5 logclose) (std20 ret))\n", + "forme : (1643, 10)\n", + "fini : True\n" + ] + } + ], + "source": [ + "def evaluate(expr, terminals=TERMINALS):\n", + " # Evalue une expression prefixee -> ndarray (T, N). None si non evaluable.\n", + " if isinstance(expr, str):\n", + " return terminals[expr]\n", + " head = expr[0]\n", + " if head not in PRIMITIVES:\n", + " return None\n", + " fn, arity = PRIMITIVES[head]\n", + " args = []\n", + " for k in range(1, arity + 1):\n", + " v = evaluate(expr[k], terminals)\n", + " if v is None:\n", + " return None\n", + " args.append(v)\n", + " try:\n", + " out = fn(*args)\n", + " except Exception:\n", + " return None\n", + " out = np.asarray(out, dtype=float)\n", + " if out.shape != (len(close), close.shape[1]):\n", + " return None\n", + " return out\n", + "\n", + "\n", + "def expression_size(expr):\n", + " if isinstance(expr, str):\n", + " return 1\n", + " return 1 + sum(expression_size(e) for e in expr[1:])\n", + "\n", + "\n", + "def to_prefix(expr):\n", + " if isinstance(expr, str):\n", + " return expr\n", + " return \"(%s %s)\" % (expr[0], \" \".join(to_prefix(e) for e in expr[1:]))\n", + "\n", + "\n", + "# Convention d'ecriture : un TERMINAL est une chaine nue (\"logclose\"), jamais une\n", + "# liste a un element ([\"logclose\"]) -- une liste est un noeud d'operateur, et\n", + "# [\"logclose\"] serait alors lu comme un appel a une primitive inexistante.\n", + "demo = [\"div\", [\"delta5\", \"logclose\"], [\"std20\", \"ret\"]]\n", + "d = evaluate(demo)\n", + "print(\"expression :\", to_prefix(demo))\n", + "print(\"forme :\", None if d is None else d.shape)\n", + "print(\"fini :\", None if d is None else bool(np.isfinite(d).any()))" + ] + }, + { + "cell_type": "markdown", + "id": "a551dbbf", + "metadata": {}, + "source": [ + "### Fonction de fitness\n", + "\n", + "Le fitness est l'**IC transversal moyen** (correlation de rang, jour par jour, entre signal et\n", + "rendement du lendemain) sur la **fenetre d'entrainement** uniquement. Un IC moyen eleve et stable\n", + "est ce qu'un alpha doit produire.\n", + "\n", + "Le fitness n'est **jamais** calcule sur la fenetre de test : c'est ce qui rend le verdict\n", + "hors-echantillon credible." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "765f07e0", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-09T11:40:25.985053Z", + "iopub.status.busy": "2026-10-09T11:40:25.984656Z", + "iopub.status.idle": "2026-10-09T11:40:25.993960Z", + "shell.execute_reply": "2026-10-09T11:40:25.992610Z" + } + }, + "outputs": [], + "source": [ + "def signal_to_rank(sig):\n", + " return pd.DataFrame(sig).rank(axis=1, pct=True).values\n", + "\n", + "\n", + "def daily_ic(sig, target):\n", + " s = signal_to_rank(sig)\n", + " t = pd.DataFrame(target).rank(axis=1, pct=True).values\n", + " both = np.isfinite(s) & np.isfinite(t)\n", + " n = both.sum(axis=1)\n", + " keep = n >= 5\n", + " if keep.sum() < 20:\n", + " return np.nan\n", + " s, t = s[keep], t[keep]\n", + " sm = s - s.mean(axis=1, keepdims=True)\n", + " tm = t - t.mean(axis=1, keepdims=True)\n", + " num = (sm * tm).sum(axis=1)\n", + " den = np.sqrt((sm ** 2).sum(axis=1) * (tm ** 2).sum(axis=1))\n", + " ic = np.where(den == 0, np.nan, num / den)\n", + " return float(np.nanmean(ic))\n", + "\n", + "\n", + "def long_short_daily(sig, target, q=0.3):\n", + " # Rendement quotidien d'un portefeuille long top-q / short bottom-q, equipondere.\n", + " s = signal_to_rank(sig)\n", + " k = max(1, int(round(s.shape[1] * q)))\n", + " out = np.full(s.shape[0], np.nan)\n", + " for i in range(s.shape[0]):\n", + " row, tgt = s[i], target[i]\n", + " ok = np.isfinite(row) & np.isfinite(tgt)\n", + " if ok.sum() < 2 * k:\n", + " continue\n", + " idx = np.argsort(row[ok])\n", + " labs = np.array(tgt[ok])[idx]\n", + " out[i] = labs[-k:].mean() - labs[:k].mean()\n", + " return out\n", + "\n", + "\n", + "def sharpe(daily):\n", + " d = daily[np.isfinite(daily)]\n", + " if len(d) < 20 or d.std() == 0:\n", + " return np.nan\n", + " return float(d.mean() / d.std() * np.sqrt(365))" + ] + }, + { + "cell_type": "markdown", + "id": "478b170c", + "metadata": {}, + "source": [ + "## 3. Baseline - les facteurs fixes\n", + "\n", + "Le **temoin negatif**. Quatre facteurs classiques, chacun un signal connu :\n", + "\n", + "| Facteur | Signal |\n", + "|---|---|\n", + "| momentum | rendement cumule sur 20 jours |\n", + "| reversal | negation du rendement de la veille |\n", + "| volatilite | ecart-type glissant 20 jours, negatif (low-vol) |\n", + "| volume | rang transversal du volume |\n", + "\n", + "Si l'evolution ne bat pas ces facteurs, le verdict est `NO BEATS` - et c'est un resultat utile.\n", + "\n", + "### Le decoupage, et ce que chaque etape lit\n", + "\n", + "La serie est coupee en **6 intervalles** contigus, qui forment **5 folds** : le fold *i*\n", + "entraine sur l'intervalle *i* et teste sur le suivant. Les 6 intervalles sont donc lus, sans\n", + "reste : c'est le decoupage **walk-forward a 5 blocs** que la doctrine §C demande (§C en exige\n", + "cinq ; un decoupage a 4 folds laissait le dernier intervalle hors de toute lecture).\n", + "\n", + "La **baseline** est caracterisee sur les 5 folds. Le **champion evolue**, lui, est entraine\n", + "**une seule fois** (sur l'intervalle d'entrainement du fold 0) puis re-evalue sur les blocs de\n", + "test des folds **1 a 4** - il ne peut pas etre re-entraine par fold, sinon chaque fold aurait\n", + "son propre champion et l'ecart inter-seeds ne mesurerait plus la dispersion d'une meme methode.\n", + "\n", + "La comparaison finale porte donc sur **la meme fenetre pour les deux** : la colonne\n", + "`Sharpe OOS (comparaison)` de la table ci-dessous restreint la baseline a ces quatre blocs, ce\n", + "qui la rend homometrique du champion. Comparer le champion (4 blocs) a une baseline moyennee\n", + "sur 5 blocs comparerait deux fenetres differentes - l'ecart ne voudrait rien dire.\n", + "\n", + "Enfin, le **facteur de reference** est choisi sur l'**IC d'entrainement** (colonne\n", + "`IC train moyen`), jamais sur le Sharpe hors-echantillon de la fenetre de comparaison :\n", + "selectionner la reference sur la fenetre meme ou l'on compare reviendrait a choisir le\n", + "meilleur adversaire *apres* avoir vu le resultat." + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "89cd2fca", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-09T11:40:25.996902Z", + "iopub.status.busy": "2026-10-09T11:40:25.996528Z", + "iopub.status.idle": "2026-10-09T11:40:26.287005Z", + "shell.execute_reply": "2026-10-09T11:40:26.285991Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "baseline facteurs fixes (walk-forward 5 blocs ; comparaison sur 4 blocs)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
IC train moyenSharpe OOS (5 blocs)Sharpe OOS (comparaison)
facteur
lowvol0.0586-0.8184-0.6359
volume0.05090.27391.0793
reversal0.0199-0.8939-0.8912
momentum0.01021.68901.5247
\n", + "
" + ], + "text/plain": [ + " IC train moyen Sharpe OOS (5 blocs) Sharpe OOS (comparaison)\n", + "facteur \n", + "lowvol 0.0586 -0.8184 -0.6359\n", + "volume 0.0509 0.2739 1.0793\n", + "reversal 0.0199 -0.8939 -0.8912\n", + "momentum 0.0102 1.6890 1.5247" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "FIXED = {\n", + " \"momentum\": evaluate([\"delta20\", \"logclose\"]),\n", + " \"reversal\": evaluate([\"neg\", \"ret\"]),\n", + " \"lowvol\": evaluate([\"neg\", [\"std20\", \"ret\"]]),\n", + " \"volume\": evaluate([\"rank\", \"vol\"]),\n", + "}\n", + "if any(v is None for v in FIXED.values()):\n", + " raise RuntimeError(\"facteur fixe non evaluable : %s\"\n", + " % [k for k, v in FIXED.items() if v is None])\n", + "\n", + "# Decoupage walk-forward a 5 blocs : 6 intervalles contigus, tous consommes\n", + "# (la doctrine §C exige 5 folds ; a 4 folds le dernier intervalle n'etait lu par personne).\n", + "n = len(ret)\n", + "edges = np.linspace(0, n, 7).astype(int)\n", + "FOLDS = [(edges[i], edges[i + 1], edges[i + 1], edges[i + 2]) for i in range(5)]\n", + "\n", + "# Le champion est entraine UNE fois sur FOLDS[0] puis re-evalue sur les blocs de test\n", + "# de FOLDS[1:] : ce sont ces memes blocs qui portent la comparaison baseline <-> champion,\n", + "# d'ou EVAL_FOLDS, utilise aussi bien dans la table ci-dessous qu'a la section 5.\n", + "EVAL_FOLDS = FOLDS[1:]\n", + "\n", + "rows = []\n", + "for name, sig in FIXED.items():\n", + " if sig is None:\n", + " continue\n", + " ics, shps, shps_eval = [], [], []\n", + " for tr0, tr1, te0, te1 in FOLDS:\n", + " ics.append(daily_ic(sig[tr0:tr1], fwd.values[tr0:tr1]))\n", + " shps.append(sharpe(long_short_daily(sig[te0:te1], fwd.values[te0:te1])))\n", + " for tr0, tr1, te0, te1 in EVAL_FOLDS:\n", + " shps_eval.append(sharpe(long_short_daily(sig[te0:te1], fwd.values[te0:te1])))\n", + " rows.append({\"facteur\": name,\n", + " \"IC train moyen\": np.nanmean(ics),\n", + " \"Sharpe OOS (5 blocs)\": np.nanmean(shps),\n", + " \"Sharpe OOS (comparaison)\": np.nanmean(shps_eval)})\n", + "\n", + "baseline = pd.DataFrame(rows).set_index(\"facteur\").round(4)\n", + "print(\"baseline facteurs fixes (walk-forward %d blocs ; comparaison sur %d blocs)\"\n", + " % (len(FOLDS), len(EVAL_FOLDS)))\n", + "# Reference choisie sur l'IC d'ENTRAINEMENT : la ligne de comparaison est celle du facteur\n", + "# retenu, visible ici pour que le lecteur puisse verifier qu'il n'a pas ete choisi apres coup.\n", + "baseline.sort_values(\"IC train moyen\", ascending=False)" + ] + }, + { + "cell_type": "markdown", + "id": "b976c9c1", + "metadata": {}, + "source": [ + "## 4. Evolution d'expressions avec `deap`\n", + "\n", + "On evolue des arbres d'expressions. Le fitness est l'IC transversal sur la **fenetre\n", + "d'entrainement** (`eaMuPlusLambda`, selection par tournoi). Chaque seed est une execution\n", + "independante : c'est le plancher **multi-seed ≥ 4** de la doctrine §C.\n", + "\n", + "La taille de l'arbre est **bornee** : un alpha doit rester lisible, et une expression obeese\n", + "sur-ajuste." + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "ad7a43dd", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-09T11:40:26.290259Z", + "iopub.status.busy": "2026-10-09T11:40:26.289962Z", + "iopub.status.idle": "2026-10-09T11:41:00.613812Z", + "shell.execute_reply": "2026-10-09T11:41:00.612306Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "seed 0 IC train 0.1273 taille 7 (zscore (mean20 (div (tsrank20 ret) (rank logclose))))\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "seed 1 IC train 0.0711 taille 9 (div (div (rank logclose) (std20 ret)) (tsrank20 (std20 vol)))\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "seed 7 IC train 0.0956 taille 4 (mean20 (neg (std20 ret)))\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "seed 42 IC train 0.0835 taille 7 (zscore (mul vol (div vol (std20 ret))))\n", + "duree : 34.3 s\n" + ] + } + ], + "source": [ + "import random\n", + "from deap import base, creator, tools, algorithms\n", + "\n", + "MAX_DEPTH = 3\n", + "POP, NGEN, LAMBDA = 60, 8, 60\n", + "\n", + "PRIM_NAMES = list(PRIMITIVES)\n", + "TERM_NAMES = list(TERMINALS)\n", + "\n", + "\n", + "def random_expr(rng, depth=0):\n", + " if depth >= MAX_DEPTH or (depth > 0 and rng.random() < 0.35):\n", + " return rng.choice(TERM_NAMES)\n", + " name = rng.choice(PRIM_NAMES)\n", + " arity = PRIMITIVES[name][1]\n", + " return [name] + [random_expr(rng, depth + 1) for _ in range(arity)]\n", + "\n", + "\n", + "def mutate_expr(expr, rng):\n", + " if rng.random() < 0.3:\n", + " return random_expr(rng)\n", + " if isinstance(expr, str):\n", + " return rng.choice(TERM_NAMES)\n", + " idx = rng.randrange(1, len(expr))\n", + " expr = list(expr)\n", + " expr[idx] = mutate_expr(expr[idx], rng)\n", + " return expr\n", + "\n", + "\n", + "if not hasattr(creator, \"FitnessMaxAlpha\"):\n", + " creator.create(\"FitnessMaxAlpha\", base.Fitness, weights=(1.0,))\n", + "if not hasattr(creator, \"IndividualAlpha\"):\n", + " creator.create(\"IndividualAlpha\", list, fitness=creator.FitnessMaxAlpha)\n", + "\n", + "\n", + "def evolution(tr0, tr1, seed):\n", + " # Fait evoluer des expressions sur la fenetre d'entrainement [tr0, tr1). Rend le meilleur prefixe.\n", + " # evaluate() rend le signal sur TOUTE la serie : la fitness doit le decouper sur la\n", + " # meme fenetre que la cible, sinon daily_ic compare (T, N) a (tr1-tr0, N).\n", + " rng = random.Random(seed)\n", + " # DEAP puise dans le module `random` GLOBAL, pas dans notre generateur local :\n", + " # la selection (`selTournament`, qui ne prend aucun argument `rng` en 1.4) ET le\n", + " # choix du parent a muter (`algorithms.varOr` -> `random.choice`). Sans ce seed,\n", + " # deux executions du meme carnet rendaient des champions differents -- mesure sur\n", + " # la fenetre d'entrainement, seed 0 : Sharpe OOS 0.1304 puis 0.6912 -- et la\n", + " # validation multi-seed ne voulait plus rien dire. Le seed global est repose a\n", + " # chaque appel, donc chaque seed de SEEDS reste independant des autres.\n", + " random.seed(seed)\n", + " target = fwd.values[tr0:tr1]\n", + "\n", + " def fit(individual):\n", + " v = evaluate(individual)\n", + " if v is None:\n", + " return (-1.0,)\n", + " ic = daily_ic(v[tr0:tr1], target)\n", + " if ic is None or not np.isfinite(ic):\n", + " return (-1.0,)\n", + " return (float(ic) - 0.001 * expression_size(individual),)\n", + "\n", + " tb = base.Toolbox()\n", + " tb.register(\"individual\", tools.initIterate, creator.IndividualAlpha,\n", + " lambda: random_expr(rng))\n", + " tb.register(\"population\", tools.initRepeat, list, tb.individual)\n", + " tb.register(\"evaluate\", fit)\n", + " tb.register(\"mate\", lambda a, b: (a, b))\n", + " # La mutation doit rendre un IndividualAlpha, pas une list nue : un descendant\n", + " # de type list perd son attribut .fitness et selTournament leve\n", + " # \"AttributeError: 'list' object has no attribute 'fitness'\". Le noeud racine\n", + " # reste une liste (mutate_expr ne remplace qu'un sous-noeud d'indice >= 1).\n", + " tb.register(\"mutate\",\n", + " lambda ind: (creator.IndividualAlpha(mutate_expr(list(ind), rng)),))\n", + " tb.register(\"select\", tools.selTournament, tournsize=3)\n", + "\n", + " pop = tb.population(n=POP)\n", + " hof = tools.HallOfFame(1)\n", + " algorithms.eaMuPlusLambda(pop, tb, mu=POP, lambda_=LAMBDA,\n", + " cxpb=0.0, mutpb=1.0, ngen=NGEN,\n", + " halloffame=hof, verbose=False)\n", + " return hof[0]\n", + "\n", + "\n", + "t0 = time.time()\n", + "champions = {}\n", + "for seed in SEEDS:\n", + " tr0, tr1, te0, te1 = FOLDS[0]\n", + " best = evolution(tr0, tr1, seed)\n", + " champions[seed] = best\n", + " v = evaluate(best)\n", + " ic = daily_ic(v[tr0:tr1], fwd.values[tr0:tr1]) if v is not None else float(\"nan\")\n", + " print(\"seed %-3d IC train %.4f taille %2d %s\" % (\n", + " seed, ic, expression_size(best), to_prefix(best)[:90]))\n", + "print(\"duree : %.1f s\" % (time.time() - t0))" + ] + }, + { + "cell_type": "markdown", + "id": "f6d60645", + "metadata": {}, + "source": [ + "## 5. Evaluation hors-echantillon et Diebold-Mariano\n", + "\n", + "Le champion de chaque seed est **re-evalue sur les blocs de test**, qu'il n'a jamais vus. On\n", + "mesure :\n", + "\n", + "- l'**IC hors-echantillon** ;\n", + "- le **Sharpe** du portefeuille long-short ;\n", + "- un test de **Diebold-Mariano** sur une perte de precision (`mse`) : le modele evolue est\n", + " confronte au **meilleur facteur fixe**, jour par jour, sur la meme cible.\n", + "\n", + "Le champion est entraine **une seule fois** (intervalle d'entrainement du fold 0) et les blocs de\n", + "test qu'il rencontre ici sont **exactement** ceux qui portent la valeur de comparaison de la\n", + "baseline : la colonne `Sharpe OOS (comparaison)` de la section 3. Sans cette homometrie, on\n", + "comparerait une moyenne sur 4 blocs a une moyenne sur 5.\n", + "\n", + "La doctrine §C demande la **conjonction** de **trois** choses :\n", + "\n", + "1. un ecart inter-seeds significatif (≥ 2 sigma) ;\n", + "2. `dm_p_median < 0.05` ;\n", + "3. un **signe favorable** de la statistique de Diebold-Mariano.\n", + "\n", + "Le troisieme point est celui qui manquait. La statistique est\n", + "`moyenne(perte_evolue - perte_fixe) / se` : **positive, elle dit que l'evolue est MOINS precis**.\n", + "`p < 0.05` seul recompenserait donc une difference **significative et defavorable** - c'est-a-dire\n", + "une defaite nette - exactement comme une victoire. La cellule 5.3 ferme ce trou et le prouve par\n", + "un **temoin negatif** execute : deux series de pertes ou l'evolue est significativement moins\n", + "precis, que l'ancien critere acceptait et que le nouveau refuse." + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "b74f037f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-09T11:41:00.617101Z", + "iopub.status.busy": "2026-10-09T11:41:00.616799Z", + "iopub.status.idle": "2026-10-09T11:41:01.864178Z", + "shell.execute_reply": "2026-10-09T11:41:01.862972Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "facteur fixe de reference (choisi sur l'IC d'entrainement) : lowvol\n", + " (pour transparence, Sharpe OOS de comparaison des 4 facteurs :)\n", + "facteur\n", + "momentum 1.5247\n", + "reversal -0.8912\n", + "lowvol -0.6359\n", + "volume 1.0793\n" + ] + }, + { + "name": "stdout", + "output_type": "stream", + "text": [ + "\n", + "champion evolue, hors-echantillon (walk-forward, 4 blocs)\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
IC OOSSharpe OOSDM statDM p
seed
0-0.1192-0.20133.72610.0002
10.03470.1523-4.64250.0000
70.0383-0.4268-0.00180.9986
420.0577-0.2530-3.64360.0003
\n", + "
" + ], + "text/plain": [ + " IC OOS Sharpe OOS DM stat DM p\n", + "seed \n", + "0 -0.1192 -0.2013 3.7261 0.0002\n", + "1 0.0347 0.1523 -4.6425 0.0000\n", + "7 0.0383 -0.4268 -0.0018 0.9986\n", + "42 0.0577 -0.2530 -3.6436 0.0003" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Diebold-Mariano implemente localement (Newey-West HAC, lag 1) : aucune dependance exotique.\n", + "def dm_test_hac(loss_a, loss_b, lag=1):\n", + " d = np.asarray(loss_a, float) - np.asarray(loss_b, float)\n", + " d = d[np.isfinite(d)]\n", + " n = len(d)\n", + " if n < 30:\n", + " return np.nan, np.nan\n", + " mean_d = d.mean()\n", + " dc = d - mean_d\n", + " gamma0 = (dc ** 2).sum() / n\n", + " s = gamma0\n", + " for k in range(1, lag + 1):\n", + " gk = (dc[k:] * dc[:-k]).sum() / n\n", + " s += 2.0 * (1.0 - k / (lag + 1.0)) * gk\n", + " se = np.sqrt(s / n)\n", + " if se == 0:\n", + " return np.nan, np.nan\n", + " stat = mean_d / se\n", + " from scipy import stats as st\n", + " p = 2 * (1 - st.t.cdf(abs(stat), df=n - 1))\n", + " return float(stat), float(p)\n", + "\n", + "\n", + "def oos_losses(sig, target):\n", + " # Perte de precision (mse) du signal pris comme predicteur du rendement du lendemain.\n", + " s = signal_to_rank(sig)\n", + " pred = (s - 0.5) * 2.0\n", + " err = pred - pd.DataFrame(target).rank(axis=1, pct=True).values\n", + " return np.nanmean(err ** 2, axis=1)\n", + "\n", + "\n", + "# Reference choisie sur l'IC d'ENTRAINEMENT, pas sur le Sharpe OOS de la fenetre de\n", + "# comparaison : choisir le meilleur facteur sur la fenetre ou l'on compare reviendrait a\n", + "# designer l'adversaire apres avoir vu le score. La table ci-dessus rend les deux colonnes\n", + "# visibles pour que le lecteur puisse juger le choix.\n", + "best_fixed_name = baseline[\"IC train moyen\"].idxmax()\n", + "best_fixed = FIXED[best_fixed_name]\n", + "print(\"facteur fixe de reference (choisi sur l'IC d'entrainement) :\", best_fixed_name)\n", + "print(\" (pour transparence, Sharpe OOS de comparaison des 4 facteurs :)\")\n", + "print(baseline[\"Sharpe OOS (comparaison)\"].to_string())\n", + "\n", + "rows = []\n", + "for seed in SEEDS:\n", + " sig = evaluate(champions[seed])\n", + " ics, shps, losses = [], [], []\n", + " for tr0, tr1, te0, te1 in EVAL_FOLDS:\n", + " ics.append(daily_ic(sig[te0:te1], fwd.values[te0:te1]))\n", + " shps.append(sharpe(long_short_daily(sig[te0:te1], fwd.values[te0:te1])))\n", + " losses.append(oos_losses(sig[te0:te1], fwd.values[te0:te1]))\n", + " rows.append({\"seed\": seed, \"IC OOS\": np.nanmean(ics),\n", + " \"Sharpe OOS\": np.nanmean(shps),\n", + " \"_loss\": np.concatenate(losses)})\n", + "\n", + "evolved = pd.DataFrame([{k: v for k, v in r.items() if k != \"_loss\"} for r in rows])\n", + "\n", + "fixed_loss = np.concatenate([oos_losses(best_fixed[te0:te1], fwd.values[te0:te1])\n", + " for _, _, te0, te1 in EVAL_FOLDS])\n", + "\n", + "dm_stats = []\n", + "for r in rows:\n", + " L = min(len(r[\"_loss\"]), len(fixed_loss))\n", + " dm_stats.append(dm_test_hac(r[\"_loss\"][:L], fixed_loss[:L]))\n", + "evolved[\"DM stat\"] = [s for s, _ in dm_stats]\n", + "evolved[\"DM p\"] = [p for _, p in dm_stats]\n", + "evolved = evolved.set_index(\"seed\").round(4)\n", + "\n", + "print(\"\\nchampion evolue, hors-echantillon (walk-forward, %d blocs)\" % len(EVAL_FOLDS))\n", + "evolved" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "64eee2d4", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-09T11:41:01.866629Z", + "iopub.status.busy": "2026-10-09T11:41:01.866318Z", + "iopub.status.idle": "2026-10-09T11:41:01.874906Z", + "shell.execute_reply": "2026-10-09T11:41:01.873792Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "facteur fixe de reference : lowvol Sharpe OOS -0.6359\n", + "champion evolue : Sharpe OOS -0.1822 (sigma inter-seeds 0.2430)\n", + "edge - baseline : 0.4537 >= 2 sigma ? False\n", + "Diebold-Mariano p median : 0.0003 < 0.05 ? True\n", + "Diebold-Mariano stat med. : -1.8227 < 0 (favorable) ? True\n", + "\n", + "VERDICT : INCONCLUSIVE\n" + ] + } + ], + "source": [ + "edge = evolved[\"Sharpe OOS\"].mean()\n", + "sigma = evolved[\"Sharpe OOS\"].std(ddof=1)\n", + "# Meme fenetre que `edge` : la colonne de comparaison restreint la baseline aux blocs de\n", + "# test de EVAL_FOLDS, ceux-la memes que le champion vient de parcourir.\n", + "fixed_sharpe = baseline.loc[best_fixed_name, \"Sharpe OOS (comparaison)\"]\n", + "dm_p_median = float(np.nanmedian(evolved[\"DM p\"]))\n", + "dm_stat_median = float(np.nanmedian(evolved[\"DM stat\"]))\n", + "\n", + "sigma_ok = np.isfinite(sigma) and sigma > 0 and (edge - fixed_sharpe) >= 2 * sigma\n", + "# Conjonction complete : significatif ET favorable. `dm_stat_median > 0` signifie que\n", + "# l'evolue a une perte PLUS ELEVEE que le facteur fixe -- significatif, mais defavorable.\n", + "dm_sign_ok = np.isfinite(dm_stat_median) and dm_stat_median < 0\n", + "dm_ok = np.isfinite(dm_p_median) and dm_p_median < 0.05 and dm_sign_ok\n", + "\n", + "print(\"facteur fixe de reference : %-9s Sharpe OOS %.4f\" % (best_fixed_name, fixed_sharpe))\n", + "print(\"champion evolue : Sharpe OOS %.4f (sigma inter-seeds %.4f)\" % (edge, sigma))\n", + "print(\"edge - baseline : %.4f >= 2 sigma ? %s\" % (edge - fixed_sharpe, sigma_ok))\n", + "print(\"Diebold-Mariano p median : %.4f < 0.05 ? %s\" % (dm_p_median, dm_p_median < 0.05))\n", + "print(\"Diebold-Mariano stat med. : %+.4f < 0 (favorable) ? %s\" % (dm_stat_median, dm_sign_ok))\n", + "\n", + "if sigma_ok and dm_ok:\n", + " verdict = \"BEATS\"\n", + "elif (not sigma_ok) and (not dm_ok):\n", + " verdict = \"NO BEATS\"\n", + "else:\n", + " verdict = \"INCONCLUSIVE\"\n", + "print(\"\\nVERDICT :\", verdict)" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "a28dfd07", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-09T11:41:01.877650Z", + "iopub.status.busy": "2026-10-09T11:41:01.877443Z", + "iopub.status.idle": "2026-10-09T11:41:01.904905Z", + "shell.execute_reply": "2026-10-09T11:41:01.904100Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "conjonction §C, facteur de reference par facteur :\n" + ] + }, + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
Sharpe OOS compar.edge>= 2 sigmaDM p medDM stat medconjonction
facteur
momentum1.5247-1.7069False0.0085-2.3570False
reversal-0.89120.7090True0.0069-2.0056True
lowvol-0.63590.4537False0.0002-1.8227False
volume1.0793-1.2615False0.10570.5090False
\n", + "
" + ], + "text/plain": [ + " Sharpe OOS compar. edge >= 2 sigma DM p med DM stat med conjonction\n", + "facteur \n", + "momentum 1.5247 -1.7069 False 0.0085 -2.3570 False\n", + "reversal -0.8912 0.7090 True 0.0069 -2.0056 True\n", + "lowvol -0.6359 0.4537 False 0.0002 -1.8227 False\n", + "volume 1.0793 -1.2615 False 0.1057 0.5090 False" + ] + }, + "execution_count": 11, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "# Robustesse au choix de la reference. La conjonction §C depend du facteur fixe auquel on se\n", + "# compare : un verdict qui ne tient que sous un choix de reference n'est pas un verdict, c'est\n", + "# un artefact de selection. On recalcule donc les deux jambes pour les QUATRE facteurs et on\n", + "# publie la table entiere, plutot que le seul chiffre du facteur retenu.\n", + "robust = []\n", + "for name, sig in FIXED.items():\n", + " fl = np.concatenate([oos_losses(sig[te0:te1], fwd.values[te0:te1])\n", + " for _, _, te0, te1 in EVAL_FOLDS])\n", + " ps, ss = [], []\n", + " for r in rows:\n", + " L = min(len(r[\"_loss\"]), len(fl))\n", + " st, p = dm_test_hac(r[\"_loss\"][:L], fl[:L])\n", + " ss.append(st); ps.append(p)\n", + " p_med, s_med = float(np.nanmedian(ps)), float(np.nanmedian(ss))\n", + " dm_ok_f = np.isfinite(p_med) and p_med < 0.05 and np.isfinite(s_med) and s_med < 0\n", + " fs = baseline.loc[name, \"Sharpe OOS (comparaison)\"]\n", + " s_ok_f = np.isfinite(sigma) and sigma > 0 and (edge - fs) >= 2 * sigma\n", + " robust.append({\"facteur\": name, \"Sharpe OOS compar.\": fs, \"edge\": edge - fs,\n", + " \">= 2 sigma\": s_ok_f, \"DM p med\": p_med, \"DM stat med\": s_med,\n", + " \"conjonction\": bool(s_ok_f and dm_ok_f)})\n", + "robust = pd.DataFrame(robust).set_index(\"facteur\").round(4)\n", + "print(\"conjonction §C, facteur de reference par facteur :\")\n", + "robust" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "38d151be", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-09T11:41:01.907421Z", + "iopub.status.busy": "2026-10-09T11:41:01.907102Z", + "iopub.status.idle": "2026-10-09T11:41:01.914393Z", + "shell.execute_reply": "2026-10-09T11:41:01.913245Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "temoin defavorable : DM stat +7.7405 p 0.0000\n", + " ancien critere (p < 0.05 seul) : True\n", + " critere retenu (p < 0.05 ET stat < 0) : False\n", + "temoin negatif OK : significatif, defavorable, et refuse par le critere retenu.\n" + ] + } + ], + "source": [ + "# Temoin negatif du critere directionnel. Deux series de pertes ou le modele \"evolu\"\n", + "# est significativement MOINS precis que le fixe : la difference est reelle et\n", + "# significative (p < 0.05), mais elle est defavorable. Le critere retenu doit la refuser,\n", + "# l'ancien (p < 0.05 seul) l'aurait acceptee comme une victoire.\n", + "rng_w = np.random.RandomState(0)\n", + "_l_fixe = rng_w.normal(0.0, 1.0, 400)\n", + "_l_evol = _l_fixe + 0.25 + rng_w.normal(0.0, 0.5, 400) # perte systematiquement plus elevee\n", + "_w_stat, _w_p = dm_test_hac(_l_evol, _l_fixe)\n", + "print(\"temoin defavorable : DM stat %+.4f p %.4f\" % (_w_stat, _w_p))\n", + "print(\" ancien critere (p < 0.05 seul) : %s\" % (_w_p < 0.05))\n", + "print(\" critere retenu (p < 0.05 ET stat < 0) : %s\" % (_w_p < 0.05 and _w_stat < 0))\n", + "assert _w_p < 0.05 and _w_stat > 0, \"le temoin doit etre significatif ET defavorable\"\n", + "assert not (_w_p < 0.05 and _w_stat < 0), \"le critere directionnel doit refuser ce cas\"\n", + "print(\"temoin negatif OK : significatif, defavorable, et refuse par le critere retenu.\")" + ] + }, + { + "cell_type": "markdown", + "id": "90f2a44e", + "metadata": {}, + "source": [ + "## 6. Lecture du verdict\n", + "\n", + "Le verdict ci-dessus est produit par la **conjonction** exigee par la doctrine §C, pas par une\n", + "impression. Trois cas, et ce qu'ils veulent dire :\n", + "\n", + "- **BEATS** - l'ecart au facteur fixe depasse 2 sigma inter-seeds **et** Diebold-Mariano est\n", + " significatif **et favorable** (statistique negative : l'evolue est plus precis). Une difference\n", + " significative mais defavorable n'ouvre pas cette branche : c'est le troisieme terme ajoute par la\n", + " cellule 5.3.\n", + "- **NO BEATS** - aucune des conditions ne tient. Resultat honnete, et le plus frequent sur un panier\n", + " de 10 actifs : l'evolution sur-ajuste le bloc d'entrainement.\n", + "- **INCONCLUSIVE** - une partie seulement de la conjonction tient. C'est le piege que la doctrine\n", + " nomme : un `+N sigma` avec un `p` non significatif n'est **pas** une victoire ; un `p`\n", + " significatif avec un signe defavorable n'en est pas une non plus.\n", + "\n", + "### Le verdict depend du facteur de reference - et la table le montre\n", + "\n", + "La cellule precedente publie la conjonction pour les **quatre** facteurs fixes, pas seulement pour\n", + "celui retenu : un verdict qui ne tient que sous un choix de reference n'est pas un verdict, c'est un\n", + "artefact de selection. Ici elle ne tient que pour `reversal`, qui est justement le **plus faible**\n", + "des quatre hors-echantillon - autrement dit, le seul facteur que le champion evolue bat est celui\n", + "dont la baseline est la moins bonne.\n", + "\n", + "La reference retenue est choisie sur l'**IC d'entrainement**, sans regarder la fenetre d'evaluation ;\n", + "sous ce choix, la conjonction echoue sur la jambe sigma et le verdict est `INCONCLUSIVE`. C'est le\n", + "verdict publie, et la table donne au lecteur de quoi le contester.\n", + "\n", + "### Le biais, separe de la precision\n", + "\n", + "Un modele peut « gagner » parce qu'il est **biaise** dans le bon sens, pas parce qu'il est plus\n", + "**precis**. La cellule suivante separe les deux, comme l'exige §C.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "1dbb67d0", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-09T11:41:01.916990Z", + "iopub.status.busy": "2026-10-09T11:41:01.916667Z", + "iopub.status.idle": "2026-10-09T11:41:01.991549Z", + "shell.execute_reply": "2026-10-09T11:41:01.990280Z" + } + }, + "outputs": [ + { + "data": { + "text/html": [ + "
\n", + "\n", + "\n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + " \n", + "
biais signe moyenecart-type erreur
modele
evolue (seed 0)-0.440.6584
fixe: lowvol-0.450.6323
\n", + "
" + ], + "text/plain": [ + " biais signe moyen ecart-type erreur\n", + "modele \n", + "evolue (seed 0) -0.44 0.6584\n", + "fixe: lowvol -0.45 0.6323" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "def bias_report(sig, target, te0, te1):\n", + " s = signal_to_rank(sig[te0:te1])\n", + " t = pd.DataFrame(target[te0:te1]).rank(axis=1, pct=True).values\n", + " pred = (s - 0.5) * 2.0\n", + " e = pred - t\n", + " return float(np.nanmean(e)), float(np.nanstd(e))\n", + "\n", + "\n", + "acc = []\n", + "for label, sig in [(\"evolue (seed 0)\", evaluate(champions[SEEDS[0]])),\n", + " (\"fixe: %s\" % best_fixed_name, best_fixed)]:\n", + " bs, ss = [], []\n", + " for _, _, te0, te1 in FOLDS[1:]:\n", + " b, s = bias_report(sig, fwd.values, te0, te1)\n", + " bs.append(b); ss.append(s)\n", + " acc.append({\"modele\": label, \"biais signe moyen\": np.nanmean(bs),\n", + " \"ecart-type erreur\": np.nanmean(ss)})\n", + "\n", + "pd.DataFrame(acc).set_index(\"modele\").round(4)" + ] + }, + { + "cell_type": "markdown", + "id": "4de6f5f4", + "metadata": {}, + "source": [ + "## 7. Exercices\n", + "\n", + "Trois exercices, dans l'ordre de difficulte. Ils ne bloquent pas l'execution du carnet : les\n", + "cellules ci-dessous s'executent telles quelles meme non completees.\n", + "\n", + "### Exercice 1 - enrichir le jeu de primitives\n", + "\n", + "Ajoutez un operateur `corr20` (correlation glissante 20 jours entre le signal courant et un\n", + "second signal) au dictionnaire `PRIMITIVES`, en respectant l'aritie 2. Verifiez que\n", + "`random_expr` peut le tirer et que `evaluate` l'evalue sans `None`.\n", + "\n", + "### Exercice 2 - changer la cible\n", + "\n", + "Le carnet predit le rendement du **lendemain**. Remplacez la cible par le rendement cumule sur\n", + "5 jours, puis relancez l'evolution. L'IC monte-t-il, et le verdict change-t-il ? Attention : la\n", + "fenetre de prediction plus longue reduit le nombre d'observations independantes.\n", + "\n", + "### Exercice 3 - mesurer la sur-ajustement\n", + "\n", + "Comparez l'IC **d'entrainement** du champion a son IC **hors-echantillon**. Un ecart large est la\n", + "signature du sur-ajustement. Proposez une modification du fitness qui penalise cet ecart." + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "1a0c9765", + "metadata": { + "execution": { + "iopub.execute_input": "2026-10-09T11:41:01.994023Z", + "iopub.status.busy": "2026-10-09T11:41:01.993826Z", + "iopub.status.idle": "2026-10-09T11:41:02.000173Z", + "shell.execute_reply": "2026-10-09T11:41:01.999021Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "cible 5 jours disponible : (1643, 10)\n", + "Exercices a completer : 1, 2, 3\n" + ] + } + ], + "source": [ + "# --- Exercice 1 : ajouter une primitive -------------------------------------\n", + "# TODO etudiant : definir corr20(x, y) puis l'enregistrer dans PRIMITIVES.\n", + "def corr20(x, y):\n", + " # Indice : pd.DataFrame(x).rolling(20).corr(pd.Series(...)) ne diffuse pas directement ;\n", + " # calculer la correlation glissante ligne a ligne sur les deux panneaux.\n", + " return None # TODO etudiant : remplacer par le calcul\n", + "\n", + "\n", + "# --- Exercice 2 : changer la cible ------------------------------------------\n", + "fwd_5 = close.pct_change(5).shift(-5).reindex(ret.index) # TODO etudiant : ne pas oublier l'anti-fuite\n", + "print(\"cible 5 jours disponible :\", fwd_5.shape)\n", + "\n", + "\n", + "# --- Exercice 3 : ecart train / hors-echantillon -----------------------------\n", + "def gap_train_oos(individual):\n", + " # TODO etudiant : rendre (IC train, IC OOS) du meme individu sur les memes blocs.\n", + " return None # TODO etudiant\n", + "\n", + "\n", + "print(\"Exercices a completer : 1, 2, 3\")" + ] + }, + { + "cell_type": "markdown", + "id": "0b07ad56", + "metadata": {}, + "source": [ + "## 8. Ce que ce carnet ne fait pas\n", + "\n", + "- **Pas de mini-boucle LLM.** La comparaison « evolution vs LLM » demande un acces modele et un\n", + " budget d'evaluation identique pour les deux approches ; elle est suivie separement (#20041).\n", + "- **Pas de backtest QuantConnect.** Le carnet mesure un signal de recherche (IC, portefeuille\n", + " long-short jouet) ; le passage a une strategie LEAN est un autre travail, avec ses couts.\n", + "- **Pas de couts de transaction** dans le Sharpe affiche : un alpha a fort turnover y perdrait\n", + " l'essentiel de son edge. C'est une limite **declaree**, pas un oubli.\n", + "\n", + "## References\n", + "\n", + "- Li, Yang, Yang, Xu, Wang, Liu, Bian - *R&D-Agent-Quant: A Multi-Agent Framework for Data-Centric\n", + " Factors and Model Joint Optimization*, Microsoft Research Asia, arXiv:2505.15155. Archive au\n", + " gisement : `G:\\Mon Drive\\MyIA\\IA\\Bibliographie IA\\Trading\\`.\n", + "- Serie `Search/Part1-Foundations/Search-05-GeneticAlgorithms.ipynb` - l'organe evolutionnaire natif.\n", + "- `.claude/rules/pr-review-discipline.md` §C - la doctrine de validation appliquee comme fitness :\n", + " walk-forward **5 blocs**, **4 seeds**, Diebold-Mariano sur une perte de precision (`mse`) et\n", + " **conjonction avec le signe**. Le carnet applique §C ; il ne pretend pas faire plus.\n", + "\n", + "Issue de suivi : #20041 (Part of #19306)." + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (CoursIA-2)", + "language": "python", + "name": "python3-coursia2" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.15" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/MyIA.AI.Notebooks/QuantConnect/Python/README.md b/MyIA.AI.Notebooks/QuantConnect/Python/README.md index e7c7dd3228..6de83ec676 100644 --- a/MyIA.AI.Notebooks/QuantConnect/Python/README.md +++ b/MyIA.AI.Notebooks/QuantConnect/Python/README.md @@ -82,6 +82,7 @@ Suite à l'audit du 5 mai 2026, voici l'état honnête de chaque notebook. **Auc | QC-Py-32-RL-DQN-Trading | EXÉCUTÉ | | | QC-Py-40-PaperTrading-Binance | EXÉCUTÉ | | | QC-Py-41-PaperTrading-IBKR | EXÉCUTÉ | | +| QC-Py-42-Alpha-Mining-Evolution | EXÉCUTÉ | cellules exécutées sur le panier crypto local (10 actifs ; fenêtre effective 2020-09 → 2025-03) — verdict hors-échantillon `INCONCLUSIVE` | | QC-Py-Cloud-01-FinBERT-Sentiment | EXÉCUTÉ | | | QC-Py-Cloud-02-ML-Classification | EXÉCUTÉ | | | QC-Py-Cloud-12-SectorRotation-Momentum | doc cloud | markdown-only — backtest sur QC Cloud | @@ -228,6 +229,22 @@ Approfondissement RL au-delà du DQN de la Phase 8 : PPO, SAC/A2C, application p | [QC-Py-40-PaperTrading-Binance](QC-Py-40-PaperTrading-Binance.ipynb) | Binance | | [QC-Py-41-PaperTrading-IBKR](QC-Py-41-PaperTrading-IBKR.ipynb) | Interactive Brokers | +## Recherche — alpha mining par évolution (QC-Py-42) + +Recherche *from scratch* : plutôt que de coder un facteur à la main, on **fait évoluer des +expressions alpha** (arbres d'opérateurs sur des features de base) avec `deap`, puis on les +confronte à une baseline de facteurs fixes. C'est le pont entre la série `Search/` +(algorithmes évolutionnaires) et la série `QuantConnect` (validation financière). + +| Notebook | Méthode | Ce qu'il démontre | +|----------|---------|-------------------| +| [QC-Py-42-Alpha-Mining-Evolution](QC-Py-42-Alpha-Mining-Evolution.ipynb) | Évolution génétique d'expressions (`deap`), walk-forward 5 blocs, 4 seeds, Diebold-Mariano à critère directionnel | Un moteur de recherche peut **produire** des alphas plausibles — et le verdict hors-échantillon dit s'il bat la baseline. Ici : non (`INCONCLUSIVE`), ce qui est le résultat, pas l'échec | + +Le carnet tourne sur les données **locales** du dépôt (panier crypto, 10 actifs) : aucune +dépendance réseau, exécution reproductible. Sa fonction de fitness applique la doctrine de +validation du dépôt (walk-forward, multi-seed, Diebold-Mariano sur une perte de précision) — +l'objectif pédagogique est autant la **méthode de validation** que le résultat. + ## Stratégies Cloud (QC-Py-Cloud-*) Notebooks de recherche et stratégies exécutées sur QuantConnect Cloud.