Stratégies en cascade : exécution prioritaire avec remplissage de secours
Final de la série "Backtests sans illusions". Comment construire un orchestrateur à partir de N stratégies sur M paires, implémenter le mode cascade avec priorité et exécution de secours, choisir dual_size, et pourquoi les portefeuilles de stratégies ne peuvent pas être backtestés en additionnant simplement le PnL.
Pourquoi un portefeuille de stratégies est nécessaire
Plusieurs stratégies se disputent un capital limité — la plupart restent inactives tandis que seules quelques-unes tradent à un instant donné
Vous avez fait passer une stratégie par tout le pipeline. Le bootstrap Monte Carlo a montré un 5e percentile acceptable. Le walk-forward a confirmé des rendements hors échantillon. Les taux de financement sont pris en compte, l'analyse de plateau est réussie. La stratégie fonctionne réellement.
Mais elle trade 15 % du temps. Les 85 % restants, votre capital reste inactif.
Lancer une deuxième stratégie ? Une troisième ? Une dixième ? L'idée est évidente. L'implémentation ne l'est pas. Un portefeuille de stratégies crée des problèmes qui n'existent pas avec un seul bot :
- Conflits : deux stratégies veulent ouvrir des positions opposées sur la même paire.
- Contraintes : l'exchange/la gestion du risque n'autorise pas plus de positions simultanées.
- Allocation : quelle fraction du capital attribuer à chaque stratégie ?
- Corrélation : 10 stratégies sur des paires crypto corrélées ne représentent pas une diversification x10.
La stratégie en cascade est un modèle architectural qui résout ces problèmes : la stratégie primaire reçoit la taille de position complète, tandis que la stratégie de secours comble le temps inactif avec une position réduite.
Le concept de cascade : primaire + secours

