← Voltar aos artigos
March 8, 2026
5 min read

Estratégias em cascata: execução prioritária com preenchimento de reserva

Estratégias em cascata: execução prioritária com preenchimento de reserva
#algotrading
#orchestration
#portfolio
#cascade
#strategies
#slot management

Final da série "Backtests sem ilusões". Como construir um orquestrador a partir de N estratégias em M pares, implementar o modo cascata com prioridade e execução de reserva, escolher dual_size, e por que carteiras de estratégias não podem ser testadas retroativamente apenas somando o PnL.

Por que você precisa de uma carteira de estratégias

Carteira de estratégias com capital ocioso Várias estratégias competem por capital limitado — a maioria fica parada enquanto apenas algumas operam em determinado momento

Você levou uma estratégia por todo o pipeline. O bootstrap de Monte Carlo mostrou um 5º percentil aceitável. O walk-forward confirmou retornos fora da amostra. As taxas de financiamento foram contabilizadas, a análise de platô foi aprovada. A estratégia realmente funciona.

Mas ela opera 15% do tempo. Nos 85% restantes, seu capital fica ocioso.

Rodar uma segunda estratégia? Uma terceira? Uma décima? A ideia é óbvia. A implementação não é. Uma carteira de estratégias cria problemas que não existem com um único bot:

  • Conflitos: duas estratégias querem abrir posições opostas no mesmo par.
  • Restrições: a exchange/gestão de risco não permite mais do que KK posições simultâneas.
  • Alocação: qual fração do capital dar a cada estratégia?
  • Correlação: 10 estratégias em pares de criptomoedas correlacionados não é uma diversificação 10x.

A estratégia em cascata é um padrão arquitetural que resolve esses problemas: a estratégia primária recebe o tamanho de posição completo, enquanto a estratégia de reserva preenche o tempo ocioso com uma posição reduzida.

O conceito de cascata: primária + reserva

Sobreposição da linha do tempo da estratégia em cascata

Estratégia de alta convicção (Primária)

A primária é uma estratégia com critérios de entrada rigorosos. Por exemplo, timeframe triplo com três níveis de confirmação: sinal diário + 4 horas + horário, com filtragem de volatilidade e volume.

Características:

  • Poucos trades (dezenas ao longo do período de backtest)
  • Alto PnL por trade
  • Pouco tempo em posição (5-15%)
  • Alta confiança em cada entrada

Estratégia de reserva

A reserva é uma estratégia com critérios relaxados. Timeframe duplo, menos filtros, tolerâncias mais amplas. Ela opera com mais frequência, mas com menor edge por trade.

Características:

  • Mais trades (centenas ao longo do período)
  • PnL moderado por trade
  • Muito tempo em posição (30-50%)
  • Confiança moderada — compensada pela posição reduzida

Modo cascata

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

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

Quando a primária abre uma posição, a reserva fica em silêncio (ou fecha). Quando a primária está inativa, a reserva opera com posição reduzida (dual_size). A prioridade é incondicional: a primária sempre desloca a reserva.

Estratégias usadas nos exemplos

Ao longo da série, usamos três estratégias. Aqui estão seus parâmetros para o período de 750 dias:

Parâmetro Estratégia A Estratégia B Estratégia C
PnL +55% +27% +300%
Trades ~500 ~40 ~400
Tempo operando ~15% ~5% ~45%
MaxDD ~0.9% ~0.75% ~17%
PnL/dia ativo 0.49%/d 0.72%/d 0.89%/d
Caráter Atividade média Raro, alta convicção Frequente, agressivo

Como mostramos em PnL por tempo ativo, classificar pelo PnL bruto e pelo PnL/dia ativo produz resultados diferentes. Para a orquestração em cascata, é a segunda métrica que importa.

dual_size ótimo

Superfície de otimização de dual_size A busca em grade sobre dual_size revela um pico do índice de Sharpe — grande demais aumenta o drawdown, pequeno demais desperdiça tempo ocioso

O problema de seleção

dual_size é a fração da posição completa que a estratégia de reserva recebe. É o parâmetro-chave da cascata:

  • Grande demais (ex.: 0,5 = 50%): quando a primária e a reserva estão ativas simultaneamente, a exposição total = 150% do alvo. O drawdown dobra. A assimetria perda-lucro torna isso desproporcionalmente caro.

  • Pequeno demais (ex.: 0,01 = 1%): a reserva preenche 85% do tempo ocioso, mas ganha centavos. O capital fica efetivamente ocioso.

  • Ótimo: a reserva contribui com PnL significativo sem aumentar criticamente o drawdown durante a operação simultânea com a primária.

