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

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

MarketMaker.cc Team
Количественные исследования и стратегии
Финал серии «Бэктесты без иллюзий». Как построить оркестратор из N стратегий на M парах, реализовать каскадный режим с приоритетным и fallback-исполнением, выбрать dual_size, и почему портфель стратегий нельзя бэктестить простым суммированием PnL.
Вы провели стратегию через полный пайплайн. Monte Carlo bootstrap показал приемлемый 5-й перцентиль. Walk-forward подтвердил out-of-sample доходность. Funding rates учтены, plateau analysis пройден. Стратегия реально работает.
Но она торгует 15% времени. Остальные 85% ваш капитал простаивает.
Запустить вторую стратегию? Третью? Десятую? Идея очевидна. Реализация — нет. Портфель стратегий порождает задачи, которых нет у одиночного бота:
Cascade-стратегия — архитектурный паттерн, который решает эти задачи: приоритетная стратегия получает полный размер позиции, а fallback-стратегия заполняет простои по уменьшенной позиции.

Primary — стратегия со строгими критериями входа. Например, тройной таймфрейм с тремя подтверждающими уровнями: сигнал на дневном + 4-часовом + часовом, с фильтрацией по волатильности и объёмам.
Характеристики:
Fallback — стратегия с ослабленными критериями. Двойной таймфрейм, меньше фильтров, шире допуски. Она торгует чаще, но с меньшим edge на сделку.
Характеристики:
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 — доля от полной позиции, которую получает fallback-стратегия. Это ключевой параметр cascade:
Слишком большой (например, 0.5 = 50%): когда primary и fallback активны одновременно, суммарная экспозиция = 150% от целевой. Просадка удваивается. Асимметрия убытков делает это непропорционально дорогим.
Слишком малый (например, 0.01 = 1%): fallback заполняет 85% простоя, но зарабатывает копейки. Капитал фактически простаивает.
Оптимум: fallback вносит значимую долю PnL без критического увеличения drawdown при одновременной работе с primary.
Пусть:
Суммарный PnL cascade:
Суммарный MaxDD (worst case — полная корреляция):
Если ограничить суммарный drawdown уровнем :
На практике оптимальный 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%):
Ограничение по drawdown не является binding — оптимум определяется Sharpe cascade. На практике grid search обычно даёт (6.8%).
Когда стратегий больше двух, cascade обобщается в score-based allocation.
Как подробно описано в статье PnL по активному времени, score стратегии рассчитывается с учётом:
Strategy B с 40 сделками требует серьёзного штрафа. Используем нижнюю границу доверительного интервала:
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))
На perpetual фьючерсах funding выплачивается каждые 8 часов. При leverage и средней ставке :
Для Strategy A с MaxLev = 55x и средним funding rate 0.01%:
При PnL/active day = 0.49% net PnL отрицателен: /day. Стратегия убыточна на полном leverage. Подробный разбор — в статье Funding rates убивают ваш leverage.

