← Terug naar artikelen
March 8, 2026
5 min leestijd

Cascadestrategieën: prioriteitsuitvoering met fallback-opvulling

Cascadestrategieën: prioriteitsuitvoering met fallback-opvulling
#algotrading
#orchestration
#portfolio
#cascade
#strategies
#slot management

Finale van de reeks "Backtests zonder illusies". Hoe bouw je een orchestrator uit N strategieën op M paren, implementeer je cascademodus met prioriteit en fallback-uitvoering, kies je dual_size, en waarom strategieportfolio's niet kunnen worden teruggetest door simpelweg de PnL op te tellen.

Waarom je een strategieportfolio nodig hebt

Strategieportfolio met ongebruikt kapitaal Meerdere strategieën strijden om beperkt kapitaal — de meeste liggen stil terwijl slechts enkele op elk moment handelen

Je hebt een strategie door de volledige pipeline gehaald. Monte-Carlo-bootstrap toonde een acceptabel 5e percentiel. Walk-forward bevestigde out-of-sample rendementen. Funding rates zijn verrekend, plateau-analyse geslaagd. De strategie werkt echt.

Maar ze handelt 15% van de tijd. De overige 85% ligt je kapitaal stil.

Een tweede strategie draaien? Een derde? Een tiende? Het idee ligt voor de hand. De implementatie niet. Een strategieportfolio creëert problemen die bij één enkele bot niet bestaan:

  • Conflicten: twee strategieën willen tegengestelde posities openen op hetzelfde paar.
  • Beperkingen: de exchange/het risicobeheer staat niet meer dan KK gelijktijdige posities toe.
  • Allocatie: welk deel van het kapitaal geef je aan elke strategie?
  • Correlatie: 10 strategieën op gecorreleerde cryptoparen is geen 10x diversificatie.

De cascadestrategie is een architectuurpatroon dat deze problemen oplost: de primaire strategie krijgt de volledige positiegrootte, terwijl de fallback-strategie de ongebruikte tijd opvult met een verkleinde positie.

Het cascadeconcept: primair + fallback

Overlay van de cascadestrategie-tijdlijn

Strategie met hoge overtuiging (Primair)

Primair is een strategie met strikte instapcriteria. Bijvoorbeeld triple timeframe met drie bevestigende niveaus: signaal op dagelijkse + 4-uurs + uurbasis, met filtering op volatiliteit en volume.

Kenmerken:

  • Weinig trades (tientallen over de backtestperiode)
  • Hoge PnL per trade
  • Weinig tijd in positie (5-15%)
  • Hoog vertrouwen bij elke instap

Fallback-strategie

Fallback is een strategie met versoepelde criteria. Dubbel timeframe, minder filters, ruimere toleranties. Ze handelt vaker, maar met een lagere edge per trade.

Kenmerken:

  • Meer trades (honderden over de periode)
  • Gematigde PnL per trade
  • Veel tijd in positie (30-50%)
  • Gematigd vertrouwen — gecompenseerd door verkleinde positiegrootte

Cascademodus

timeline:  ──────────────────────────────────────────────────
primary:   ___████___________________████████____███________
fallback:  ███____███████████████████________████___████████

capital:   [dual][ full ][ dual_size ][  full  ][ dual  ]

Wanneer de primaire strategie een positie opent, valt de fallback stil (of sluit). Wanneer de primaire strategie inactief is, handelt de fallback met een verkleinde positie (dual_size). De prioriteit is onvoorwaardelijk: primair verdringt fallback altijd.

Strategieën voor de voorbeelden

Door de hele reeks heen hebben we drie strategieën gebruikt. Hier zijn hun parameters voor de periode van 750 dagen:

Parameter Strategie A Strategie B Strategie C
PnL +55% +27% +300%
Trades ~500 ~40 ~400
Handelstijd ~15% ~5% ~45%
MaxDD ~0.9% ~0.75% ~17%
PnL/actieve dag 0.49%/d 0.72%/d 0.89%/d
Karakter Gemiddelde activiteit Zeldzaam, hoge overtuiging Frequent, agressief

