From 33ae3aa46c60126c6470a43720e9b0f5c84b6bad Mon Sep 17 00:00:00 2001 From: Your Date: Sun, 4 Oct 2026 21:56:55 +0200 Subject: [PATCH] feat(qc,#18904): Four-Sleeve 774 reimplementation and its benchmarks, verdict INCONCLUSIVE Declared reimplementation of Strategy Explorer 774 (no source readable), with the SPY / 60-40 references and two proxy baskets in a companion project. Verdict pre-registered on #18904: INCONCLUSIVE against both references (Holm p 0.513). Results and per-run table in the README. main.py files are byte-identical to what ran on QC (sha256 9b4ff36f..., 8c4088c5...). Part of #18904. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../projects/FourSleeve774/README.md | 129 ++++++ .../projects/FourSleeve774/config.json | 3 + .../projects/FourSleeve774/main.py | 366 ++++++++++++++++++ .../FourSleeve774Benchmarks/config.json | 3 + .../projects/FourSleeve774Benchmarks/main.py | 84 ++++ 5 files changed, 585 insertions(+) create mode 100644 MyIA.AI.Notebooks/QuantConnect/projects/FourSleeve774/README.md create mode 100644 MyIA.AI.Notebooks/QuantConnect/projects/FourSleeve774/config.json create mode 100644 MyIA.AI.Notebooks/QuantConnect/projects/FourSleeve774/main.py create mode 100644 MyIA.AI.Notebooks/QuantConnect/projects/FourSleeve774Benchmarks/config.json create mode 100644 MyIA.AI.Notebooks/QuantConnect/projects/FourSleeve774Benchmarks/main.py diff --git a/MyIA.AI.Notebooks/QuantConnect/projects/FourSleeve774/README.md b/MyIA.AI.Notebooks/QuantConnect/projects/FourSleeve774/README.md new file mode 100644 index 0000000000..39efa62188 --- /dev/null +++ b/MyIA.AI.Notebooks/QuantConnect/projects/FourSleeve774/README.md @@ -0,0 +1,129 @@ +# Four-Sleeve 774 (réimplémentation déclarée) + +Évaluation, sous frais de courtier, de la stratégie publique **774** « Four-Sleeve +Adaptive Growth Strategy - Cash account » du Strategy Explorer QuantConnect (auteur +affiché : sanchari, v1.0.1 du 28/09/2026). Protocole : issue +[jsboige/CoursIA#18904](https://github.com/jsboige/CoursIA/issues/18904). Discussion +publique de la fiche : [forum QuantConnect, discussion 21464](https://www.quantconnect.com/forum/discussion/21464/). +Projets compagnons : [`FourSleeve774Benchmarks`](../FourSleeve774Benchmarks/) (références +de comparaison et paniers proxys de corrélation). + +## Pourquoi une réimplémentation + +Le projet publié (`37078429`) n'est **pas lisible** par le compte de la flotte +(`read_project` : « You do not own this project ») et la discussion 21464 ne contient pas +de code. Aucune ligne du code d'origine n'est donc reprise : `main.py` est écrit d'après +la **description publique** de la fiche, lue par l'API `POST /api/v2/strategies/read`. +Chaque point que cette description laisse ouvert est tranché ci-dessous et **déclaré**. +Un écart entre nos chiffres et ceux de la fiche peut donc venir de ces choix, et pas +seulement de la stratégie : il est rapporté, il ne sert pas de verdict. + +## Ce que dit la description publique + +Quatre poches dans un seul compte sans marge, exposition brute visée de 98 %, plafond +de 30 % par titre, aucun levier, actions entières, réserve de liquidités de 5 % : + +| Poche | Part | Description publiée | +|-------|------|---------------------| +| 1 | 35 % | retour à la moyenne court terme parmi les 100 plus grandes actions US, jusqu'à 10 titres, rang des plus bas récents, choisis chaque mois | +| 2 | 25 % | momentum filtré par la tendance (cours au-dessus de l'EMA 189 jours, ADX sous 35), rendements multi-horizons pondérés, jusqu'à 10 titres ; bons du Trésor quand le stress de largeur dépasse 45 % | +| 3 | 25 % | momentum des grandes capitalisations (≥ 5 Md$), mêmes conditions de tendance, nombre de lignes divisé par deux en marché agité | +| 4 | 15 % | ETF tactiques, évalués chaque jour : 70 % de rotation à règles entre actions, obligations, or et ETF inverses ; 30 % de régime de tendance Nasdaq avec dimensionnement par le VIX | + +Signaux sur barres journalières closes, ordres au marché à l'ouverture de la séance +suivante, règlement T+1, coupe de la dérive chaque jour, relance des ordres différés, +dimensionnement par la volatilité. + +## Choix déclarés + +| Point laissé ouvert | Choix de cette réimplémentation | +|---------------------|---------------------------------| +| Univers | 500 plus grandes capitalisations US (prix > 5 $, volume en dollars > 5 M$), revu le premier jour de bourse du mois | +| Stress de largeur | part des 500 titres dont le cours est sous leur EMA (`ema`, 189 par défaut) | +| Poche 1 : rang des plus bas | les 10 titres des 100 plus grandes capitalisations dont le cours est le plus proche de son plus bas des 20 dernières séances | +| Poche 2 : rendements pondérés | moyenne simple des rendements 3, 6 et 12 mois ; 10 meilleurs parmi les titres au-dessus de leur EMA avec ADX 14 sous `adx_max` ; toute la poche en bons du Trésor si le stress dépasse `stress_max` ; une place non pourvue va aux bons du Trésor | +| Poche 3 : marché agité | « agité » = stress au-dessus de `stress_max` ; momentum 12 mois hors dernier mois ; 10 positions, 5 en marché agité (l'autre moitié en bons du Trésor) | +| Dimensionnement dans une poche | inverse de la volatilité des rendements journaliers sur 63 séances (`sizing=invvol`) ; poids égaux en variante (`sizing=equal`) | +| Bons du Trésor | ETF `BIL` (exempt du plafond de 30 %) | +| Poche 4 : rotation (70 %) | SPY au-dessus de sa moyenne 200 jours → QQQ, ou `BIL` si le RSI 10 de QQQ dépasse 80 ; sinon RSI 10 de QQQ sous 30 → QQQ ; sinon QQQ sous sa moyenne 20 jours → PSQ (inverse) ; sinon le meilleur de TLT et GLD sur 21 séances s'il est positif, `BIL` sinon | +| Poche 4 : tendance Nasdaq (30 %) | QQQ au-dessus de sa moyenne 100 jours → QQQ à hauteur de min(1, 20 / VIX), arrondi par paliers de 0,25 ; le reste en `BIL` | +| ETF à effet de levier | **écartés** (la description parle de « zero leverage ») | +| Réserve de 5 % | appliquée au dimensionnement : chaque ligne vise 95 % de sa cible, soit environ 93 % investis | +| Ordres | événement 20 minutes avant l'ouverture, ordres `market_on_open` ; ventes d'abord ; achats limités à 97 % des liquidités réglées, réduits au prorata ; la part non servie repart à la séance suivante (relance différée) ; ordre minimal 0,2 % du portefeuille | +| Coupe de la dérive | une ligne au-delà de 1,25 fois sa cible est ramenée à la cible (ventes seulement, pas de complément en cours de mois) | +| Rebalancement | poches 1 à 3 le premier jour de bourse du mois, poche 4 chaque séance ; un ordre ne part que pour une ligne dont la cible a changé ou qui a dérivé | +| Frais et compte | modèle de frais par défaut de Lean pour ce courtier, compte sans marge, règlement T+1 des actions | + +## Paramètres de backtest + +| Paramètre | Défaut | Rôle | +|-----------|--------|------| +| `start`, `end` | aucun (obligatoires) | fenêtre, contrat du rejeu en ombre (#18923) | +| `fee_mult` | 1 | multiplicateur des frais (2 = frais doublés) | +| `ema` | 189 | longueur de l'EMA du filtre de tendance et du stress | +| `adx_max` | 35 | seuil d'ADX du filtre de tendance | +| `stress_max` | 0.45 | seuil de stress de largeur | +| `sizing` | `invvol` | `invvol` ou `equal` | +| `sleeve` | `all` | `all`, ou `1`, `2`, `3`, `4` : une poche seule, portée à tout le portefeuille, plafond par ligne mis à la même échelle | + +## Sorties + +Contrat du rejeu en ombre (`ML-Training-Pipeline/shadow/README.md`) : à chaque clôture, +valeur du portefeuille dans le graphique `shadow` (séries `e0` à `e4` à tour de rôle), +frais cumulés (`fees`, en fraction du capital de départ) et rotation cumulée (`turnover`). +Les mesures (Sharpe à taux sans risque nul, CAGR, pire baisse, rotation annuelle) se +calculent sur ces séries, pas sur les statistiques du rapport QuantConnect. + +## Résultats + +Règle de verdict pré-enregistrée sur #18904 avant le premier backtest. La grille de +7 runs a été retirée avant son premier run (complément daté sur l'issue) : le test +principal excluait déjà `BEATS`, elle ne pouvait donc plus changer le verdict. + +### Verdict : `INCONCLUSIVE` contre les deux références + +Fenêtre 2018-01-01 → 2026-09-25, 2 194 rendements journaliers alignés. Bootstrap +circulaire par blocs de 21 séances, 10 000 tirages, correction de Holm. + +| Comparaison | Différence de Sharpe | p Holm | IC 95 % | Verdict | +|-------------|----------------------|--------|---------|---------| +| 774 − SPY détenu | +0,193 | 0,513 | [−0,357 ; 0,699] | `INCONCLUSIVE` | +| 774 − 60/40 SPY/IEF | +0,163 | 0,513 | [−0,411 ; 0,706] | `INCONCLUSIVE` | + +La différence reste positive à frais doublés et sur 2 des 3 sous-périodes (négative +sur 2021-2023), contre les deux références. + +### Mesures par run + +Sharpe à taux sans risque nul. Frais annuels en % du capital de départ. + +| Run | Sharpe | CAGR | Pire baisse | Rotation / an | Frais / an | Ordres | +|-----|--------|------|-------------|---------------|------------|--------| +| 774 | 0,978 | 17,31 % | −21,51 % | 16,3 | 0,71 % | 5 971 | +| 774, frais doublés | 0,955 | 16,82 % | −21,58 % | 16,2 | 1,42 % | 5 967 | +| SPY détenu | 0,785 | 13,92 % | −33,61 % | 0,11 | 0,00 % | 1 | +| 60/40 SPY/IEF | 0,815 | 8,87 % | −21,19 % | 0,32 | 0,02 % | 173 | +| Poche 1 seule | 0,347 | 4,36 % | −35,64 % | 18,9 | 0,35 % | 2 810 | +| Poche 1 seule, sans frais | 0,367 | 4,70 % | −35,62 % | 18,9 | 0,00 % | 2 817 | +| Poche 2 seule | 0,923 | 25,27 % | −33,97 % | 12,2 | 0,32 % | 1 855 | +| Poche 3 seule | 0,921 | 26,38 % | −33,05 % | 11,0 | 0,30 % | 1 914 | +| Poche 4 seule | 0,976 | 15,70 % | −20,04 % | 21,4 | 0,22 % | 949 | + +Ce que ces runs montrent : + +- **La poche 1 est la plus faible**, alors qu'elle a la plus grosse part (35 %), et + elle le reste sans frais : les frais ne lui coûtent que 0,34 point de CAGR. Les + poches 2 et 3 portent le rendement ; le mélange ramène la pire baisse à −21,5 %. +- **Les frais suivent le nombre d'ordres.** Environ 680 ordres par an, à 0,10 point de + base du capital de départ en moyenne, à peu près le minimum par ordre du modèle de + frais : le même nombre d'ordres pèse plus lourd, en proportion, sur un capital plus + petit. +- **Écart à la fiche.** Sur la fenêtre de 5 ans de la fiche (2021-09-28 → 2026-09-25), + CAGR 18,61 % contre 31,8 % affichés, pire baisse −19,29 % contre −22,9 %. L'écart + n'est pas attribuable sans le code d'origine. +- **Corrélations hebdomadaires** avec les paniers proxys du projet compagnon : 0,69 + (`vt2`) et 0,57 (`aw`) sur la fenêtre principale, où leur Sharpe (1,048 et 1,033) + dépasse celui de la 774. + +Détail complet (sous-périodes, ordres refusés, version du code, chemin des séries) : +[commentaire de verdict sur #18904](https://github.com/jsboige/CoursIA/issues/18904#issuecomment-5983808875). diff --git a/MyIA.AI.Notebooks/QuantConnect/projects/FourSleeve774/config.json b/MyIA.AI.Notebooks/QuantConnect/projects/FourSleeve774/config.json new file mode 100644 index 0000000000..fa61d8190f --- /dev/null +++ b/MyIA.AI.Notebooks/QuantConnect/projects/FourSleeve774/config.json @@ -0,0 +1,3 @@ +{ + "local-id": 37353263 +} diff --git a/MyIA.AI.Notebooks/QuantConnect/projects/FourSleeve774/main.py b/MyIA.AI.Notebooks/QuantConnect/projects/FourSleeve774/main.py new file mode 100644 index 0000000000..0b64f12a65 --- /dev/null +++ b/MyIA.AI.Notebooks/QuantConnect/projects/FourSleeve774/main.py @@ -0,0 +1,366 @@ +# region imports +from AlgorithmImports import * +# endregion +# Reimplementation declaree de la strategie 774 du Strategy Explorer QuantConnect, +# « Four-Sleeve Adaptive Growth Strategy - Cash account » (auteur : sanchari, +# v1.0.1 du 28/09/2026). Le code source du projet publie n'est pas lisible depuis +# ce depot : tout ce qui suit est ecrit d'apres la description publique de la +# fiche et de la discussion 21464. Chaque point que la description laisse ouvert +# est un choix declare (README.md, table « Choix declares »), pas une lecture du +# code d'origine. Evaluation : issue #18904. +# +# Contrat du rejeu en ombre (#18923, shadow/README.md) : dates par les parametres +# `start` et `end` sans valeur par defaut ; a chaque cloture, valeur du +# portefeuille dans le graphique `shadow` (series e0..e4 a tour de role), plus +# `fees` et `turnover` cumules. + +from datetime import datetime + +import numpy as np +import pandas as pd + +GROSS = 0.98 # exposition brute visee +SIZE = 0.95 # reserve de liquidites de 5 % appliquee au dimensionnement +NAME_CAP = 0.30 # plafond par ligne (hors ligne de bons du Tresor) +DRIFT = 0.25 # coupe une ligne quand son poids depasse sa cible de 25 % +MIN_ORDER = 0.002 # ordre minimal : 0,2 % du portefeuille +BUDGETS = {1: 0.35, 2: 0.25, 3: 0.25, 4: 0.15} +TBILL = "BIL" + + +class _ScaledFeeModel(FeeModel): + """Frais du courtier (modele par defaut de Lean), mis a l'echelle (identite a 1.0).""" + + def __init__(self, multiplier): + self._multiplier = multiplier + self._base = InteractiveBrokersFeeModel() + + def get_order_fee(self, parameters): + fee = self._base.get_order_fee(parameters) + if fee is None or self._multiplier == 1.0: + return fee + amount = float(fee.value.amount) * self._multiplier + return OrderFee(CashAmount(amount, fee.value.currency)) + + +class _Initializer(BrokerageModelSecurityInitializer): + """Modeles du courtier, prix amorce a l'ajout, frais mis a l'echelle, reglement T+1.""" + + def __init__(self, brokerage_model, seeder, fee_mult): + super().__init__(brokerage_model, seeder) + self._fee_mult = fee_mult + + def initialize(self, security): + super().initialize(security) + security.set_fee_model(_ScaledFeeModel(self._fee_mult)) + if security.type == SecurityType.EQUITY: + security.set_settlement_model(DelayedSettlementModel(1, timedelta(hours=8))) + + +def _adx(high, low, close, n=14): + """ADX de Wilder, colonne par colonne (une colonne par titre).""" + up = high.diff() + down = -low.diff() + plus_dm = up.where((up > down) & (up > 0), 0.0) + minus_dm = down.where((down > up) & (down > 0), 0.0) + prev = close.shift() + tr = np.maximum(np.maximum((high - low).values, (high - prev).abs().values), + (low - prev).abs().values) + tr = pd.DataFrame(tr, index=close.index, columns=close.columns) + alpha = 1.0 / n + atr = tr.ewm(alpha=alpha, adjust=False).mean() + pdi = 100 * plus_dm.ewm(alpha=alpha, adjust=False).mean() / atr + mdi = 100 * minus_dm.ewm(alpha=alpha, adjust=False).mean() / atr + dx = 100 * (pdi - mdi).abs() / (pdi + mdi) + return dx.ewm(alpha=alpha, adjust=False).mean() + + +class FourSleeve774(QCAlgorithm): + + def initialize(self): + start = datetime.strptime(self.get_parameter("start"), "%Y-%m-%d") + end = datetime.strptime(self.get_parameter("end"), "%Y-%m-%d") + self.set_start_date(start.year, start.month, start.day) + self.set_end_date(end.year, end.month, end.day) + self.set_cash(100000) + self.start_value = 100000.0 + + # Parametres de la grille pre-enregistree (#18904) ; valeurs par defaut = fiche. + self.fee_mult = float(self.get_parameter("fee_mult", "1")) + self.ema_len = int(self.get_parameter("ema", "189")) + self.adx_max = float(self.get_parameter("adx_max", "35")) + self.stress_max = float(self.get_parameter("stress_max", "0.45")) + self.sizing = self.get_parameter("sizing", "invvol") # invvol | equal + sleeve = self.get_parameter("sleeve", "all") # all | 1 | 2 | 3 | 4 + if sleeve == "all": + self.budgets = dict(BUDGETS) + self.name_cap = NAME_CAP + else: + # Une poche seule recoit tout le portefeuille ; le plafond par ligne suit le + # meme facteur, pour que la poche garde la composition qu'elle a dans l'ensemble. + self.budgets = {k: (1.0 if str(k) == sleeve else 0.0) for k in BUDGETS} + self.name_cap = NAME_CAP / BUDGETS[int(sleeve)] + + self.set_brokerage_model(BrokerageName.INTERACTIVE_BROKERS_BROKERAGE, AccountType.CASH) + self.set_security_initializer(_Initializer( + self.brokerage_model, FuncSecuritySeeder(self.get_last_known_prices), self.fee_mult)) + self.settings.automatic_indicator_warm_up = True + + self.spy = self.add_equity("SPY", Resolution.DAILY).symbol + self.etf = {t: self.add_equity(t, Resolution.DAILY).symbol + for t in ("QQQ", "TLT", "GLD", "PSQ", TBILL)} + self.vix = self.add_data(CBOE, "VIX", Resolution.DAILY).symbol + qqq = self.etf["QQQ"] + self.spy_sma200 = self.sma(self.spy, 200, Resolution.DAILY) + self.qqq_rsi = self.rsi(qqq, 10, MovingAverageType.WILDERS, Resolution.DAILY) + self.qqq_sma20 = self.sma(qqq, 20, Resolution.DAILY) + self.qqq_sma100 = self.sma(qqq, 100, Resolution.DAILY) + self.tlt_roc = self.roc(self.etf["TLT"], 21, Resolution.DAILY) + self.gld_roc = self.roc(self.etf["GLD"], 21, Resolution.DAILY) + + # Univers d'actions choisi une fois par mois (premiere seance). + self.broad, self.top100, self.big = [], [], set() + if any(self.budgets[k] > 0 for k in (1, 2, 3)): + self.universe_settings.resolution = Resolution.DAILY + self.universe_settings.schedule.on(self.date_rules.month_start(self.spy)) + self.add_universe(self._select) + + self.stock_targets = {} # poches 1-3, recalculees chaque mois + self.etf_targets = {} # poche 4, recalculee chaque seance + self.targets = {} + self.dirty = set() # lignes dont la cible a change et n'est pas encore atteinte + self.stock_month = None + + # Signal sur barres closes, ordres au marche a l'ouverture de la seance suivante. + self.schedule.on(self.date_rules.every_day(self.spy), + self.time_rules.before_market_open(self.spy, 20), self._session) + + self.closes = 0 + self.traded = 0.0 + + # ---------------------------------------------------------------- univers + + def _select(self, fundamental): + eligible = [f for f in fundamental + if f.has_fundamental_data and f.price > 5 and f.market_cap > 0 + and f.dollar_volume > 5e6] + eligible.sort(key=lambda f: f.market_cap, reverse=True) + eligible = eligible[:500] + self.broad = [f.symbol for f in eligible] + self.top100 = self.broad[:100] + self.big = {f.symbol for f in eligible if f.market_cap >= 5e9} + return self.broad + + # ---------------------------------------------------------------- poches 1 a 3 + + def _sleeve_weights(self, picks, vol, budget): + """Poids dans le portefeuille d'une poche : inverse de la volatilite ou poids egaux.""" + if not picks or budget <= 0: + return {} + if self.sizing == "equal": + raw = {s: 1.0 for s in picks} + else: + raw = {s: 1.0 / max(float(vol[s]), 1e-4) for s in picks} + total = sum(raw.values()) + return {s: budget * v / total for s, v in raw.items()} + + def _stock_sleeves(self): + symbols = list(self.broad) + if not symbols: + return None + h = self.history(symbols, 400, Resolution.DAILY) + if h is None or h.empty: + return None + close = h["close"].unstack(level=0) + high = h["high"].unstack(level=0) + low = h["low"].unstack(level=0) + ok = close.iloc[-260:].notna().all() & close.iloc[-1].notna() + close, high, low = close.loc[:, ok], high.loc[:, ok], low.loc[:, ok] + if close.shape[1] < 50: + return None + + last = close.iloc[-1] + ema = close.ewm(span=self.ema_len, adjust=False).mean().iloc[-1] + adx = _adx(high, low, close).iloc[-1] + above = last > ema + stress = 1.0 - float(above.mean()) # part des titres sous leur EMA + trend_ok = above & (adx < self.adx_max) + vol = np.log(close).diff().iloc[-63:].std() + + b1, b2, b3 = (GROSS * self.budgets[k] for k in (1, 2, 3)) + targets, tbill = {}, 0.0 + + # Poche 1 : retour a la moyenne court terme, 100 plus grandes capitalisations, + # les 10 titres les plus proches de leur plus bas des 20 dernieres seances. + pool1 = [s for s in self.top100 if s in close.columns] + dist = (last[pool1] / low[pool1].iloc[-20:].min() - 1.0).dropna() + picks1 = list(dist.nsmallest(10).index) + sleeve1 = self._sleeve_weights(picks1, vol, b1) + + # Poche 2 : momentum filtre par la tendance (au-dessus de l'EMA, ADX sous le + # seuil), moyenne des rendements 3, 6 et 12 mois ; bons du Tresor si la part + # des titres sous leur EMA depasse le seuil. + sleeve2 = {} + if stress > self.stress_max: + tbill += b2 + else: + score2 = ((last / close.iloc[-64] - 1) + (last / close.iloc[-127] - 1) + + (last / close.iloc[-253] - 1)) / 3.0 + cand2 = score2[trend_ok].dropna() + picks2 = list(cand2.nlargest(10).index) + sleeve2 = self._sleeve_weights(picks2, vol, b2 * len(picks2) / 10.0) + tbill += b2 * (10 - len(picks2)) / 10.0 + + # Poche 3 : momentum des grandes capitalisations (>= 5 Md$), 12 mois sauf le + # dernier ; 10 lignes, 5 quand la part des titres sous leur EMA depasse le seuil + # (la moitie de la poche passe alors en bons du Tresor). + pool3 = [s for s in close.columns if s in self.big] + score3 = (close[pool3].iloc[-22] / close[pool3].iloc[-253] - 1)[trend_ok[pool3]] + n3 = 5 if stress > self.stress_max else 10 + picks3 = list(score3.dropna().nlargest(n3).index) + sleeve3 = self._sleeve_weights(picks3, vol, b3 * len(picks3) / 10.0) + tbill += b3 * (10 - len(picks3)) / 10.0 + + for part in (sleeve1, sleeve2, sleeve3): + for s, w in part.items(): + targets[s] = targets.get(s, 0.0) + w + if tbill > 0: + targets[self.etf[TBILL]] = targets.get(self.etf[TBILL], 0.0) + tbill + self.log(f"stocks {self.time.date()} stress={stress:.2f} n1={len(picks1)} " + f"n2={len(sleeve2)} n3={len(picks3)} tbill={tbill:.3f}") + return targets + + # ---------------------------------------------------------------- poche 4 + + def _etf_sleeve(self): + b4 = GROSS * self.budgets[4] + if b4 <= 0: + return {} + e = self.etf + + def price(s): + return float(self.securities[s].price) + + rot, trend = 0.70 * b4, 0.30 * b4 + targets = {} + + # Rotation (70 %) : actions, obligations, or, inverse. + if price(self.spy) > self.spy_sma200.current.value: + pick = e[TBILL] if self.qqq_rsi.current.value > 80 else e["QQQ"] + elif self.qqq_rsi.current.value < 30: + pick = e["QQQ"] + elif price(e["QQQ"]) < self.qqq_sma20.current.value: + pick = e["PSQ"] + else: + best = max(("TLT", "GLD"), + key=lambda t: (self.tlt_roc if t == "TLT" else self.gld_roc).current.value) + roc = (self.tlt_roc if best == "TLT" else self.gld_roc).current.value + pick = e[best] if roc > 0 else e[TBILL] + targets[pick] = rot + + # Regime de tendance Nasdaq (30 %), echelle VIX par paliers de 0,25. + vix = price(self.vix) + scale = min(1.0, 20.0 / vix) if vix > 0 else 1.0 + scale = np.floor(scale * 4) / 4 + on = price(e["QQQ"]) > self.qqq_sma100.current.value + w_qqq = trend * scale if on else 0.0 + if w_qqq > 0: + targets[e["QQQ"]] = targets.get(e["QQQ"], 0.0) + w_qqq + if trend - w_qqq > 0: + targets[e[TBILL]] = targets.get(e[TBILL], 0.0) + trend - w_qqq + return targets + + # ---------------------------------------------------------------- seance + + def _session(self): + month = (self.time.year, self.time.month) + if month != self.stock_month: + if any(self.budgets[k] > 0 for k in (1, 2, 3)): + new = self._stock_sleeves() + if new is not None: + self._mark(self.stock_targets, new) + self.stock_targets = new + self.stock_month = month + else: + self.stock_month = month + new_etf = self._etf_sleeve() + self._mark(self.etf_targets, new_etf) + self.etf_targets = new_etf + + combined = dict(self.stock_targets) + for s, w in self.etf_targets.items(): + combined[s] = combined.get(s, 0.0) + w + tbill = self.etf[TBILL] + self.targets = {s: (w if s == tbill else min(w, self.name_cap)) + for s, w in combined.items()} + self._trade() + + def _mark(self, old, new): + for s in set(old) | set(new): + if abs(old.get(s, 0.0) - new.get(s, 0.0)) > 1e-9: + self.dirty.add(s) + + def _trade(self): + """Ordres a l'ouverture : ventes d'abord, achats dans la limite des liquidites reglees. + + Un achat qui ne tient pas dans les liquidites est reduit et sa ligne reste a + completer : il repart a la seance suivante (ventes reglees en T+1).""" + tpv = float(self.portfolio.total_portfolio_value) + if tpv <= 0: + return + self.transactions.cancel_open_orders() + held = {s for s, h in self.portfolio.items() if h.invested} + sells, buys = [], [] + for s in set(self.targets) | held: + price = float(self.securities[s].price) + if price <= 0: + continue + qty = float(self.portfolio[s].quantity) + w = self.targets.get(s, 0.0) + if w <= 0: + if qty > 0: + sells.append((s, -qty)) + self.dirty.discard(s) + continue + cur = qty * price / tpv + if s not in self.dirty and cur <= w * (1 + DRIFT): + continue + want = int(w * tpv * SIZE / price) + delta = want - qty + if abs(delta) * price < MIN_ORDER * tpv: + self.dirty.discard(s) + continue + if delta < 0: + sells.append((s, delta)) + self.dirty.discard(s) + elif s in self.dirty: + buys.append((s, delta, price)) + for s, q in sells: + self.market_on_open_order(s, int(q)) + cash = float(self.portfolio.cash_book["USD"].amount) + need = sum(q * p for _, q, p in buys) + scale = min(1.0, max(0.0, 0.97 * cash / need)) if need > 0 else 0.0 + for s, q, p in buys: + n = int(q * scale) + if n > 0 and n * p >= MIN_ORDER * tpv: + self.market_on_open_order(s, n) + if n == int(q): + self.dirty.discard(s) + + # ---------------------------------------------------------------- contrat ombre + + def on_order_event(self, event): + if event.status == OrderStatus.INVALID: + self.dirty.add(event.symbol) # ordre refuse : la ligne repart a la seance suivante + return + if event.status in (OrderStatus.FILLED, OrderStatus.PARTIALLY_FILLED): + self.traded += (abs(event.fill_quantity * event.fill_price) + / self.portfolio.total_portfolio_value) + + def on_data(self, data): + if not data.bars.contains_key(self.spy): + return + self.plot("shadow", f"e{self.closes % 5}", self.portfolio.total_portfolio_value) + self.plot("shadow", "fees", self.portfolio.total_fees / self.start_value) + self.plot("shadow", "turnover", self.traded) + self.closes += 1 diff --git a/MyIA.AI.Notebooks/QuantConnect/projects/FourSleeve774Benchmarks/config.json b/MyIA.AI.Notebooks/QuantConnect/projects/FourSleeve774Benchmarks/config.json new file mode 100644 index 0000000000..1a788f207b --- /dev/null +++ b/MyIA.AI.Notebooks/QuantConnect/projects/FourSleeve774Benchmarks/config.json @@ -0,0 +1,3 @@ +{ + "local-id": 37353266 +} diff --git a/MyIA.AI.Notebooks/QuantConnect/projects/FourSleeve774Benchmarks/main.py b/MyIA.AI.Notebooks/QuantConnect/projects/FourSleeve774Benchmarks/main.py new file mode 100644 index 0000000000..7bc9e49448 --- /dev/null +++ b/MyIA.AI.Notebooks/QuantConnect/projects/FourSleeve774Benchmarks/main.py @@ -0,0 +1,84 @@ +# region imports +from AlgorithmImports import * +# endregion +# References de comparaison pour l'evaluation de la reimplementation declaree de la +# strategie 774 (issue #18904). Un seul projet, mode par parametre de backtest : +# spy SPY detenu (reference principale du verdict) +# 6040 60 % SPY / 40 % IEF (seconde reference, correction de Holm) +# vt2 SPY / QQQ / IEF / GLD a poids egaux (panier proxy, correlations seulement) +# aw SPY 0,30 / IEF 0,30 / GLD 0,30 / XLP 0,10 (panier proxy, correlations seulement) +# Rebalancement le premier jour de bourse du mois, frais du courtier mis a l'echelle +# comme dans la strategie. +# +# Contrat du rejeu en ombre (#18923, shadow/README.md) : dates par les parametres +# `start` et `end` sans valeur par defaut ; valeur du portefeuille a chaque cloture dans +# le graphique `shadow` (series e0..e4 a tour de role), plus `fees` et `turnover`. + +from datetime import datetime + +MODES = { + "spy": {"SPY": 1.0}, + "6040": {"SPY": 0.6, "IEF": 0.4}, + "vt2": {"SPY": 0.25, "QQQ": 0.25, "IEF": 0.25, "GLD": 0.25}, + "aw": {"SPY": 0.30, "IEF": 0.30, "GLD": 0.30, "XLP": 0.10}, +} + +class _ScaledFeeModel(FeeModel): + """Frais du courtier (modele par defaut de Lean), mis a l'echelle (identite a 1.0).""" + + def __init__(self, multiplier): + self._multiplier = multiplier + self._base = InteractiveBrokersFeeModel() + + def get_order_fee(self, parameters): + fee = self._base.get_order_fee(parameters) + if fee is None or self._multiplier == 1.0: + return fee + amount = float(fee.value.amount) * self._multiplier + return OrderFee(CashAmount(amount, fee.value.currency)) + +class Bench774(QCAlgorithm): + + def initialize(self): + start = datetime.strptime(self.get_parameter("start"), "%Y-%m-%d") + end = datetime.strptime(self.get_parameter("end"), "%Y-%m-%d") + self.set_start_date(start.year, start.month, start.day) + self.set_end_date(end.year, end.month, end.day) + self.set_cash(100000) + self.start_value = 100000.0 + + mode = self.get_parameter("mode", "spy") + if mode not in MODES: + raise ValueError(f"mode inconnu : {mode}") + self.fee_mult = float(self.get_parameter("fee_mult", "1")) + self.set_security_initializer( + lambda security: security.set_fee_model(_ScaledFeeModel(self.fee_mult))) + + self.spy = self.add_equity("SPY", Resolution.DAILY).symbol + self.weights = {} + for ticker, w in MODES[mode].items(): + symbol = self.spy if ticker == "SPY" else self.add_equity(ticker, Resolution.DAILY).symbol + self.weights[symbol] = w + + # month_start sans symbole saute les mois dont le 1er n'est pas une seance (#18941). + self.schedule.on(self.date_rules.month_start(self.spy), + self.time_rules.after_market_open(self.spy, 30), self._rebalance) + + self.closes = 0 + self.traded = 0.0 + + def _rebalance(self): + self.set_holdings([PortfolioTarget(s, w) for s, w in self.weights.items()]) + + def on_order_event(self, event): + if event.status in (OrderStatus.FILLED, OrderStatus.PARTIALLY_FILLED): + self.traded += (abs(event.fill_quantity * event.fill_price) + / self.portfolio.total_portfolio_value) + + def on_data(self, data): + if not data.bars.contains_key(self.spy): + return + self.plot("shadow", f"e{self.closes % 5}", self.portfolio.total_portfolio_value) + self.plot("shadow", "fees", self.portfolio.total_fees / self.start_value) + self.plot("shadow", "turnover", self.traded) + self.closes += 1