Formalização

Seja:

  • PpP_p — PnL da primária por unidade de tempo
  • PfP_f — PnL da reserva por unidade de tempo
  • tpt_p — fração do tempo em posição (primária)
  • tft_f — fração do tempo em posição (reserva)
  • dd — dual_size (0..1)
  • toverlapt_{overlap} — fração do tempo em que ambas estão em posição

PnL total da cascata:

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

MaxDD total (pior caso — correlação total):

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

Se restringirmos o drawdown total a DtargetD_{target}:

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

Busca em grade

Na prática, o dual_size ótimo é encontrado por busca em grade no backtest em cascata:

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)

Ótimo típico para estratégias de cripto: dual_size na faixa de 0,05-0,10 (5-10% da posição completa). Com a Estratégia B como primária (MaxDD 0,75%) e a Estratégia A como reserva (MaxDD 0,9%):

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

A restrição de drawdown não é vinculante — o ótimo é determinado pelo Sharpe da cascata. Na prática, a busca em grade normalmente produz d0.068d \approx 0.068 (6,8%).

Alocação baseada em score

Ranking de estratégias baseado em score Estratégias classificadas por score composto — o ajuste de confiança penaliza amostras pequenas, os custos de financiamento reduzem o edge líquido

Quando há mais de duas estratégias, a cascata se generaliza para uma alocação baseada em score.

Classificação por PnL por tempo ativo

Como descrito em detalhes em PnL por tempo ativo, o score da estratégia é calculado levando em conta:

  1. PnL por dia ativo — eficiência do uso do capital
  2. Ajuste de confiança — penalidade para amostras pequenas (distribuição t)
  3. Custos de financiamento — custo real da alavancagem (Taxas de financiamento)
  4. MaxLev — escalonamento considerando o drawdown (Assimetria perda-lucro)

score=PnLnet/diaeficieˆncia×365ffillanualizar×MaxLevescala×cconfconfiabilidade\text{score} = \underbrace{\text{PnL}_{net/dia}}_{\text{eficiência}} \times \underbrace{365 \cdot f_{fill}}_{\text{anualizar}} \times \underbrace{\text{MaxLev}}_{\text{escala}} \times \underbrace{c_{conf}}_{\text{confiabilidade}}

Ajuste de confiança para estratégias raras

A Estratégia B com 40 trades exige uma penalidade séria. Usamos o limite inferior do intervalo de confiança:

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

Integração do custo de financiamento

Em futuros perpétuos, o financiamento é pago a cada 8 horas. Com alavancagem LL e taxa média rfr_f:

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

Para a Estratégia A com MaxLev = 55x e taxa média de financiamento de 0,01%:

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

Com PnL/dia ativo = 0,49%, o PnL líquido é negativo: 0.49%1.65%=1.16%0.49\% - 1.65\% = -1.16\%/dia. A estratégia não é lucrativa com alavancagem total. Análise detalhada em Taxas de financiamento matam sua alavancagem.

Orquestrador multiestratégia

Alocação de slots e fila de prioridade do orquestrador

Arquitetura

O orquestrador gerencia NN estratégias em MM pares de negociação. Número total de posições potenciais: N×MN \times M. Mas o capital é limitado — não são permitidas mais de KK posições simultâneas (slots).

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

Gestão de slots

from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
import heapq
import time


class SlotType(Enum):
    FULL = "full"        # primary strategy, 100% position
    DUAL = "dual"        # fallback strategy, dual_size position


@dataclass
class Signal:
    strategy_id: str
    pair: str
    direction: str       # "long" | "short"
    score: float
    is_primary: bool     # primary or fallback
    timestamp: float


@dataclass(order=True)
class Slot:
    """A single orchestrator slot."""
    priority: float = field(compare=True)  # negative score for min-heap
    strategy_id: str = field(compare=False)
    pair: str = field(compare=False)
    slot_type: SlotType = field(compare=False)
    entry_time: float = field(compare=False)