Zoals we hebben laten zien in PnL per actieve tijd, levert rangschikken op ruwe PnL en op PnL/actieve dag verschillende resultaten op. Voor cascade-orchestratie is de tweede metriek doorslaggevend.

Optimale dual_size

dual_size-optimalisatieoppervlak Grid search over dual_size onthult een piek in de Sharpe-ratio — te groot verhoogt de drawdown, te klein verspilt ongebruikte tijd

Het selectieprobleem

dual_size is het aandeel van de volledige positie dat de fallback-strategie krijgt. Het is de belangrijkste cascadeparameter:

  • Te groot (bijv. 0,5 = 50%): wanneer primair en fallback tegelijk actief zijn, is de totale blootstelling 150% van het doel. De drawdown verdubbelt. De verlies-winst-asymmetrie maakt dit onevenredig duur.

  • Te klein (bijv. 0,01 = 1%): fallback vult 85% van de ongebruikte tijd, maar verdient een habbekrats. Kapitaal ligt effectief stil.

  • Optimaal: fallback draagt betekenisvolle PnL bij zonder de drawdown kritiek te verhogen tijdens gelijktijdige werking met primair.

Formalisering

Laat:

  • PpP_p — primaire PnL per tijdseenheid
  • PfP_f — fallback-PnL per tijdseenheid
  • tpt_p — aandeel van de tijd in positie (primair)
  • tft_f — aandeel van de tijd in positie (fallback)
  • dd — dual_size (0..1)
  • toverlapt_{overlap} — aandeel van de tijd waarin beide in positie zijn

Totale cascade-PnL:

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

Totale MaxDD (worstcase — volledige correlatie):

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

Als we de totale drawdown beperken tot DtargetD_{target}:

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

Grid search

In de praktijk wordt de optimale dual_size gevonden via grid search op de cascade-backtest:

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)

Typisch optimum voor cryptostrategieën: dual_size in het bereik 0,05-0,10 (5-10% van de volledige positie). Met Strategie B als primair (MaxDD 0,75%) en Strategie A als fallback (MaxDD 0,9%):

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

De drawdown-beperking is niet bindend — het optimum wordt bepaald door de cascade-Sharpe. In de praktijk levert grid search doorgaans d0.068d \approx 0.068 (6,8%) op.

Score-gebaseerde allocatie

Score-gebaseerde strategierangschikking Strategieën gerangschikt op samengestelde score — de betrouwbaarheidscorrectie bestraft kleine steekproeven, funding-kosten verlagen de netto-edge

Wanneer er meer dan twee strategieën zijn, generaliseert cascade naar score-gebaseerde allocatie.

Rangschikking op PnL per actieve tijd

Zoals uitvoerig beschreven in PnL per actieve tijd, wordt de strategiescore berekend met inachtneming van:

  1. PnL per actieve dag — efficiëntie van kapitaalgebruik
  2. Betrouwbaarheidscorrectie — straf voor kleine steekproeven (t-verdeling)
  3. Funding-kosten — reële kosten van leverage (Funding rates)
  4. MaxLev — schaling met inachtneming van drawdown (Verlies-winst-asymmetrie)

score=PnLnet/dagefficie¨ntie×365ffillannualiseren×MaxLevschaal×cconfbetrouwbaarheid\text{score} = \underbrace{\text{PnL}_{net/dag}}_{\text{efficiëntie}} \times \underbrace{365 \cdot f_{fill}}_{\text{annualiseren}} \times \underbrace{\text{MaxLev}}_{\text{schaal}} \times \underbrace{c_{conf}}_{\text{betrouwbaarheid}}

Betrouwbaarheidscorrectie voor zeldzame strategieën

Strategie B met 40 trades vereist een serieuze straf. We gebruiken de ondergrens van het betrouwbaarheidsinterval:

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

Integratie van funding-kosten

Bij perpetual futures wordt elke 8 uur funding betaald. Met leverage LL en gemiddelde rate rfr_f:

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

