# Parameters
BATCH_MODE = "true"<< Sommaire QC | Précédent : QC-Py-35-RL-Portfolio-Construction << | Suivant : QC-Py-41-PaperTrading-IBKR >>
QC-Py-40 : Paper Trading Binance - Mean Reversion Crypto
Objectif : Demontrer le workflow complet backtest -> paper trading deploy sur crypto via Binance.
Prerequis
- QC-Py-09 (Order Types) : types d’ordres supportes
- QC-Py-12 (Backtesting Analysis) : analyse de résultats de backtest
- QC-Py-27 (Production Deployment) : workflow de deploiement
Plan du notebook
- Concepts du paper trading et architecture
- Stratégie mean-reversion Bollinger Bands
- Backtest historique 2021-2024
- Analyse des résultats
- Deploy en paper trading via MCP QC
- Monitoring : lire les stats live
- Exercice : adapter les paramètres BB
import os
import json
import time
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
from dataclasses import dataclass, field
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import warnings
warnings.filterwarnings('ignore')
# Configuration matplotlib
plt.style.use('seaborn-v0_8-darkgrid')
%matplotlib inline
print(f"QC-Py-40 Paper Trading Binance - {datetime.now().strftime('%Y-%m-%d')}")QC-Py-40 Paper Trading Binance - 2026-09-03
1. Concepts du Paper Trading
Le paper trading utilise les mêmes données de marche en temps reel que le live trading, mais simule les fills d’ordres au lieu de les envoyer a un broker reel.
| Aspect | Paper Trading | Live Trading |
|---|---|---|
| Données | Reelles (temps reel) | Reelles (temps reel) |
| Exécution | Simulee (fills fictifs) | Reelle (broker) |
| Capital | Fictif | Reel |
| Slippage | Aucun (sauf si configure) | Variable |
| Commissions | Simulees | Reelles |
Le paper trading repond a la question : “Mon algorithme fonctionne-t-il avec des données reelles ?” sans exposer de capital.
Deux questions distinctes, deux instruments. Le backtest repond : cette strategie a-t-elle un edge sur l’historique disponible ? Il simule les prix avec des donnees closes, des fills immediats et aucune dependance d’infrastructure. Le paper trading repond : l’implementation reelle de cette strategie, tournant sur un node QuantConnect et envoyant de vrais ordres, se comporte-t-elle comme le backtest l’a predit ? Un compte Binance paper (testnet spot) recoit des fills simules a partir de vraies conditions de marche — la meilleure approximation d’une execution reelle sans risque de capital.
# Architecture du paper trading chez QuantConnect
# L'algorithme LEAN tourne sur un node live (L-MICRO) dans les serveurs co-locates de QC
paper_trading_config = {
"brokerage": "BinanceBrokerage",
"environment": "paper", # Spot Test Network
"node_type": "L-MICRO", # 1 CPU, 0.5 GB RAM
"assets": ["BTCUSDT", "ETHUSDT"],
"resolution": "Minute",
"fees_simulated": "0.1% maker/taker (VIP Level 0)",
"order_types_supported": ["MarketOrder", "LimitOrder", "StopLimitOrder"],
"order_updates": False, # Binance: annuler + recreer
}
print("Configuration Paper Trading Binance:")
for k, v in paper_trading_config.items():
print(f" {k}: {v}")Configuration Paper Trading Binance:
brokerage: BinanceBrokerage
environment: paper
node_type: L-MICRO
assets: ['BTCUSDT', 'ETHUSDT']
resolution: Minute
fees_simulated: 0.1% maker/taker (VIP Level 0)
order_types_supported: ['MarketOrder', 'LimitOrder', 'StopLimitOrder']
order_updates: False
Lecture de la configuration
Quatre champs structurent le simulateur Binance. Le noeud L-MICRO est le plus petit noeud live QC — suffisant pour deux actifs et une strategie qui tourne sur barres minutes. La resolution Minute aligne le paper sur la frequence du backtest (une barre minute par pas de simulation). Les frais 0.1% maker/taker (VIP Level 0) sont le tarif public Binance sans volume — au taux le plus bas possible, un aller-retour coute 0.2%, ce qui plafonne la frequence viable des trades. Enfin, order_updates: False est la contrainte majeure du simulateur : pas d’evenement de cycle de vie d’ordre (soumis/rempli/annule) — le monitoring devra interroger l’etat plutot que recevoir des notifications. C’est l’asymetrie structurelle avec un broker multi-asset type IBKR (voir QC-Py-41), qui, lui, les fournit.
2. Stratégie : Mean-Reversion Bollinger Bands
La stratégie exploite les retours a la moyenne des cours crypto sur un horizon court :
- Entree : quand le prix touche la bande inferieure de Bollinger (prix << moyenne)
- Sortie : quand le prix revient a la bande mediane (retour a la moyenne)
- Stop-loss : en cas de breakout continu, couper la position
Pourquoi cette strategie a un edge (et quand elle casse). Sur un horizon court, le crypto mean-revertit : les market makers cotent autour d’une valeur fondamentale et absorbent le desequilibre temporaire de flux d’ordres. Quand un taker pousse le prix sur la bande inferieure, il est statistiquement bon marche, et le retour a la moyenne est l’edge. L’hypothese est que le marche reste range-bound : elle cede lors d’un breakout soutenu (tendance), ou ‘bon marche’ devient ‘encore plus bon marche’ – d’ou le stop-loss, qui n’est pas une coupe optionnelle mais le garde-fou de changement de regime. Les bandes se recalibrent seules : elles s’elargissent en regime volatil (moins d’entrees, stops plus larges) et se resserrent au calme (plus d’entrees, stops plus serres).
Le code ci-dessous est l’algorithme LEAN complet pret a etre deploye.
# Algorithme LEAN : Mean-Reversion Bollinger Bands sur BTC/ETH
# Ce code est copiable tel quel dans un projet QuantConnect
LEAN_ALGORITHM = '''# region imports
from AlgorithmImports import *
# endregion
class BinanceMeanReversionBB(QCAlgorithm):
"""
Binance Crypto Mean-Reversion via Bollinger Bands.
Paper trading demonstration strategy.
Logic:
- Buy when price < lower BB (oversold)
- Sell when price > middle BB (mean reversion complete)
- Stop-loss at -5% from entry
"""
def initialize(self):
self.set_start_date(2021, 1, 1)
self.set_end_date(2024, 12, 31)
self.set_cash(10000) # 10 000 USD fictif
# Brokerage Binance (mode paper = Testnet)
self.set_brokerage_model(BrokerageName.BINANCE, AccountType.CASH)
# Actifs crypto
self.btc = self.add_crypto("BTCUSDT", Resolution.MINUTE, Market.BINANCE)
self.eth = self.add_crypto("ETHUSDT", Resolution.MINUTE, Market.BINANCE)
self.symbols = [self.btc.symbol, self.eth.symbol]
# Bollinger Bands (20-period, 2 std) -- dict renomme pour ne PAS masquer
# QCAlgorithm.bb() / .rsi() (helpers LEAN). Voir issue #13117 Phase B.
# NOTE Phase C : les helpers bb/rsi de QCAlgorithm prennent `resolution`
# en *keyword argument* (4e positional = `moving_average_type`, defaut Simple).
# Sans `resolution=...`, le 4e arg Resolution.MINUTE etait pris comme
# moving_average_type -> TypeError a l'execution. Voir #13117 Phase C.
self.bb_period = 20
self.bb_std = 2.0
self.bb_indicator = {}
for sym in self.symbols:
self.bb_indicator[sym] = self.bb(sym, self.bb_period, self.bb_std, resolution=Resolution.MINUTE)
# RSI pour confirmer l'oversold
self.rsi_indicator = {}
for sym in self.symbols:
self.rsi_indicator[sym] = self.rsi(sym, 14, resolution=Resolution.MINUTE)
# Parametres
self.stop_loss_pct = 0.05 # -5% stop-loss
self.position_weight = 0.4 # 40% par position (2 positions max)
self.entry_prices = {}
# Warmup pour les indicateurs
self.set_warm_up(self.bb_period, Resolution.MINUTE)
def on_data(self, data):
if self.is_warming_up:
return
for sym in self.symbols:
if sym not in data or data[sym] is None:
continue
if not self.bb_indicator[sym].is_ready or not self.rsi_indicator[sym].is_ready:
continue
price = data[sym].price
bb_lower = self.bb_indicator[sym].lower_band.current.value
bb_middle = self.bb_indicator[sym].middle_band.current.value
bb_upper = self.bb_indicator[sym].upper_band.current.value
rsi_val = self.rsi_indicator[sym].current.value
# Position ouverte ? verifier stop-loss et sortie
if self.portfolio[sym].invested:
entry = self.entry_prices.get(sym, price)
pnl = (price - entry) / entry
# Stop-loss
if pnl < -self.stop_loss_pct:
self.liquidate(sym)
self.entry_prices.pop(sym, None)
self.log(f"STOP-LOSS {sym}: pnl={pnl:.2%}")
continue
# Sortie : retour a la mediane BB
if price > bb_middle:
self.liquidate(sym)
self.entry_prices.pop(sym, None)
self.log(f"EXIT {sym}: price={price:.2f} > BB mid={bb_middle:.2f}")
continue
# Entree : prix sous BB inferieure + RSI oversold
if not self.portfolio[sym].invested:
if price < bb_lower and rsi_val < 35:
self.set_holdings(sym, self.position_weight)
self.entry_prices[sym] = price
self.log(f"ENTER {sym}: price={price:.2f} < BB low={bb_lower:.2f}, RSI={rsi_val:.1f}")
def on_end_of_algorithm(self):
final = self.portfolio.total_portfolio_value
ret = (final - 10000) / 10000
self.log(f"BB MeanRev Binance: Final=${final:,.2f}, Return={ret:.2%}")
'''
print("Algorithme LEAN pret pour backtest/paper trading.")
print(f"Taille: {len(LEAN_ALGORITHM)} caracteres")
LEAN_ALGORITHM_B1 = """# region imports
from AlgorithmImports import *
# endregion
class BinanceMeanReversionBB_B1(QCAlgorithm):
'''
Phase D B1 = v6 + instrumentation diagnostique.
NE CHANGE PAS la logique d'entree (toujours price < BB lower AND RSI < 35).
Ajoute compteurs par categorie pour discriminer :
- guard-skip silencieux (is_warming_up, sym not in data, is_ready false)
- rarete reelle (n_price_below, n_rsi_below, n_both)
- trades effectivement executes
Log : 1 ligne / 10 000 barres avec (ts, sym, price, bb_lower, rsi, is_ready).
Final log : tous les compteurs dans on_end_of_algorithm.
'''
def initialize(self):
self.set_start_date(2021, 1, 1)
self.set_end_date(2024, 12, 31)
self.set_cash(10000)
self.set_brokerage_model(BrokerageName.BINANCE, AccountType.CASH)
self.btc = self.add_crypto("BTCUSDT", Resolution.MINUTE, Market.BINANCE)
self.eth = self.add_crypto("ETHUSDT", Resolution.MINUTE, Market.BINANCE)
self.symbols = [self.btc.symbol, self.eth.symbol]
self.bb_period = 20
self.bb_std = 2.0
self.bb_indicator = {}
for sym in self.symbols:
self.bb_indicator[sym] = self.bb(sym, self.bb_period, self.bb_std, resolution=Resolution.MINUTE)
self.rsi_indicator = {}
for sym in self.symbols:
self.rsi_indicator[sym] = self.rsi(sym, 14, resolution=Resolution.MINUTE)
self.stop_loss_pct = 0.05
self.position_weight = 0.4
self.entry_prices = {}
self.set_warm_up(self.bb_period, Resolution.MINUTE)
# === B1 INSTRUMENTATION : compteurs ===
self.n_bars_total = 0
self.n_guard_skip_warmup = 0
self.n_guard_skip_no_data = 0
self.n_guard_skip_not_ready = 0
self.n_price_below = 0
self.n_rsi_below = 0
self.n_both = 0
self.n_entry_attempts = 0
self.n_trades_filled = 0
self.sample_log_counter = 0
def on_data(self, data):
if self.is_warming_up:
self.n_guard_skip_warmup += 1
return
for sym in self.symbols:
if sym not in data or data[sym] is None:
self.n_guard_skip_no_data += 1
continue
if not self.bb_indicator[sym].is_ready or not self.rsi_indicator[sym].is_ready:
self.n_guard_skip_not_ready += 1
continue
self.n_bars_total += 1
price = data[sym].price
bb_lower = self.bb_indicator[sym].lower_band.current.value
bb_middle = self.bb_indicator[sym].middle_band.current.value
rsi_val = self.rsi_indicator[sym].current.value
price_ok = price < bb_lower
rsi_ok = rsi_val < 35
if price_ok:
self.n_price_below += 1
if rsi_ok:
self.n_rsi_below += 1
if price_ok and rsi_ok:
self.n_both += 1
self.sample_log_counter += 1
if self.sample_log_counter >= 10000:
self.sample_log_counter = 0
self.log(f"SAMPLE {self.Time} {sym} price={price:.2f} bb_low={bb_lower:.2f} rsi={rsi_val:.1f} bb_ready={self.bb_indicator[sym].is_ready} rsi_ready={self.rsi_indicator[sym].is_ready} price_ok={price_ok} rsi_ok={rsi_ok}")
if self.portfolio[sym].invested:
entry = self.entry_prices.get(sym, price)
pnl = (price - entry) / entry
if pnl < -self.stop_loss_pct:
self.liquidate(sym)
self.entry_prices.pop(sym, None)
self.log(f"STOP-LOSS {sym}: pnl={pnl:.2%}")
continue
if price > bb_middle:
self.liquidate(sym)
self.entry_prices.pop(sym, None)
self.log(f"EXIT {sym}: price={price:.2f} > BB mid={bb_middle:.2f}")
continue
if not self.portfolio[sym].invested:
if price_ok and rsi_ok:
self.n_entry_attempts += 1
self.set_holdings(sym, self.position_weight)
self.entry_prices[sym] = price
self.log(f"ENTER {sym}: price={price:.2f} < BB low={bb_lower:.2f}, RSI={rsi_val:.1f}")
def on_order_event(self, order_event):
if order_event.status == OrderStatus.FILLED:
self.n_trades_filled += 1
def on_end_of_algorithm(self):
self.log(f"B1_DIAG n_bars={self.n_bars_total} warmup={self.n_guard_skip_warmup} no_data={self.n_guard_skip_no_data} not_ready={self.n_guard_skip_not_ready} price_below={self.n_price_below} rsi_below={self.n_rsi_below} both={self.n_both} entry_attempts={self.n_entry_attempts} fills={self.n_trades_filled}")
final = self.portfolio.total_portfolio_value
ret = (final - 10000) / 10000
self.log(f"B1_FINAL ${final:,.2f} return={ret:.2%}")
"""
print("LEAN_ALGORITHM_B1 (Phase D instrumentation) pret pour backtest QC Cloud.")
print(f"Taille B1: {len(LEAN_ALGORITHM_B1)} caracteres")Algorithme LEAN pret pour backtest/paper trading.
Taille: 4065 caracteres
LEAN_ALGORITHM_B1 (Phase D instrumentation) pret pour backtest QC Cloud.
Taille B1: 4978 caracteres
Pourquoi une deuxieme version de l’algorithme
La cellule precedente porte deux chaines : LEAN_ALGORITHM, la strategie enseignee, et LEAN_ALGORITHM_B1, la meme strategie instrumentee de compteurs de diagnostic. Toutes deux exigent price < BB_lower ET RSI < 35 pour entrer en position.
La cellule suivante definit LEAN_ALGORITHM_B2, identique a B1 sur tout sauf un operateur : la condition d’entree y devient price < BB_lower OU RSI < 35. Cette variante n’est pas une amelioration proposee — c’est un instrument de mesure. Elle repond a une question precise : l’absence de trades vient-elle d’une conjonction trop stricte pour etre satisfaite, ou d’autre chose ?
Comparer deux programmes qui ne different que par un seul element est ce qui rend la reponse attribuable : si le comportement change, on sait a quoi l’imputer. La section 3 montre ou cette methode a mene — et pourquoi la reponse s’est revelee etre « autre chose ».
LEAN_ALGORITHM_B2 = """# region imports
from AlgorithmImports import *
# endregion
class BinanceMeanReversionBB_B2(QCAlgorithm):
'''
Phase D B2 = v6 + instrumentation diagnostique + condition OR au lieu de AND.
NE CHANGE PAS l'instrumentation (memes 10 compteurs que B1).
Change la condition d'entree : price < BB lower OR RSI < 35 (au lieu de AND).
Permet de discriminer si l'absence d'edge est duee a la conjonction stricte
(jamais les 2 sous-conditions vraies en meme temps) ou si chaque sous-condition
prise isolement ne suffit pas non plus.
'''
def initialize(self):
self.set_start_date(2021, 1, 1)
self.set_end_date(2024, 12, 31)
self.set_cash(10000)
self.set_brokerage_model(BrokerageName.BINANCE, AccountType.CASH)
self.btc = self.add_crypto("BTCUSDT", Resolution.MINUTE, Market.BINANCE)
self.eth = self.add_crypto("ETHUSDT", Resolution.MINUTE, Market.BINANCE)
self.symbols = [self.btc.symbol, self.eth.symbol]
self.bb_period = 20
self.bb_std = 2.0
self.bb_indicator = {}
for sym in self.symbols:
self.bb_indicator[sym] = self.bb(sym, self.bb_period, self.bb_std, resolution=Resolution.MINUTE)
self.rsi_indicator = {}
for sym in self.symbols:
self.rsi_indicator[sym] = self.rsi(sym, 14, resolution=Resolution.MINUTE)
self.stop_loss_pct = 0.05
self.position_weight = 0.4
self.entry_prices = {}
self.set_warm_up(self.bb_period, Resolution.MINUTE)
# === B1 INSTRUMENTATION : compteurs ===
self.n_bars_total = 0
self.n_guard_skip_warmup = 0
self.n_guard_skip_no_data = 0
self.n_guard_skip_not_ready = 0
self.n_price_below = 0
self.n_rsi_below = 0
self.n_both = 0
self.n_entry_attempts = 0
self.n_trades_filled = 0
self.sample_log_counter = 0
def on_data(self, data):
if self.is_warming_up:
self.n_guard_skip_warmup += 1
return
for sym in self.symbols:
if sym not in data or data[sym] is None:
self.n_guard_skip_no_data += 1
continue
if not self.bb_indicator[sym].is_ready or not self.rsi_indicator[sym].is_ready:
self.n_guard_skip_not_ready += 1
continue
self.n_bars_total += 1
price = data[sym].price
bb_lower = self.bb_indicator[sym].lower_band.current.value
bb_middle = self.bb_indicator[sym].middle_band.current.value
rsi_val = self.rsi_indicator[sym].current.value
price_ok = price < bb_lower
rsi_ok = rsi_val < 35
if price_ok:
self.n_price_below += 1
if rsi_ok:
self.n_rsi_below += 1
# B2: OR au lieu de AND pour discriminer conjonction stricte
if price_ok or rsi_ok:
self.n_both += 1 # semantique B2 = "au moins une des deux"
self.sample_log_counter += 1
if self.sample_log_counter >= 10000:
self.sample_log_counter = 0
self.log(f"SAMPLE {self.Time} {sym} price={price:.2f} bb_low={bb_lower:.2f} rsi={rsi_val:.1f} bb_ready={self.bb_indicator[sym].is_ready} rsi_ready={self.rsi_indicator[sym].is_ready} price_ok={price_ok} rsi_ok={rsi_ok}")
if self.portfolio[sym].invested:
entry = self.entry_prices.get(sym, price)
pnl = (price - entry) / entry
if pnl < -self.stop_loss_pct:
self.liquidate(sym)
self.entry_prices.pop(sym, None)
self.log(f"STOP-LOSS {sym}: pnl={pnl:.2%}")
continue
if price > bb_middle:
self.liquidate(sym)
self.entry_prices.pop(sym, None)
self.log(f"EXIT {sym}: price={price:.2f} > BB mid={bb_middle:.2f}")
continue
if not self.portfolio[sym].invested:
# B2: OR au lieu de AND
if price_ok or rsi_ok:
self.n_entry_attempts += 1
self.set_holdings(sym, self.position_weight)
self.entry_prices[sym] = price
self.log(f"ENTER {sym}: price={price:.2f} < BB low={bb_lower:.2f}, RSI={rsi_val:.1f}")
def on_order_event(self, order_event):
if order_event.status == OrderStatus.FILLED:
self.n_trades_filled += 1
def on_end_of_algorithm(self):
self.log(f"B2_DIAG n_bars={self.n_bars_total} warmup={self.n_guard_skip_warmup} no_data={self.n_guard_skip_no_data} not_ready={self.n_guard_skip_not_ready} price_below={self.n_price_below} rsi_below={self.n_rsi_below} either={self.n_both} entry_attempts={self.n_entry_attempts} fills={self.n_trades_filled}")
final = self.portfolio.total_portfolio_value
ret = (final - 10000) / 10000
self.log(f"B1_FINAL ${final:,.2f} return={ret:.2%}")
"""
print("LEAN_ALGORITHM_B2 (Phase D OR-condition discrimination) pret pour backtest QC Cloud.")
print(f"Taille B2: {len(LEAN_ALGORITHM_B2)} caracteres")LEAN_ALGORITHM_B2 (Phase D OR-condition discrimination) pret pour backtest QC Cloud.
Taille B2: 5066 caracteres
3. Backtest Historique
Avant de deployer en paper trading, on valide la stratégie sur historique (2021-2024).
Le backtest est lance via le MCP QuantConnect. Le workflow : 1. Créer un projet QC 2. Ecrire le fichier main.py 3. Compiler 4. Lancer le backtest
Pourquoi backtester avant de paper-trader. Les trois etages repondent a des questions differentes. Le backtest demande : le signal predit-il un rendement en echantillon ? Le paper trading demande : l’edge survit-il aux donnees temps reel et a la friction d’execution ? Le live ajoute le capital reel et le slippage. Chaque etage ajoute une couche de realisme ; un etage qui echoue revele ce que le precedent ne pouvait pas voir. La periode 2021-2024 couvre intentionnellement trois regimes – bull 2021, bear 2022, recovery 2023-24 – pour stress-tester l’hypothese de mean-reversion sur tout le cycle, pas sur un seul regime favorable.
- Analyser les metriques
Les resultats ci-dessous sont simules (reproduits d’un backtest QC de reference pour la demonstration) : ils servent de support de lecture et de calibration, pas de preuve de rentabilite. La methode de lecture — comparer a un benchmark, ajuster au risque, douter des couts — est ce qui reste transferable.
# Backtest QC Cloud -- Re-Phase C (issue #13117, c.701) : VERDICT INCONCLUSIVE
# 4 versions du backtest (v3-v6) executees sur QC Cloud avec fee model 0.1% :
# AUCUN trade execute. Voir `_results/QC-Py-40-Re-Phase-C.json` pour le
# diagnostic detaille (warmup order, fee model API, entry condition).
#
# Verdict de l'epoque : la strategie ne produit AUCUN fill sur la periode,
# la condition d'entree etant supposee "trop stricte" pour la granularite
# Minute sur 4 ans.
#
# ATTENTION -- CE VERDICT EST FALSIFIE. Il est conserve ici tel qu'il a ete
# ecrit, parce que la maniere dont il a ete infirme est le vrai contenu
# pedagogique de la section. Les Phases D et E (cellules plus bas) le
# reprennent sur mesure :
# - "la condition ne se declenche jamais" est faux : elle se declenche
# 151 653 fois en AND (v4/B1) et 487 172 fois en OR (B2) ;
# - la cause du remplissage nul n'etait pas le signal mais la DEVISE DE
# COMPTE (USD contre paires reglees en USDT, sur un compte sans marge).
# Ne pas reutiliser le champ `verdict_reason` ci-dessous comme une
# conclusion : lire la cellule Phase D/E qui suit.
backtest_results = {
"strategy": "Binance Mean-Reversion BB (20, 2.0)",
"period": "2021-01-01 to 2024-12-31",
"initial_capital": 10000,
"fee_model": "ConstantFeeModel(0.001) = 0.1% maker/taker",
"warmup": "timedelta(days=2) BEFORE indicator creation",
"resolution": "MINUTE",
"brokerage": "BinanceBrokerage, AccountType.CASH",
"qc_project_id": 35674722,
"qc_project_url": "https://www.quantconnect.com/project/35674722",
"qc_backtest_results": {
"v3_id": "df0827172f79005662081999bfa894fa",
"v3_orders": 181813,
"v3_filled": 0,
"v4_id": "4b85d281ee39b1139504cbe3113e461d",
"v4_orders": 151653,
"v4_filled": 0,
"v5_id": "627b58197f03336d40d8aba7f4c4e83b",
"v5_orders": 0,
"v5_filled": 0,
"v5_runtime_error": "2025-01-01 self.trades attribute missing (warmup reached + execution started)",
"v6_id": "769a748768e0b321fcc07756bb197d7b",
"v6_orders": 0,
"v6_filled": 0,
},
"metrics": {
"Total Return": "0% (0 trades filled)",
"CAGR": "0% (0 trades filled)",
"Sharpe Ratio": "N/A (0 trades filled)",
"Max Drawdown": "0% (no positions taken)",
"Win Rate": "N/A (0 trades filled)",
"Total Trades": 0,
"Profit Factor": "N/A (0 trades filled)",
"Avg Trade Duration": "N/A (0 trades filled)",
},
"benchmark": {
# Phase C : benchmark BTC/ETH sur la meme fenetre -- non execute
# car la strategie n'a pas produit de trades, donc comparaison
# triviale (BTC +H > 0% sur 2021-2024 > strategie 0%).
"BTC Buy & Hold": "Non execute (pas de strategie a comparer)",
"ETH Buy & Hold": "Non execute (pas de strategie a comparer)",
},
"verdict": "INCONCLUSIVE",
"verdict_reason": (
"4 backtests executes sur QC Cloud avec fee model 0.1%, warmup timedelta(days=2) "
"AVANT indicators, RSI threshold assoupli a 40 (vs 35). Aucun trade rempli. "
"[FALSIFIE en Phase D/E -- conserve pour la trace] L'explication avancee alors "
"etait que la condition d'entree (price < lower BB AND RSI < 40) serait trop "
"stricte pour la granularite Minute. La mesure la contredit dans la cellule "
"meme qui la porte : v4 rapporte 151 653 ordres emis, ce qui est incompatible "
"avec une condition qui ne se declencherait pas. Cause reelle etablie en "
"Phase E : devise de compte USD contre paires reglees en USDT."
),
"path_forward": (
"[SUPERSEDE par les Phases D et E] La voie envisagee alors -- balayer la grille "
"de parametres (BB period 10-30, RSI 30-45, OR au lieu de AND) -- aurait ete "
"inutile : B2 a effectivement teste OR et a produit 3,2x PLUS d'ordres pour "
"toujours zero fill. Aucun reglage de la condition d'entree n'aurait debloque "
"le remplissage, parce que le blocage n'etait pas la. Voir la cellule Phase D/E."
),
}
print("=== Re-Phase C (c.701) : verdict INCONCLUSIVE ===")
print(f"Strategie : {backtest_results['strategy']}")
print(f"Periode : {backtest_results['period']}")
print(f"Capital initial : ${backtest_results['initial_capital']:,}")
print(f"QC Project : {backtest_results['qc_project_url']}")
print()
print("Backtests executes :")
for k, v in backtest_results['qc_backtest_results'].items():
print(f" {k}: {v}")
print()
print("Metriques reelles (issues des backtests ci-dessus) :")
for k, v in backtest_results["metrics"].items():
print(f" {k}: {v}")
print()
print(f"Verdict : {backtest_results['verdict']}")
print(f"Raison : {backtest_results['verdict_reason']}")
print()
print(f"Path forward : {backtest_results['path_forward']}")
print()
print("Detail dans _results/QC-Py-40-Re-Phase-C.json (4 attempts trail).")
# =============================================================================
# Phase D (issue #13117, c.863) — Instrumentation B1
# =============================================================================
# B1 = v6 + compteurs diagnostiques. NE CHANGE PAS la logique d'entree.
# Objectif : discriminer bug (guard-skip silencieux / NaN) vs rarete reelle.
#
# Plan Phase D (5 runs, sub-agent qc-strategy-analyzer async) :
# B1 : v6 + instrumentation (compteurs + log echantillonne 1/10000 barres)
# B2 : BB20, RSI<35, OR (au lieu de AND) -- variable la plus discriminante
# B3 : BB10, RSI<35, AND -- periode courte
# B4 : BB30, RSI<35, AND -- periode longue (conditionnel)
# B5 : BB20, RSI<45, AND -- seuil haut (borne grille)
#
# Resultat B1 attendu : soit guard-skip domine (not_ready >> n_bars), soit
# rarete reelle (n_both = 0 mais n_price_below > 0 et n_rsi_below > 0),
# soit bug logique (n_both > 0 mais 0 fill).
#
# Acceptance :
# SUCCESS : >= 1 config avec >= 10 trades filled sur 2021-2024
# INCONCLUSIVE prouve : instrumentation + >= 3 axes sweepes a 0 trade
# Refuse : verdict "condition trop stricte" non instrumente
#
# A executer : create_compile + create_backtest B1 via MCP qc-mcp-lite.
# Le code B1 est dans `LEAN_ALGORITHM_B1` (cellule ci-dessus).
# Resultats B1 seront importes dans QC-Py-40-Phase-D.json apres run.
# =============================================================================
# Phase D B2 (issue #13117, c.866) -- OR condition (au lieu de AND)
# =============================================================================
# B2 = B1 + 1 variable flippee : entry `price < BB lower OR RSI < 35` au lieu de AND.
# Objectif : discriminer si l'absence d'edge est duee a la conjonction stricte
# (jamais les 2 sous-conditions vraies en meme temps) ou si chaque sous-condition
# prise isolement ne suffit pas non plus.
#
# Ajout vs B1 : `n_either` compteur = "au moins une des 2" (= meme chose que AND pour la garde d'entree).
# Discrimination possible en lisant `n_either` vs `n_both` :
# - n_either >> n_both : les sous-conditions se rencontrent separement mais jamais ensemble (rarete de conjonction)
# - n_either == n_both : AND et OR equivalents (rarete de sous-condition individuelle)
#
# Plan Phase D execution (c.866 narrow worker direct) :
# B1 instrumente : compile SUCCESS + backtest 151653 ordres / 0 fill
# B2 OR condition : compile SUCCESS + backtest 487172 ordres / 0 fill / NetProfit $0
#
# CORRECTION (c.909) : une premiere lecture annoncait "B2 = 0 ordre emis". C'etait un
# artefact de MESURE, pas un resultat : read_backtest avait ete appele pendant que le
# backtest tournait encore (cree 09:31:07), etat dans lequel l'API rend 0 ordre et des
# statistiques "-". Relu apres completion, B2 rend 487172 ordres sur 1461 jours.
#
# Verdict B2 (corrige) : la condition OR s'est declenchee CONSTAMMENT -- 3,2x plus
# d'ordres que B1 en AND (487172 vs 151653), exactement ce qu'on attend d'une condition
# moins stricte. L'hypothese "la condition d'entree est trop rare" est FALSIFIEE.
# Ce qui bloque n'est donc pas la rarete du signal mais le NON-REMPLISSAGE des ordres :
# les deux variantes emettent massivement et remplissent zero.
#
# Verdict final Phase D : INCONCLUSIVE_CONFIRMED -- signal abondant, remplissage nul.
# Cf. _results/QC-Py-40-Phase-D.json pour les 2 backtests + le diagnostic complet.
#
# Tell c.866-L4 (corrige c.909) : les ordres des deux variantes sont emis puis non
# remplis -- pour B1 (151653) comme pour B2 (487172). L'hypothese "B2 discriminant
# par 0 entry" tombe avec la mesure corrigee : B2 emet PLUS que B1, pas moins.
# Cause-racine avancee en Phase D : `liquidate(sym)` appele sans guard
# `if self.portfolio[sym].invested:`. Elle etait explicitement marquee "probable,
# NON VERIFIEE".
#
# ECARTEE c.931 (Phase E) : la verification l'infirme. Les 6 appels liquidate() du
# notebook sont deja tous gardes par `if self.portfolio[sym].invested:` -- lignes
# 166/172/179 (LEAN_ALGORITHM), 291/295/300 (B1), 414/418/423 (B2). Le guard
# n'a jamais manque. Un correctif "ajouter le guard" aurait ete du travail fabrique.
#
# CAUSE REELLE (Phase E, backtest B3) : la devise de compte. Voir la cellule
# Phase D/E ci-dessous.
#
# Le notebook reste pedagogique (code correct, pas PNL).=== Re-Phase C (c.701) : verdict INCONCLUSIVE ===
Strategie : Binance Mean-Reversion BB (20, 2.0)
Periode : 2021-01-01 to 2024-12-31
Capital initial : $10,000
QC Project : https://www.quantconnect.com/project/35674722
Backtests executes :
v3_id: df0827172f79005662081999bfa894fa
v3_orders: 181813
v3_filled: 0
v4_id: 4b85d281ee39b1139504cbe3113e461d
v4_orders: 151653
v4_filled: 0
v5_id: 627b58197f03336d40d8aba7f4c4e83b
v5_orders: 0
v5_filled: 0
v5_runtime_error: 2025-01-01 self.trades attribute missing (warmup reached + execution started)
v6_id: 769a748768e0b321fcc07756bb197d7b
v6_orders: 0
v6_filled: 0
Metriques reelles (issues des backtests ci-dessus) :
Total Return: 0% (0 trades filled)
CAGR: 0% (0 trades filled)
Sharpe Ratio: N/A (0 trades filled)
Max Drawdown: 0% (no positions taken)
Win Rate: N/A (0 trades filled)
Total Trades: 0
Profit Factor: N/A (0 trades filled)
Avg Trade Duration: N/A (0 trades filled)
Verdict : INCONCLUSIVE
Raison : 4 backtests executes sur QC Cloud avec fee model 0.1%, warmup timedelta(days=2) AVANT indicators, RSI threshold assoupli a 40 (vs 35). Aucun trade rempli. [FALSIFIE en Phase D/E -- conserve pour la trace] L'explication avancee alors etait que la condition d'entree (price < lower BB AND RSI < 40) serait trop stricte pour la granularite Minute. La mesure la contredit dans la cellule meme qui la porte : v4 rapporte 151 653 ordres emis, ce qui est incompatible avec une condition qui ne se declencherait pas. Cause reelle etablie en Phase E : devise de compte USD contre paires reglees en USDT.
Path forward : [SUPERSEDE par les Phases D et E] La voie envisagee alors -- balayer la grille de parametres (BB period 10-30, RSI 30-45, OR au lieu de AND) -- aurait ete inutile : B2 a effectivement teste OR et a produit 3,2x PLUS d'ordres pour toujours zero fill. Aucun reglage de la condition d'entree n'aurait debloque le remplissage, parce que le blocage n'etait pas la. Voir la cellule Phase D/E.
Detail dans _results/QC-Py-40-Re-Phase-C.json (4 attempts trail).
Lecture du resultat : backtest Re-Phase C INCONCLUSIVE (issue #13117, c.701)
La cellule precedente affiche le verdict INCONCLUSIVE issu de 4 executions reelles sur QC Cloud (v3-v6), pas des placeholders. Chaque tentative a reussi a compiler et a executer sur le moteur LEAN, mais aucune n’a produit un seul trade rempli :
- v3 (close vs price + warmup manquant) : 181K ordres envoyes, 0 fill
- v4 (set_warm_up APRES indicators) : 151K ordres, 0 fill
- v5 (warmup corrige, mais crash sur
self.tradesdiagnostic) : Runtime Error 2025-01-01 - v6 (warmup corrige + sans crash) : 0 ordres envoyes
Ce que ces quatre lignes disent deja, et qu’on n’a pas lu. Un backtest qui envoie 151 000 ordres et n’en remplit aucun n’est pas un backtest ou “la condition d’entree ne se declenche jamais” : c’est un backtest ou elle se declenche 151 000 fois et ou quelque chose empeche l’execution. Les deux enonces sont incompatibles, et ils cohabitaient dans la meme cellule. L’explication “condition trop stricte” a tenu quatre iterations parce qu’elle etait plausible, pas parce qu’elle etait compatible avec le chiffre affiche juste au-dessus d’elle.
Ce que le blocage revele quand meme sur la strategie : elle a ete calibree pour illustrer le workflow paper trading (lancer un backtest QC, lire ses sorties, brancher un fee model, deployer sur le testnet Binance), pas pour produire un edge rentable. La Phase B (PR #13153) avait corrige le masquage self.bb / self.rsi, mais n’avait valide ni la faisabilite du signal, ni – on le verra – la coherence du compte avec les paires tradees.
Distinction pedagogique preservee. Un verdict INCONCLUSIVE est une mesure : il est strictement plus informatif qu’un placeholder PENDING -- Phase C. On sait que le code compile, que les indicateurs se remplissent, que le warmup fonctionne. Ce qu’on ne savait pas encore, c’est pourquoi les ordres ne se remplissaient pas – et c’est l’objet des deux phases suivantes, qui reprennent ce diagnostic et le corrigent sur mesure.
Phases D et E : instrumenter, puis trouver la vraie cause
Les phases D et E reprennent le verdict ci-dessus et le soumettent a la mesure. Elles illustrent une methode transferable : quand une explication et un chiffre se contredisent, c’est l’explication qui cede.
Phase D – discriminer par une variable. Plutot que de balayer une grille de parametres, on change une seule chose et on regarde ce que le compte d’ordres devient.
| Run | Condition d’entree | Ordres emis | Fills |
|---|---|---|---|
| B1 | price < BB_lower AND RSI < 35 |
151 653 | 0 |
| B2 | price < BB_lower OR RSI < 35 |
487 172 | 0 |
Le rapport 487 172 / 151 653 = 3,2 est exactement ce qu’on attend en relachant une conjonction : la condition OR est satisfaite par strictement plus de barres que la condition AND. Donc l’hypothese “la conjonction stricte est trop rare” est falsifiee – relacher la conjonction multiplie les ordres et ne produit toujours aucun fill. Le blocage n’est pas le signal, c’est le remplissage.
Phase E – localiser le remplissage. L’hypothese suivante etait un liquidate() sans garde, qui aurait re-emis un ordre a chaque barre sur une position vide. Elle etait notee probable, non verifiee. La verification l’infirme : les six appels liquidate() du notebook sont deja tous gardes par if self.portfolio[sym].invested:. Corriger ce “bug” aurait produit un correctif sans objet.
L’hypothese retenue porte sur la devise du compte : les paires BTCUSDT et ETHUSDT se reglent en USDT, alors que set_cash(10000) finance le compte dans la devise LEAN par defaut, l’USD. En AccountType.CASH – c’est-a-dire sans marge – LEAN exige le solde de la devise de cotation pour acheter. Ce solde est nul, donc chaque ordre d’achat est rejete. Et comme la position ne devient jamais invested, la garde d’entree reste ouverte : set_holdings est re-emis a la barre suivante, puis a la suivante. Le compte d’ordres ne mesure pas un bug de boucle, il mesure le nombre de barres qui satisfont la condition.
Le test. B3 = B1 avec une seule ligne changee :
self.set_account_currency("USDT") # AVANT set_cash
self.set_cash(10000)Prediction falsifiable posee avant le run : si l’hypothese est juste, des fills apparaissent ; sinon elle tombe, et on le dit. La cellule suivante lit le resultat depuis le fichier de mesures, sans le retaper.
# Phases D et E : les resultats sont LUS depuis le fichier de mesures,
# jamais retapes a la main. Le fichier est ecrit a partir des reponses de
# l'API QuantConnect ; le notebook n'en est que le lecteur.
import json
from pathlib import Path
CANDIDATS = [
Path("../_results/QC-Py-40-Phase-D.json"),
Path("_results/QC-Py-40-Phase-D.json"),
Path("MyIA.AI.Notebooks/QuantConnect/_results/QC-Py-40-Phase-D.json"),
]
chemin = next((p for p in CANDIDATS if p.exists()), None)
if chemin is None:
print("Fichier de mesures introuvable depuis", Path.cwd())
print("Attendu : MyIA.AI.Notebooks/QuantConnect/_results/QC-Py-40-Phase-D.json")
phase_d = None
else:
phase_d = json.loads(chemin.read_text(encoding="utf-8"))
runs = phase_d["results_summary"]
print("=== Phases D et E -- mesures QC Cloud (projet %s) ===" % phase_d["qc_project_id"])
print()
print("%-4s %-38s %12s %s" % ("Run", "Backtest", "Ordres", "Resultat net"))
print("-" * 78)
for nom in ("B1", "B2", "B3"):
r = runs[nom]
print("%-4s %-38s %12s %s" % (
nom,
r["backtest_name"][:38],
format(r["totalOrders"], ",d").replace(",", " "),
r["statistics"]["totalNetProfit"],
))
print()
b3 = runs["B3"]
print("--- B3 : la variable changee ---")
print(b3["variable_unique_changee_vs_B1"])
print()
print("--- Verdict de l'hypothese : %s ---" % b3["verdict"])
print(b3["preuve"])
print()
print("--- Ce que la correction revele ensuite ---")
print(b3["second_enseignement"])
print()
print("--- Hypothese ecartee en chemin ---")
print(phase_d["cause_racine_etablie_c931"]["hypothese_ecartee"])
print()
print("--- Ce qui reste ouvert ---")
for item in phase_d["path_forward"]:
if item.startswith("RESTE OUVERT"):
print(" *", item)=== Phases D et E -- mesures QC Cloud (projet 35674722) ===
Run Backtest Ordres Resultat net
------------------------------------------------------------------------------
B1 B1_PhaseD_instrumented 151 653 0%
B2 B2 OR condition (Phase D B2 narrow wor 487 172 0%
B3 B3_PhaseE_account_currency_USDT 917 199 -99.880%
--- B3 : la variable changee ---
self.set_account_currency('USDT') place AVANT set_cash(10000). Rien d'autre ne change : meme condition d'entree AND, memes indicateurs BB(20,2.0)/RSI(14), meme resolution Minute, meme fee model 0.1%, meme AccountType.CASH.
--- Verdict de l'hypothese : HYPOTHESE CONFIRMEE ---
Deux signaux concordants, tous deux absents de B1 et B2. (1) La devise du P&L bascule de $ a ₮ (USDT) : la configuration a bien pris effet. (2) Les statistiques cessent d'etre nulles -- sharpeRatio -2.961, drawdown 99.900%, totalNetProfit -99.880%, netProfitAbsolute ₮-9,986.50. Un compte qui perd 99,88% de sa valeur a NECESSAIREMENT execute des trades ; B1 et B2 rendaient 0% partout, ce qui est la signature du remplissage nul.
--- Ce que la correction revele ensuite ---
Une fois le remplissage debloque, la strategie ne devient pas rentable -- elle devient ruineuse : 917 199 ordres sur 4 ans, soit ~628 ordres par jour de bourse, a 0,1% de frais par ordre. Le capital passe de ₮10 000 a ₮13,50. Le probleme reel du notebook n'etait donc pas 'la condition d'entree ne se declenche jamais' (elle se declenche constamment) mais un sur-trading massif : `set_holdings` est appele a chaque barre Minute qui satisfait la condition, sans aucun controle de frequence.
--- Hypothese ecartee en chemin ---
`liquidate(sym)` sans guard `if self.portfolio[sym].invested:` -- ECARTEE par verification mecanique du source : les 6 appels liquidate du notebook sont tous gardes. L'hypothese avait ete avancee comme 'probable, non verifiee' ; la verification l'infirme.
--- Ce qui reste ouvert ---
* RESTE OUVERT : le sur-trading. 917 199 ordres pour ₮10 000 de capital est le vrai defaut pedagogique de la strategie. Piste : throttling (une entree par jour maximum), ou passage en resolution Hour, ou un filtre de cooldown apres sortie. C'est un grain distinct, a traiter en PR separee -- il change la strategie, pas le diagnostic.
* RESTE OUVERT (tentative mesuree 2026-09-03 par myia-po-2023:CoursIA, suivi #14142) : les compteurs custom (n_bars_total, n_either, B1_DIAG) sortent par `Log()`, que le MCP qc-mcp-lite n'expose pas. Tentative Playwright (modalite QC autorisee, OAuth Google cache) : session QC Cloud expiree, popup OAuth Google BLOQUEE en contexte headless (0 onglet ouvert, 0 requete accounts.google.com emise) -- lecture UI confirmee RECOVERABLE-USER-HAND par la mesure, pas seulement par declaration. Substance diagnostique deja etablie sans eux : totalOrders=487172 (B2) prouve que on_data a ete appele et a emis massivement (hypothese (b) run-qui-n-a-rien-ingere exclue par l'API). La lecture UI ne resterait qu'une confirmation de cardinalite des barres.
Lecture du resultat : ce que B3 a tranche
Les deux signaux qui comptent dans la sortie ci-dessus sont absents de B1 et B2, et c’est leur apparition simultanee qui fait la preuve.
- La devise du P&L bascule de
$a₮. C’est le controle que la configuration a bien pris effet – sans lui, un resultat inchange serait indiscernable d’unset_account_currencysans effet. - Les statistiques cessent d’etre nulles. B1 et B2 rendaient
0%partout : c’est la signature du remplissage nul. B3 rend un Sharpe de -2,961, un drawdown de 99,900 % et un resultat net de -99,880 %. Un compte qui perd 99,88 % de sa valeur a necessairement execute des trades.
L’hypothese de la devise est donc confirmee. Elle n’etait pas la premiere : la precedente – un liquidate() sans garde – a ete ecartee par une lecture du source, pas par un run. Les deux ont coute le meme effort ; seule la seconde a survecu a la verification.
Et le resultat corrige est pire que le blocage. Une fois le remplissage debloque, la strategie emet 917 199 ordres en quatre ans, soit environ 628 par jour de bourse, chacun ponctionne de 0,1 % de frais. Le capital passe de ₮10 000 a ₮13,50. Le defaut reel du notebook n’etait donc ni “la condition ne se declenche jamais”, ni un guard manquant : c’est un sur-trading massif, set_holdings etant appele a chaque barre Minute qui satisfait la condition, sans le moindre controle de frequence.
C’est le point pedagogique de toute la section. Un backtest a 0 % ressemble a une strategie neutre ; il cachait ici une strategie qui detruit 99,9 % du capital. Un blocage technique peut masquer un desastre strategique, et la seule facon de le savoir est de lever le blocage. Le correctif du sur-trading (throttling, resolution horaire, cooldown apres sortie) change la strategie elle-meme : il releve d’un travail distinct, trace comme tel dans le fichier de mesures.
Phase F : corriger le sur-trading – deux remedes, une variable chacun
B3 a etabli le diagnostic : set_holdings etait appele a chaque barre Minute ou la condition d’entree tenait, d’ou 917 199 ordres en quatre ans – environ 628 par jour – chacun ponctionne de 0,1 % de frais, pour un resultat net de -99,88 %. La Phase F teste deux remedes candidats, en conservant la discipline des runs B : une seule variable changee par backtest.
B4 – entrer sur front montant (edge-triggered). Un circuit numerique peut reagir au niveau d’un signal (tant que la condition tient, j’agis – a chaque barre) ou a son front (j’agis une seule fois, a l’instant precis ou la condition passe de fausse a vraie). B4 memorise l’etat de la condition AND a la barre precedente et n’emet un ordre que sur la transition False -> True. Tant que la condition reste vraie, plus aucun ordre n’est emis. Un detail d’implementation compte : l’etat du signal est mis a jour chaque barre, avant le traitement des sorties – le continue des sorties ne doit jamais sauter cette mise a jour, sinon la sortie laisse un etat perime qui fabrique, ou avale, un front a la barre suivante.
B5 – cooldown de 60 minutes apres sortie. Apres chaque sortie (stop-loss ou retour a la mediane), aucune re-entree sur le symbole pendant une heure. Ce garde est strictement plus restrictif que B4 sur les entrees : il plafonne theoriquement la frequence a ~24 entrees par jour et par symbole.
Les deux algoritmes sont ceux des backtests QC Cloud du projet 35674722 (B4_PhaseF_edge_triggered_entry, B5_PhaseF_cooldown_60min).
# Algorithmes LEAN Phase F : B4 (edge-triggered) puis B5 (+ cooldown 60 min)
# Ce code est celui du projet QC Cloud 35674722, une variable par run.
LEAN_ALGORITHM_B4 = """# region imports
from AlgorithmImports import *
# endregion
class BinanceMeanReversionBB_B4(QCAlgorithm):
'''
Phase F B4 = B3 a UNE seule variable pres : l'entree devient EDGE-TRIGGERED.
B3 entrait sur NIVEAU (set_holdings a chaque barre Minute ou la condition
AND tient, tant que la position est plate) : 917 199 ordres en 4 ans,
628/jour, chaque remplissage ponctionne de 0,1% de frais -> net -99,88%.
B4 entre sur TRANSITION (front montant de la condition AND, false -> true).
Tout le reste (dates, devise USDT, indicateurs, sorties, compteurs) est B3.
'''
def initialize(self):
self.set_start_date(2021, 1, 1)
self.set_end_date(2024, 12, 31)
self.set_account_currency("USDT")
self.set_cash(10000)
self.set_brokerage_model(BrokerageName.BINANCE, AccountType.CASH)
self.btc = self.add_crypto("BTCUSDT", Resolution.MINUTE, Market.BINANCE)
self.eth = self.add_crypto("ETHUSDT", Resolution.MINUTE, Market.BINANCE)
self.symbols = [self.btc.symbol, self.eth.symbol]
self.bb_period = 20
self.bb_std = 2.0
self.bb_indicator = {}
for sym in self.symbols:
self.bb_indicator[sym] = self.bb(sym, self.bb_period, self.bb_std, resolution=Resolution.MINUTE)
self.rsi_indicator = {}
for sym in self.symbols:
self.rsi_indicator[sym] = self.rsi(sym, 14, resolution=Resolution.MINUTE)
self.stop_loss_pct = 0.05
self.position_weight = 0.4
self.entry_prices = {}
self.set_warm_up(self.bb_period, Resolution.MINUTE)
# === B1 INSTRUMENTATION : compteurs (inchanges) ===
self.n_bars_total = 0
self.n_guard_skip_warmup = 0
self.n_guard_skip_no_data = 0
self.n_guard_skip_not_ready = 0
self.n_price_below = 0
self.n_rsi_below = 0
self.n_both = 0
self.n_entry_attempts = 0
self.n_trades_filled = 0
self.sample_log_counter = 0
# === B4 : SEULE VARIABLE CHANGEE vs B3 ===
# Etat du signal d'entree a la barre precedente, par symbole.
# L'entree se declenche uniquement sur front montant (False -> True).
self.prev_entry_signal = {sym: False for sym in self.symbols}
self.n_entry_edges = 0
def on_data(self, data):
if self.is_warming_up:
self.n_guard_skip_warmup += 1
return
for sym in self.symbols:
if sym not in data or data[sym] is None:
self.n_guard_skip_no_data += 1
continue
if not self.bb_indicator[sym].is_ready or not self.rsi_indicator[sym].is_ready:
self.n_guard_skip_not_ready += 1
continue
self.n_bars_total += 1
price = data[sym].price
bb_lower = self.bb_indicator[sym].lower_band.current.value
bb_middle = self.bb_indicator[sym].middle_band.current.value
rsi_val = self.rsi_indicator[sym].current.value
price_ok = price < bb_lower
rsi_ok = rsi_val < 35
entry_signal = price_ok and rsi_ok
if price_ok:
self.n_price_below += 1
if rsi_ok:
self.n_rsi_below += 1
if entry_signal:
self.n_both += 1
self.sample_log_counter += 1
if self.sample_log_counter >= 10000:
self.sample_log_counter = 0
self.log(f"SAMPLE {self.Time} {sym} price={price:.2f} bb_low={bb_lower:.2f} rsi={rsi_val:.1f} bb_ready={self.bb_indicator[sym].is_ready} rsi_ready={self.rsi_indicator[sym].is_ready} price_ok={price_ok} rsi_ok={rsi_ok}")
# === B4 : entree sur FRONT MONTANT uniquement ===
# L'etat du signal est mis a jour CHAQUE barre, AVANT les sorties :
# le `continue` des sorties ne doit jamais laisser un etat stale
# qui fabrique (ou avale) un front a la barre suivante.
entry_edge = entry_signal and not self.prev_entry_signal[sym]
self.prev_entry_signal[sym] = entry_signal
if entry_edge:
self.n_entry_edges += 1
if self.portfolio[sym].invested:
entry = self.entry_prices.get(sym, price)
pnl = (price - entry) / entry
if pnl < -self.stop_loss_pct:
self.liquidate(sym)
self.entry_prices.pop(sym, None)
self.log(f"STOP-LOSS {sym}: pnl={pnl:.2%}")
continue
if price > bb_middle:
self.liquidate(sym)
self.entry_prices.pop(sym, None)
self.log(f"EXIT {sym}: price={price:.2f} > BB mid={bb_middle:.2f}")
continue
if not self.portfolio[sym].invested:
if entry_edge:
self.n_entry_attempts += 1
self.set_holdings(sym, self.position_weight)
self.entry_prices[sym] = price
self.log(f"ENTER {sym}: front montant a price={price:.2f} < BB low={bb_lower:.2f}, RSI={rsi_val:.1f}")
def on_order_event(self, order_event):
if order_event.status == OrderStatus.FILLED:
self.n_trades_filled += 1
def on_end_of_algorithm(self):
self.log(f"B4_DIAG n_bars={self.n_bars_total} warmup={self.n_guard_skip_warmup} no_data={self.n_guard_skip_no_data} not_ready={self.n_guard_skip_not_ready} price_below={self.n_price_below} rsi_below={self.n_rsi_below} both={self.n_both} entry_edges={self.n_entry_edges} entry_attempts={self.n_entry_attempts} fills={self.n_trades_filled}")
final = self.portfolio.total_portfolio_value
ret = (final - 10000) / 10000
self.log(f"B4_FINAL ${final:,.2f} return={ret:.2%}")
"""
LEAN_ALGORITHM_B5 = """# region imports
from AlgorithmImports import *
# endregion
class BinanceMeanReversionBB_B5(QCAlgorithm):
'''
Phase F B5 = B4 a UNE seule variable pres : un COOLDOWN de 60 minutes apres sortie.
B3 entrait sur NIVEAU (set_holdings a chaque barre Minute ou la condition
AND tient, tant que la position est plate) : 917 199 ordres en 4 ans,
628/jour, chaque remplissage ponctionne de 0,1% de frais -> net -99,88%.
B4 etait deja edge-triggered mais le resultat a falsifie l'hypothese : 879 847
ordres contre 917 199 pour B3 (-4 % seulement) -- la condition AND clignote
deja barre apres barre au granularity Minute, chaque front est une entree,
et le cycle entree->sortie (bande basse -> mediane) dure quelques minutes.
Le defaut est la FREQUENCE des allers-retours (~300/jour), pas le niveau.
B5 ajoute donc : apres une sortie (stop-loss ou retour a la mediane), aucun
re-entree sur ce symbole pendant 60 minutes. Tout le reste est B4.
'''
def initialize(self):
self.set_start_date(2021, 1, 1)
self.set_end_date(2024, 12, 31)
self.set_account_currency("USDT")
self.set_cash(10000)
self.set_brokerage_model(BrokerageName.BINANCE, AccountType.CASH)
self.btc = self.add_crypto("BTCUSDT", Resolution.MINUTE, Market.BINANCE)
self.eth = self.add_crypto("ETHUSDT", Resolution.MINUTE, Market.BINANCE)
self.symbols = [self.btc.symbol, self.eth.symbol]
self.bb_period = 20
self.bb_std = 2.0
self.bb_indicator = {}
for sym in self.symbols:
self.bb_indicator[sym] = self.bb(sym, self.bb_period, self.bb_std, resolution=Resolution.MINUTE)
self.rsi_indicator = {}
for sym in self.symbols:
self.rsi_indicator[sym] = self.rsi(sym, 14, resolution=Resolution.MINUTE)
self.stop_loss_pct = 0.05
self.position_weight = 0.4
self.entry_prices = {}
self.set_warm_up(self.bb_period, Resolution.MINUTE)
# === B1 INSTRUMENTATION : compteurs (inchanges) ===
self.n_bars_total = 0
self.n_guard_skip_warmup = 0
self.n_guard_skip_no_data = 0
self.n_guard_skip_not_ready = 0
self.n_price_below = 0
self.n_rsi_below = 0
self.n_both = 0
self.n_entry_attempts = 0
self.n_trades_filled = 0
self.sample_log_counter = 0
# === B4 : SEULE VARIABLE CHANGEE vs B3 ===
# Etat du signal d'entree a la barre precedente, par symbole.
# L'entree se declenche uniquement sur front montant (False -> True).
self.prev_entry_signal = {sym: False for sym in self.symbols}
self.n_entry_edges = 0
# === B5 : SEULE VARIABLE CHANGEE vs B4 ===
# Cooldown par symbole apres sortie : re-entree interdite pendant 60 min.
from datetime import timedelta as _td
self.cooldown = _td(minutes=60)
self.last_exit_time = {}
def on_data(self, data):
if self.is_warming_up:
self.n_guard_skip_warmup += 1
return
for sym in self.symbols:
if sym not in data or data[sym] is None:
self.n_guard_skip_no_data += 1
continue
if not self.bb_indicator[sym].is_ready or not self.rsi_indicator[sym].is_ready:
self.n_guard_skip_not_ready += 1
continue
self.n_bars_total += 1
price = data[sym].price
bb_lower = self.bb_indicator[sym].lower_band.current.value
bb_middle = self.bb_indicator[sym].middle_band.current.value
rsi_val = self.rsi_indicator[sym].current.value
price_ok = price < bb_lower
rsi_ok = rsi_val < 35
entry_signal = price_ok and rsi_ok
if price_ok:
self.n_price_below += 1
if rsi_ok:
self.n_rsi_below += 1
if entry_signal:
self.n_both += 1
self.sample_log_counter += 1
if self.sample_log_counter >= 10000:
self.sample_log_counter = 0
self.log(f"SAMPLE {self.Time} {sym} price={price:.2f} bb_low={bb_lower:.2f} rsi={rsi_val:.1f} bb_ready={self.bb_indicator[sym].is_ready} rsi_ready={self.rsi_indicator[sym].is_ready} price_ok={price_ok} rsi_ok={rsi_ok}")
# === B4 : entree sur FRONT MONTANT uniquement ===
# L'etat du signal est mis a jour CHAQUE barre, AVANT les sorties :
# le `continue` des sorties ne doit jamais laisser un etat stale
# qui fabrique (ou avale) un front a la barre suivante.
entry_edge = entry_signal and not self.prev_entry_signal[sym]
self.prev_entry_signal[sym] = entry_signal
if entry_edge:
self.n_entry_edges += 1
if self.portfolio[sym].invested:
entry = self.entry_prices.get(sym, price)
pnl = (price - entry) / entry
if pnl < -self.stop_loss_pct:
self.liquidate(sym)
self.entry_prices.pop(sym, None)
self.last_exit_time[sym] = self.time
self.log(f"STOP-LOSS {sym}: pnl={pnl:.2%}")
continue
if price > bb_middle:
self.liquidate(sym)
self.entry_prices.pop(sym, None)
self.last_exit_time[sym] = self.time
self.log(f"EXIT {sym}: price={price:.2f} > BB mid={bb_middle:.2f}")
continue
if not self.portfolio[sym].invested:
en_cooldown = (
sym in self.last_exit_time
and (self.time - self.last_exit_time[sym]) < self.cooldown
)
if entry_edge and not en_cooldown:
self.n_entry_attempts += 1
self.set_holdings(sym, self.position_weight)
self.entry_prices[sym] = price
self.log(f"ENTER {sym}: front montant a price={price:.2f} < BB low={bb_lower:.2f}, RSI={rsi_val:.1f}")
def on_order_event(self, order_event):
if order_event.status == OrderStatus.FILLED:
self.n_trades_filled += 1
def on_end_of_algorithm(self):
self.log(f"B5_DIAG n_bars={self.n_bars_total} warmup={self.n_guard_skip_warmup} no_data={self.n_guard_skip_no_data} not_ready={self.n_guard_skip_not_ready} price_below={self.n_price_below} rsi_below={self.n_rsi_below} both={self.n_both} entry_edges={self.n_entry_edges} entry_attempts={self.n_entry_attempts} fills={self.n_trades_filled}")
final = self.portfolio.total_portfolio_value
ret = (final - 10000) / 10000
self.log(f"B5_FINAL ${final:,.2f} return={ret:.2%}")
"""
print("LEAN_ALGORITHM_B4 + B5 (Phase F remediation sur-trading) prets pour backtest QC Cloud.")
print(f"Taille B4: {len(LEAN_ALGORITHM_B4)} caracteres ; B5: {len(LEAN_ALGORITHM_B5)} caracteres")LEAN_ALGORITHM_B4 + B5 (Phase F remediation sur-trading) prets pour backtest QC Cloud.
Taille B4: 5863 caracteres ; B5: 6824 caracteres
Phase G : benchmarks BTC / ETH buy-and-hold sur la meme fenetre 2021-2024
L’acceptance #13117 inclut une comparaison au benchmark sur fenetre alignee, avec verdict derive des mesures. Apres les Phases D/E (causalite devise USDT, remplissage debloque) et F (remediation sur-trading), on dispose de 3 strategies avec resultats nets :
- B3 Phase E : strategie reelle (compte USDT), 917 199 ordres sur 4 ans, -99.88 % net, sur-trading massif ;
- B4 Phase F : strategie reelle edge-triggered, 879 847 ordres, -99.877 % net, falsifie ;
- B5 Phase F : strategie reelle + cooldown 60 min, ~1 140 574 ordres, ~-99.87 % net, falsifie.
Pour les comparer honnetement, on a besoin des memes metriques sur la meme fenetre pour un investisseur passif : buy-and-hold BTCUSDT et ETHUSDT sur 2021-01-01 -> 2024-12-31, compte USDT, frais Binance 0.1 %, MINUTE resolution, capital initial 10 000 USDT.
Discipline Phase G : - 2 projets QC Cloud distincts (un seul algo par projet, QC n’autorise pas 3 classes actives simultanees dans le meme main.py) : QC-Py-40-BnH-BTC (36043732) et QC-Py-40-BnH-ETH (36043751). - Pas d’indicateurs, pas de logique de signal, juste SetHoldings(symbol, 1.0) au warmup end (j+2, premieres donnees valides). - Meme devise, meme frais, meme univers (BTCUSDT/ETHUSDT) que Phase E/F. - Verdict derive : comparer Sharpe / CAGR / Drawdown / net profit aux Phases D/F.
Ce que Phase G n’est PAS : - Pas une validation de la strategie (qui est deja falsifiee Phase F). - Pas un ajout de logique au notebook (juste 2 runs de reference). - Pas une comparaison au S&P 500 (l’univers est crypto Binance, le benchmark adapte est BTC/ETH buy-and-hold).
# Phase G : benchmarks BTC/ETH buy-and-hold, lecture depuis JSON
# (meme convention que Phase D/E/F : resultats LUS depuis le fichier de mesures,
# jamais retapes a la main).
import json
from pathlib import Path
def _trouver(nom_fichier):
candidats = [
Path("../_results") / nom_fichier,
Path("_results") / nom_fichier,
Path("MyIA.AI.Notebooks/QuantConnect/_results") / nom_fichier,
]
return next((p for p in candidats if p.exists()), None)
chemin_g = _trouver("QC-Py-40-Phase-G.json")
chemin_d = _trouver("QC-Py-40-Phase-D.json")
chemin_f = _trouver("QC-Py-40-Phase-F.json")
if chemin_g is None or chemin_d is None or chemin_f is None:
print("Fichier de mesures introuvable depuis", Path.cwd())
phase_g = None
else:
phase_g = json.loads(chemin_g.read_text(encoding="utf-8"))
phase_d = json.loads(chemin_d.read_text(encoding="utf-8"))
phase_f = json.loads(chemin_f.read_text(encoding="utf-8"))
print("=== Phase G -- benchmarks buy-and-hold (projets %s et %s) ===" % (
phase_g["results_summary"]["BnH_BTC"]["qc_project_id"],
phase_g["results_summary"]["BnH_ETH"]["qc_project_id"],
))
print()
print("Fenetre : 2021-01-01 -> 2024-12-31, MINUTE, USDT, frais 0.1 %")
print()
print("%-12s %-26s %10s %8s %9s %10s %s" % (
"Leg", "Backtest", "Ordres", "Sharpe", "MaxDD", "NetProfit", "CAGR"))
print("-" * 100)
# Benchmarks BnH
for leg in ("BnH_BTC", "BnH_ETH"):
r = phase_g["results_summary"][leg]
s = r["statistics"]
print("%-12s %-26s %10s %8s %9s %10s %s" % (
leg,
r["backtest_name"][:26],
format(r["totalOrders"], ",d").replace(",", " "),
s["sharpeRatio"],
s["drawdown"],
s["totalNetProfit"],
s["compoundingAnnualReturn"],
))
print()
print("--- Comparaison aux strategies Phases D/F (meme fenetre, meme univers) ---")
for leg in ("B3", "B4", "B5"):
src = phase_d if leg == "B3" else phase_f
r = src["results_summary"][leg]
s = r["statistics"]
print("%-12s %-26s %10s %8s %9s %10s %s" % (
leg,
r["backtest_name"][:26],
format(r["totalOrders"], ",d").replace(",", " "),
s["sharpeRatio"],
s["drawdown"],
s["totalNetProfit"],
s["compoundingAnnualReturn"],
))
print()
print("--- Verdict derive des mesures ---")
print(phase_g["verdict"])=== Phase G -- benchmarks buy-and-hold (projets 36043732 et 36043751) ===
Fenetre : 2021-01-01 -> 2024-12-31, MINUTE, USDT, frais 0.1 %
Leg Backtest Ordres Sharpe MaxDD NetProfit CAGR
----------------------------------------------------------------------------------------------------
BnH_BTC BnH-BTC-2021-2024-PhaseG-v 1 0.942 76.900% 219.078% 33.625%
BnH_ETH BnH-ETH-2021-2024-PhaseG-v 1 1.216 80.100% 348.410% 45.481%
--- Comparaison aux strategies Phases D/F (meme fenetre, meme univers) ---
B3 B3_PhaseE_account_currency 917 199 -2.961 99.900% -99.880% -81.382%
B4 B4_PhaseF_edge_triggered_e 879 847 -2.963 99.900% -99.877% -81.233%
B5 B5_PhaseF_cooldown_60min 1 140 574 -2.527 99.900% -99.874% -81.149%
--- Verdict derive des mesures ---
INCONCLUSIVE au sens strategie (les Phases D-F l'ont deja montre) ; mais Phase G DELIVRE le benchmark manquant : BTC BnH +219 %, ETH BnH +348 %. Le defaut de la strategie n'est PAS la selection d'asset (BTC/ETH montent fortement sur la fenetre) -- le defaut est l'EXCES d'ordres : ~628/jour en mode Phase E (B3, sans guard), 602/jour en Phase F B4 (edge-triggered), 781/jour en Phase F B5 (cooldown 60 min) -- 600 a 800 ordres/jour qui mangent le capital en frais 0.1 %. Une version 'entree edge-triggered + cooldown 24h + position hold minimum 24h' ferait probablement aussi bien que BnH avec 1 seul ordre au lieu de 600 a 800/jour. C'est un autre grain, pas Phase G.
# Phase F : les resultats sont LUS depuis le fichier de mesures, jamais
# retapes a la main (meme convention que la Phase D/E, cellule precedente).
import json
from pathlib import Path
def _trouver(nom_fichier):
candidats = [
Path("../_results") / nom_fichier,
Path("_results") / nom_fichier,
Path("MyIA.AI.Notebooks/QuantConnect/_results") / nom_fichier,
]
return next((p for p in candidats if p.exists()), None)
chemin_f = _trouver("QC-Py-40-Phase-F.json")
chemin_d = _trouver("QC-Py-40-Phase-D.json")
if chemin_f is None or chemin_d is None:
print("Fichier de mesures introuvable depuis", Path.cwd())
phase_f = None
else:
phase_f = json.loads(chemin_f.read_text(encoding="utf-8"))
phase_d = json.loads(chemin_d.read_text(encoding="utf-8"))
runs = [
("B3", phase_d["results_summary"]["B3"]),
("B4", phase_f["results_summary"]["B4"]),
("B5", phase_f["results_summary"]["B5"]),
]
print("=== Phase F -- remediation du sur-trading (projet %s) ===" % phase_f["qc_project_id"])
print()
print("%-4s %-30s %12s %8s %9s %s" % ("Run", "Backtest", "Ordres", "Sharpe", "MaxDD", "Resultat net"))
print("-" * 88)
for nom, r in runs:
print("%-4s %-30s %12s %8s %9s %s" % (
nom,
r["backtest_name"][:30],
format(r["totalOrders"], ",d").replace(",", " "),
r["statistics"]["sharpeRatio"],
r["statistics"]["drawdown"],
r["statistics"]["totalNetProfit"],
))
print()
for nom in ("B4", "B5"):
r = phase_f["results_summary"][nom]
print("--- %s : la variable changee ---" % nom)
print(r["variable_unique_changee_vs_" + ("B3" if nom == "B4" else "B4")])
print()
print("--- %s : verdict %s ---" % (nom, r["verdict"]))
print(r["preuve"])
print()=== Phase F -- remediation du sur-trading (projet 35674722) ===
Run Backtest Ordres Sharpe MaxDD Resultat net
----------------------------------------------------------------------------------------
B3 B3_PhaseE_account_currency_USD 917 199 -2.961 99.900% -99.880%
B4 B4_PhaseF_edge_triggered_entry 879 847 -2.963 99.900% -99.877%
B5 B5_PhaseF_cooldown_60min 1 140 574 -2.527 99.900% -99.874%
--- B4 : la variable changee ---
Entree EDGE-TRIGGERED : l'ordre n'est emis que sur le front montant de la condition AND (False -> True), l'etat du signal etant memorise par symbole et mis a jour chaque barre AVANT le traitement des sorties (le continue des sorties ne doit jamais laisser un etat stale). Tout le reste (dates, devise USDT, indicateurs, sorties, compteurs) est B3.
--- B4 : verdict HYPOTHESE FALSIFIEE -- NO BEATS vs B3 ---
879 847 ordres contre 917 199 pour B3 : -4 % seulement. L'entree sur niveau n'etait PAS le moteur du volume. Au granularity Minute la condition AND clignote deja barre apres barre : chaque front montant est une entree possible, et la position etant plate apres chaque sortie rapide (retour a la mediane en quelques minutes), les fronts se convertissent en entrees presque 1:1. Net (-99,877 %), Sharpe (-2,963) et drawdown (99,900 %) indiscernables de B3.
--- B5 : la variable changee ---
COOLDOWN de 60 minutes par symbole apres chaque sortie (stop-loss ou retour a la mediane) : aucune re-entree pendant le delai. Strictement plus restrictif que B4 sur les entrees.
--- B5 : verdict HYPOTHESE FALSIFIEE AUSSI -- NO BEATS vs B3, ordres EN HAUSSE ---
1 140 574 ordres : PLUS que B3 (917 199) et B4 (879 847), alors que le cooldown ne peut que reduire les entrees. Le volume d'ordres n'est donc pas pilote par la frequence des decisions d'entree mais par un mecanisme d'un autre ordre. Candidat le plus plausible (HYPOTHESE, non tranchee par ces runs) : tempete de re-soumissions en fin de vie du compte -- quand l'equity tombe a quelques dizaines de USDT, des positions ciblees a 40 % x equity passent sous le montant minimal d'ordre Binance, l'ordre est rejete, la position reste invested, et chaque barre re-emet liquidate. Les trois runs convergent vers la meme equity finale (~13,50 USDT) : la queue du backtest est identique, seule sa longueur varie.
Lecture du resultat : deux remedes evidents, deux falsifications
Le tableau ci-dessus est la lecon la plus utile de toute la section : les deux corrections “evidentes” du sur-trading ont ete mesurees, et aucune des deux ne le corrige.
- B4 (entree sur front montant) : 879 847 ordres contre 917 199 pour B3, soit -4 %. Au granularity Minute, la condition AND clignote deja barre apres barre – chaque front montant est une nouvelle entree possible. Entrer sur le front plutot que sur le niveau ne change presque rien quand le signal lui-meme oscille a chaque barre.
- B5 (cooldown de 60 minutes apres sortie) : 1 140 574 ordres – plus que B3 et B4. Le cooldown est pourtant strictement plus restrictif que B4 sur les entrees. Le volume d’ordres n’est donc pas pilote par la frequence des decisions de la strategie : il est domine par un mecanisme d’un autre ordre, dont le candidat le plus plausible est la tempete de re-soumissions en fin de vie du compte – quand l’equity tombe a quelques dizaines de USDT, des positions de 40 % x equity passent sous le montant minimal d’ordre Binance, les ordres sont rejete, la position reste
invested, et chaque barre re-emetliquidate. Les trois runs convergent d’ailleurs vers la meme equity finale (~13,50 USDT) : la queue du backtest est identique, seule sa longueur varie.
Verdict honnete : NO BEATS. Ni B4 ni B5 n’amelioore B3 sur aucune metrique (net, Sharpe, drawdown statistiquement indiscernables ; volume non reduit). Ce n’est pas un echec de methode mais le resultat lui-meme : la discipline une-variable-par-run a converti deux intuitions raisonnables en deux falsifications mesurees. C’est exactement ce qu’un backtest est cense faire.
Ce qui discriminerait la suite : le detail par ordre (combien d’entrees vs combien de re-soumissions de liquidate) est accessible par les compteurs B4_DIAG/B5_DIAG (entry_edges, entry_attempts, fills) dans les logs QC Cloud, ou par read_backtest_orders – ni l’un ni l’autre n’est expose par le MCP (qc-mcp-lite), la lecture demande l’interface QC Cloud (RECOVERABLE-USER-HAND). Le suspect structurel reste l’economie par trade au granularity Minute : les bandes de Bollinger 20-period Minute sont si etroites que le captage du retour a la moyenne (~0,1-0,3 %) ne couvre pas les frais aller-retour (0,2 %) – aucune strategie de cette forme ne peut etre profitable a cette resolution, quel que soit son filtrage de frequence. Le remede structurel (resolution Hour, ou bandes elargies) est un grain ulterieur, a ne tenter qu’apres la lecture des compteurs.
# Visualisation des metriques cles -- Re-Phase C (issue #13117) : INCONCLUSIVE
# Re-Phase C : equity constante a $10,000 -- 0 trade execute, aucun mouvement.
fig, axes = plt.subplots(1, 2, figsize=(14, 5))
# Equity : constante a $10,000 (0 trade rempli -- le capital ne bouge pas).
axes[0].axhline(y=10000, color='#2196F3', linewidth=1.5, label='Strategie BB (0 trade) -- equity constante $10,000')
axes[0].axhline(y=10000, color='gray', linestyle='--', alpha=0.5, label='Capital initial ($10,000)')
axes[0].set_title("Courbe de P&L (Backtest) -- Re-Phase C : 0 trades (INCONCLUSIVE)")
axes[0].set_ylabel('Valeur Portfolio ($)')
axes[0].legend()
axes[0].tick_params(axis='x', rotation=45)
# Drawdown : 0 par construction (aucune position prise).
axes[1].axhline(y=0, color='#F44336', linewidth=1.5)
axes[1].set_title("Drawdown (%) -- Re-Phase C : 0% (aucune position)")
axes[1].set_ylabel('Drawdown (%)')
axes[1].tick_params(axis='x', rotation=45)
plt.tight_layout()
plt.show()
plt.close()
print("RE-PHASE C : equity constante a $10,000 -- 0 trades filled (verdict INCONCLUSIVE)")
print(" Le backtest a bien tourne sur QC Cloud (4 tentatives v3-v6) mais aucun fill.")
print(" Voir _results/QC-Py-40-Re-Phase-C.json pour le diagnostic detaille.")
RE-PHASE C : equity constante a $10,000 -- 0 trades filled (verdict INCONCLUSIVE)
Le backtest a bien tourne sur QC Cloud (4 tentatives v3-v6) mais aucun fill.
Voir _results/QC-Py-40-Re-Phase-C.json pour le diagnostic detaille.
Lecture des courbes : Re-Phase C INCONCLUSIVE (issue #13117)
La figure affiche, en Re-Phase C, deux droites a y = 10000 (equity constante) et un drawdown a 0 %. Ce n’est pas un placeholder : c’est le resultat mesure de 4 backtests reels executes sur QC Cloud (v3-v6, cf. cellule 1bd078ca) qui n’ont produit aucun trade. La cellule ne fabrique pas de trajectoire avec np.random.normal(seed=42) comme l’ancienne version (cf. issue #13117 pour le diagnostic) : l’equity reste a $10,000 parce que le capital n’a jamais bouge.
Lecture honnete : une equity constante n’est pas un backtest reussi, c’est l’absence de trade – et c’est exactement le verdict INCONCLUSIVE. La strategie mean-reversion Bollinger Bands (BB 20-period 2-sigma + RSI < 40 simultanement, donnees Minute 2021-2024) est trop stricte pour se declencher sur cette fenetre. Le drawdown a 0 % est coherent : aucune position ouverte, aucun risque de creux.
Ce que ca revele : ce n’est pas un echec d’execution (le code compile, les indicateurs se remplissent, le warmup fonctionne), c’est un signal de faisabilite – la condition d’entree ne se declare jamais. Les 4 versions (v3-v6) differaient par l’ordre du warmup et le seuil RSI ; le verdict final est INCONCLUSIVE (voir _results/QC-Py-40-Re-Phase-C.json).
Methode de lecture generalisable : un portefeuille d’equity constante sur une fenetre ou un backtest a reellement tourne est un signal fort – soit la strategie ne genere aucun signal (ici), soit la logique d’entree est defectueuse. On ne conclut pas un alpha positif si la courbe ne bouge pas et que le drawdown reste a 0 ; les deux sont coherents ici par absence de trades.
4. Analyse des Résultats
Points cles a evaluer avant de passer en paper trading :
- Sharpe Ratio > 0.5 : la stratégie genere un rendement ajuste au risque positif
- Max Drawdown acceptable : -28% est eleve mais typique pour du crypto
- Win Rate > 50% : les trades gagnants sont plus frequents que les perdants
- Vs Benchmark : performance inferieure au BTC buy & hold, mais drawdown plus faible
La stratégie est un complement au holding, pas un remplacement.
Comment lire ces metriques : le couple a regarder n’est pas le rendement total seul mais le ratio rendement/risque — deux strategies a +45% peuvent etre radicalement differentes si l’une descend de -10% et l’autre de -28%. La distribution des retours par trade (histogramme ci-dessous) complete le recit : elle montre si le resultat repose sur quelques gros trades ou sur un flux regulier de petits gains.
# Analyse detaillee : distribution des retours par trade -- Re-Phase C (issue #13117) : INCONCLUSIVE
# Re-Phase C : histogramme vide -- 0 trade execute, aucune distribution.
fig, ax = plt.subplots(figsize=(10, 5))
# Histogramme vide -- 0 trade rempli, donc aucune distribution a tracer.
ax.axvline(x=0, color='red', linestyle='--', linewidth=1.5, label='Break-even')
ax.set_title('Distribution des retours par trade -- Re-Phase C : 0 trades (INCONCLUSIVE)')
ax.set_xlabel('Retour par trade (%)')
ax.set_ylabel('Frequence')
ax.legend()
plt.tight_layout()
plt.show()
plt.close()
print("RE-PHASE C : histogramme vide -- 0 trades filled (verdict INCONCLUSIVE)")
print(" Aucun trade n'a ete execute, donc aucune distribution de P&L n'existe.")
print(" Voir _results/QC-Py-40-Re-Phase-C.json pour le diagnostic detaille.")
RE-PHASE C : histogramme vide -- 0 trades filled (verdict INCONCLUSIVE)
Aucun trade n'a ete execute, donc aucune distribution de P&L n'existe.
Voir _results/QC-Py-40-Re-Phase-C.json pour le diagnostic detaille.
Lecture de la distribution : histogramme vide en Re-Phase C (issue #13117)
L’histogramme est vide par construction : total_trades = 0 apres 4 backtests reels. L’ancienne version affichait une distribution simulee par deux lois normales (seed 123, 342 trades dont 58 % gagnants), avec une note qui arguait d’un decalage int(342*0.58)=198 – cette asymetrie est exactement ce qu’un backtest reel ne permet pas : les trades sont comptes par le moteur, pas simules.
Lecture honnete de l’etat Re-Phase C : aucun trade n’a ete execute, donc aucune distribution n’existe. Les metriques qui en dependaient (Win Rate 58 %, Profit Factor 1.45, ratio gain/perte 1.40, moyenne +0.326 %) ne sont pas mesurables et ont ete retirees de la cellule precedente. Ce n’est pas une perte d’information : c’est un verdict – la strategie ne produit pas de signaux sur la fenetre Minute 2021-2024.
Ce que ca revele : la calibration initiale visait a illustrer le workflow paper trading (lancer un backtest, lire ses sorties, brancher un fee model), pas a produire un edge. Le verdict INCONCLUSIVE (0 trades) est plus informatif qu’un placeholder : il prouve que le code compile et tourne, mais que la logique d’entree est trop stricte. La comparaison moyenne vs ratio gain/perte, qui distinguait un profil de scalping d’une loterie, reste un outil valide – mais s’appliquera sur des trades reels quand la strategie en produira (parameter sweep), pas sur une distribution fabriquee.
5. Deploy en Paper Trading via MCP QC
Le deploiement suit le workflow standard QuantConnect :
1. Créer le projet cloud
2. Ecrire main.py (l'algorithme LEAN ci-dessus)
3. Compiler (create_compile + read_compile)
4. Configurer le node live
5. Deployer avec le brokerage Binance paper
Note : Pour le paper trading Binance, on utilise le Spot Test Network (pas de compte Binance reel requis). Les API keys se generent sur testnet.binance.vision avec un compte GitHub.
Le deploiement passe par les memes outils MCP que le backtest (compile -> node -> create_live), la difference tenant a la configuration du broker : compte Binance paper, environnement paper, noeud L-MICRO. Les identifiants restent dans les champs securises QC, jamais dans le notebook (regle secrets : aucun literal). Un point a connaitre avant de deploiyer : Binance paper ne remonte pas de mises a jour d’ordre (order_updates: False dans la configuration) — le monitoring se fait donc par interrogation, pas par evenements.
6. Monitoring : Lire les Stats Live
Une fois le paper trading deploye, on surveille : - Portfolio : valeur, positions ouvertes - Orders : ordres executes, fills - Logs : erreurs, messages de trading
Lire les stats comme des signaux de sante, pas comme un tableau de bord. Une croissance reguliere du portfolio = l’edge persiste ; un plat ou un declin = l’edge se degrade (changement de regime ou parametres perimes). Des erreurs > 0 = probleme de connectivite (rate-limit, auth expiree, reseau) – et les erreurs ne coutent pas que des trades isoles, elles brisent la boucle de retroaction (signaux manques = trainee de performance silencieuse). Un slippage fill-vs-mid croissant = l’impact de marche s’accumule ou la liquidite s’amincit ; la strategie approche de sa capacite. La question diagnostique : le P&L live suit-il la courbe d’equity du backtest dans la tolerance ? Une divergence au-dela de ~20% (cf. Exercice 1) = stop et investiguer.
- Insights : signaux generes par l’algorithme
En pratique, trois stats live commandent le verdict : la valeur du portfolio (crash ?), le nombre de trades (l’algorithme tourne-t-il vraiment ?) et le drawdown courant (s’eloigne-t-il du MaxDD backtest ?). La checklist de la cellule suivante transforme ces lectures en seuils de decision — le passage paper-to-live se gate sur des nombres, pas sur des impressions.
# Code de reference pour le monitoring
monitoring_workflow = """
# Lire l'etat de l'algorithme live
status = read_live_algorithm(projectId=PROJECT_ID)
# status.contain: chart data, runtime stats, etc.
# Lire les ordres executes
orders = read_live_orders(projectId=PROJECT_ID, start=0, end=100)
# Lire les logs
logs = read_live_logs(
projectId=PROJECT_ID,
algorithmId="L-xxxxx",
startLine=0,
endLine=250
)
# Lire le portfolio
portfolio = read_live_portfolio(projectId=PROJECT_ID)
# portfolio.contain: holdings, cash, total value
# Lire les insights (signaux)
insights = read_live_insights(projectId=PROJECT_ID, end=100)
"""
# Metriques a surveiller en paper trading
monitoring_checklist = [
"Portfolio value > 90% du capital initial (pas de crash)",
"Ordres bien executes (pas d'erreurs repetees)",
"Nombre de trades coherent avec le backtest",
"Drawdown live <= MaxDD du backtest (ou proche)",
"Pas de logs d'erreur critiques",
"Fills simules a des prix realistes",
]
print("Checklist monitoring paper trading:")
for i, item in enumerate(monitoring_checklist, 1):
print(f" {i}. {item}")Checklist monitoring paper trading:
1. Portfolio value > 90% du capital initial (pas de crash)
2. Ordres bien executes (pas d'erreurs repetees)
3. Nombre de trades coherent avec le backtest
4. Drawdown live <= MaxDD du backtest (ou proche)
5. Pas de logs d'erreur critiques
6. Fills simules a des prix realistes
Lecture de la checklist
Les six items sont ordonnes par gravite, pas par commodite. Le point 1 (portfolio > 90% du capital initial) est le pare-feu : sous ce seuil, quelque chose ne fonctionne pas comme le backtest le predisait — c’est la decision a prendre, pas a observer. Le point 3 (nombre de trades coherent) detecte l’implementation muette : un algorithme qui tourne sans trader est un bug silencieux que seul un compteur confronte au backtest revele. Le point 4 (drawdown live <= MaxDD backtest) est le test le plus honnete : il compare le vivant a sa propre calibration. Les points 2, 5 et 6 (executions, logs, prix de fills) sont le diagnostic fin quand l’un des trois premiers a sonne.
Exercice 1 : Alerte de deviation backtest vs paper
En paper trading, les performances reelles devient du backtest. Un système d’alerte detecte ces deviations.
Objectif : Implementer un detecteur de deviation entre metriques backtest et paper trading.
Règles : - Metriques a comparer : Sharpe, max drawdown, win rate, trade frequency - Seuils d’alerte : deviation > 20% sur n’importe quelle metrique - Alerte critique : Sharpe paper < Sharpe backtest * 0.5 - Affichez un rapport de deviation avec niveau (OK/WARNING/CRITICAL)
Indices : - Indice : deviation = abs(paper - backtest) / abs(backtest) - Indice : Assignez un niveau par metrique puis prenez le max
# Exercice 3 : Alerte deviation backtest vs paper
# TODO etudiant : Implementer un detecteur de deviation
# Indice : Comparer metriques, seuils 20%/50%, rapport OK/WARNING/CRITICAL
# Etape 1 : Definir les metriques backtest et paper (simulees)
# Etape 2 : Calculer les deviations
# Etape 3 : Appliquer les seuils d'alerte
# Etape 4 : Generer le rapport de deviation
result = None # TODO etudiant : remplacer par le detecteur de deviation
print("Exercice a completer")Exercice a completer
Du detecteur d’alerte au rapport quotidien
L’exercice ci-dessus demande de detecter une derive. La cellule suivante montre ce qu’on fait du resultat une fois detecte : un rapport quotidien qui condense une journee de paper trading en quatre nombres (valeur du portefeuille, nombre de trades, erreurs, positions ouvertes).
Les deux repondent a des besoins distincts, et un systeme de paper trading a besoin des deux. Le detecteur repond a « dois-je aller regarder ? » : il se tait tant que rien ne depasse le seuil. Le rapport repond a « qu’est-ce qui s’est passe ? » : il sort tous les jours, y compris quand tout va bien. Sans detecteur, on lit des rapports d’apparence saine pendant que la strategie derive ; sans rapport, on ne sait pas ce qui a change le jour ou l’alerte tombe.
La fonction ci-dessous est un exemple illustre a valeurs simulees, pas la sortie d’un deploiement reel — la lecture qui suit la cellule le detaille.
# Simulation d'un rapport de monitoring quotidien
def generate_daily_report(day: int, portfolio_value: float, trades: int,
errors: int, positions: List[str]) -> str:
"""Genere un rapport quotidien de paper trading."""
pnl = (portfolio_value - 10000) / 10000 * 100
status = "OK" if errors == 0 else f"{errors} ERREUR(S)"
report = f"""
================================================
RAPPORT PAPER TRADING - Jour {day}
================================================
Portfolio: ${portfolio_value:,.2f} (P&L: {pnl:+.2f}%)
Trades aujourd'hui: {trades}
Positions ouvertes: {', '.join(positions) if positions else 'Aucune'}
Erreurs: {status}
================================================
"""
return report
# Exemple de rapport
report = generate_daily_report(
day=7,
portfolio_value=10150.0,
trades=3,
errors=0,
positions=["BTCUSDT"]
)
print(report)
================================================
RAPPORT PAPER TRADING - Jour 7
================================================
Portfolio: $10,150.00 (P&L: +1.50%)
Trades aujourd'hui: 3
Positions ouvertes: BTCUSDT
Erreurs: OK
================================================
Lecture du rapport : exemple illustre en Phase B (issue #13117)
Le rapport du jour 7 condense une journee en quatre nombres : $10,150.00 de portfolio (+1.50% de P&L), 3 trades aujourd’hui, positions BTCUSDT, Erreurs : OK – tous issus d’un exemple simulé (generate_daily_report(day=7, ...)), pas d’une execution reelle.
Lecture assumee en etat Phase B : aucun de ces chiffres ne provient d’un paper trading deploye. La cellule montre le format d’un rapport (print + str.format), pas une mesure. En Phase C, ce rapport sera renseigne par les vraies statistiques d’un algorithme deploye via read_live_portfolio(), read_live_orders(), read_live_logs() et read_live_insights() (les signatures sont documentees dans la cellule 8640fef4 precedente). Le rythme observe (3 trades/jour pour ce jour illustre) sera a confronter au rythme de la strategie reelle une fois executee – pas pris comme prevision.
Distinction pedagogique : la cellule precedente a retire les chiffres fabriques (Win Rate 58 %, Profit Factor 1.45, etc.) du notebook. Ce rapport du jour 7 reste un exemple de forme ; les seuils quantitatifs de la checklist de la section 6 (portfolio > 90 % capital initial, nombre de trades coherent avec le backtest, drawdown live <= MaxDD backtest) sont ce qui tient independamment du run, et c’est eux que la Phase C appliquera sans les recalibrer.
Exercice 2 : Rapport de slippage estimation
Créez une fonction qui estime le slippage moyen a partir d’une liste de trades paper vs prix mid. Le slippage est la différence entre le prix d’exécution et le prix mid au moment de l’ordre.
Indices : - # Indice : slippage = |fill_price - mid_price| / mid_price - # Étape 1 : Simuler une liste de trades avec prix mid et prix fill - # Étape 2 : Calculer le slippage pour chaque trade - # Étape 3 : Afficher le slippage moyen et la distribution
# Exercice 1 : Estimation du slippage
# TODO etudiant : Calculer le slippage moyen a partir de trades simules
# Etape 1 : Simuler 20 trades (prix mid + prix fill)
# Etape 2 : Calculer slippage = |fill - mid| / mid
# Etape 3 : Afficher slippage moyen en bps
avg_slippage_bps = None # TODO etudiant : remplacer par le calcul
print("Exercice a completer : Estimation du slippage")Exercice a completer : Estimation du slippage
7. Exercice : Adapter les Paramètres BB
L’objectif est d’explorer l’impact des paramètres de Bollinger Bands sur la performance.
Paramètres a tester : - bb_period : periode de la moyenne mobile (10, 20, 30) - bb_std : nombre d’ecarts-types (1.5, 2.0, 2.5) - rsi_threshold : seuil RSI pour l’entree (25, 30, 35, 40) - stop_loss_pct : stop-loss (3%, 5%, 8%)
TODO etudiant : remplacer les valeurs ci-dessous pour experimenter.
Pourquoi explorer les parametres : la mean-reversion Bollinger vit de son couple (period, std) — un BB court (period 10) reagit plus vite mais genere plus de faux signaux, un BB long (period 50) filtre le bruit mais entre trop tard. Le but de l’exercice n’est pas de trouver le meilleur backtest possible (sur-optimisation), mais de cartographier la sensibilite : si les parametres voisins changent radicalement le resultat, la strategie est fragile et le paper trading le revelera.
# Exercice : tester differentes configurations de parametres
# Configuration de base (deja backtestee)
base_config = {
"bb_period": 20,
"bb_std": 2.0,
"rsi_threshold": 35,
"stop_loss_pct": 0.05,
}
# TODO etudiant : proposez 3 configurations alternatives et predisez
# laquelle sera la meilleure en paper trading. Justifiez votre choix.
config_a = { # Plus reactif
"bb_period": None, # TODO etudiant : choisir une valeur
"bb_std": None, # TODO etudiant : choisir une valeur
"rsi_threshold": None, # TODO etudiant : choisir une valeur
"stop_loss_pct": None, # TODO etudiant : choisir une valeur
}
config_b = { # Plus conservateur
"bb_period": None, # TODO etudiant : choisir une valeur
"bb_std": None, # TODO etudiant : choisir une valeur
"rsi_threshold": None, # TODO etudiant : choisir une valeur
"stop_loss_pct": None, # TODO etudiant : choisir une valeur
}
config_c = { # Aggressif
"bb_period": None, # TODO etudiant : choisir une valeur
"bb_std": None, # TODO etudiant : choisir une valeur
"rsi_threshold": None, # TODO etudiant : choisir une valeur
"stop_loss_pct": None, # TODO etudiant : choisir une valeur
}
# Affichage de la configuration de base
print("Configuration de base (resultats backtest):")
for k, v in base_config.items():
print(f" {k}: {v}")
print("\nTODO: Completez config_a, config_b, config_c avec vos choix.")
print("Indice: un BB plus court (period=10) reagira plus vite mais generera plus de faux signaux.")Configuration de base (resultats backtest):
bb_period: 20
bb_std: 2.0
rsi_threshold: 35
stop_loss_pct: 0.05
TODO: Completez config_a, config_b, config_c avec vos choix.
Indice: un BB plus court (period=10) reagira plus vite mais generera plus de faux signaux.
Lecture des parametres
La configuration de base pose quatre leviers : bb_period 20, bb_std 2.0, rsi_threshold 35, stop_loss 5%. Chacun joue un role different — la bande (period, std) fixe le canal de mean-reversion ; le RSI (35) filtre les entrees en fin de range ; le stop borne la queue de perte. L’exercice demande trois variantes (config_a/b/c) : le bon geste n’est pas de tatonner mais de faire varier un levier a la fois et de mesurer l’effet — c’est la definition d’une sensibilite. La configuration est aussi le lieu ou le gap backtest/paper apparait d’abord : un stop qui ne se remplit pas en paper (slippage) se lit immediatement dans les quatre nombres du rapport quotidien.
# TODO etudiant : Analysez les ecarts potentiels entre backtest et paper trading
# Remplissez le tableau ci-dessous avec vos observations
ecarts_analysis = {
"Slippage": "En paper trading, les fills sont simules au prix du marche. " +
"En live, le slippage peut etre significatif sur les marches crypto.",
"Latence": None, # TODO etudiant : decrire l'impact de la latence
"Liquidite": None, # TODO etudiant : decrire l'impact de la liquidite
"Market impact": None, # TODO etudiant : decrire l'impact sur le marche
}
print("Analyse des ecarts backtest / paper trading:")
for k, v in ecarts_analysis.items():
if v:
print(f" {k}: {v}")
else:
print(f" {k}: [A completer]")Analyse des ecarts backtest / paper trading:
Slippage: En paper trading, les fills sont simules au prix du marche. En live, le slippage peut etre significatif sur les marches crypto.
Latence: [A completer]
Liquidite: [A completer]
Market impact: [A completer]
Conclusion
Ce notebook a couvert le workflow complet :
- Concepts : paper trading vs live, architecture Binance sur QC
- Stratégie : mean-reversion Bollinger Bands sur BTC/ETH
- Backtest : wiring préparé (algo LEAN corrigé du masquage
self.bb/self.rsi– voir cellulea28a5593et issue #13117 Phase B) – exécution réelle en Phase C sur QC Cloud, voir issue #13117 - Deploy : workflow MCP QC pour lancer en paper trading
- Monitoring : surveillance quotidienne des performances
- Exercice : exploration des paramètres
Prochaines étapes
- Phase C (issue #13117) : backtest QC Cloud sur la période 2021-2024 + benchmarks BTC/ETH buy & hold sur la même fenêtre et les mêmes frais
- QC-Py-41 : Paper Trading IBKR (equities via Interactive Brokers)
- Comparer les résultats paper avec le backtest sur la même période
- Document
PAPER_TO_LIVE_TRANSITION.mdpour la transition vers le live
Ce qu’il faut retenir :
- Le paper trading valide l’implementation, pas l’edge – le backtest reste la seule preuve de stratégie. En etat Re-Phase C, 4 backtests ont ete executes sur QC Cloud (v3-v6, cf. cellule
1bd078ca) et ont produit 0 trade : les cellules des sections 3 et 4 affichent le verdict INCONCLUSIVE (equity constante $10,000, histogramme vide) – cf._results/QC-Py-40-Re-Phase-C.jsonet le body de la PR. Ce n’est pas une absence de mesure : c’est un verdict de faisabilite, la strategie ne se declenche pas a cette granularite. - Le principe pédagogique qu’un résultat honnête peut être un échec instructif (la stratégie mean-reversion mean-revertie peut sous-performer son benchmark buy & hold en régime trending) reste valide comme illustration générale : la version Phase C tranchera sur des données réelles. La prose n’utilise plus les chiffres illustratifs antérieurs (
+45.2%,+67%,Sharpe 0.82, etc.) comme exemples depuis la revueclusterManager-Myiadu 26/08. - Binance paper est le simulateur le plus simple à mettre en route (testnet gratuit, zero prérequis) ; son point faible est l’absence de mises à jour d’ordre, compensée par un monitoring par interrogation.
- Le passage paper-to-live se gate sur des seuils quantitatifs (checklist de la section 6), jamais sur des impressions.
Et après : QC-Py-41 transpose le même workflow chez Interactive Brokers (multi-asset, order updates, TWS) – la comparaison des deux simulateurs est le fil rouge de la paire.