class Orchestrator:
    """
    Multi-strategy orchestrator with cascade mode.

    Manages N strategies x M pairs within max_parallel_positions slots.
    Primary strategies have unconditional priority over fallback.
    """

    def __init__(
        self,
        max_parallel_positions: int = 10,
        dual_size: float = 0.068,
        min_score: float = 0,
    ):
        self.max_parallel = max_parallel_positions
        self.dual_size = dual_size
        self.min_score = min_score

        self.active_slots: dict[str, Slot] = {}  # pair -> Slot
        self.pending_signals: list[Signal] = []

    def on_signal(self, signal: Signal) -> Optional[dict]:
        """
        Process a new signal. Returns an action or None.

        Actions:
        - {"action": "open", "pair": ..., "size": ..., "slot_type": ...}
        - {"action": "replace", "pair": ..., "close_strategy": ..., "open_strategy": ...}
        - None (signal rejected)
        """
        if signal.score < self.min_score:
            return None

        pair = signal.pair

        if pair in self.active_slots:
            existing = self.active_slots[pair]

            if signal.is_primary and existing.slot_type == SlotType.DUAL:
                self.active_slots[pair] = Slot(
                    priority=-signal.score,
                    strategy_id=signal.strategy_id,
                    pair=pair,
                    slot_type=SlotType.FULL,
                    entry_time=signal.timestamp,
                )
                return {
                    "action": "replace",
                    "pair": pair,
                    "close_strategy": existing.strategy_id,
                    "open_strategy": signal.strategy_id,
                    "size": 1.0,
                }

            if signal.score > -existing.priority:
                slot_type = SlotType.FULL if signal.is_primary else SlotType.DUAL
                size = 1.0 if signal.is_primary else self.dual_size
                self.active_slots[pair] = Slot(
                    priority=-signal.score,
                    strategy_id=signal.strategy_id,
                    pair=pair,
                    slot_type=slot_type,
                    entry_time=signal.timestamp,
                )
                return {
                    "action": "replace",
                    "pair": pair,
                    "close_strategy": existing.strategy_id,
                    "open_strategy": signal.strategy_id,
                    "size": size,
                }

            return None  # existing has higher priority

        if len(self.active_slots) < self.max_parallel:
            slot_type = SlotType.FULL if signal.is_primary else SlotType.DUAL
            size = 1.0 if signal.is_primary else self.dual_size

            self.active_slots[pair] = Slot(
                priority=-signal.score,
                strategy_id=signal.strategy_id,
                pair=pair,
                slot_type=slot_type,
                entry_time=signal.timestamp,
            )
            return {
                "action": "open",
                "pair": pair,
                "strategy": signal.strategy_id,
                "size": size,
                "slot_type": slot_type,
            }

        worst_pair = min(
            self.active_slots,
            key=lambda p: -self.active_slots[p].priority,
        )
        worst_slot = self.active_slots[worst_pair]

        if signal.score > -worst_slot.priority:
            del self.active_slots[worst_pair]

            slot_type = SlotType.FULL if signal.is_primary else SlotType.DUAL
            size = 1.0 if signal.is_primary else self.dual_size

            self.active_slots[pair] = Slot(
                priority=-signal.score,
                strategy_id=signal.strategy_id,
                pair=pair,
                slot_type=slot_type,
                entry_time=signal.timestamp,
            )
            return {
                "action": "replace",
                "pair": pair,
                "close_strategy": worst_slot.strategy_id,
                "close_pair": worst_pair,
                "open_strategy": signal.strategy_id,
                "size": size,
            }

        return None  # all active slots have higher scores

    def on_exit(self, pair: str) -> None:
        """Strategy closed a position."""
        if pair in self.active_slots:
            del self.active_slots[pair]

    def utilization(self) -> float:
        """Current slot utilization."""
        return len(self.active_slots) / self.max_parallel

    def fill_efficiency_snapshot(self) -> float:
        """Weighted utilization: FULL=1.0, DUAL=dual_size."""
        total = sum(
            1.0 if s.slot_type == SlotType.FULL else self.dual_size
            for s in self.active_slots.values()
        )
        return total / self.max_parallel

Resolução de conflitos

Três níveis de conflito:

Nível 1 — Mesmo par, mesma direção. A estratégia com maior score vence. Se ambas forem primárias — o score determina a vencedora. Se uma for primária e a outra reserva — a primária vence incondicionalmente.