Voor Strategie A met MaxLev = 55x en gemiddelde funding-rate 0,01%:

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

Bij PnL/actieve dag = 0,49% is de netto-PnL negatief: 0.49%1.65%=1.16%0.49\% - 1.65\% = -1.16\%/dag. De strategie is onrendabel bij volledige leverage. Gedetailleerde analyse in Funding rates vreten je leverage op.

Multi-strategie-orchestrator

Slottoewijzing en prioriteitswachtrij van de orchestrator

Architectuur

De orchestrator beheert NN strategieën op MM handelsparen. Totaal aantal potentiële posities: N×MN \times M. Maar kapitaal is beperkt — niet meer dan KK gelijktijdige posities (slots) zijn toegestaan.

┌─────────────────────────────────────────────┐
│                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    │
└─────────────────────────────────────────────┘

Slotbeheer

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

Conflictresolutie

Drie niveaus van conflict:

Niveau 1 — Zelfde paar, zelfde richting. De strategie met de hogere score wint. Als beide primair zijn — bepaalt de score de winnaar. Als de ene primair is en de andere fallback — wint primair onvoorwaardelijk.

Niveau 2 — Zelfde paar, tegengestelde richting. Verboden: je kunt niet gelijktijdig long en short zijn op hetzelfde paar. De strategie met de hoogste score wint.

Niveau 3 — Concurrentie tussen paren. Wanneer alle slots bezet zijn, verdringt een nieuw signaal de slot met de laagste score. Dit werkt als een prioriteitswachtrij.

Cascade-backtesting: methodologie

Gezamenlijke simulatie van cascadestrategieën Gezamenlijke simulatie: equity-curves van primair en fallback met overlapzones en het gecombineerde cascaderesultaat

Waarom je PnL niet zomaar kunt optellen

De naïeve aanpak: elke strategie apart terugtesten, de PnL optellen. Dit levert om drie redenen een opgeblazen resultaat op:

  1. Tijdsoverlap. Wanneer primair en fallback gelijktijdig actief zijn, zou fallback niet moeten handelen (of moeten handelen met dual_size). Simpelweg optellen negeert deze overlap.

  2. Kapitaalbeperking. De totale positie is beperkt. Als 5 strategieën gelijktijdig willen openen maar er slechts 3 slots zijn — treden twee strategieën niet in. Hun PnL mag niet worden meegeteld.

  3. Transactiekosten. De cascadewissel (fallback sluiten, primair openen) genereert extra commissies die niet aanwezig zijn in individuele backtests.

Gezamenlijke simulatie

De correcte cascade-backtest is een gezamenlijke simulatie van alle strategieën op een gedeelde tijdlijn:

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,
    }

Transactiekosten bij het wisselen

Elke cascadewissel (fallback -> primair) vereist:

  1. Sluiten van de fallback-positie: taker-fee (0,04% bij Binance futures)
  2. Openen van de primaire positie: taker-fee (0,04%)
  3. Spread: ~0,01-0,02%

Totale wisselkosten: ~0,06-0,10% per wissel. Bij 100 wissels over de periode:

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

Dat is een aanzienlijk bedrag. Een cascade met frequent wisselen kan door de transactiekosten slechter presteren dan een enkele strategie.

Multi-pair-uitbreiding: N strategieën op M paren

Multi-pair-strategienetwerk Netwerk van N strategieën verbonden met M handelsparen — de correlatiesterkte bepaalt de effectieve diversificatie

Combinatieruimte

3 strategieën op 10 paren = 30 potentiële signalen. Bij max_slots = 5 selecteert de orchestrator de top 5 op score. Dit is een combinatorisch probleem: (305)=142506\binom{30}{5} = 142\,506 mogelijke portfolio's op elk moment.

In de praktijk levert een greedy-algoritme (sorteren op score, van boven naar beneden vullen) bijna optimale resultaten op in O(NMlogK)O(N \cdot M \cdot \log K).

Correlatie tussen paren