Stratégie à forte conviction (Primaire)
La primaire est une stratégie avec des critères d'entrée stricts. Par exemple, triple timeframe avec trois niveaux de confirmation : signal en journalier + 4 heures + horaire, avec filtrage de volatilité et de volume.
Caractéristiques :
- Peu de trades (dizaines sur la période de backtest)
- PnL élevé par trade
- Faible temps en position (5-15 %)
- Grande confiance dans chaque entrée
Stratégie de secours
Le secours est une stratégie aux critères assouplis. Double timeframe, moins de filtres, tolérances plus larges. Elle trade plus fréquemment, mais avec un edge par trade plus faible.
Caractéristiques :
- Plus de trades (centaines sur la période)
- PnL modéré par trade
- Temps en position élevé (30-50 %)
- Confiance modérée — compensée par une taille de position réduite
Mode cascade
timeline: ──────────────────────────────────────────────────
primary: ___████___________________████████____███________
fallback: ███____███████████████████________████___████████
capital: [dual][ full ][ dual_size ][ full ][ dual ]
Lorsque la primaire ouvre une position, le secours se tait (ou se ferme). Lorsque la primaire est inactive, le secours trade avec une position réduite (dual_size). La priorité est inconditionnelle : la primaire déplace toujours le secours.
Stratégies utilisées pour les exemples
Tout au long de la série, nous avons utilisé trois stratégies. Voici leurs paramètres pour la période de 750 jours :
| Paramètre | Stratégie A | Stratégie B | Stratégie C |
|---|---|---|---|
| PnL | +55% | +27% | +300% |
| Trades | ~500 | ~40 | ~400 |
| Temps de trading | ~15% | ~5% | ~45% |
| MaxDD | ~0.9% | ~0.75% | ~17% |
| PnL/jour actif | 0.49%/j | 0.72%/j | 0.89%/j |
| Caractère | Activité moyenne | Rare, forte conviction | Fréquent, agressif |
Comme nous l'avons montré dans PnL par temps actif, classer selon le PnL brut ou selon le PnL/jour actif produit des résultats différents. Pour l'orchestration en cascade, c'est la seconde métrique qui compte.
dual_size optimal
Le grid search sur dual_size révèle un pic du ratio de Sharpe — trop grand augmente le drawdown, trop petit gaspille le temps inactif
Le problème de sélection
dual_size est la fraction de la position complète que reçoit la stratégie de secours. C'est le paramètre clé de la cascade :
-
Trop grand (par ex., 0,5 = 50 %) : lorsque la primaire et le secours sont actifs simultanément, l'exposition totale = 150 % de la cible. Le drawdown double. L'asymétrie perte-profit rend cela démesurément coûteux.
-
Trop petit (par ex., 0,01 = 1 %) : le secours comble 85 % du temps inactif mais ne gagne que des miettes. Le capital reste effectivement inactif.
-
Optimal : le secours contribue un PnL significatif sans augmenter de manière critique le drawdown lors d'un fonctionnement simultané avec la primaire.
Formalisation
Soit :
- — PnL de la primaire par unité de temps
- — PnL du secours par unité de temps
- — fraction du temps en position (primaire)
- — fraction du temps en position (secours)
- — dual_size (0..1)
- — fraction du temps où les deux sont en position
PnL total de la cascade :
MaxDD total (pire cas — corrélation totale) :
Si l'on contraint le drawdown total à :
Grid search
En pratique, le dual_size optimal est trouvé par grid search sur le backtest en cascade :
import numpy as np
from dataclasses import dataclass
@dataclass
class CascadeResult:
dual_size: float
total_pnl: float
max_dd: float
sharpe: float
pnl_per_active_day: float
def grid_search_dual_size(
primary_equity: np.ndarray, # equity curve primary (minute bars)
fallback_equity: np.ndarray, # equity curve fallback (minute bars)
primary_positions: np.ndarray, # 1 = in position, 0 = flat
fallback_positions: np.ndarray,
grid: np.ndarray = np.arange(0.01, 0.30, 0.005),
) -> list[CascadeResult]:
"""
Grid search for dual_size.
primary_equity and fallback_equity are log-returns, minute bars.
"""
results = []
for d in grid:
fallback_active = fallback_positions & ~primary_positions
cascade_returns = (
primary_equity * primary_positions
+ d * fallback_equity * fallback_active
)
equity_curve = np.cumprod(1 + cascade_returns)
peak = np.maximum.accumulate(equity_curve)
drawdown = (equity_curve - peak) / peak
max_dd = drawdown.min()
total_pnl = equity_curve[-1] - 1
sharpe = (
np.mean(cascade_returns) / np.std(cascade_returns)
* np.sqrt(525_600) # minutes per year
) if np.std(cascade_returns) > 0 else 0
active_minutes = np.sum(primary_positions | fallback_active)
active_days = active_minutes / (24 * 60)
pnl_per_day = total_pnl / active_days if active_days > 0 else 0
results.append(CascadeResult(
dual_size=d,
total_pnl=total_pnl,
max_dd=max_dd,
sharpe=sharpe,
pnl_per_active_day=pnl_per_day,
))
return sorted(results, key=lambda r: r.sharpe, reverse=True)
Optimum typique pour les stratégies crypto : dual_size dans la plage 0,05-0,10 (5-10 % de la position complète). Avec la Stratégie B comme primaire (MaxDD 0,75 %) et la Stratégie A comme secours (MaxDD 0,9 %) :
La contrainte de drawdown n'est pas contraignante — l'optimum est déterminé par le Sharpe de la cascade. En pratique, le grid search donne typiquement (6,8 %).
Allocation basée sur le score
Stratégies classées par score composite — l'ajustement de confiance pénalise les petits échantillons, les coûts de financement réduisent l'edge net
Lorsqu'il y a plus de deux stratégies, la cascade se généralise en une allocation basée sur le score.
Classement par PnL par temps actif
Comme décrit en détail dans PnL par temps actif, le score de la stratégie est calculé en tenant compte de :
- PnL par jour actif — efficacité de l'utilisation du capital
- Ajustement de confiance — pénalité pour les petits échantillons (distribution de Student)
- Coûts de financement — coût réel du levier (Taux de financement)
- MaxLev — mise à l'échelle tenant compte du drawdown (Asymétrie perte-profit)
Ajustement de confiance pour les stratégies rares
La Stratégie B avec 40 trades nécessite une pénalité sérieuse. Nous utilisons la borne inférieure de l'intervalle de confiance :
import scipy.stats as st
import numpy as np
def confidence_factor(trade_returns: np.ndarray, confidence: float = 0.95) -> float:
"""Confidence factor: 0..1, penalty for small samples."""
n = len(trade_returns)
if n < 10:
return 0.0
mean_r = np.mean(trade_returns)
if mean_r <= 0:
return 0.0
se = np.std(trade_returns, ddof=1) / np.sqrt(n)
t_crit = st.t.ppf(1 - (1 - confidence) / 2, df=n - 1)
ci_lower = mean_r - t_crit * se
return max(0.0, ci_lower / mean_r)
cf_b = confidence_factor(np.random.normal(0.0067, 0.028, 40))
cf_a = confidence_factor(np.random.normal(0.0011, 0.008, 500))
Intégration du coût de financement
Sur les contrats perpétuels, le financement est payé toutes les 8 heures. Avec un levier et un taux moyen :
Pour la Stratégie A avec MaxLev = 55x et un taux de financement moyen de 0,01 % :
Avec PnL/jour actif = 0,49 %, le PnL net est négatif : /jour. La stratégie n'est pas rentable avec un levier complet. Analyse détaillée dans Les taux de financement tuent votre levier.
Orchestrateur multi-stratégies