Nível 2 — Mesmo par, direção oposta. Proibido: não se pode estar simultaneamente comprado e vendido no mesmo par. A estratégia com maior score vence.

Nível 3 — Concorrência entre pares. Quando todos os slots estão ocupados, um novo sinal expulsa o slot com menor score. Isso funciona como uma fila de prioridade.

Backtesting em cascata: metodologia

Simulação conjunta de estratégias em cascata Simulação conjunta: curvas de equity da primária e da reserva com zonas de sobreposição e o resultado combinado da cascata

Por que você não pode simplesmente somar o PnL

A abordagem ingênua: testar retroativamente cada estratégia separadamente, somar o PnL. Isso produz um resultado inflado por três razões:

  1. Sobreposição temporal. Quando a primária e a reserva estão ativas simultaneamente, a reserva não deveria operar (ou deveria operar com dual_size). A soma simples ignora essa sobreposição.

  2. Restrição de capital. A posição total é limitada. Se 5 estratégias quiserem abrir simultaneamente, mas houver apenas 3 slots — duas estratégias não entrarão. Seu PnL não pode ser contado.

  3. Custos de transação. A troca de cascata (fechar a reserva, abrir a primária) gera comissões adicionais que não estão presentes em backtests individuais.

Simulação conjunta

O backtest correto de cascata é uma simulação conjunta de todas as estratégias em uma linha do tempo compartilhada:

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

Custo de transação na troca

Cada troca de cascata (reserva -> primária) requer:

  1. Fechar a posição de reserva: taxa taker (0,04% na Binance futures)
  2. Abrir a posição primária: taxa taker (0,04%)
  3. Spread: ~0,01-0,02%

Custo total de troca: ~0,06-0,10% por troca. Com 100 trocas ao longo do período:

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

Esse é um valor significativo. Uma cascata com trocas frequentes pode apresentar desempenho inferior a uma estratégia única devido aos custos de transação.

Extensão multipares: N estratégias em M pares

Rede de estratégias multipares Rede de N estratégias conectadas a M pares de negociação — a força da correlação determina a diversificação efetiva

Espaço de combinações

3 estratégias em 10 pares = 30 sinais potenciais. Com max_slots = 5, o orquestrador seleciona os 5 melhores por score. Este é um problema combinatório: (305)=142506\binom{30}{5} = 142\,506 carteiras possíveis a cada momento.

Na prática, um algoritmo guloso (ordenar por score, preencher de cima para baixo) produz resultados quase ótimos em O(NMlogK)O(N \cdot M \cdot \log K).

Correlação entre pares

Pares de criptomoedas são fortemente correlacionados. BTC cai — ETH, SOL, AVAX caem juntos. Isso significa que 5 posições compradas em 5 pares diferentes são efetivamente uma grande posição no "mercado cripto".

Como analisamos em detalhes em Correlação de sinais, o número efetivo de posições independentes é:

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

onde ρˉ\bar{\rho} é a correlação média entre os pares.

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

Cinco posições em pares correlacionados equivalem a 1,3 posição independente. A diversificação é praticamente inexistente.

Implicações práticas para a cascata

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


O orquestrador deve levar em conta a correlação ao preencher os slots. Duas opções:

  1. Bônus de diversificação: ao classificar, adicionar um bônus ao score de estratégias em pares não correlacionados.
  2. Teto de correlação: limitar o número de posições na mesma direção em pares correlacionados.

Pipeline de otimização da cascata

Pipeline de otimização de oito estágios Oito estágios conectados, da preparação dos dados até a validação e a orquestração ao vivo — cada um se baseia no anterior

O pipeline completo dos dados até a produção consiste em 8 estágios:

Estágio 0: Preparação dos dados

Carregar dados históricos, construir cache Parquet para acesso multi-timeframe. Sem um cache eficiente, os estágios seguintes são inaceitavelmente lentos.

Estágio 1: TF + comprimento (grade de hill-climbing)

Selecionar o timeframe base e os comprimentos de janela dos indicadores. Grade grosseira: TF de {1m, 5m, 15m, 1h, 4h}, comprimento de {10, 20, 50, 100, 200}. Hill-climbing a partir do melhor ponto da grade.

Estágio 2: Separação (descida por coordenadas, 12 parâmetros)