Cryptoparen zijn sterk gecorreleerd. BTC daalt — ETH, SOL, AVAX dalen mee. Dit betekent dat 5 longposities op 5 verschillende paren effectief één grote positie zijn op de "cryptomarkt".

Zoals we uitvoerig hebben geanalyseerd in Signaalcorrelatie, is het effectieve aantal onafhankelijke posities:

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

waarbij ρˉ\bar{\rho} de gemiddelde correlatie tussen de paren is.

Bij ρˉ=0.7\bar{\rho} = 0.7 en 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

Vijf posities op gecorreleerde paren zijn equivalent aan 1,3 onafhankelijke posities. Diversificatie is vrijwel afwezig.

Praktische implicaties voor de 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


De orchestrator moet bij het vullen van slots rekening houden met correlatie. Twee opties:

  1. Diversificatiebonus: bij het rangschikken een bonus toevoegen aan de score van strategieën op ongecorreleerde paren.
  2. Correlatieplafond: het aantal posities in dezelfde richting op gecorreleerde paren beperken.

Cascade-optimalisatiepijplijn

Achttrapsoptimalisatiepijplijn Acht met elkaar verbonden fasen van dataprep via validatie tot live orchestratie — elke bouwt voort op de vorige

De volledige pijplijn van data tot productie bestaat uit 8 fasen:

Fase 0: Datavoorbereiding

Historische data laden, Parquet-cache bouwen voor multi-timeframe-toegang. Zonder efficiënte caching zijn de volgende fasen onaanvaardbaar traag.

Fase 1: TF + lengte (hill-climbing-grid)

Basis-timeframe en indicator-vensterlengtes selecteren. Grof grid: TF uit {1m, 5m, 15m, 1h, 4h}, lengte uit {10, 20, 50, 100, 200}. Hill-climbing vanaf het beste gridpunt.

Fase 2: Separatie (coördinaatafdaling, 12 parameters)

Separatieparameters optimaliseren (in-/uitstappen). Coördinaatafdaling over 12 parameters — indicatordrempels, filters, stop-losses, take-profits. Coördinaatafdaling is goedkoper dan Optuna voor hoogdimensionale deterministische doelfuncties.

Fase 3: Meta-parameters (coördinaatafdaling)

Meta-parameters: maximale houdtijd, minimale PnL voor uitstap, trailing-stop-configuratie. Weer coördinaatafdaling. Robuustheid controleren via plateau-analyse — als het optimum puntvormig is, is de strategie overgeoptimaliseerd.

Fase 4: Combo-optimalisatie

Grid search over paren (primair, fallback). Voor elke combinatie: dual_size selecteren, cascade-PnL berekenen via gezamenlijke simulatie.

Fase 5: Validatie

Meerlaagse validatie:

Fase 6: Rangschikking en selectie

Cascadecombinaties rangschikken op score. Top-K-combinaties gaan door naar Fase 7. De score houdt rekening met betrouwbaarheidscorrectie, funding-kosten en fill_efficiency.

Fase 7: Orchestratie

Laatste fase: de orchestrator starten met NN strategieën en MM paren in cascademodus. Slotbeheer, prioriteitswachtrij, conflictresolutie — alles zoals hierboven beschreven.

Prestatieanalyse: cascade vs. individueel

Prestaties van cascade vs. individuele strategieën Vergelijking naast elkaar: de cascadeportfolio presteert beter dan individuele strategieën door benutting van ongebruikte tijd

Theoretisch cascadevoordeel

Stel dat primair tp=15%t_p = 15\% van de tijd handelt met PnL/dag = 0,49%. Fallback handelt tf=45%t_f = 45\% met PnL/dag = 0,89%. Overlap = tp×tf=6.75%t_p \times t_f = 6.75\% (uitgaande van onafhankelijkheid).

Alleen primair (Strategie A):

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

Cascade (A primair + C fallback):

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\%

Cascadewinst: +31% PnL dankzij fallback, met een minimale toename van de drawdown (0.068×17%=1.16%0.068 \times 17\% = 1.16\% toegevoegd aan de MaxDD).

