← Retour aux articles
March 8, 2026
5 min de lecture

Stratégies en cascade : exécution prioritaire avec remplissage de secours

Stratégies en cascade : exécution prioritaire avec remplissage de secours
#algotrading
#orchestration
#portfolio
#cascade
#strategies
#slot management

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

Portefeuille de stratégies avec capital inactif 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 KK 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

Superposition des chronologies de la stratégie en cascade

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

Surface d'optimisation de dual_size 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 :

  • PpP_p — PnL de la primaire par unité de temps
  • PfP_f — PnL du secours par unité de temps
  • tpt_p — fraction du temps en position (primaire)
  • tft_f — fraction du temps en position (secours)
  • dd — dual_size (0..1)
  • toverlapt_{overlap} — fraction du temps où les deux sont en position

PnL total de la cascade :

PnLcascade=Pptp+dPf(tftoverlap)\text{PnL}_{cascade} = P_p \cdot t_p + d \cdot P_f \cdot (t_f - t_{overlap})

MaxDD total (pire cas — corrélation totale) :

DDcascadeDDp+dDDf\text{DD}_{cascade} \approx \text{DD}_p + d \cdot \text{DD}_f

Si l'on contraint le drawdown total à DtargetD_{target} :

dmax=DtargetDDpDDfd_{max} = \frac{D_{target} - \text{DD}_p}{\text{DD}_f}

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 %) :

dmax=2%0.75%0.9%=1.39d_{max} = \frac{2\% - 0.75\%}{0.9\%} = 1.39

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 d0.068d \approx 0.068 (6,8 %).

Allocation basée sur le score

Classement des stratégies basé 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 :

  1. PnL par jour actif — efficacité de l'utilisation du capital
  2. Ajustement de confiance — pénalité pour les petits échantillons (distribution de Student)
  3. Coûts de financement — coût réel du levier (Taux de financement)
  4. MaxLev — mise à l'échelle tenant compte du drawdown (Asymétrie perte-profit)

score=PnLnet/jourefficaciteˊ×365ffillannualiser×MaxLeveˊchelle×cconffiabiliteˊ\text{score} = \underbrace{\text{PnL}_{net/jour}}_{\text{efficacité}} \times \underbrace{365 \cdot f_{fill}}_{\text{annualiser}} \times \underbrace{\text{MaxLev}}_{\text{échelle}} \times \underbrace{c_{conf}}_{\text{fiabilité}}

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 :

cconf=max(0, rˉtα/2,n1snrˉ)c_{conf} = \max\left(0,\ \frac{\bar{r} - t_{\alpha/2, n-1} \cdot \frac{s}{\sqrt{n}}}{\bar{r}}\right)

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 LL et un taux moyen rfr_f :

Fundingdaily=3rfL\text{Funding}_{daily} = 3 \cdot r_f \cdot L

Pour la Stratégie A avec MaxLev = 55x et un taux de financement moyen de 0,01 % :

Fundingdaily=3×0.0001×55=0.0165=1.65%/jour\text{Funding}_{daily} = 3 \times 0.0001 \times 55 = 0.0165 = 1.65\%/\text{jour}

Avec PnL/jour actif = 0,49 %, le PnL net est négatif : 0.49%1.65%=1.16%0.49\% - 1.65\% = -1.16\%/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

Allocation des slots et file de priorité de l'orchestrateur

Architecture

L'orchestrateur gère NN stratégies sur MM paires de trading. Nombre total de positions potentielles : N×MN \times M. Mais le capital est limité — pas plus de KK 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 des stratégies en cascade 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 :

  1. 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.

  2. 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é.

  3. 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 :

  1. Fermer la position de secours : frais taker (0,04 % sur Binance futures)
  2. Ouvrir la position primaire : frais taker (0,04 %)
  3. Spread : ~0,01-0,02 %

Coût total de changement : ~0,06-0,10 % par changement. Avec 100 changements sur la période :