Otimizar os parâmetros de separação (entradas/saídas). Descida por coordenadas em 12 parâmetros — limiares de indicadores, filtros, stop-losses, take-profits. A descida por coordenadas é mais barata que o Optuna para funções objetivo determinísticas de alta dimensão.

Estágio 3: Meta-parâmetros (descida por coordenadas)

Meta-parâmetros: tempo máximo de retenção, PnL mínimo para saída, configuração do trailing stop. Novamente descida por coordenadas. Verificar a robustez via análise de platô — se o ótimo for pontual, a estratégia está sobreotimizada.

Estágio 4: Otimização de combinações

Busca em grade sobre pares (primária, reserva). Para cada combinação: selecionar dual_size, calcular o PnL da cascata por meio de simulação conjunta.

Estágio 5: Validação

Validação em vários níveis:

Estágio 6: Classificação e seleção

Classificar as combinações de cascata por score. As melhores K combinações avançam para o Estágio 7. O score considera o ajuste de confiança, os custos de financiamento e o fill_efficiency.

Estágio 7: Orquestração

Estágio final: lançar o orquestrador com NN estratégias e MM pares em modo cascata. Gestão de slots, fila de prioridade, resolução de conflitos — tudo descrito acima.

Análise de desempenho: cascata vs. individual

Desempenho de cascata vs. estratégias individuais Comparação lado a lado: a carteira em cascata supera as estratégias individuais graças ao aproveitamento do tempo ocioso

Vantagem teórica da cascata

Suponha que a primária opera tp=15%t_p = 15\% do tempo com PnL/dia = 0,49%. A reserva opera tf=45%t_f = 45\% com PnL/dia = 0,89%. Sobreposição = tp×tf=6.75%t_p \times t_f = 6.75\% (assumindo independência).

Apenas primária (Estratégia A):

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

Cascata (A primária + C reserva):

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

Ganho da cascata: +31% de PnL graças à reserva, com aumento mínimo do drawdown (0.068×17%=1.16%0.068 \times 17\% = 1.16\% adicionado ao MaxDD).

Quando a cascata não ajuda

A cascata é ineficaz quando:

  1. A primária está ativa >80% do tempo. Pouco tempo ocioso — sem espaço para a reserva.
  2. As estratégias são altamente correlacionadas. A primária e a reserva geram sinais simultaneamente — a sobreposição é alta, e a reserva fica inativa justamente quando a primária também está.
  3. Os custos de troca superam o PnL da reserva. Com trocas frequentes, as comissões da cascata consomem os lucros da reserva.
  4. dual_size é pequeno demais. Com d=0.01d = 0.01, a reserva ganha apenas 1% de seu potencial — abaixo das comissões.

Tabela comparativa

Configuração PnL anual MaxDD Sharpe Custos de troca
Estratégia A sozinha 26.8% 0.9% 1.42 0
Estratégia C sozinha 146.1% 17% 1.15 0
Cascata A+C (d=0.068) 35.2% 2.06% 1.58 ~1.2%
Cascata B+A (d=0.068) 19.4% 1.36% 1.71 ~0.3%
Orquestrador de 3 estratégias 48.7% 3.1% 1.63 ~2.1%

Cascata A+C: a primária A ganha +8,4% graças à reserva C. O Sharpe aumenta pelo aproveitamento do tempo ocioso. O MaxDD cresce moderadamente (0.9%+0.068×17%2.06%0.9\% + 0.068 \times 17\% \approx 2.06\%).

A matemática da diversificação temporal

A vantagem da cascata descrita acima — preencher o tempo ocioso — é uma de duas formas fundamentalmente diferentes de diversificar uma carteira de estratégias, e elas escalam de maneira diferente. Entender bem essa distinção diz exatamente o que uma cascata pode e não pode comprar.

Seja uma estratégia base que gera retornos i.i.d. com média μ\mu e volatilidade σ\sigma por período ativo, de modo que seu Sharpe por período é s=μ/σs = \mu/\sigma. Cada Sharpe abaixo é anualizado por 252\sqrt{252}; o exemplo trabalhado usa s=0.05s = 0.05 por período (s252=0.79s\sqrt{252} = 0.79 anualizado), e cada número vem de uma simulação de 2.000.000 de períodos que corresponde à forma fechada até duas casas decimais.

Diversificação temporal: preenchendo o tempo ocioso