Wanneer cascade niet helpt

Cascade is ineffectief wanneer:

  1. Primair >80% van de tijd actief is. Weinig ongebruikte tijd — geen ruimte voor fallback.
  2. De strategieën sterk gecorreleerd zijn. Primair en fallback genereren gelijktijdig signalen — de overlap is hoog, en fallback is inactief precies wanneer primair dat ook is.
  3. Wisselkosten de fallback-PnL overtreffen. Bij frequent wisselen vreten de cascadecommissies de fallback-winst op.
  4. dual_size te klein is. Bij d=0.01d = 0.01 verdient fallback slechts 1% van zijn potentieel — onder de commissies.

Vergelijkingstabel

Configuratie Jaarlijkse PnL MaxDD Sharpe Wisselkosten
Strategie A alleen 26.8% 0.9% 1.42 0
Strategie C alleen 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%
Orchestrator met 3 strategieën 48.7% 3.1% 1.63 ~2.1%

Cascade A+C: primair A wint +8,4% dankzij fallback C. De Sharpe stijgt door benutting van ongebruikte tijd. MaxDD groeit gematigd (0.9%+0.068×17%2.06%0.9\% + 0.068 \times 17\% \approx 2.06\%).

De wiskunde van temporele diversificatie

Het hierboven beschreven cascadevoordeel — het opvullen van ongebruikte tijd — is een van twee fundamenteel verschillende manieren om een boek van strategieën te diversifiëren, en ze schalen anders. De juiste onderscheiding maken vertelt je precies wat een cascade wel en niet kan kopen.

Laat een basisstrategie i.i.d.-rendementen opleveren met gemiddelde μ\mu en volatiliteit σ\sigma per actieve periode, zodat de Sharpe per periode s=μ/σs = \mu/\sigma is. Elke Sharpe hieronder wordt geannualiseerd met 252\sqrt{252}; het uitgewerkte voorbeeld gebruikt s=0.05s = 0.05 per periode (s252=0.79s\sqrt{252} = 0.79 geannualiseerd), en elk cijfer komt uit een simulatie van 2.000.000 perioden die overeenkomt met de gesloten vorm tot twee decimalen.

Temporele diversificatie: ongebruikte tijd opvullen

Een strategie die slechts een fractie pp van de tijd in de markt zit — de rest flat (nulrendement, maar ook nulrisico) — heeft een volledige-tijdlijn-Sharpe van

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

Ongebruikte tijd verdunt het gemiddelde met pp, maar de standaardafwijking slechts met p\sqrt{p}, dus de verhouding daalt als p\sqrt{p}: bij p=0.25p = 0.25 daalt de geannualiseerde Sharpe van 0.790.79 naar 0.390.39. Daarom ziet een primaire strategie met hoge overtuiging die 15% van de tijd handelt er middelmatig uit op de volledige equity-curve.

Een cascade vult die ongebruikte tijd op. Door kk tijdelijk disjuncte strategieën zo naast elkaar te leggen dat ze samen de gehele tijdlijn afdekken — precies één actief per periode — is de gecombineerde stroom weer N(μ,σ)N(\mu,\sigma):

SRcascade=s(onafhankelijk van k).SR_{\text{cascade}} = s \qquad(\text{onafhankelijk van } k).

De overgang van één strategie met 1/k1/k dekking naar kk strategieën met volledige dekking verhoogt de Sharpe met k\sqrt{k} — geverifieerd: 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 — maar het verzadigt bij ss, de Sharpe per periode van een enkele strategie. Temporele diversificatie kan het risicogecorrigeerde rendement van het boek niet boven wat één strategie tijdens het handelen verdient tillen, want op elk moment houdt men precies één strategie aan: geen middeling, geen variantiereductie. Wat het wel koopt, is kapitaalhergebruik — één kapitaaleenheid draait alle kk strategieën na elkaar. Om ze gelijktijdig te draaien zou je kk eenheden nodig hebben (of kkx leverage); de cascade oogst alle kk edges op één eenheid. Een kapitaalefficiëntiewinst, geen risicowinst.

