← К списку статей
March 8, 2026
5 мин. чтения

Cascade-стратегии: приоритетное исполнение с fallback-заполнением

Cascade-стратегии: приоритетное исполнение с fallback-заполнением
#алготрейдинг
#оркестрация
#портфель
#cascade
#стратегии
#slot management

Финал серии «Бэктесты без иллюзий». Как построить оркестратор из N стратегий на M парах, реализовать каскадный режим с приоритетным и fallback-исполнением, выбрать dual_size, и почему портфель стратегий нельзя бэктестить простым суммированием PnL.

Зачем нужен портфель стратегий

Портфель стратегий с простаивающим капиталом Множество стратегий конкурируют за ограниченный капитал — большинство простаивает, пока лишь немногие торгуют в каждый момент времени

Вы провели стратегию через полный пайплайн. Monte Carlo bootstrap показал приемлемый 5-й перцентиль. Walk-forward подтвердил out-of-sample доходность. Funding rates учтены, plateau analysis пройден. Стратегия реально работает.

Но она торгует 15% времени. Остальные 85% ваш капитал простаивает.

Запустить вторую стратегию? Третью? Десятую? Идея очевидна. Реализация — нет. Портфель стратегий порождает задачи, которых нет у одиночного бота:

  • Конфликты: две стратегии хотят открыть противоположные позиции на одной паре.
  • Ограничения: биржа/риск-менеджмент допускает не более KK одновременных позиций.
  • Аллокация: какую долю капитала отдать каждой стратегии?
  • Корреляция: 10 стратегий на коррелированных крипто-парах — это не 10× диверсификация.

Cascade-стратегия — архитектурный паттерн, который решает эти задачи: приоритетная стратегия получает полный размер позиции, а fallback-стратегия заполняет простои по уменьшенной позиции.

Концепция каскада: primary + fallback

Cascade strategy timeline overlay

High-conviction стратегия (primary)

Primary — стратегия со строгими критериями входа. Например, тройной таймфрейм с тремя подтверждающими уровнями: сигнал на дневном + 4-часовом + часовом, с фильтрацией по волатильности и объёмам.

Характеристики:

  • Мало сделок (десятки за период бэктеста)
  • Высокий PnL на сделку
  • Низкое время в позиции (5-15%)
  • Высокая уверенность в каждом входе

Fallback-стратегия

Fallback — стратегия с ослабленными критериями. Двойной таймфрейм, меньше фильтров, шире допуски. Она торгует чаще, но с меньшим edge на сделку.

Характеристики:

  • Больше сделок (сотни за период)
  • Умеренный PnL на сделку
  • Высокое время в позиции (30-50%)
  • Средняя уверенность — компенсируется уменьшенной позицией

Режим cascade

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

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

Когда primary открывает позицию — fallback молчит (или закрывается). Когда primary в простое — fallback торгует по уменьшенной позиции (dual_size). Приоритет безусловен: primary всегда вытесняет fallback.

Стратегии для примеров

На протяжении всей серии мы использовали три стратегии. Вот их параметры для периода 750 дней:

Параметр Strategy A Strategy B Strategy C
PnL +55% +27% +300%
Сделок ~500 ~40 ~400
Trading time ~15% ~5% ~45%
MaxDD ~0.9% ~0.75% ~17%
PnL/active day 0.49%/d 0.72%/d 0.89%/d
Характер Medium activity Rare, high conviction Frequent, aggressive

Как мы показали в статье PnL по активному времени, ранжирование по raw PnL и по PnL/active day даёт разные результаты. Для cascade-оркестрации критична именно вторая метрика.

Оптимальный dual_size

Поверхность оптимизации dual_size Grid search по dual_size выявляет пик Sharpe ratio — слишком большое значение увеличивает просадку, слишком малое — теряет простои

Проблема выбора