Uma estratégia presente no mercado apenas uma fração pp do tempo — parada (retorno zero, mas também risco zero) no restante — tem um Sharpe de linha do tempo completa

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

O tempo ocioso dilui a média por pp, mas o desvio padrão apenas por p\sqrt{p}, então a razão cai como p\sqrt{p}: com p=0.25p = 0.25, o Sharpe anualizado cai de 0.790.79 para 0.390.39. É por isso que uma primária de alta convicção que opera 15% do tempo parece medíocre na curva de equity completa.

Uma cascata preenche esse tempo ocioso. Combinando kk estratégias temporalmente disjuntas de modo que juntas cubram toda a linha do tempo — exatamente uma ativa por período — o fluxo combinado volta a ser N(μ,σ)N(\mu,\sigma):

SRcascade=s(independente de k).SR_{\text{cascade}} = s \qquad(\text{independente de } k).

Passar de uma estratégia com cobertura 1/k1/k para kk estratégias com cobertura total eleva o Sharpe em k\sqrt{k} — verificado: 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 — mas satura em ss, o Sharpe por período de uma única estratégia. A diversificação temporal não consegue elevar o retorno ajustado ao risco da carteira acima do que uma única estratégia ganha enquanto opera, porque a cada instante mantém-se exatamente uma estratégia: sem média, sem redução de variância. O que ela de fato compra é reutilização de capital — uma unidade de capital executa as kk estratégias em sequência. Para executá-las simultaneamente, seriam necessárias kk unidades (ou alavancagem kk vezes maior); a cascata colhe as kk vantagens em uma única unidade. Um ganho de eficiência de capital, não um ganho de risco.

Diversificação correlacional: o √N ao qual uma cascata renuncia

O outro tipo executa NN estratégias simultaneamente, divide o capital e as faz a média a cada instante. Para estratégias equicorrelacionadas com correlação par a par ρ\rho,

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

que é Ns\sqrt{N}\,s quando não correlacionadas (ρ=0\rho = 0) e colapsa para ss à medida que ρ1\rho \to 1. Verificado: 8 estratégias não correlacionadas atingem um valor anualizado de 2.252.25 (isto é 8×0.79\sqrt{8}\times 0.79), mas com ρ=0.5\rho = 0.5 as mesmas oito conseguem apenas 1.041.04 — pouco melhor do que uma única. Este é exatamente o desconto de diversificação efetiva da seção multipares: a correlação é o imposto sobre o N\sqrt{N}.

A alocação de Kelly é o ótimo correlacional. Com o vetor de médias μ\boldsymbol\mu e a covariância Σ\Sigma, os pesos ótimos de crescimento são w=Σ1μ\mathbf{w}^{*} = \Sigma^{-1}\boldsymbol\mu, e o Sharpe que eles alcançam é

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

que reproduz exatamente o escalonamento N/(1+(N1)ρ)s\sqrt{N/(1+(N-1)\rho)}\,s (a simulação e a forma fechada coincidem: 2.252.25 para oito não correlacionadas, 1.001.00 com ρ=0.5\rho=0.5). Σ1μ\Sigma^{-1}\boldsymbol\mu é o que significa "tirar a média a cada instante" por escrito.

O que uma cascata realmente compra, e seu teto

Dimensão Temporal (cascata) Correlacional (paralela)
Quando as estratégias operam disjuntas no tempo simultaneamente
Capital reutilizado (1 unidade executa todas) dividido / alavancado (NN unidades)
Escalonamento do Sharpe satura em ss (preenche o ócio, k\sqrt{k} até cobertura total) N/(1+(N1)ρ)s\sqrt{N/(1+(N-1)\rho)}\,\cdot s
Redução de risco nenhuma (uma estratégia por instante) N\sqrt{N}, limitada pela correlação
O que compra eficiência de capital, tempo no mercado retorno ajustado ao risco

Os dois se multiplicam. Um orquestrador realista executa mm estratégias (fracamente correlacionadas) a cada instante — comprando o m\sqrt{m} por instante — e combina essas janelas ao longo do tempo, reutilizando o capital. O Sharpe agregado é definido pela estrutura por instante,

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

verificado em m=1,2,4m = 1, 2, 4 (anualizado 0.790.79, 1.141.14, 1.58=ms1.58 = \sqrt{m}\,s), enquanto o mosaico temporal multiplica a eficiência de capital, não o Sharpe.