Correlationele diversificatie: het √N waarvan een cascade afziet

Het andere type draait NN strategieën gelijktijdig, verdeelt het kapitaal en middelt ze op elk moment. Voor equicorrelerende strategieën met paarsgewijze correlatie ρ\rho,

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

wat gelijk is aan Ns\sqrt{N}\,s bij afwezigheid van correlatie (ρ=0\rho = 0) en instort naar ss naarmate ρ1\rho \to 1. Geverifieerd: 8 ongecorreleerde strategieën bereiken een geannualiseerde waarde van 2.252.25 (dat is 8×0.79\sqrt{8}\times 0.79), maar bij ρ=0.5\rho = 0.5 behalen dezelfde acht slechts 1.041.04 — nauwelijks beter dan één. Dit is precies de effectieve-diversificatie-korting uit de multi-pair-sectie: correlatie is de belasting op het N\sqrt{N}.

De Kelly-allocatie is het correlationele optimum. Met gemiddeldevector μ\boldsymbol\mu en covariantie Σ\Sigma zijn de groei-optimale gewichten w=Σ1μ\mathbf{w}^{*} = \Sigma^{-1}\boldsymbol\mu, en de Sharpe die ze bereiken is

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

wat exact de schaling N/(1+(N1)ρ)s\sqrt{N/(1+(N-1)\rho)}\,s reproduceert (simulatie en gesloten vorm komen overeen: 2.252.25 voor acht ongecorreleerde, 1.001.00 bij ρ=0.5\rho=0.5). Σ1μ\Sigma^{-1}\boldsymbol\mu is wat "op elk moment middelen" opgeschreven eruitziet.

Wat een cascade werkelijk koopt, en het plafond ervan

Dimensie Temporeel (cascade) Correlationeel (parallel)
Wanneer strategieën draaien disjunct in tijd gelijktijdig
Kapitaal hergebruikt (1 eenheid draait alles) verdeeld / geleveraged (NN eenheden)
Sharpe-schaling verzadigt bij ss (vult ongebruikte tijd, k\sqrt{k} tot volledige dekking) N/(1+(N1)ρ)s\sqrt{N/(1+(N-1)\rho)}\,\cdot s
Risicoreductie geen (één strategie per moment) N\sqrt{N}, begrensd door correlatie
Wat het koopt kapitaalefficiëntie, time-in-market risicogecorrigeerd rendement

De twee vermenigvuldigen elkaar. Een realistische orchestrator draait op elk moment mm (zwak gecorreleerde) strategieën — koopt de m\sqrt{m} per moment — en legt die vensters naast elkaar over de tijd, waarbij kapitaal wordt hergebruikt. De geaggregeerde Sharpe wordt bepaald door de structuur per moment,

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

geverifieerd bij m=1,2,4m = 1, 2, 4 (geannualiseerd 0.790.79, 1.141.14, 1.58=ms1.58 = \sqrt{m}\,s), terwijl temporele tegeling de kapitaalefficiëntie vermenigvuldigt, niet de Sharpe.

Waar de grens ligt. Een pure cascade — één strategie tegelijk actief — heeft een geaggregeerde Sharpe van exact ss, ongeacht hoeveel strategieën in de wachtrij wachten. Ze maximaliseert time-in-market en kapitaalefficiëntie, maar het risicogecorrigeerde rendement is begrensd tot dat van een enkele strategie. Om ss te overtreffen moet je binnen het moment diversifiëren — meerdere zwak gecorreleerde strategieën tegelijk draaien — en die winst is begrensd door N\sqrt{N} en wordt uitgehold door correlatie. Hieruit volgt de ontwerpregel: gebruik de cascade om ongebruikte tijd goedkoop terug te winnen op één kapitaaleenheid, en besteed het schaarse, dure N\sqrt{N} van gelijktijdige diversificatie alleen aan strategieën die werkelijk ongecorreleerd zijn. Het stapelen van gecorreleerde strategieën — in tijd of parallel — levert bijna niets op.