Switch costs=100×0.0008=8%\text{Switch costs} = 100 \times 0.0008 = 8\%

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 stratégies multi-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 : (305)=142506\binom{30}{5} = 142\,506 portefeuilles possibles à chaque instant.

En pratique, un algorithme glouton (trier par score, remplir de haut en bas) produit des résultats quasi-optimaux en O(NMlogK)O(N \cdot M \cdot \log K).

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 :

Neff=N1+(N1)ρˉN_{eff} = \frac{N}{1 + (N-1) \cdot \bar{\rho}}

ρˉ\bar{\rho} est la corrélation moyenne entre les paires.

Avec ρˉ=0.7\bar{\rho} = 0.7 et N=5N = 5 :

Neff=51+4×0.7=53.8=1.32N_{eff} = \frac{5}{1 + 4 \times 0.7} = \frac{5}{3.8} = 1.32

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 :

  1. Bonus de diversification : lors du classement, ajouter un bonus au score des stratégies sur des paires non corrélées.
  2. 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

Pipeline d'optimisation en huit étapes 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 :

É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 NN stratégies et MM 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

Performance de la cascade vs. stratégies individuelles 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 tp=15%t_p = 15\% du temps avec PnL/jour = 0,49 %. Le secours trade tf=45%t_f = 45\% avec PnL/jour = 0,89 %. Chevauchement = tp×tf=6.75%t_p \times t_f = 6.75\% (en supposant l'indépendance).

Primaire seule (Stratégie A) :

Annual PnL=0.49%×0.15×365=26.8%\text{Annual PnL} = 0.49\% \times 0.15 \times 365 = 26.8\%

Cascade (A primaire + C secours) :

Annual PnL=0.49%×0.15×365+0.068×0.89%×(0.450.0675)×365=26.8%+8.4%=35.2%\text{Annual PnL} = 0.49\% \times 0.15 \times 365 + 0.068 \times 0.89\% \times (0.45 - 0.0675) \times 365 = 26.8\% + 8.4\% = 35.2\%

Gain de la cascade : +31 % de PnL grâce au secours, avec une augmentation minimale du drawdown (0.068×17%=1.16%0.068 \times 17\% = 1.16\% ajouté au MaxDD).

Quand la cascade n'aide pas

La cascade est inefficace lorsque :

  1. La primaire est active >80 % du temps. Peu de temps inactif — aucune place pour le secours.
  2. 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.
  3. 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.
  4. dual_size est trop petit. À d=0.01d = 0.01, 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 (0.9%+0.068×17%2.06%0.9\% + 0.068 \times 17\% \approx 2.06\%).

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 μ\mu et de volatilité σ\sigma par période active, de sorte que son Sharpe par période est s=μ/σs = \mu/\sigma. Chaque Sharpe ci-dessous est annualisé par 252\sqrt{252} ; l'exemple traité utilise s=0.05s = 0.05 par période (s252=0.79s\sqrt{252} = 0.79 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 pp du temps — plate (rendement nul, mais aussi risque nul) le reste du temps — a un Sharpe de chronologie complète de

SRfull=ps.SR_{\text{full}} = \sqrt{p}\,\cdot s.

Le temps inactif dilue la moyenne par pp mais l'écart-type seulement par p\sqrt{p}, de sorte que le ratio chute comme p\sqrt{p} : à p=0.25p = 0.25, le Sharpe annualisé passe de 0.790.79 à 0.390.39. 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 kk stratégies temporellement disjointes de sorte qu'ensemble elles couvrent toute la chronologie — exactement une active à chaque période — le flux combiné redevient N(μ,σ)N(\mu,\sigma) :

SRcascade=s(indeˊpendant de k).SR_{\text{cascade}} = s \qquad(\text{indépendant de } k).

Passer d'une stratégie à couverture 1/k1/k à kk stratégies à couverture complète augmente le Sharpe de k\sqrt{k} — vérifié : k=21.42×k=2 \to 1.42\times, k=41.96×k=4 \to 1.96\times, k=82.93×k=8 \to 2.93\times — mais il sature à ss, 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 kk stratégies en séquence. Pour les faire tourner simultanément, il faudrait kk unités (ou un levier xkk) ; la cascade récolte les kk 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 NN stratégies simultanément, divise le capital et les moyenne à chaque instant. Pour des stratégies équicorrélées de corrélation par paire ρ\rho,

SRparallel=N1+(N1)ρ  s,SR_{\text{parallel}} = \sqrt{\frac{N}{1 + (N-1)\rho}}\;\cdot s,

qui vaut Ns\sqrt{N}\,s en l'absence de corrélation (ρ=0\rho = 0) et s'effondre vers ss quand ρ1\rho \to 1. Vérifié : 8 stratégies non corrélées atteignent un Sharpe annualisé de 2.252.25 (soit 8×0.79\sqrt{8}\times 0.79), mais à ρ=0.5\rho = 0.5, ces mêmes huit n'atteignent que 1.041.04 — à 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 N\sqrt{N}.

L'allocation Kelly est l'optimum corrélationnel. Avec un vecteur de moyennes μ\boldsymbol\mu et une covariance Σ\Sigma, les poids optimaux de croissance sont w=Σ1μ\mathbf{w}^{*} = \Sigma^{-1}\boldsymbol\mu, et le Sharpe qu'ils atteignent est

SRKelly=μΣ1μ,SR_{\text{Kelly}} = \sqrt{\boldsymbol\mu^{\top} \Sigma^{-1} \boldsymbol\mu},

ce qui reproduit exactement l'échelle N/(1+(N1)ρ)s\sqrt{N/(1+(N-1)\rho)}\,s (la simulation et la forme fermée concordent : 2.252.25 pour huit non corrélées, 1.001.00 à ρ=0.5\rho=0.5). Σ1μ\Sigma^{-1}\boldsymbol\mu 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 (NN unités)
Échelle du Sharpe sature à ss (comble l'inactivité, k\sqrt{k} jusqu'à couverture complète) N/(1+(N1)ρ)s\sqrt{N/(1+(N-1)\rho)}\,\cdot s
Réduction du risque aucune (une stratégie par instant) N\sqrt{N}, 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 mm stratégies (faiblement corrélées) à chaque instant — achetant le m\sqrt{m} 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,

SRbook=m1+(m1)ρ  s,SR_{\text{book}} = \sqrt{\frac{m}{1 + (m-1)\rho}}\;\cdot s,

vérifié à m=1,2,4m = 1, 2, 4 (annualisé 0.790.79, 1.141.14, 1.58=ms1.58 = \sqrt{m}\,s), 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 à ss, 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 ss, 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 N\sqrt{N} 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 N\sqrt{N} 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

Jauge et heatmap de fill efficiency 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 :

  1. Constante fixe (0,80) — approximative mais universelle
  2. Estimation analytique via (1p)Neff(1-p)^{N_{eff}} — tient compte de la corrélation
  3. 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

Checklist d'ingénierie pratique 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"

Carte de connaissances de la série 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 :

  1. 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.

  2. 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.

  3. 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

  1. López de Prado — Advances in Financial Machine Learning: Portfolio Construction
  2. Pardo, R. — The Evaluation and Optimization of Trading Strategies
  3. Ernest Chan — Algorithmic Trading: Winning Strategies and Their Rationale
  4. Perry Kaufman — Trading Systems and Methods, Chapter on Portfolio Allocation
  5. Tomasini, Jaekle — Trading Systems: A New Approach to System Development and Portfolio Optimisation
  6. Bailey, D.H. & López de Prado — The Deflated Sharpe Ratio
  7. Markowitz, H. — Portfolio Selection (1952)
  8. 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.}
}
blog.disclaimer

Authors

Eugen Soloviov
Eugen Soloviov

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.

Newsletter

Gardez une longueur d'avance sur le marché

Abonnez-vous à notre newsletter pour des insights exclusifs sur le trading IA, des analyses de marché et des mises à jour de la plateforme.

Nous respectons votre vie privée. Désabonnement possible à tout moment.