Architecture
L'orchestrateur gère stratégies sur paires de trading. Nombre total de positions potentielles : . Mais le capital est limité — pas plus de positions simultanées (slots) ne sont autorisées.
┌─────────────────────────────────────────────┐
│ ORCHESTRATOR │
│ │
│ Signal Queue (sorted by score): │
│ ┌──────────────────────────────────────┐ │
│ │ 1. Strategy C × ETHUSDT score=223 │ │
│ │ 2. Strategy B × BTCUSDT score=142 │ │
│ │ 3. Strategy A × SOLUSDT score=100 │ │
│ │ 4. Strategy C × BTCUSDT score=89 │ │
│ │ 5. Strategy A × ETHUSDT score=76 │ │
│ └──────────────────────────────────────┘ │
│ │
│ Active Slots (max_parallel = 3): │
│ ┌──────────────────────────────────────┐ │
│ │ Slot 1: Strategy C × ETHUSDT [FULL] │ │
│ │ Slot 2: Strategy B × BTCUSDT [FULL] │ │
│ │ Slot 3: Strategy A × SOLUSDT [DUAL] │ │
│ └──────────────────────────────────────┘ │
│ │
│ Conflict Rules: │
│ - One position per pair │
│ - Primary displaces fallback on same pair │
│ - Higher score wins for cross-pair slots │
└─────────────────────────────────────────────┘
Gestion des slots
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
import heapq
import time
class SlotType(Enum):
FULL = "full" # primary strategy, 100% position
DUAL = "dual" # fallback strategy, dual_size position
@dataclass
class Signal:
strategy_id: str
pair: str
direction: str # "long" | "short"
score: float
is_primary: bool # primary or fallback
timestamp: float
@dataclass(order=True)
class Slot:
"""A single orchestrator slot."""
priority: float = field(compare=True) # negative score for min-heap
strategy_id: str = field(compare=False)
pair: str = field(compare=False)
slot_type: SlotType = field(compare=False)
entry_time: float = field(compare=False)
class Orchestrator:
"""
Multi-strategy orchestrator with cascade mode.
Manages N strategies x M pairs within max_parallel_positions slots.
Primary strategies have unconditional priority over fallback.
"""
def __init__(
self,
max_parallel_positions: int = 10,
dual_size: float = 0.068,
min_score: float = 0,
):
self.max_parallel = max_parallel_positions
self.dual_size = dual_size
self.min_score = min_score
self.active_slots: dict[str, Slot] = {} # pair -> Slot
self.pending_signals: list[Signal] = []
def on_signal(self, signal: Signal) -> Optional[dict]:
"""
Process a new signal. Returns an action or None.
Actions:
- {"action": "open", "pair": ..., "size": ..., "slot_type": ...}
- {"action": "replace", "pair": ..., "close_strategy": ..., "open_strategy": ...}
- None (signal rejected)
"""
if signal.score < self.min_score:
return None
pair = signal.pair
if pair in self.active_slots:
existing = self.active_slots[pair]
if signal.is_primary and existing.slot_type == SlotType.DUAL:
self.active_slots[pair] = Slot(
priority=-signal.score,
strategy_id=signal.strategy_id,
pair=pair,
slot_type=SlotType.FULL,
entry_time=signal.timestamp,
)
return {
"action": "replace",
"pair": pair,
"close_strategy": existing.strategy_id,
"open_strategy": signal.strategy_id,
"size": 1.0,
}
if signal.score > -existing.priority:
slot_type = SlotType.FULL if signal.is_primary else SlotType.DUAL
size = 1.0 if signal.is_primary else self.dual_size
self.active_slots[pair] = Slot(
priority=-signal.score,
strategy_id=signal.strategy_id,
pair=pair,
slot_type=slot_type,
entry_time=signal.timestamp,
)
return {
"action": "replace",
"pair": pair,
"close_strategy": existing.strategy_id,
"open_strategy": signal.strategy_id,
"size": size,
}
return None # existing has higher priority
if len(self.active_slots) < self.max_parallel:
slot_type = SlotType.FULL if signal.is_primary else SlotType.DUAL
size = 1.0 if signal.is_primary else self.dual_size
self.active_slots[pair] = Slot(
priority=-signal.score,
strategy_id=signal.strategy_id,
pair=pair,
slot_type=slot_type,
entry_time=signal.timestamp,
)
return {
"action": "open",
"pair": pair,
"strategy": signal.strategy_id,
"size": size,
"slot_type": slot_type,
}
worst_pair = min(
self.active_slots,
key=lambda p: -self.active_slots[p].priority,
)
worst_slot = self.active_slots[worst_pair]
if signal.score > -worst_slot.priority:
del self.active_slots[worst_pair]
slot_type = SlotType.FULL if signal.is_primary else SlotType.DUAL
size = 1.0 if signal.is_primary else self.dual_size
self.active_slots[pair] = Slot(
priority=-signal.score,
strategy_id=signal.strategy_id,
pair=pair,
slot_type=slot_type,
entry_time=signal.timestamp,
)
return {
"action": "replace",
"pair": pair,
"close_strategy": worst_slot.strategy_id,
"close_pair": worst_pair,
"open_strategy": signal.strategy_id,
"size": size,
}
return None # all active slots have higher scores
def on_exit(self, pair: str) -> None:
"""Strategy closed a position."""
if pair in self.active_slots:
del self.active_slots[pair]
def utilization(self) -> float:
"""Current slot utilization."""
return len(self.active_slots) / self.max_parallel
def fill_efficiency_snapshot(self) -> float:
"""Weighted utilization: FULL=1.0, DUAL=dual_size."""
total = sum(
1.0 if s.slot_type == SlotType.FULL else self.dual_size
for s in self.active_slots.values()
)
return total / self.max_parallel
Résolution des conflits
Trois niveaux de conflit :
Niveau 1 — Même paire, même direction. La stratégie avec le score le plus élevé gagne. Si les deux sont primaires — le score détermine le gagnant. Si l'une est primaire et l'autre secours — la primaire gagne inconditionnellement.
Niveau 2 — Même paire, direction opposée. Interdit : on ne peut pas être simultanément long et short sur la même paire. La stratégie avec le score le plus élevé gagne.
Niveau 3 — Concurrence inter-paires. Lorsque tous les slots sont occupés, un nouveau signal évince le slot avec le score le plus bas. Cela fonctionne comme une file de priorité.
Backtesting en cascade : méthodologie
Simulation conjointe : courbes d'équité de la primaire et du secours avec zones de chevauchement et résultat combiné de la cascade
Pourquoi on ne peut pas simplement additionner le PnL
L'approche naïve : backtester chaque stratégie séparément, additionner le PnL. Cela produit un résultat gonflé pour trois raisons :
-
Chevauchement temporel. Lorsque la primaire et le secours sont actifs simultanément, le secours ne devrait pas trader (ou trader à dual_size). La simple addition ignore ce chevauchement.
-
Contrainte de capital. La position totale est limitée. Si 5 stratégies veulent ouvrir simultanément mais qu'il n'y a que 3 slots — deux stratégies n'entreront pas. Leur PnL ne peut pas être comptabilisé.
-
Coûts de transaction. Le changement de cascade (fermeture du secours, ouverture de la primaire) génère des commissions supplémentaires absentes des backtests individuels.
Simulation conjointe
Le backtest de cascade correct est une simulation conjointe de toutes les stratégies sur une chronologie partagée :
import numpy as np
from typing import NamedTuple
class Trade(NamedTuple):
strategy: str
pair: str
entry_time: int # minute index
exit_time: int # minute index
pnl_per_minute: float # log-return per minute
is_primary: bool
score: float
def backtest_cascade(
all_trades: list[Trade],
total_minutes: int,
max_slots: int = 10,
dual_size: float = 0.068,
switch_cost: float = 0.0006, # 0.06% round-trip
) -> dict:
"""
Joint simulation of cascade portfolio.
Walk through each minute, apply orchestrator rules,
calculate PnL accounting for overlap and slot constraints.
"""
entries = {}
exits = {}
active_trades = {} # trade_id -> Trade
for i, trade in enumerate(all_trades):
entries.setdefault(trade.entry_time, []).append((i, trade))
exits.setdefault(trade.exit_time, []).append((i, trade))
active_slots = {} # pair -> (trade_id, SlotType)
equity = np.ones(total_minutes)
switch_costs_total = 0.0
for t in range(1, total_minutes):
for trade_id, trade in exits.get(t, []):
if trade.pair in active_slots:
slot_id, _ = active_slots[trade.pair]
if slot_id == trade_id:
del active_slots[trade.pair]
new_signals = sorted(
entries.get(t, []),
key=lambda x: x[1].score,
reverse=True,
)
for trade_id, trade in new_signals:
pair = trade.pair
if pair in active_slots:
existing_id, existing_type = active_slots[pair]
existing_trade = all_trades[existing_id]
if trade.is_primary and existing_type == SlotType.DUAL:
active_slots[pair] = (trade_id, SlotType.FULL)
switch_costs_total += switch_cost
continue
if trade.score > existing_trade.score:
slot_type = SlotType.FULL if trade.is_primary else SlotType.DUAL
active_slots[pair] = (trade_id, slot_type)
switch_costs_total += switch_cost
elif len(active_slots) < max_slots:
slot_type = SlotType.FULL if trade.is_primary else SlotType.DUAL
active_slots[pair] = (trade_id, slot_type)
minute_return = 0.0
for pair, (trade_id, slot_type) in active_slots.items():
trade = all_trades[trade_id]
size = 1.0 if slot_type == SlotType.FULL else dual_size
minute_return += trade.pnl_per_minute * size
equity[t] = equity[t - 1] * (1 + minute_return)
peak = np.maximum.accumulate(equity)
max_dd = ((equity - peak) / peak).min()
total_pnl = equity[-1] - 1 - switch_costs_total
return {
"total_pnl": total_pnl,
"max_dd": max_dd,
"switch_costs": switch_costs_total,
"equity_curve": equity,
}
Coût de transaction lors du changement
Chaque changement de cascade (secours -> primaire) nécessite :
- Fermer la position de secours : frais taker (0,04 % sur Binance futures)
- Ouvrir la position primaire : frais taker (0,04 %)
- Spread : ~0,01-0,02 %
Coût total de changement : ~0,06-0,10 % par changement. Avec 100 changements sur la période :
C'est un montant significatif. Une cascade avec des changements fréquents peut sous-performer une stratégie unique en raison des coûts de transaction.
Extension multi-paires : N stratégies sur M paires
Réseau de N stratégies connectées à M paires de trading — la force de corrélation détermine la diversification effective
Espace de combinaisons
3 stratégies sur 10 paires = 30 signaux potentiels. Avec max_slots = 5, l'orchestrateur sélectionne les 5 meilleurs par score. C'est un problème combinatoire : portefeuilles possibles à chaque instant.
En pratique, un algorithme glouton (trier par score, remplir de haut en bas) produit des résultats quasi-optimaux en .
Corrélation entre paires
Les paires crypto sont fortement corrélées. BTC baisse — ETH, SOL, AVAX baissent ensemble. Cela signifie que 5 positions longues sur 5 paires différentes constituent effectivement une seule grande position sur le "marché crypto".
Comme nous l'avons analysé en détail dans Corrélation des signaux, le nombre effectif de positions indépendantes est :
où est la corrélation moyenne entre les paires.
Avec et :
Cinq positions sur des paires corrélées équivalent à 1,3 position indépendante. La diversification est pratiquement absente.
Implications pratiques pour la cascade
def effective_diversification(
positions: list[dict], # [{"pair": "BTCUSDT", "direction": "long"}, ...]
correlation_matrix: np.ndarray,
pair_index: dict[str, int],
) -> float:
"""
Calculate effective diversification of open positions.
Returns:
N_eff / N — diversification coefficient (0..1)
"""
n = len(positions)
if n <= 1:
return 1.0
total_corr = 0.0
pairs_count = 0
for i in range(n):
for j in range(i + 1, n):
idx_i = pair_index[positions[i]["pair"]]
idx_j = pair_index[positions[j]["pair"]]
rho = correlation_matrix[idx_i, idx_j]
if positions[i]["direction"] != positions[j]["direction"]:
rho = -rho
total_corr += rho
pairs_count += 1
avg_rho = total_corr / pairs_count if pairs_count > 0 else 0
n_eff = n / (1 + (n - 1) * max(0, avg_rho))
return n_eff / n
L'orchestrateur doit tenir compte de la corrélation lors du remplissage des slots. Deux options :
- Bonus de diversification : lors du classement, ajouter un bonus au score des stratégies sur des paires non corrélées.
- Plafond de corrélation : limiter le nombre de positions dans la même direction sur des paires corrélées.
Pipeline d'optimisation de la cascade
Huit étapes connectées, de la préparation des données à la validation puis à l'orchestration en direct — chacune s'appuie sur la précédente
Le pipeline complet des données jusqu'à la production comprend 8 étapes :
Étape 0 : Préparation des données
Charger les données historiques, construire un cache Parquet pour un accès multi-timeframe. Sans mise en cache efficace, les étapes suivantes sont inacceptablement lentes.
Étape 1 : TF + longueur (grille de hill-climbing)
Sélectionner le timeframe de base et les longueurs de fenêtre des indicateurs. Grille grossière : TF parmi {1m, 5m, 15m, 1h, 4h}, longueur parmi {10, 20, 50, 100, 200}. Hill-climbing depuis le meilleur point de la grille.
Étape 2 : Séparation (descente coordonnée, 12 paramètres)
Optimiser les paramètres de séparation (entrées/sorties). Descente coordonnée sur 12 paramètres — seuils d'indicateurs, filtres, stop-loss, take-profit. La descente coordonnée est moins coûteuse qu'Optuna pour des fonctions objectifs déterministes de haute dimension.
Étape 3 : Méta-paramètres (descente coordonnée)
Méta-paramètres : durée maximale de détention, PnL minimal pour sortie, configuration du trailing stop. À nouveau descente coordonnée. Vérifier la robustesse via l'analyse de plateau — si l'optimum est ponctuel, la stratégie est sur-optimisée.
Étape 4 : Optimisation des combinaisons
Grid search sur les paires (Primaire, Secours). Pour chaque combinaison : sélectionner dual_size, calculer le PnL de la cascade par simulation conjointe.
Étape 5 : Validation
Validation multi-niveaux :
- Multi-symbole : la stratégie testée sur 10+ paires, pas seulement sur la paire d'optimisation
- Walk-forward : fenêtre glissante IS/OOS
- Stabilité des paramètres : analyse de plateau à chaque étape
- Bootstrap Monte Carlo : intervalles de confiance pour le PnL de la cascade
- Parité backtest-direct : comparaison du backtest avec le paper trading
Étape 6 : Classement et sélection
Classer les combinaisons de cascade par score. Les Top-K combinaisons avancent à l'étape 7. Le score tient compte de l'ajustement de confiance, des coûts de financement et du fill_efficiency.
Étape 7 : Orchestration
Étape finale : lancer l'orchestrateur avec stratégies et paires en mode cascade. Gestion des slots, file de priorité, résolution de conflits — tout ce qui est décrit ci-dessus.
Analyse de performance : cascade vs. individuel
Comparaison côte à côte : le portefeuille en cascade surpasse les stratégies individuelles grâce à l'utilisation du temps inactif
Avantage théorique de la cascade
Supposons que la primaire trade du temps avec PnL/jour = 0,49 %. Le secours trade avec PnL/jour = 0,89 %. Chevauchement = (en supposant l'indépendance).
Primaire seule (Stratégie A) :
Cascade (A primaire + C secours) :
Gain de la cascade : +31 % de PnL grâce au secours, avec une augmentation minimale du drawdown ( ajouté au MaxDD).
Quand la cascade n'aide pas
La cascade est inefficace lorsque :
- La primaire est active >80 % du temps. Peu de temps inactif — aucune place pour le secours.
- Les stratégies sont fortement corrélées. La primaire et le secours génèrent des signaux simultanément — le chevauchement est élevé, et le secours est inactif précisément quand la primaire l'est aussi.
- Les coûts de changement dépassent le PnL du secours. Avec des changements fréquents, les commissions de la cascade dévorent les profits du secours.
- dual_size est trop petit. À , le secours ne gagne que 1 % de son potentiel — en dessous des commissions.
Tableau comparatif
| Configuration | PnL annuel | MaxDD | Sharpe | Coûts de changement |
|---|---|---|---|---|
| Stratégie A seule | 26.8% | 0.9% | 1.42 | 0 |
| Stratégie C seule | 146.1% | 17% | 1.15 | 0 |
| Cascade A+C (d=0.068) | 35.2% | 2.06% | 1.58 | ~1.2% |
| Cascade B+A (d=0.068) | 19.4% | 1.36% | 1.71 | ~0.3% |
| Orchestrateur à 3 stratégies | 48.7% | 3.1% | 1.63 | ~2.1% |
Cascade A+C : la primaire A gagne +8,4 % grâce au secours C. Le Sharpe augmente grâce à l'utilisation du temps inactif. Le MaxDD croît modérément ().
Les mathématiques de la diversification temporelle
L'avantage de la cascade décrit ci-dessus — combler le temps inactif — est l'une des deux façons fondamentalement différentes de diversifier un book de stratégies, et elles s'échelonnent différemment. Bien saisir la distinction indique exactement ce qu'une cascade peut et ne peut pas acheter.
Soit une stratégie de base générant des rendements i.i.d. de moyenne et de volatilité par période active, de sorte que son Sharpe par période est . Chaque Sharpe ci-dessous est annualisé par ; l'exemple traité utilise par période ( annualisé), et chaque chiffre provient d'une simulation de 2 000 000 de périodes correspondant à la forme fermée à deux décimales près.
Diversification temporelle : combler le temps inactif
Une stratégie présente sur le marché seulement une fraction du temps — plate (rendement nul, mais aussi risque nul) le reste du temps — a un Sharpe de chronologie complète de
Le temps inactif dilue la moyenne par mais l'écart-type seulement par , de sorte que le ratio chute comme : à , le Sharpe annualisé passe de à . C'est pourquoi une primaire à forte conviction tradant 15 % du temps paraît médiocre sur la courbe d'équité complète.
Une cascade comble ce temps inactif. En juxtaposant stratégies temporellement disjointes de sorte qu'ensemble elles couvrent toute la chronologie — exactement une active à chaque période — le flux combiné redevient :
Passer d'une stratégie à couverture à stratégies à couverture complète augmente le Sharpe de — vérifié : , , — mais il sature à , le Sharpe par période d'une seule stratégie. La diversification temporelle ne peut pas pousser le rendement ajusté au risque du book au-delà de ce qu'une seule stratégie gagne pendant qu'elle trade, car à chaque instant on ne détient qu'une seule stratégie : aucune moyenne, aucune réduction de variance. Ce qu'elle achète réellement, c'est la réutilisation du capital — une unité de capital fait tourner les stratégies en séquence. Pour les faire tourner simultanément, il faudrait unités (ou un levier x) ; la cascade récolte les edges sur une seule unité. Un gain d'efficacité de capital, pas un gain de risque.
Diversification corrélationnelle : le √N qu'une cascade abandonne
L'autre type fait tourner stratégies simultanément, divise le capital et les moyenne à chaque instant. Pour des stratégies équicorrélées de corrélation par paire ,
qui vaut en l'absence de corrélation () et s'effondre vers quand . Vérifié : 8 stratégies non corrélées atteignent un Sharpe annualisé de (soit ), mais à , ces mêmes huit n'atteignent que — à peine mieux qu'une seule. C'est exactement la décote de diversification effective de la section multi-paires : la corrélation est la taxe sur le .
L'allocation Kelly est l'optimum corrélationnel. Avec un vecteur de moyennes et une covariance , les poids optimaux de croissance sont , et le Sharpe qu'ils atteignent est
ce qui reproduit exactement l'échelle (la simulation et la forme fermée concordent : pour huit non corrélées, à ). est ce à quoi ressemble "moyenner à chaque instant" une fois écrit.
Ce qu'une cascade achète réellement, et son plafond
| Dimension | Temporelle (cascade) | Corrélationnelle (parallèle) |
|---|---|---|
| Quand les stratégies tournent | disjointes dans le temps | simultanément |
| Capital | réutilisé (1 unité fait tout tourner) | divisé / levier ( unités) |
| Échelle du Sharpe | sature à (comble l'inactivité, jusqu'à couverture complète) | |
| Réduction du risque | aucune (une stratégie par instant) | , plafonnée par la corrélation |
| Ce que ça achète | efficacité du capital, temps en marché | rendement ajusté au risque |
Les deux se multiplient. Un orchestrateur réaliste fait tourner stratégies (faiblement corrélées) à chaque instant — achetant le par instant — et juxtapose ces fenêtres dans le temps, en réutilisant le capital. Le Sharpe agrégé est fixé par la structure par instant,
vérifié à (annualisé , , ), tandis que la juxtaposition temporelle multiplie l'efficacité du capital, pas le Sharpe.
Où se trouve la limite. Une cascade pure — une seule stratégie active à la fois — a un Sharpe agrégé exactement égal à , quel que soit le nombre de stratégies en attente dans la file. Elle maximise le temps en marché et l'efficacité du capital, mais son rendement ajusté au risque est plafonné à celui d'une seule stratégie. Pour dépasser , il faut diversifier au sein de l'instant — faire tourner plusieurs stratégies faiblement corrélées à la fois — et ce gain est borné par et érodé par la corrélation. La règle de conception s'ensuit : utiliser la cascade pour récupérer le temps inactif à moindre coût sur une unité de capital, et dépenser le rare et coûteux de diversification simultanée uniquement sur des stratégies véritablement non corrélées. Empiler des stratégies corrélées — dans le temps ou en parallèle — n'apporte presque rien.
Orchestration : fill_efficiency en pratique
Fill efficiency à ~78 % : la heatmap montre l'utilisation du temps entre stratégies et paires, les cellules claires indiquent un trading actif
Le paramètre fill_efficiency détermine quelle fraction du temps inactif l'orchestrateur utilise réellement. Comme montré dans PnL par temps actif, il peut être estimé de trois manières :
- Constante fixe (0,80) — approximative mais universelle
- Estimation analytique via — tient compte de la corrélation
- Simulation à partir des données — la plus précise
Pour une cascade avec 3 stratégies sur 10 paires :
def cascade_fill_efficiency(
strategies: list[dict], # [{"trading_time": 0.15, "is_primary": True}, ...]
n_pairs: int = 10,
correlation_factor: float = 3.0,
) -> float:
"""Estimate fill_efficiency for a cascade portfolio."""
n_eff = n_pairs / correlation_factor
primary_times = [s["trading_time"] for s in strategies if s["is_primary"]]
p_primary = 1 - np.prod([(1 - t) ** n_eff for t in primary_times])
fallback_times = [s["trading_time"] for s in strategies if not s["is_primary"]]
p_fallback = 1 - np.prod([(1 - t) ** n_eff for t in fallback_times])
fill = p_primary + (1 - p_primary) * p_fallback
return min(fill, 1.0)
strategies = [
{"trading_time": 0.05, "is_primary": True}, # Strategy B
{"trading_time": 0.15, "is_primary": True}, # Strategy A
{"trading_time": 0.45, "is_primary": False}, # Strategy C as fallback
]
eff = cascade_fill_efficiency(strategies, n_pairs=10, correlation_factor=3.0)
Recommandations pratiques
Six recommandations clés pour le déploiement en cascade — du démarrage modeste à la recalibration adaptative
1. Commencer avec deux stratégies
Ne lancez pas 10 stratégies sur 20 paires d'emblée. Commencez avec une primaire + une secours sur 3-5 paires. Assurez-vous que la simulation conjointe correspond au comportement réel. La parité backtest-direct est cruciale : si le backtest de la cascade diverge du direct ne serait-ce que de 5-10 % — il y a une erreur dans la logique de l'orchestrateur.
2. dual_size issu du grid search, pas de l'intuition
Le dual_size optimal dépend de la paire spécifique de stratégies. 6,8 % est une indication, pas une constante universelle. Exécutez un grid search de 1 % à 30 % par pas de 0,5 % et sélectionnez le maximum du Sharpe.
3. La limite de slots définit l'architecture
Avec max_slots = 1, la cascade dégénère en simple changement de stratégie. Avec max_slots = 50, la contrainte n'est pas contraignante et le problème se réduit à un portefeuille indépendant. La zone intéressante : max_slots = 3-10, où la gestion des slots impacte réellement les résultats.
4. Tenir compte de la latence
En trading en direct, le changement de cascade n'est pas instantané. Fermer une position de secours + ouvrir la primaire = 2 appels API + latence réseau + appariement de l'exchange. Sur un marché volatil, le prix peut bouger en 200-500 ms. Prévoyez un budget de slippage.
5. Surveiller le fill_efficiency
Suivez le fill_efficiency réel en production. S'il est significativement inférieur à celui du backtest — l'orchestrateur n'utilise pas le temps inactif comme prévu. Causes : délais API, ordres rejetés, contraintes de marge.
6. Utiliser l'optimisation adaptative
Les paramètres de la cascade (dual_size, poids du score, limites de slots) ne devraient pas être statiques. Utilisez le drill-down adaptatif pour une recalibration périodique sur des données fraîches. Le marché change — les paramètres de la cascade doivent suivre.
Synthèse de la série "Backtests sans illusions"
Architecture complète du système : 13 modules interconnectés, des mathématiques à la validation jusqu'à l'orchestration en direct
Cet article est le final d'une série de 13+ articles. Chaque article a traité un problème spécifique sur le chemin du backtest à la production. Voici comment ils se connectent :
Fondation : mathématiques du rendement
Asymétrie perte-profit — la nature multiplicative des rendements, le volatility drag, le critère de Kelly. C'est le fondement mathématique de tout ce qui suit : pourquoi le MaxDD détermine le levier, pourquoi le Sharpe compte plus que le PnL brut, pourquoi un taux de réussite de 50 % avec un R:R symétrique n'est pas rentable.
Validation : intervalles de confiance et robustesse
Bootstrap Monte Carlo — transformer une estimation ponctuelle en distribution avec intervalles de confiance. Toute métrique (PnL, MaxDD, Sharpe) n'a de sens qu'avec un intervalle de confiance.
Optimisation walk-forward — validation hors échantillon. Un backtest sur données historiques est un résultat IS ; le WFO montre comment la stratégie se comporte sur de nouvelles données.
Analyse de plateau — vérification de la robustesse des paramètres. Si l'optimum est ponctuel, la stratégie est sur-optimisée.
Parité backtest-direct — comparaison du backtest avec les résultats réels. La vérification finale avant la mise à l'échelle.
Coûts réalistes : financement et levier
Les taux de financement tuent le levier — le coût caché du levier sur les contrats perpétuels. Sans prise en compte du financement, un beau backtest se transforme en perte.
Arbitrage de taux de financement — comment transformer le financement d'une dépense en source de revenus via des stratégies cross-exchange.
Métriques et classement
PnL par temps actif — la métrique pour classer les stratégies dans un portefeuille. Le PnL brut ne s'échelonne pas ; le PnL/jour actif oui.
Corrélation des signaux — diversification effective dans un portefeuille de paires corrélées.
Infrastructure et optimisation
Cache Parquet pour backtests multi-timeframe — infrastructure de données pour des itérations rapides.
Drill-down adaptatif — optimisation adaptative : grille grossière -> affinage dans les zones prometteuses.
Optuna vs. descente coordonnée — choix de l'optimiseur : Optuna pour les faibles dimensions avec objectifs bruités, descente coordonnée pour les hautes dimensions avec objectifs lisses.
Polars vs Pandas — performance des opérations DataFrame pour le backtesting.
Orchestration (cet article)
Stratégies en cascade — combiner tous les composants précédents en un système fonctionnel. L'allocation basée sur le score utilise le PnL/temps actif, l'ajustement de confiance, les coûts de financement. Le mode cascade comble le temps inactif. La simulation conjointe valide le portefeuille. Le bootstrap Monte Carlo fournit des intervalles de confiance pour le PnL de la cascade.
Chaque article est un module indépendant. Ensemble, ils forment un pipeline complet du chargement des données à l'orchestration en direct d'un portefeuille de stratégies.
Conclusion
La cascade n'est pas la seule approche pour les portefeuilles de stratégies. Mais c'est l'une des plus simples et des plus pratiques : la stratégie primaire trade à pleine capacité, le secours comble le temps inactif avec une position réduite. Deux paramètres clés (dual_size et max_slots) offrent une flexibilité suffisante pour la plupart des configurations.
Trois enseignements clés :
-
La cascade ne doit être backtestée que par simulation conjointe. Additionner le PnL individuel gonfle les résultats. Les coûts de changement, le chevauchement, les contraintes de slots — tout cela n'est capturé que dans la simulation conjointe.
-
dual_size détermine le compromis PnL vs. drawdown. L'optimum typique est de 5-10 %. Le grid search sur le Sharpe est une méthode de sélection fiable.
-
L'orchestrateur est une file de priorité basée sur le score. Tout se réduit à un seul nombre (score) pour chaque signal. Score = f(PnL/jour actif, MaxLev, confiance, financement). Les stratégies avec le score le plus élevé obtiennent des slots. Le reste attend.
La série "Backtests sans illusions" démontre une chose : entre un beau backtest et un profit réel se cachent des dizaines de pièges. Chaque article en élimine un. L'orchestration en cascade est la dernière étape : transformer un ensemble de stratégies validées en un portefeuille fonctionnel.
Liens utiles
- López de Prado — Advances in Financial Machine Learning: Portfolio Construction
- Pardo, R. — The Evaluation and Optimization of Trading Strategies
- Ernest Chan — Algorithmic Trading: Winning Strategies and Their Rationale
- Perry Kaufman — Trading Systems and Methods, Chapter on Portfolio Allocation
- Tomasini, Jaekle — Trading Systems: A New Approach to System Development and Portfolio Optimisation
- Bailey, D.H. & López de Prado — The Deflated Sharpe Ratio
- Markowitz, H. — Portfolio Selection (1952)
- Kelly, J.L. — A New Interpretation of Information Rate (1956)
Citation
@article{soloviov2026cascadestrategies,
author = {Soloviov, Eugen},
title = {Cascade Strategies: Priority Execution with Fallback Filling},
year = {2026},
url = {https://marketmaker.cc/ru/blog/post/cascade-strategies-orchestration},
version = {0.1.0},
description = {Finale of the "Backtests Without Illusions" series. How to build an orchestrator from N strategies x M pairs, implement cascade mode with priority and fallback filling, choose dual\_size, and why strategy portfolios cannot be backtested by summing PnL.}
}
Authors
Trading-systems engineer
Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.