dual_size — доля от полной позиции, которую получает fallback-стратегия. Это ключевой параметр cascade:

  • Слишком большой (например, 0.5 = 50%): когда primary и fallback активны одновременно, суммарная экспозиция = 150% от целевой. Просадка удваивается. Асимметрия убытков делает это непропорционально дорогим.

  • Слишком малый (например, 0.01 = 1%): fallback заполняет 85% простоя, но зарабатывает копейки. Капитал фактически простаивает.

  • Оптимум: fallback вносит значимую долю PnL без критического увеличения drawdown при одновременной работе с primary.

Формализация

Пусть:

  • PpP_p — PnL primary за единицу времени
  • PfP_f — PnL fallback за единицу времени
  • tpt_p — доля времени в позиции (primary)
  • tft_f — доля времени в позиции (fallback)
  • dd — dual_size (0..1)
  • toverlapt_{overlap} — доля времени, когда оба в позиции

Суммарный PnL cascade:

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

Суммарный MaxDD (worst case — полная корреляция):

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

Если ограничить суммарный drawdown уровнем DtargetD_{target}:

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

Grid search

На практике оптимальный dual_size подбирается grid search на бэктесте 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 для dual_size.

    primary_equity и fallback_equity — log-returns, минутные бары.
    """
    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)  # минут в году
        ) 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)

Типичный оптимум для крипто-стратегий: dual_size в диапазоне 0.05-0.10 (5-10% от полной позиции). При Strategy B как primary (MaxDD 0.75%) и Strategy A как fallback (MaxDD 0.9%):

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

Ограничение по drawdown не является binding — оптимум определяется Sharpe cascade. На практике grid search обычно даёт d0.068d \approx 0.068 (6.8%).

Score-based allocation

Ранжирование стратегий по score Стратегии ранжируются по композитному score — коррекция на уверенность штрафует малые выборки, funding снижает чистый edge

Когда стратегий больше двух, cascade обобщается в score-based allocation.

Ранжирование по PnL per active time

Как подробно описано в статье PnL по активному времени, score стратегии рассчитывается с учётом:

  1. PnL per active day — эффективность использования капитала
  2. Confidence adjustment — штраф за малую выборку (t-распределение)
  3. Funding costs — реальная стоимость leverage (Funding rates)
  4. MaxLev — масштабирование с учётом drawdown (Асимметрия убытков)

score=PnLnet/dayэффективность×365ffillannualize×MaxLevмасштаб×cconfнадёжность\text{score} = \underbrace{\text{PnL}_{net/day}}_{\text{эффективность}} \times \underbrace{365 \cdot f_{fill}}_{\text{annualize}} \times \underbrace{\text{MaxLev}}_{\text{масштаб}} \times \underbrace{c_{conf}}_{\text{надёжность}}

Confidence adjustment для редких стратегий

Strategy B с 40 сделками требует серьёзного штрафа. Используем нижнюю границу доверительного интервала:

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, штраф за малую выборку."""
    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))

Funding cost integration

На perpetual фьючерсах funding выплачивается каждые 8 часов. При leverage LL и средней ставке rfr_f:

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

Для Strategy A с MaxLev = 55x и средним funding rate 0.01%:

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

При PnL/active day = 0.49% net PnL отрицателен: 0.49%1.65%=1.16%0.49\% - 1.65\% = -1.16\%/day. Стратегия убыточна на полном leverage. Подробный разбор — в статье Funding rates убивают ваш leverage.

Multi-strategy orchestrator

Orchestrator slot allocation and priority queue

Архитектура

Оркестратор управляет NN стратегий на MM торговых парах. Общее число потенциальных позиций: N×MN \times M. Но капитал ограничен — допустимо не более KK одновременных позиций (слотов).

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

Slot management

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:
    """Один слот оркестратора."""
    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 с cascade mode.

    Управляет N стратегий × M пар в рамках max_parallel_positions слотов.
    Primary стратегии имеют безусловный приоритет над 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]:
        """
        Обработка нового сигнала. Возвращает action или 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:
        """Стратегия закрыла позицию."""
        if pair in self.active_slots:
            del self.active_slots[pair]

    def utilization(self) -> float:
        """Текущая утилизация слотов."""
        return len(self.active_slots) / self.max_parallel

    def fill_efficiency_snapshot(self) -> float:
        """Взвешенная утилизация: 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

