Hommage à Godric Bouteloup — projet M2, EPITA SCIA 2026. Son travail part d’une question financière concrète : peut-on suivre un indice avec peu de lignes tout en respectant budget, cardinalité, secteurs et évolution du portefeuille ? Le choix de lots entiers, de variables de sélection et d’un solveur CP-SAT donne une forme combinatoire explicite à une intuition souvent traitée uniquement par régression continue. Cette articulation entre finance et contraintes est le geste central que cette application conserve.
Un tracker sparse poursuit deux promesses qu’il faut séparer. La première est statistique : reproduire les rendements d’un benchmark hors échantillon. La seconde est opérationnelle : réduire le nombre de lignes détenues et, peut-être, les coûts. Une faible erreur sur la période qui a servi à choisir l’univers ne prouve pas la première ; une petite cardinalité ne prouve pas la seconde. Entre les deux se trouvent le protocole chronologique, le turnover réellement déplacé, les frais et le niveau de preuve du solveur.
Cette maturation s’appuie désormais sur une seule recherche, lue à deux niveaux :
App-27, lecture de méthode — données synthétiques à vérité connue, formulation CP-SAT, validateur indépendant, séparation calibration/validation/test, contre-protocole contaminé et certificats incumbent–borne–gap ;
Sparse-Index-Tracking-QC, épreuve du réel — backtests QC Cloud déjà fusionnés par la PR #14068, données réelles, frais de 5 bps et comparaison sparse/full sur 2015–2026.
Le notebook ne refait pas une seconde recherche de marché. Il cite l’artefact QuantConnect autoritatif, en reprend le protocole, les chiffres et le verdict, puis explique ce que ces résultats changent dans la lecture du modèle. Cette séparation évite deux erreurs symétriques : appeler « backtest » une expérience synthétique, ou importer dans le cours des chiffres réels sans appareil pour comprendre leurs garanties.
Objectifs d’apprentissage
À la fin du parcours, vous saurez :
distinguer sélection d’univers, calibration, validation et test futur ;
dériver un portefeuille sparse en lots entiers avec cardinalité, caps sectoriels et turnover temporel ;
lire FEASIBLE, incumbent, borne et gap sans fabriquer d’optimalité ;
valider budget, cardinalité, secteurs et turnover hors du modèle ;
séparer RMSE active, biais actif et tracking error annualisée ;
interpréter ensemble cardinalité, nombre d’ordres, turnover, frais et performance QC Cloud.
Le notebook est une réécriture CoursIA autonome : aucun code, texte, cellule, figure ou output étudiant n’est copié. Durée estimée : 75 min. Provenance détaillée : data/app27-sparse-index-tracking/SOURCE.md.
1. Construire un laboratoire à vérité connue
Une expérience financière rejouable doit préciser ce qu’elle simplifie. Un téléchargement vivant change avec les corrections de données, les symboles radiés et les fournisseurs ; une liste de constituants observée aujourd’hui introduit un biais de survivant si on la projette dix ans en arrière. Pour étudier d’abord le protocole, nous préférons un marché factoriel seedé dont toutes les règles sont visibles.
Le laboratoire contient 24 actifs répartis en quatre secteurs. Pour l’actif \(i\) du secteur \(s(i)\), le rendement quotidien combine un facteur marché \(m_t\), un facteur sectoriel \(f_{t,s(i)}\), une sensibilité \(\beta_i\) et un bruit idiosyncratique \(\varepsilon_{t,i}\) :
Le benchmark est une combinaison diversifiée des actifs, augmentée d’un petit bruit. À partir du jour 140, la volatilité de deux secteurs augmente. Ce changement de régime crée une vraie difficulté hors échantillon : les corrélations estimées avant le changement ne décrivent plus exactement le futur.
La graine fixe rend les sorties reproductibles ; elle ne rend pas le scénario représentatif de tous les marchés. Le but est plus modeste et plus contrôlable : vérifier que l’optimiseur ne voit jamais la règle cachée ni les rendements futurs, puis mesurer ce qui se passe lorsqu’un contre-protocole les consulte. L’épreuve réelle viendra de l’artefact QuantConnect déjà exécuté, pas d’une prétention réaliste attachée à ces données synthétiques.
from dataclasses import dataclassfrom math import sqrtfrom typing import Iterableimport numpy as npimport pandas as pdfrom ortools.sat.python import cp_modelSEED =20260901RNG = np.random.default_rng(SEED)print(f"Environnement prêt : numpy={np.__version__}, pandas={pd.__version__}, seed={SEED}")
Avant toute optimisation : fixer les invariants du marché
L’univers contient 24 actifs et 240 jours ouvrés. Les poids du benchmark sont tirés une fois, strictement positifs et normalisés. Les actifs partagent un facteur marché, mais les facteurs sectoriels et les bruits propres empêchent une réplication triviale.
Trois invariants doivent être lisibles avant d’interpréter un score :
la matrice de rendements a exactement la forme attendue ;
les poids du benchmark somment à 1 ;
le changement de régime est injecté dans la génération, jamais communiqué au solveur.
Cette distinction entre vérité du générateur et information disponible est fondamentale. Nous, lecteurs, savons que le régime change au jour 140 ; l’algorithme ne dispose que de fenêtres historiques. Utiliser cette connaissance pour choisir un actif ou une date serait déjà une fuite, même si aucun index de tableau ne dépassait explicitement la frontière du train.
Les poids benchmark servent ici à générer une cible connue, pas à fournir une solution au problème sparse. Le solveur reçoit uniquement les séries de rendements et doit reconstruire une combinaison en lots qui suit cette cible sous contraintes.
Le futur existe dans le générateur, pas dans la décision
Les sorties confirment 240 jours, 24 actifs, quatre secteurs et un benchmark pleinement investi. Ces nombres ne mesurent aucune performance ; ils certifient que l’expérience exécutée correspond au scénario annoncé.
Le changement de régime est particulièrement utile parce qu’il met à l’épreuve la chronologie. Une sélection calculée sur les 240 jours bénéficierait rétrospectivement d’informations sur les secteurs devenus volatils. Elle pourrait produire une meilleure métrique globale tout en étant inutilisable à la date de décision. À l’inverse, une sélection construite sur la seule calibration peut se dégrader dans le futur sans que le protocole soit fautif : c’est précisément ce qu’un test hors échantillon est chargé de révéler.
Ainsi, « sans fuite » ne signifie pas « meilleur score ». Cela signifie que le score conserve une interprétation prospective. Cette nuance guidera la section 6 : le protocole contaminé pourra paraître meilleur sur un bloc et moins bon sur un autre ; dans les deux cas, son accès au futur invalide l’estimation.
2. Trois métriques actives, trois questions différentes
Pour un portefeuille de poids \(w\), le rendement répliqué vaut \(r_{p,t}=\sum_i w_i r_{t,i}\). Le rendement actif est l’écart au benchmark :
\[a_t=r_{p,t}-r_{b,t}.\]
À partir de la même série \(a_t\), trois résumés répondent à des questions distinctes.
La RMSE pénalise à la fois la variabilité et un biais constant. Elle correspond à une lecture quadratique de la réplication et se compare naturellement à un objectif de moindres carrés.
Un biais négatif persistant signifie que le tracker sous-performe systématiquement la cible, même si les fluctuations autour de cet écart sont faibles.
Cette convention financière centre implicitement la série autour de sa moyenne. Elle mesure l’instabilité du rendement actif, pas sa distance quadratique totale. Une seule std ne peut donc représenter simultanément fidélité, direction et volatilité.
Le choix de métrique intervient à trois endroits : objectif d’optimisation, sélection de l’hyperparamètre et reporting final. Mélanger ces rôles peut faire paraître cohérents des nombres qui répondent en réalité à des questions différentes. App-27 publie les trois afin que la lecture reste possible même lorsque l’une d’elles est flatteuse.
Le piège du retard constant : TE nulle, réplication fausse
Le contre-exemple imprime rmse=0.001, bias=-0.001 et te_annualized=0. Le portefeuille fictif sous-performe le benchmark de 10 points de base chaque jour. Comme cet écart ne varie pas, son écart-type est nul ; pourtant, la réplication est systématiquement incorrecte.
à la convention près sur le dénominateur de la variance. Elle montre pourquoi RMSE et tracking error peuvent diverger : la première conserve le carré du biais, la seconde le retire par centrage.
Cette cellule ne dit pas que la tracking error est une mauvaise métrique. Elle dit qu’une métrique correctement nommée a un domaine de validité. La TE répond à « quelle est la volatilité de mon écart ? » ; elle ne répond pas seule à « mon portefeuille reproduit-il le niveau de rendement du benchmark ? ». Dans les résultats synthétiques puis QuantConnect, nous éviterons donc de transformer une seule colonne en verdict global.
Exercice 1 — Auditer une métrique active
Complétez audit_metric pour retourner RMSE, biais et tracking error annualisée, puis ajoutez un invariant qui détecte les erreurs de signe ou de dimension.
Questions d’interprétation :
Quel résultat attendez-vous pour un retard constant de 10 points de base ?
Peut-on reconstruire la RMSE à partir du biais et de la variance active ?
Quelle métrique choisiriez-vous pour sélectionner un modèle, et lesquelles publieriez-vous ensuite ?
Étapes suggérées : convertir les entrées en tableaux de même longueur, appeler active_metrics, vérifier que la RMSE est non négative et tester le cas constant. Le stub reste exécutable tant que la réponse n’est pas remplie.
def audit_metric(portfolio: np.ndarray, target: np.ndarray):# TODO étudiant : retourner les trois métriques et ajouter un invariant utile.returnNoneprint("Exercice 1 à compléter : audit des métriques actives")
Exercice 1 à compléter : audit des métriques actives
3. Dériver le modèle CP-SAT en lots entiers
Nous discrétisons le budget en 100 lots. Pour chaque actif \(i\), la variable entière \(q_i\in\{0,\ldots,100\}\) représente son nombre de lots et le booléen \(z_i\) indique sa sélection. Les poids traduits sont \(w_i=q_i/100\).
Budget et cardinalité exacte
Le portefeuille est pleinement investi :
\[\sum_i q_i=100.\]
La cardinalité exacte s’écrit
\[\sum_i z_i=K,\]
avec le couplage \(z_i\le q_i\le100z_i\). Ainsi, un actif sélectionné reçoit au moins un lot, et un actif non sélectionné en reçoit zéro. Sans la borne inférieure, le modèle pourrait compter des actifs sélectionnés mais de poids nul ; sans la borne supérieure, les lots pourraient contourner le booléen.
Concentration sectorielle
Pour chaque secteur \(s\), la charge est plafonnée à 45 lots :
\[\sum_{i:\,sector(i)=s}q_i\le45.\]
Cette contrainte ne garantit pas une diversification parfaite : deux secteurs peuvent dominer ensemble, et aucune borne individuelle n’est encore imposée. Elle matérialise seulement le contrat annoncé, que le validateur recalculera ensuite.
Turnover entre deux dates, pas entre deux hyperparamètres
Si \(q_i^{prev}\) désigne le portefeuille réellement détenu à la date précédente, le turnover aller simple est
La contrainte sum(absolute_changes) <= 2 * turnover_cap borne donc \(\tau\) à 30 %. Le facteur 2 vient du fait qu’une vente et un achat apparaissent tous deux dans la distance L1. Surtout, previous_lots n’est fourni qu’entre deux rebalancements chronologiques. Les candidats \(K=4,6,8\) d’une même date sont résolus indépendamment ; les relier transformerait l’ordre de la boucle d’hyperparamètres en pseudo-histoire de portefeuille.
Objectif L1 et scaling
Pour chaque jour de calibration, l’erreur entière approche
après multiplication des rendements par scale=100000. CP-SAT minimise \(\sum_t|e_t|\). Ce choix L1 est robuste et compatible avec les variables entières, mais il ne coïncide pas avec la RMSE utilisée ensuite pour sélectionner \(K\). Le notebook rend cette différence explicite au lieu de prétendre résoudre exactement un objectif quadratique.
Le solveur publie la meilleure solution trouvée, sa borne et le gap relatif. Un budget de temps court peut donc rendre une affectation valide avant d’avoir fermé sa preuve.
@dataclassclass SolveResult: weights: np.ndarray status: str objective: float bound: float gap: float wall_time: floatdef build_and_solve( x_train: np.ndarray, y_train: np.ndarray, asset_sectors: np.ndarray, k: int, previous_lots: np.ndarray |None=None, turnover_cap: int=30, time_limit: float=3.0,) -> SolveResult: scale =100_000 x_scaled = np.rint(x_train * scale).astype(int) y_scaled = np.rint(y_train * scale).astype(int) n = x_train.shape[1] model = cp_model.CpModel() lots = [model.new_int_var(0, 100, f"lot_{i}") for i inrange(n)] selected = [model.new_bool_var(f"selected_{i}") for i inrange(n)] model.add(sum(lots) ==100) model.add(sum(selected) == k)for i inrange(n): model.add(lots[i] >= selected[i]) model.add(lots[i] <=100* selected[i])for sector in np.unique(asset_sectors): model.add(sum(lots[i] for i inrange(n) if asset_sectors[i] == sector) <=45)if previous_lots isnotNone: absolute_changes = []for i inrange(n): change = model.new_int_var(0, 100, f"turnover_{i}") model.add_abs_equality(change, lots[i] -int(previous_lots[i])) absolute_changes.append(change) model.add(sum(absolute_changes) <=2* turnover_cap) errors = [] max_error =int(200* scale)for t inrange(len(y_scaled)): error = model.new_int_var(-max_error, max_error, f"error_{t}") model.add(error ==sum(int(x_scaled[t, i]) * lots[i] for i inrange(n)) -int(y_scaled[t]) *100) absolute_error = model.new_int_var(0, max_error, f"absolute_error_{t}") model.add_abs_equality(absolute_error, error) errors.append(absolute_error) model.minimize(sum(errors)) solver = cp_model.CpSolver() solver.parameters.max_time_in_seconds = time_limit solver.parameters.num_search_workers =1 solver.parameters.random_seed = SEED status_code = solver.solve(model)if status_code notin (cp_model.OPTIMAL, cp_model.FEASIBLE):raiseRuntimeError(f"CP-SAT n'a pas trouvé d'incumbent : {solver.status_name(status_code)}") weights = np.array([solver.value(v) for v in lots], dtype=int) objective =float(solver.objective_value) bound =float(solver.best_objective_bound) gap =0.0if objective ==0elsemax(0.0, (objective - bound) /abs(objective))return SolveResult(weights /100, solver.status_name(status_code), objective, bound, gap, solver.wall_time)smoke = build_and_solve(returns[:60], benchmark[:60], sectors, k=6, time_limit=2.0)print(f"Smoke CP-SAT : statut={smoke.status}, actifs={(smoke.weights >0).sum()}, somme={smoke.weights.sum():.2f}, gap={smoke.gap:.3%}")
Un portefeuille utilisable n’est pas un optimum démontré
Le smoke test retourne six actifs, une somme de poids égale à 1 et un statut FEASIBLE. L’affectation constitue donc un incumbent : le solveur a trouvé une solution qui respecte son modèle. Le gap de 83,679 % indique toutefois que la meilleure borne est encore loin de la valeur de cet incumbent sous le budget de deux secondes.
Pour une minimisation, la borne fournit un plancher sur l’objectif encore atteignable. Le couple s’interprète comme un intervalle :
FEASIBLE ne signifie ni « mauvais », ni « presque optimal ». Il signifie que la recherche a été interrompue avec une solution mais sans certificat de fermeture. La taille du gap dépend aussi du scaling et de l’objectif L1 ; elle ne mesure pas directement l’écart de RMSE hors échantillon. Un rerun sur une autre version ou charge machine peut améliorer l’incumbent ou la borne sans changer le modèle : c’est pourquoi le notebook publie les sorties fraîches au lieu de figer une qualification historique.
Le résultat peut néanmoins être tradé ou évalué si le validateur indépendant confirme ses contraintes. C’est une distinction importante pour les problèmes financiers : faisabilité opérationnelle, qualité statistique et optimalité du modèle sont trois axes différents. Les prochains tableaux les maintiennent séparés.
4. Valider hors du solveur — du poids affiché au contrat métier
Le solveur certifie les contraintes encodées, pas celles que le texte voulait exprimer. Une erreur de facteur 100, une mauvaise table de secteurs ou une extraction incorrecte peut produire FEASIBLE ou OPTIMAL pour un objet qui ne respecte pas le contrat financier.
validate_portfolio repart donc du seul vecteur de poids et recalcule :
budget — la somme vaut 1 à la tolérance numérique près ;
cardinalité — exactement \(K\) poids sont strictement positifs ;
cap sectoriel — chaque somme sectorielle reste sous 45 % ;
turnover — la demi-distance L1 au portefeuille précédent ne dépasse pas 30 %.
Le validateur ignore volontairement les variables lots, selected, les contraintes CP-SAT et le statut. Cette indépendance est limitée — il réutilise les mêmes données de secteurs et le même contrat — mais elle protège contre une classe différente d’erreurs : mauvaise liaison booléen–lot, mauvais scaling, solution extraite sur le mauvais univers ou confusion entre distance L1 et turnover aller simple.
Une assertion bloque immédiatement l’interprétation si un invariant échoue. Dans un pipeline réel, on conserverait aussi le détail des charges sectorielles, le nombre de lots et les identifiants d’actifs afin qu’un audit puisse reproduire le verdict sans relancer le solveur.
La sortie confirme quatre propriétés distinctes : budget exact, six actifs, cap sectoriel respecté et turnover nul. Le dernier résultat est normal au premier portefeuille : aucune position antérieure n’existe, donc aucune rotation ne peut encore être mesurée.
Ce zéro ne doit pas entrer dans une moyenne de turnover comme une observation comparable aux suivantes sans explication. Il représente une condition initiale, pas un rebalancement gratuit. À partir de la deuxième date, le validateur comparera deux vecteurs complets sur le même univers global et vérifiera le plafond de 30 %.
Le statut solveur n’apparaît pas dans ce dictionnaire, et c’est intentionnel. valid=True répond à « l’objet extrait respecte-t-il le contrat ? » ; OPTIMAL ou FEASIBLE répond à « que sait-on de son objectif dans le modèle ? ». Une solution peut être valide et non optimale, ou optimale dans un modèle dont le validateur révèle une incohérence. Il faut les deux lignes de preuve.
Exercice 2 — Ajouter une borne individuelle sans casser le contrat
Ajoutez un plafond \(w_i\le20\%\) au validateur, puis au modèle en lots entiers.
Questions d’interprétation :
Pourquoi faut-il écrire le test du validateur avant d’ajouter la contrainte au solveur ?
Le plafond individuel rend-il le cap sectoriel redondant ?
Comment la faisabilité évolue-t-elle lorsque \(K\) est petit et le plafond serré ?
Étapes suggérées : vérifier weights.max() <= cap, construire un vecteur contre-exemple, traduire 20 % en 20 lots et ajouter lots[i] <= 20. Déduisez aussi une condition nécessaire simple : avec \(K\) actifs et un plafond \(c\), il faut \(Kc\ge1\) pour investir tout le budget.
Le stub retourne None et reste sûr à exécuter ; il ne contient pas la solution.
def validate_individual_cap(weights: np.ndarray, cap: float=0.20):# TODO étudiant : retourner True si tous les poids respectent cap.returnNoneprint("Exercice 2 à compléter : cap individuel")
Exercice 2 à compléter : cap individuel
5. Walk-forward — faire circuler l’information dans un seul sens
Une évaluation chronologique doit représenter ce qui était connaissable à chaque date. Pour chaque début de test \(t\), App-27 construit trois fenêtres :
calibration : jours \(t-80\) à \(t-20\), utilisée pour classer l’univers et résoudre les candidats ;
validation : jours \(t-20\) à \(t\), utilisée une seule fois pour choisir \(K\) ;
test : jours \(t\) à \(t+20\), évalué après que toutes les décisions sont figées.
Le protocole suit alors cinq étapes.
Classer les actifs par corrélation avec le benchmark sur la calibration seulement.
Ajouter les actifs déjà détenus à l’univers local, afin qu’une contrainte de turnover n’oblige pas à vendre un titre simplement parce qu’il est sorti du classement.
Résoudre indépendamment chaque \(K\in\{4,6,8\}\) sur la calibration et mesurer sa RMSE sur la validation.
Choisir \(K\) avant le test, puis recalibrer ce \(K\) sur tout l’historique disponible jusqu’à \(t\) avec le portefeuille précédent.
Évaluer une seule fois le bloc futur et archiver statut, gap, turnover, RMSE, biais et TE.
Le premier portefeuille n’a pas d’antécédent. Les suivants sont reliés dans le temps par previous_full. Cette mémoire du portefeuille est différente d’une mémoire d’algorithme : elle représente les positions détenues, donc le coût métier du passage d’une décision à la suivante.
Quatre blocs restent un exemple pédagogique, pas une étude statistique. Ils suffisent cependant à rendre auditable l’absence de chevauchement et à montrer qu’un résultat hors échantillon peut changer lorsque le régime évolue.
Quatre futurs disjoints, trois preuves fermées et un incumbent borné
Chaque ligne correspond à un bloc test de 20 jours qui n’a servi ni au classement de l’univers ni au choix de \(K\). Les dates 100, 130, 160 et 190 laissent en outre un espace de dix jours entre tests : aucun rendement futur n’est recyclé comme score voisin.
Le premier portefeuille choisit \(K=8\) et affiche un turnover nul par définition. Les trois rebalancements suivants atteignent chacun 30 % : la contrainte de turnover est donc saturée, mais reste respectée. Cette saturation est une information de résultat, pas une propriété générale du modèle ; un autre marché ou un autre budget solveur pourrait conduire à des transitions plus petites.
Les trois premières optimisations se terminent OPTIMAL, avec une borne égale à l’incumbent et un gap nul. La dernière s’arrête FEASIBLE avec un gap de 31,41 % : son portefeuille est valide, mais son optimalité globale n’est pas démontrée. Les RMSE test varient de 0,00125 à 0,00186 et les TE annualisées de 2,030 % à 2,984 %. La meilleure RMSE future n’identifie pas nécessairement le meilleur objectif de calibration ; seul le certificat CP-SAT ferme la preuve du modèle résolu.
Avec quatre blocs et une seed, on ne classe pas ces valeurs comme une stratégie réelle. Le résultat fort est structurel : pour chaque ligne, la décision précède le test, le turnover relie deux dates, la faisabilité est revérifiée et le niveau de preuve est publié. Le rerun frais montre aussi pourquoi ces quatre axes doivent être relus après chaque exécution plutôt que décrits depuis une ancienne capture.
6. Reproduire la fuite — un score invalide ne devient pas toujours meilleur
Pour rendre la contamination visible, le contre-protocole commet délibérément deux fautes : il classe l’univers avec les données disponibles jusqu’à la fin du test, puis il essaie chaque \(K\) sur ce même test et conserve le meilleur. La frontière d’information est donc franchie avant le reporting.
Ce protocole n’est pas une baseline concurrente. Une baseline doit être exécutable à la date de décision. Ici, la fonction utilise x[:test_start + 20] pour choisir l’univers et la RMSE du bloc test pour choisir \(K\). Son rôle est celui d’un témoin positif : montrer par le code la forme d’une fuite que l’on veut détecter.
On pourrait s’attendre à ce que regarder le futur améliore toujours le score. Ce n’est pas garanti sur un petit échantillon : le classement contaminé peut favoriser des actifs dont la corrélation globale ne produit pas le meilleur portefeuille sous contraintes, et l’optimisation L1 sur le train ne coïncide pas avec la RMSE test. La faute méthodologique ne se diagnostique donc pas au signe du gain apparent.
Le bon test est causal et chronologique : une information postérieure à la décision a-t-elle influencé la décision ? Si oui, le score ne mesure plus une performance prospective, même s’il est inférieur, supérieur ou identique à celui du walk-forward.
Sur le premier bloc, apparent_gain=+0.00011 : le protocole contaminé paraît marginalement meilleur. Sur les trois autres, le signe est négatif et le walk-forward fait mieux malgré son information plus pauvre. La moyenne vaut -0,00036.
Il serait tentant d’en conclure que la fuite « n’est pas grave » puisqu’elle ne gagne presque jamais ici. Ce serait inverser la logique. Une estimation hors échantillon est utile parce que sa procédure pourrait être appliquée avant de connaître la réponse. Le contre-protocole perd cette propriété dès qu’il regarde le test ; son score devient ininterprétable, pas simplement optimiste.
L’effet moyen sur une seed n’est pas un estimateur général de l’optimisme. Pour quantifier une distribution, il faudrait répéter la génération, faire varier l’intensité du changement de régime et conserver tous les runs — y compris ceux où la contamination paraît défavorable. C’est l’objet de l’exercice suivant.
La même discipline s’applique aux résultats QC Cloud : ils ne sont pas contaminés de cette manière, mais leurs hyperparamètres et leur univers fixe limitent la portée de l’inférence. Une procédure propre n’élimine pas les limites du jeu de données ; elle permet de les nommer correctement.
Exercice 3 — Transformer l’exemple guide en distribution
Faites varier l’amplitude du changement de régime et répétez plusieurs seeds. Pour chaque run, calculez la moyenne des apparent_gain et la proportion de blocs où le protocole contaminé paraît meilleur.
Questions d’interprétation :
Une moyenne positive suffit-elle à quantifier le biais de sélection ?
Pourquoi faut-il conserver aussi les quantiles et le nombre de runs infaisables ?
Comment séparer l’effet du régime, du solveur limité en temps et de la fuite ?
Étapes suggérées : factoriser la génération du marché, passer la seed en argument, exécuter les deux protocoles sur les mêmes données, stocker tous les écarts, puis rapporter médiane, intervalle interquartile et fréquence de signe positif. Ne choisissez pas après coup les seeds qui illustrent le récit attendu.
Le stub est volontairement sûr et incomplet : il retourne None sans interrompre le notebook.
def stress_regime(seeds: Iterable[int]):# TODO étudiant : régénérer le marché pour chaque seed et agréger l'écart.returnNoneprint("Exercice 3 à compléter : stress test multi-seeds")
Exercice 3 à compléter : stress test multi-seeds
7. Épreuve QuantConnect — la cardinalité compresse les lignes, pas nécessairement les coûts
Le projet Sparse-Index-Tracking-QC constitue l’épreuve réelle autoritative de cette recherche. Il utilise 40 grandes capitalisations US et SPY, du 1er janvier 2015 au 31 août 2026, avec rebalancement trimestriel et frais explicites de 5 points de base par transaction. À chaque date, 252 jours servent à la calibration, 63 jours à la validation et le trimestre suivant au test hors échantillon.
Le moteur est volontairement différent d’App-27. Le projet QC classe une short-list de 12 actifs par corrélation sur la calibration, énumère exactement chaque sous-ensemble de taille \(K\in\{6,8,10\}\)dans cette short-list, résout ses poids par NNLS et choisit \(K\) sur validation. La preuve est donc locale à la short-list ; son filtre de corrélation reste heuristique. Le mode full applique NNLS aux 40 candidats. Ce choix borné rend le backtest cloud reproductible ; il ne prétend ni aux bornes globales ni aux contraintes sectorielles et de turnover dur du laboratoire CP-SAT.
Indicateur QC Cloud
Sparse
Full
Ordres
703
1 414
Sharpe
0,611
0,698
CAGR
17,003 %
17,258 %
Drawdown maximal
34,7 %
30,7 %
Profit net
525,241 %
541,372 %
PSR
3,749 %
7,429 %
La simulation yfinance du même protocole complète la lecture opérationnelle : sparse produit une RMSE active de 0,455 % par jour, une TE annualisée de 7,22 % et 139,5 bps de frais cumulés, contre 0,202 %, 3,20 % et 58,0 bps pour full. La cardinalité moyenne est 9,2 contre 32,1 actifs.
Verdict : moins d’ordres ne signifie pas moins de turnover
Sparse émet environ deux fois moins d’ordres, mais concentre davantage de poids sur chaque ligne. Lors d’un changement de sélection, cette masse doit migrer ; la demi-distance L1 peut donc augmenter malgré un nombre d’ordres plus faible. Sur ce snapshot, sparse ne domine pas full : Sharpe et CAGR sont légèrement inférieurs, le drawdown plus profond, et les frais simulés plus élevés.
Ce résultat réfute ici l’hypothèse simple « moins de titres implique moins de coûts ». Il ne prouve pas que la cardinalité est inutile en général. Une sparse tracking peut répondre à des coûts fixes par ligne, à un mandat opérationnel ou à un univers beaucoup plus large et bruité. Les PSR de la plateforme évaluent chaque Sharpe séparément ; elles ne constituent pas un test pairé de la différence. La hiérarchie reste descriptive sous univers fixe, biais de survivant, absence de slippage et grille \(K\) prédéfinie.
App-27 ne relance donc pas une seconde recherche de marché. Il dérive les mécanismes qui permettent de lire cette épreuve réelle : frontière temporelle, validateur, certificat, métriques actives et distinction entre nombre de lignes, ordres, turnover et coûts.
Exercice 4 — Décomposer la promesse de coût
À partir d’une suite de portefeuilles, calculez séparément le nombre de lignes détenues, le nombre d’ordres, le turnover aller simple et un coût composé d’une partie fixe par ordre et d’une partie proportionnelle au notionnel déplacé.
Questions d’interprétation :
Pour quelle structure de frais sparse peut-il devenir moins coûteux malgré un turnover supérieur ?
Deux portefeuilles de même cardinalité ont-ils nécessairement le même nombre d’ordres ?
Pourquoi une no-trade band n’est-elle pas équivalente à une contrainte dure de turnover ?
Étapes suggérées : définir \(C=c_f N_{ordres}+c_p\tau\), balayer plusieurs rapports \(c_f/c_p\), puis localiser le seuil où le classement sparse/full s’inverse. Publiez les unités et distinguez coût simulé, frais réellement appliqués par QC et slippage non modélisé.
Cet exercice ne demande pas de refaire le backtest Cloud : il apprend à lire ses agrégats sans confondre trois mécanismes opérationnels.
def decompose_costs( previous_weights: np.ndarray, target_weights: np.ndarray, fixed_cost_per_order: float, proportional_cost: float,):# TODO étudiant : retourner lignes, ordres, turnover et coût composé.returnNoneprint("Exercice 4 à compléter : décomposition ordres / turnover / coûts")
8. Bilan critique — quatre niveaux de preuve et leur coût
Claim
Preuve disponible
Coût de la garantie
Limite
Portefeuille synthétique faisable
validateur indépendant + assertions
recalcul après chaque résolution
même contrat et mêmes secteurs que le modèle
Cardinalité exacte
booléens liés aux lots et validation des poids
variables discrètes, recherche combinatoire
granularité de 100 lots
Turnover temporel
demi-distance L1 entre dates successives
mémoire des positions et univers conservant les titres détenus
quatre rebalancements synthétiques
Test sans fuite
fenêtres calibration/validation/test explicites
données futures mises en quarantaine
une seed dans l’exemple guide
Qualité solveur
statut, incumbent, borne, gap
budget CPU et instrumentation
trois runs OPTIMAL, un run FEASIBLE à 31,41 % de gap
Effet d’une contamination
contre-protocole exécuté sur les mêmes blocs
construction volontaire d’un témoin invalide
signe non généralisable
Épreuve réelle
deux backtests QC Cloud et simulation documentée
données, moteur, frais et période figés
univers biaisé de survivant, pas de slippage
Comparaison sparse/full
protocole commun et indicateurs côte à côte
deux modes réellement exécutés
hiérarchie descriptive, pas test pairé
Le coût des garanties n’est pas seulement du temps solveur. Séparer validation et test réduit les données disponibles pour calibrer ; conserver les actifs détenus complexifie l’univers local ; publier borne et gap impose de ne pas résumer un run par son seul portefeuille ; un validateur indépendant du modèle duplique une partie de la logique métier. Ces coûts achètent de l’interprétabilité et empêchent les conclusions de dépasser les preuves.
Hommage — une intuition combinatoire qui survit à la maturation
Le projet de Godric Bouteloup a fourni l’intuition structurante : un tracker sparse n’est pas uniquement une régression avec beaucoup de zéros. Les lots, la sélection booléenne, la cardinalité, les secteurs et la transition entre portefeuilles forment un problème de contraintes. App-27 conserve ce geste et le place dans une chaîne de preuve chronologique ; l’épreuve QuantConnect montre ensuite ce que cette intuition produit lorsqu’elle rencontre des prix réels et des frais.
La maturation ne transforme pas les limites du protocole source en jugement sur son auteur. Elle sépare au contraire trois contributions : le geste étudiant, la reconstruction pédagogique synthétique et le test QuantConnect autoritatif. Aucun code, texte, cellule, figure ou résultat étudiant n’est recopié ; les renforcements CoursIA sont une ré-implémentation autonome et attribuée.
Conclusion — une recherche, deux lectures, aucun verdict magique
Le laboratoire synthétique montre que l’on peut construire et valider un portefeuille en lots avec cardinalité exacte, caps sectoriels et turnover entre dates, tout en publiant honnêtement FEASIBLE, OPTIMAL, incumbent, borne et gap. Le walk-forward garantit l’ordre de l’information ; le contre-protocole montre qu’une fuite reste invalide même lorsqu’elle dégrade le score ; RMSE, biais et TE empêchent une métrique unique de masquer les autres dimensions. Les budgets courts ferment trois des quatre preuves fraîches ; le dernier run conserve un incumbent FEASIBLE à 31,41 % de gap. Cette différence de certificat fait partie du résultat et ne doit pas être lissée par le récit.
L’épreuve réelle complète cette lecture sans être réinventée. Sur 2015–2026, sparse réduit les ordres de 1 414 à 703, mais ne domine pas full en Sharpe, CAGR, drawdown ou frais simulés. La cardinalité compresse le nombre de lignes ; elle ne garantit ni faible turnover, ni moindre coût, ni meilleure réplication. Sa valeur dépend de la structure des frais, du mandat et de l’univers.
Le résultat transférable n’est donc pas un portefeuille ni une promesse de surperformance. C’est une méthode de lecture : dater l’information, nommer l’objectif, valider les contraintes hors du solveur, publier le niveau de preuve et confronter l’intuition combinatoire à une épreuve réelle dont les limites restent visibles.