Onde está o limite. Uma cascata pura — uma estratégia ativa por vez — tem Sharpe agregado exatamente igual a ss, não importa quantas estratégias esperem na fila. Ela maximiza o tempo no mercado e a eficiência de capital, mas seu retorno ajustado ao risco é limitado ao de uma única estratégia. Para superar ss, é preciso diversificar dentro do instante — executar várias estratégias fracamente correlacionadas ao mesmo tempo —, e esse ganho é limitado por N\sqrt{N} e corroído pela correlação. Segue-se a regra de design: use a cascata para recuperar o tempo ocioso de forma barata em uma unidade de capital, e gaste o escasso e caro N\sqrt{N} de diversificação simultânea apenas em estratégias genuinamente não correlacionadas. Empilhar estratégias correlacionadas — no tempo ou em paralelo — não traz quase nada.

Orquestração: fill_efficiency na prática

Medidor e mapa de calor de fill efficiency Fill efficiency em ~78%: o mapa de calor mostra a utilização do tempo entre estratégias e pares, células brilhantes indicam negociação ativa

O parâmetro fill_efficiency determina qual fração do tempo ocioso o orquestrador realmente utiliza. Como mostrado em PnL por tempo ativo, ele pode ser estimado de três maneiras:

  1. Constante fixa (0,80) — aproximada, mas universal
  2. Estimativa analítica via (1p)Neff(1-p)^{N_{eff}} — considera a correlação
  3. Simulação a partir dos dados — mais precisa

Para uma cascata com 3 estratégias em 10 pares:

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)

Recomendações práticas

Checklist prático de engenharia Seis recomendações-chave para implantação em cascata — de começar pequeno até a recalibração adaptativa

1. Comece com duas estratégias

Não lance 10 estratégias em 20 pares de uma vez. Comece com uma primária + uma reserva em 3-5 pares. Certifique-se de que a simulação conjunta corresponda ao comportamento real. A paridade backtest-ao vivo é crítica: se o backtest da cascata divergir do desempenho ao vivo em apenas 5-10% — há um erro na lógica do orquestrador.

2. dual_size a partir de busca em grade, não de intuição

O dual_size ótimo depende do par específico de estratégias. 6,8% é uma referência, não uma constante universal. Execute uma busca em grade de 1% a 30% em passos de 0,5% e selecione o máximo do Sharpe.

3. O limite de slots define a arquitetura

Com max_slots = 1, a cascata degenera em uma simples troca de estratégia. Com max_slots = 50, a restrição não é vinculante e o problema se reduz a uma carteira independente. A zona interessante: max_slots = 3-10, onde a gestão de slots realmente impacta os resultados.

4. Considere a latência

Na negociação ao vivo, a troca de cascata não é instantânea. Fechar uma posição de reserva + abrir a primária = 2 chamadas de API + latência de rede + emparelhamento na exchange. Em um mercado volátil, o preço pode se mover em 200-500ms. Inclua uma margem para slippage.

5. Monitore o fill_efficiency

Acompanhe o fill_efficiency real em produção. Se estiver significativamente abaixo do backtest — o orquestrador não está utilizando o tempo ocioso como esperado. Causas: atrasos de API, ordens rejeitadas, restrições de margem.

6. Use otimização adaptativa

Os parâmetros da cascata (dual_size, pesos do score, limites de slots) não devem ser estáticos. Use o drill-down adaptativo para recalibração periódica com dados novos. O mercado muda — os parâmetros da cascata devem acompanhar.

Resumo da série "Backtests sem ilusões"

Mapa de conhecimento da série Arquitetura completa do sistema: 13 módulos interconectados, da matemática à validação até a orquestração ao vivo

Este artigo é o final de uma série de 13+ artigos. Cada artigo abordou um problema específico no caminho do backtest até a produção. Veja como se conectam:

Fundamento: matemática do retorno

Assimetria perda-lucro — a natureza multiplicativa dos retornos, o arrasto de volatilidade, o critério de Kelly. Esse é o fundamento matemático para tudo o que se segue: por que o MaxDD determina a alavancagem, por que o Sharpe importa mais que o PnL bruto, por que uma taxa de acerto de 50% com R:R simétrico não é lucrativa.

Validação: intervalos de confiança e robustez