Conflict resolution

Три уровня конфликтов:

Уровень 1 — Same pair, same direction. Побеждает strategy с более высоким score. Если обе primary — score определяет победителя. Если одна primary, другая fallback — primary безусловно.

Уровень 2 — Same pair, opposite direction. Запрещено: нельзя одновременно быть в long и short на одной паре. Побеждает стратегия с высшим score.

Уровень 3 — Cross-pair competition. Когда все слоты заняты, новый сигнал вытесняет слот с наименьшим score. Это работает как priority queue.

Бэктестинг cascade: методология

Совместная симуляция каскадных стратегий Совместная симуляция: equity-кривые primary и fallback с зонами перекрытия и итоговый каскадный результат

Почему нельзя просто сложить PnL

Наивный подход: бэктестить каждую стратегию отдельно, сложить PnL. Это даёт завышенный результат по трём причинам:

  1. Time overlap. Когда primary и fallback активны одновременно, fallback не должна торговать (или торгует по dual_size). Простое сложение игнорирует этот overlap.

  2. Capital constraint. Суммарная позиция ограничена. Если 5 стратегий хотят открыться одновременно, а слотов 3 — две стратегии не войдут. Их PnL нельзя учитывать.

  3. Transaction costs. Cascade-переключение (закрытие fallback, открытие primary) порождает дополнительные комиссии, которых нет в индивидуальных бэктестах.

Joint simulation

Корректный бэктест cascade — это совместная симуляция всех стратегий на общей временной оси:

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 cascade-портфеля.

    Проходим по каждой минуте, применяем правила оркестратора,
    считаем PnL с учётом overlap и 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,
    }

Transaction cost при переключении

Каждое cascade-переключение (fallback -> primary) требует:

  1. Закрытие fallback-позиции: taker fee (0.04% на Binance futures)
  2. Открытие primary-позиции: taker fee (0.04%)
  3. Spread: ~0.01-0.02%

Суммарный switch cost: ~0.06-0.10% на одно переключение. При 100 переключениях за период:

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

Это значимая величина. Cascade с частым переключением может проигрывать одиночной стратегии из-за transaction costs.

Multi-pair extension: N стратегий на M парах

Сеть стратегий и торговых пар Сеть N стратегий, связанных с M торговыми парами — сила корреляции определяет эффективную диверсификацию

Пространство комбинаций

3 стратегии на 10 парах = 30 потенциальных сигналов. При max_slots = 5 оркестратор выбирает 5 лучших по score. Это комбинаторная задача: (305)=142506\binom{30}{5} = 142\,506 возможных портфелей в каждый момент.

На практике greedy-алгоритм (сортировка по score, заполнение сверху вниз) даёт результат, близкий к оптимальному, за O(NMlogK)O(N \cdot M \cdot \log K).

Корреляция между парами

Крипто-пары сильно коррелированы. BTC падает — ETH, SOL, AVAX падают вместе. Это означает, что 5 long-позиций на 5 разных парах — это фактически одна большая позиция на «крипторынок».

Как мы подробно разобрали в статье Корреляция сигналов, эффективное число независимых позиций:

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

где ρˉ\bar{\rho} — средняя корреляция между парами.

При ρˉ=0.7\bar{\rho} = 0.7 и 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

Пять позиций на коррелированных парах эквивалентны 1.3 независимым позициям. Диверсификация почти отсутствует.

Практические следствия для cascade