Оркестратор управляет стратегий на торговых парах. Общее число потенциальных позиций: . Но капитал ограничен — допустимо не более одновременных позиций (слотов).
┌─────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────┘
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
Три уровня конфликтов:
Уровень 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.
Наивный подход: бэктестить каждую стратегию отдельно, сложить PnL. Это даёт завышенный результат по трём причинам:
Time overlap. Когда primary и fallback активны одновременно, fallback не должна торговать (или торгует по dual_size). Простое сложение игнорирует этот overlap.
Capital constraint. Суммарная позиция ограничена. Если 5 стратегий хотят открыться одновременно, а слотов 3 — две стратегии не войдут. Их PnL нельзя учитывать.
Transaction costs. Cascade-переключение (закрытие fallback, открытие primary) порождает дополнительные комиссии, которых нет в индивидуальных бэктестах.
Корректный бэктест 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,
}
Каждое cascade-переключение (fallback -> primary) требует:
Суммарный switch cost: ~0.06-0.10% на одно переключение. При 100 переключениях за период:
Это значимая величина. Cascade с частым переключением может проигрывать одиночной стратегии из-за transaction costs.
3 стратегии на 10 парах = 30 потенциальных сигналов. При max_slots = 5 оркестратор выбирает 5 лучших по score. Это комбинаторная задача: возможных портфелей в каждый момент.
На практике greedy-алгоритм (сортировка по score, заполнение сверху вниз) даёт результат, близкий к оптимальному, за .
Крипто-пары сильно коррелированы. BTC падает — ETH, SOL, AVAX падают вместе. Это означает, что 5 long-позиций на 5 разных парах — это фактически одна большая позиция на «крипторынок».
Как мы подробно разобрали в статье Корреляция сигналов, эффективное число независимых позиций:
где — средняя корреляция между парами.
При и :
Пять позиций на коррелированных парах эквивалентны 1.3 независимым позициям. Диверсификация почти отсутствует.
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
Оркестратор должен учитывать корреляцию при заполнении слотов. Два варианта:
Полный пайплайн от данных до продакшена состоит из 8 стадий:
Загрузка исторических данных, построение Parquet-кэша для мультитаймфреймового доступа. Без эффективного кэша дальнейшие стадии неприемлемо медленны.
Выбор базового таймфрейма и длин окон индикаторов. Грубая сетка: TF из {1m, 5m, 15m, 1h, 4h}, Length из {10, 20, 50, 100, 200}. Hill-climbing от лучшей точки сетки.
Оптимизация параметров разделения (входы/выходы). Координатный спуск по 12 параметрам — пороги индикаторов, фильтры, стоп-лоссы, тейк-профиты. Координатный спуск дешевле Optuna при высокой размерности и детерминированной целевой функции.
Мета-параметры: max hold time, min PnL для выхода, трейлинг-стоп конфигурация. Снова координатный спуск. Проверяем устойчивость через plateau analysis — если оптимум точечный, стратегия переоптимизирована.
Grid search по парам (Primary, Fallback). Для каждой комбинации: подбор dual_size, расчёт cascade PnL через joint simulation.
Многоуровневая валидация:
Ранжирование cascade-комбинаций по score. Топ-K комбинаций проходят в Stage 7. Score учитывает confidence adjustment, funding costs и fill_efficiency.
Финальная стадия: запуск оркестратора на стратегий и пар в cascade mode. Slot management, priority queue, conflict resolution — всё, что описано выше.
Пусть primary торгует времени с PnL/day = 0.49%. Fallback торгует с PnL/day = 0.89%. Overlap = (при независимости).
Отдельно primary (Strategy A):
Cascade (A primary + C fallback):
Прирост cascade: +31% к PnL от fallback, при минимальном увеличении drawdown ( к MaxDD).
Cascade неэффективен, если:
| Конфигурация | 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 растёт умеренно ().
Параметр fill_efficiency определяет, какую долю простоя оркестратор реально утилизирует. Как показано в статье PnL по активному времени, его можно оценить тремя способами:
Для 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)
Не запускайте сразу 10 стратегий на 20 парах. Начните с одной primary + одной fallback на 3-5 парах. Убедитесь, что joint simulation совпадает с реальным поведением. Backtest-live parity критична: если бэктест cascade расходится с live даже на 5-10% — ошибка в логике оркестратора.
Оптимальный dual_size зависит от конкретной пары стратегий. 6.8% — ориентир, не универсальная константа. Прогоните grid search от 1% до 30% с шагом 0.5% и выберите максимум Sharpe.
При max_slots = 1 cascade вырождается в простое переключение между стратегиями. При max_slots = 50 ограничение не binding и задача сводится к independent portfolio. Интересная зона: max_slots = 3-10, где slot management реально влияет на результат.
В live-торговле cascade-переключение не мгновенно. Закрытие fallback-позиции + открытие primary = 2 API-вызова + network latency + exchange matching. На volatile рынке цена может уйти за 200-500ms. Закладывайте slippage budget.
Отслеживайте реальную fill_efficiency в продакшене. Если она значительно ниже бэктестовой — оркестратор не утилизирует простои так, как ожидалось. Причины: задержки API, rejected orders, margin constraints.
Параметры cascade (dual_size, score weights, slot limits) не должны быть статичными. Используйте adaptive drill-down для периодической перекалибровки на свежих данных. Рынок меняется — параметры cascade должны следовать.
Эта статья — финал серии из 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 — сверка бэктеста с реальными результатами. Финальная проверка перед масштабированием.
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) дают достаточную гибкость для большинства конфигураций.
Три вывода:
Cascade бэктестится только joint simulation. Суммирование отдельных PnL завышает результат. Switch costs, overlap, slot constraints — всё это учитывается только в совместной симуляции.
dual_size определяет trade-off: PnL vs drawdown. Типичный оптимум 5-10%. Grid search по Sharpe — надёжный способ подбора.
Оркестратор — это score-based priority queue. Всё сводится к одному числу (score) для каждого сигнала. Score = f(PnL/active day, MaxLev, confidence, funding). Стратегии с высшим score получают слоты. Остальные ждут.
Серия «Бэктесты без иллюзий» показывает одну вещь: между красивым бэктестом и реальной прибылью — десятки подводных камней. Каждая статья убирает один из них. Cascade-оркестрация — последний шаг: превращение набора валидированных стратегий в работающий портфель.
@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.} }