Bootstrap de Monte Carlo — transformar uma estimativa pontual em uma distribuição com intervalos de confiança. Qualquer métrica (PnL, MaxDD, Sharpe) só faz sentido com um intervalo de confiança.

Otimização walk-forward — validação fora da amostra. Um backtest em dados históricos é um resultado IS; o WFO mostra como a estratégia se comporta com dados novos.

Análise de platô — verificação da robustez dos parâmetros. Se o ótimo for pontual, a estratégia está sobreotimizada.

Paridade backtest-ao vivo — comparação do backtest com resultados reais. A verificação final antes de escalar.

Custos realistas: financiamento e alavancagem

Taxas de financiamento matam a alavancagem — o custo oculto da alavancagem em futuros perpétuos. Sem contabilizar o financiamento, um belo backtest se transforma em prejuízo.

Arbitragem de taxa de financiamento — como transformar o financiamento de uma despesa em fonte de receita por meio de estratégias entre exchanges.

Métricas e classificação

PnL por tempo ativo — a métrica para classificar estratégias em uma carteira. O PnL bruto não escala; o PnL/dia ativo, sim.

Correlação de sinais — diversificação efetiva em uma carteira de pares correlacionados.

Infraestrutura e otimização

Cache Parquet para backtests multi-timeframe — infraestrutura de dados para iterações rápidas.

Drill-down adaptativo — otimização adaptativa: grade grosseira -> ajuste fino em zonas promissoras.

Optuna vs. descida por coordenadas — seleção de otimizador: Optuna para dimensões baixas com objetivos ruidosos, descida por coordenadas para dimensões altas com objetivos suaves.

Polars vs Pandas — desempenho de operações de DataFrame para backtesting.

Orquestração (este artigo)

Estratégias em cascata — combinando todos os componentes anteriores em um sistema funcional. A alocação baseada em score usa PnL/tempo ativo, ajuste de confiança, custos de financiamento. O modo cascata preenche o tempo ocioso. A simulação conjunta valida a carteira. O bootstrap de Monte Carlo fornece intervalos de confiança para o PnL da cascata.

Cada artigo é um módulo independente. Juntos, formam um pipeline completo desde o carregamento dos dados até a orquestração ao vivo de uma carteira de estratégias.

Conclusão

A cascata não é a única abordagem para carteiras de estratégias. Mas é uma das mais simples e práticas: a estratégia primária opera em capacidade total, a reserva preenche o tempo ocioso com uma posição reduzida. Dois parâmetros-chave (dual_size e max_slots) oferecem flexibilidade suficiente para a maioria das configurações.

Três conclusões principais:

  1. A cascata só deve ser testada retroativamente por meio de simulação conjunta. Somar o PnL individual infla os resultados. Custos de troca, sobreposição, restrições de slots — tudo isso só é capturado na simulação conjunta.

  2. dual_size determina o trade-off entre PnL e drawdown. O ótimo típico é de 5-10%. A busca em grade sobre o Sharpe é um método de seleção confiável.

  3. O orquestrador é uma fila de prioridade baseada em score. Tudo se reduz a um único número (score) para cada sinal. Score = f(PnL/dia ativo, MaxLev, confiança, financiamento). As estratégias com maior score recebem slots. As demais esperam.

A série "Backtests sem ilusões" demonstra uma coisa: entre um belo backtest e um lucro real existem dezenas de armadilhas. Cada artigo remove uma. A orquestração em cascata é o último passo: transformar um conjunto de estratégias validadas em uma carteira funcional.


Links úteis

  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)

Citação

@article{soloviov2026cascadestrategies,
  author = {Soloviov, Eugen},
  title = {Cascade Strategies: Priority Execution with Fallback Filling},
  year = {2026},
  url = {https://marketmaker.cc/ru/blog/post/cascade-strategies-orchestration},
  version = {0.1.0},
  description = {Finale of the "Backtests Without Illusions" series. How to build an orchestrator from N strategies x M pairs, implement cascade mode with priority and fallback filling, choose dual\_size, and why strategy portfolios cannot be backtested by summing PnL.}
}
blog.disclaimer

Authors

Eugen Soloviov
Eugen Soloviov

Trading-systems engineer

Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.

Newsletter

Fique à frente do mercado

Assine nossa newsletter para insights exclusivos sobre trading com IA, análises de mercado e atualizações da plataforma.

Respeitamos sua privacidade. Cancele a inscrição a qualquer momento.