def effective_diversification(
    positions: list[dict],  # [{"pair": "BTCUSDT", "direction": "long"}, ...]
    correlation_matrix: np.ndarray,
    pair_index: dict[str, int],
) -> float:
    """
    Расчёт эффективной диверсификации открытых позиций.

    Returns:
        N_eff / N — коэффициент диверсификации (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


Оркестратор должен учитывать корреляцию при заполнении слотов. Два варианта:

  1. Diversification bonus: при ранжировании добавлять бонус к score стратегий на некоррелированных парах.
  2. Correlation cap: ограничивать число однонаправленных позиций на коррелированных парах.

Pipeline оптимизации cascade

Восьмиступенчатый пайплайн оптимизации Восемь связанных стадий от подготовки данных через валидацию до запуска в продакшен — каждая опирается на предыдущую

Полный пайплайн от данных до продакшена состоит из 8 стадий:

Stage 0: Data preparation

Загрузка исторических данных, построение Parquet-кэша для мультитаймфреймового доступа. Без эффективного кэша дальнейшие стадии неприемлемо медленны.

Stage 1: TF + Length (hill-climbing grid)

Выбор базового таймфрейма и длин окон индикаторов. Грубая сетка: TF из {1m, 5m, 15m, 1h, 4h}, Length из {10, 20, 50, 100, 200}. Hill-climbing от лучшей точки сетки.

Stage 2: Separation (coordinate descent, 12 параметров)

Оптимизация параметров разделения (входы/выходы). Координатный спуск по 12 параметрам — пороги индикаторов, фильтры, стоп-лоссы, тейк-профиты. Координатный спуск дешевле Optuna при высокой размерности и детерминированной целевой функции.

Stage 3: Meta-parameters (coordinate descent)

Мета-параметры: max hold time, min PnL для выхода, трейлинг-стоп конфигурация. Снова координатный спуск. Проверяем устойчивость через plateau analysis — если оптимум точечный, стратегия переоптимизирована.

Stage 4: Combo optimization

Grid search по парам (Primary, Fallback). Для каждой комбинации: подбор dual_size, расчёт cascade PnL через joint simulation.

Stage 5: Validation

Многоуровневая валидация:

  • Multi-symbol: стратегия тестируется на 10+ парах, не только на паре оптимизации
  • Walk-forward: скользящее окно IS/OOS
  • Parameter stability: plateau analysis на каждом этапе
  • Monte Carlo bootstrap: confidence intervals для cascade PnL
  • Backtest-live parity: сверка бэктеста с paper trading

Stage 6: Ranking and selection

Ранжирование cascade-комбинаций по score. Топ-K комбинаций проходят в Stage 7. Score учитывает confidence adjustment, funding costs и fill_efficiency.

Stage 7: Orchestration

Финальная стадия: запуск оркестратора на NN стратегий и MM пар в cascade mode. Slot management, priority queue, conflict resolution — всё, что описано выше.

Performance analysis: cascade vs individual

Сравнение производительности cascade и отдельных стратегий Сравнение: каскадный портфель превосходит отдельные стратегии за счёт утилизации простоев

Теоретическое преимущество cascade

Пусть primary торгует tp=15%t_p = 15\% времени с PnL/day = 0.49%. Fallback торгует tf=45%t_f = 45\% с PnL/day = 0.89%. Overlap = tp×tf=6.75%t_p \times t_f = 6.75\% (при независимости).

Отдельно primary (Strategy A):

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

Cascade (A primary + 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\%

Прирост cascade: +31% к PnL от fallback, при минимальном увеличении drawdown (0.068×17%=1.16%0.068 \times 17\% = 1.16\% к MaxDD).

Когда cascade не помогает

Cascade неэффективен, если:

  1. Primary активна >80% времени. Мало простоев — fallback некуда встраиваться.
  2. Стратегии сильно коррелированы. Primary и fallback дают сигналы одновременно — overlap высок, и fallback простаивает именно тогда, когда primary тоже простаивает.
  3. Switch costs превышают fallback PnL. При частом переключении cascade-комиссии съедают прибыль fallback.
  4. dual_size слишком мал. При d=0.01d = 0.01 fallback зарабатывает 1% от своего потенциала — ниже комиссий.

Таблица сравнения

Конфигурация Annual PnL MaxDD Sharpe Switch costs
Strategy A alone 26.8% 0.9% 1.42 0
Strategy C alone 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%
3-strategy orchestrator 48.7% 3.1% 1.63 ~2.1%

Cascade A+C: primary A получает +8.4% от fallback C. Sharpe растёт за счёт утилизации простоев. MaxDD растёт умеренно (0.9%+0.068×17%2.06%0.9\% + 0.068 \times 17\% \approx 2.06\%).

Оркестрация: fill_efficiency на практике

Индикатор fill efficiency и тепловая карта Fill efficiency ~78%: тепловая карта показывает утилизацию времени по стратегиям и парам, яркие ячейки — активная торговля

Параметр fill_efficiency определяет, какую долю простоя оркестратор реально утилизирует. Как показано в статье PnL по активному времени, его можно оценить тремя способами:

  1. Фиксированная константа (0.80) — грубо, но универсально
  2. Аналитическая оценка через (1p)Neff(1-p)^{N_{eff}} — учитывает корреляцию
  3. Симуляция из данных — самое точное

Для cascade с 3 стратегиями на 10 парах:

def cascade_fill_efficiency(
    strategies: list[dict],   # [{"trading_time": 0.15, "is_primary": True}, ...]
    n_pairs: int = 10,
    correlation_factor: float = 3.0,
) -> float:
    """Оценка fill_efficiency для cascade-портфеля."""
    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)

Практические рекомендации

Чеклист практических рекомендаций Шесть ключевых рекомендаций для развёртывания cascade — от простого старта до адаптивной рекалибрации

1. Начинайте с двух стратегий

Не запускайте сразу 10 стратегий на 20 парах. Начните с одной primary + одной fallback на 3-5 парах. Убедитесь, что joint simulation совпадает с реальным поведением. Backtest-live parity критична: если бэктест cascade расходится с live даже на 5-10% — ошибка в логике оркестратора.

2. dual_size из grid search, не из интуиции

Оптимальный dual_size зависит от конкретной пары стратегий. 6.8% — ориентир, не универсальная константа. Прогоните grid search от 1% до 30% с шагом 0.5% и выберите максимум Sharpe.

3. Slot limit определяет архитектуру

При max_slots = 1 cascade вырождается в простое переключение между стратегиями. При max_slots = 50 ограничение не binding и задача сводится к independent portfolio. Интересная зона: max_slots = 3-10, где slot management реально влияет на результат.

4. Учитывайте latency

В live-торговле cascade-переключение не мгновенно. Закрытие fallback-позиции + открытие primary = 2 API-вызова + network latency + exchange matching. На volatile рынке цена может уйти за 200-500ms. Закладывайте slippage budget.

5. Мониторинг fill_efficiency

Отслеживайте реальную fill_efficiency в продакшене. Если она значительно ниже бэктестовой — оркестратор не утилизирует простои так, как ожидалось. Причины: задержки API, rejected orders, margin constraints.

6. Используйте адаптивную оптимизацию

Параметры cascade (dual_size, score weights, slot limits) не должны быть статичными. Используйте adaptive drill-down для периодической перекалибровки на свежих данных. Рынок меняется — параметры cascade должны следовать.

Серия «Бэктесты без иллюзий»: итог

Карта знаний серии Полная архитектура системы: 13 взаимосвязанных модулей от математики через валидацию до live-оркестрации

Эта статья — финал серии из 13+ материалов. Каждая статья закрывала одну конкретную проблему на пути от бэктеста к продакшену. Вот как они связаны:

Фундамент: математика доходностей

Асимметрия убытков и прибылей — мультипликативная природа доходностей, volatility drag, критерий Келли. Это математическая база для всего последующего: почему MaxDD определяет leverage, почему Sharpe важнее raw PnL, почему 50% винрейт при симметричном R:R убыточен.

Валидация: доверительные интервалы и робастность

Monte Carlo bootstrap — превращение single-point estimate в распределение с confidence intervals. Любая метрика (PnL, MaxDD, Sharpe) имеет смысл только с доверительным интервалом.

Walk-forward optimization — out-of-sample валидация. Бэктест на исторических данных — это IS-результат; WFO показывает, как стратегия работает на новых данных.

Plateau analysis — проверка устойчивости параметров. Если оптимум точечный — стратегия переоптимизирована.

Backtest-live parity — сверка бэктеста с реальными результатами. Финальная проверка перед масштабированием.

Реалистичные costs: funding и leverage

Funding rates убивают leverage — скрытая стоимость leverage на perpetual фьючерсах. Без учёта funding красивый бэктест превращается в убыток.

Арбитраж funding rates — как превратить funding из расхода в источник дохода через cross-exchange стратегии.

Метрики и ранжирование

PnL по активному времени — метрика для ранжирования стратегий в портфеле. Raw PnL не масштабируется; PnL/active day — масштабируется.

Корреляция сигналов — эффективная диверсификация в портфеле коррелированных пар.

Инфраструктура и оптимизация

Parquet-кэш для мультитаймфреймовых бэктестов — инфраструктура данных для быстрых итераций.

Adaptive drill-down — адаптивная оптимизация: грубая сетка -> fine-tuning в перспективных зонах.

Optuna vs координатный спуск — выбор оптимизатора: Optuna для малых размерностей с шумной целевой, координатный спуск для больших размерностей с гладкой.

Polars vs Pandas — производительность DataFrame-операций для бэктестинга.

Оркестрация (эта статья)

Cascade-стратегии — объединение всех предыдущих компонентов в работающую систему. Score-based allocation использует PnL/active time, confidence adjustment, funding costs. Cascade mode заполняет простои. Joint simulation валидирует портфель. Monte Carlo bootstrap даёт confidence intervals для cascade PnL.

Каждая статья — независимый модуль. Вместе они образуют полный пайплайн от загрузки данных до live-оркестрации портфеля стратегий.

Заключение

Cascade — не единственный подход к портфелю стратегий. Но это один из самых простых и практичных: primary стратегия торгует на полную мощность, fallback заполняет простои по уменьшенной позиции. Два ключевых параметра (dual_size и max_slots) дают достаточную гибкость для большинства конфигураций.

Три вывода:

  1. Cascade бэктестится только joint simulation. Суммирование отдельных PnL завышает результат. Switch costs, overlap, slot constraints — всё это учитывается только в совместной симуляции.

  2. dual_size определяет trade-off: PnL vs drawdown. Типичный оптимум 5-10%. Grid search по Sharpe — надёжный способ подбора.

  3. Оркестратор — это score-based priority queue. Всё сводится к одному числу (score) для каждого сигнала. Score = f(PnL/active day, MaxLev, confidence, funding). Стратегии с высшим score получают слоты. Остальные ждут.

Серия «Бэктесты без иллюзий» показывает одну вещь: между красивым бэктестом и реальной прибылью — десятки подводных камней. Каждая статья убирает один из них. Cascade-оркестрация — последний шаг: превращение набора валидированных стратегий в работающий портфель.


Полезные ссылки

  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)

Цитирование

@article{soloviov2026cascadestrategies,
  author = {Soloviov, Eugen},
  title = {Cascade-стратегии: приоритетное исполнение с fallback-заполнением},
  year = {2026},
  url = {https://marketmaker.cc/ru/blog/post/cascade-strategies-orchestration},
  version = {0.1.0},
  description = {Финал серии «Бэктесты без иллюзий». Как построить оркестратор из N стратегий × M пар, реализовать каскадный режим с приоритетным и fallback-заполнением, выбрать dual\_size, и почему портфель стратегий нельзя бэктестить суммированием PnL.}
}
Дисклеймер: Информация в этой статье предоставлена исключительно в образовательных и ознакомительных целях и не является финансовым, инвестиционным или торговым советом. Торговля криптовалютами сопряжена с высоким риском убытков.

MarketMaker.cc Team

Количественные исследования и стратегии

Обсудить в Telegram
Newsletter

Будьте в курсе событий

Подпишитесь на нашу рассылку, чтобы получать эксклюзивную аналитику по AI-трейдингу и обновления платформы.

Мы уважаем вашу конфиденциальность. Отписаться можно в любой момент.