Orchestratie: fill_efficiency in de praktijk

Fill-efficiency-meter en heatmap Fill efficiency op ~78%: de heatmap toont tijdbenutting over strategieën en paren heen, heldere cellen duiden op actieve handel

De parameter fill_efficiency bepaalt welk deel van de ongebruikte tijd de orchestrator daadwerkelijk benut. Zoals getoond in PnL per actieve tijd, kan het op drie manieren worden geschat:

  1. Vaste constante (0,80) — grof maar universeel
  2. Analytische schatting via (1p)Neff(1-p)^{N_{eff}} — houdt rekening met correlatie
  3. Simulatie vanuit data — meest nauwkeurig

Voor een cascade met 3 strategieën op 10 paren:

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)

Praktische aanbevelingen

Praktische engineeringchecklist Zes belangrijke aanbevelingen voor cascade-inzet — van klein beginnen tot adaptieve herkalibratie

1. Begin met twee strategieën

Lanceer niet meteen 10 strategieën op 20 paren. Begin met één primaire + één fallback op 3-5 paren. Zorg dat de gezamenlijke simulatie overeenkomt met het echte gedrag. Backtest-live-pariteit is cruciaal: wijkt de cascade-backtest zelfs 5-10% af van live — dan zit er een fout in de orchestratorlogica.

2. dual_size uit grid search, niet uit intuïtie

De optimale dual_size hangt af van het specifieke strategiepaar. 6,8% is een richtlijn, geen universele constante. Voer grid search uit van 1% tot 30% in stappen van 0,5% en kies het Sharpe-maximum.

3. De slotlimiet bepaalt de architectuur

Bij max_slots = 1 degenereert cascade tot eenvoudig strategiewisselen. Bij max_slots = 50 is de beperking niet bindend en reduceert het probleem tot een onafhankelijke portfolio. De interessante zone: max_slots = 3-10, waar slotbeheer de resultaten werkelijk beïnvloedt.

4. Houd rekening met latentie

In live trading is de cascadewissel niet instantaan. Fallback-positie sluiten + primair openen = 2 API-aanroepen + netwerklatentie + exchange-matching. Op een volatiele markt kan de prijs binnen 200-500 ms bewegen. Bouw een slippage-budget in.

5. Monitor fill_efficiency

Volg de echte fill_efficiency in productie. Is deze significant lager dan in de backtest — dan benut de orchestrator de ongebruikte tijd niet zoals verwacht. Oorzaken: API-vertragingen, geweigerde orders, marge-beperkingen.

6. Gebruik adaptieve optimalisatie

Cascadeparameters (dual_size, scoregewichten, slotlimieten) mogen niet statisch zijn. Gebruik adaptieve drill-down voor periodieke herkalibratie op verse data. De markt verandert — de cascadeparameters moeten volgen.

Samenvatting van de reeks "Backtests zonder illusies"

Kenniskaart van de reeks Volledige systeemarchitectuur: 13 onderling verbonden modules van wiskunde via validatie tot live orchestratie

Dit artikel is de finale van een reeks van 13+ artikelen. Elk artikel behandelde een specifiek probleem op de weg van backtest naar productie. Zo hangen ze samen:

Fundament: rendementswiskunde

Verlies-winst-asymmetrie — de multiplicatieve aard van rendementen, volatility drag, het Kelly-criterium. Dit is het wiskundige fundament voor alles wat volgt: waarom MaxDD de leverage bepaalt, waarom Sharpe belangrijker is dan ruwe PnL, waarom een winstpercentage van 50% met symmetrische R:R onrendabel is.

Validatie: betrouwbaarheidsintervallen en robuustheid

Monte-Carlo-bootstrap — het omzetten van een puntschatting in een verdeling met betrouwbaarheidsintervallen. Elke metriek (PnL, MaxDD, Sharpe) heeft alleen zin met een betrouwbaarheidsinterval.

Walk-forward-optimalisatie — out-of-sample-validatie. Een backtest op historische data is een IS-resultaat; WFO laat zien hoe de strategie presteert op nieuwe data.

Plateau-analyse — controle van parameterrobuustheid. Als het optimum puntvormig is, is de strategie overgeoptimaliseerd.

Backtest-live-pariteit — vergelijking van backtest met echte resultaten. De laatste controle vóór opschaling.

Realistische kosten: funding en leverage

Funding rates vreten de leverage op — de verborgen kosten van leverage bij perpetual futures. Zonder rekening te houden met funding verandert een prachtige backtest in verlies.

Funding-rate-arbitrage — hoe je funding van een uitgave in een inkomstenbron verandert via cross-exchange-strategieën.

Metrieken en rangschikking

PnL per actieve tijd — de metriek voor het rangschikken van strategieën in een portfolio. Ruwe PnL schaalt niet; PnL/actieve dag wel.

Signaalcorrelatie — effectieve diversificatie in een portfolio van gecorreleerde paren.

Infrastructuur en optimalisatie

Parquet-cache voor multi-timeframe-backtests — datainfrastructuur voor snelle iteraties.

Adaptieve drill-down — adaptieve optimalisatie: grof grid -> fijnafstemming in veelbelovende zones.

Optuna vs. coördinaatafdaling — optimizerkeuze: Optuna voor lage dimensies met ruizige doelfuncties, coördinaatafdaling voor hoge dimensies met gladde doelfuncties.

Polars vs Pandas — prestaties van DataFrame-bewerkingen voor backtesting.

Orchestratie (dit artikel)

Cascadestrategieën — het combineren van alle voorgaande componenten tot een werkend systeem. Score-gebaseerde allocatie gebruikt PnL/actieve tijd, betrouwbaarheidscorrectie, funding-kosten. Cascademodus vult ongebruikte tijd. Gezamenlijke simulatie valideert de portfolio. Monte-Carlo-bootstrap biedt betrouwbaarheidsintervallen voor de cascade-PnL.

Elk artikel is een onafhankelijke module. Samen vormen ze een complete pijplijn van dataladen tot live orchestratie van een strategieportfolio.

Conclusie

Cascade is niet de enige aanpak voor strategieportfolio's. Maar het is een van de eenvoudigste en meest praktische: de primaire strategie handelt op volle capaciteit, fallback vult ongebruikte tijd op met een verkleinde positie. Twee sleutelparameters (dual_size en max_slots) bieden voldoende flexibiliteit voor de meeste configuraties.

Drie kernpunten:

  1. Cascade mag alleen via gezamenlijke simulatie worden teruggetest. Het optellen van individuele PnL blaast de resultaten op. Wisselkosten, overlap, slotbeperkingen — dit alles wordt alleen vastgelegd in de gezamenlijke simulatie.

  2. dual_size bepaalt de afweging tussen PnL en drawdown. Het typische optimum is 5-10%. Grid search op de Sharpe is een betrouwbare selectiemethode.

  3. De orchestrator is een score-gebaseerde prioriteitswachtrij. Alles reduceert tot één enkel getal (score) voor elk signaal. Score = f(PnL/actieve dag, MaxLev, betrouwbaarheid, funding). Strategieën met de hoogste score krijgen slots. De rest wacht.

De reeks "Backtests zonder illusies" toont één ding aan: tussen een prachtige backtest en echte winst liggen tientallen valkuilen. Elk artikel verwijdert er één. Cascade-orchestratie is de laatste stap: een verzameling gevalideerde strategieën omzetten in een werkende portfolio.


Nuttige links

  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)

Citatie

@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.}
}
Disclaimer: De informatie in dit artikel is uitsluitend bedoeld voor educatieve en informatieve doeleinden en vormt geen financieel, beleggings- of handelsadvies. Het handelen in cryptovaluta brengt een aanzienlijk risico op verlies met zich mee.

Auteurs

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

Blijf de markt voor

Abonneer je op onze nieuwsbrief voor exclusieve AI-handelsinzichten, marktanalyses en platformupdates.

We respecteren je privacy. Je kunt je op elk moment afmelden.