← Volver a los artículos
March 8, 2026
5 min de lectura

Estrategias en cascada: ejecución prioritaria con relleno de respaldo

Estrategias en cascada: ejecución prioritaria con relleno de respaldo
#algotrading
#orchestration
#portfolio
#cascade
#strategies
#slot management

Final de la serie "Backtests sin ilusiones". Cómo construir un orquestador a partir de N estrategias sobre M pares, implementar el modo cascada con prioridad y ejecución de respaldo, elegir dual_size, y por qué las carteras de estrategias no pueden retrocederse simplemente sumando el PnL.

Por qué se necesita una cartera de estrategias

Cartera de estrategias con capital ocioso Varias estrategias compiten por un capital limitado — la mayoría permanece inactiva mientras solo unas pocas operan en un momento dado

Has llevado una estrategia por todo el pipeline. El bootstrap de Monte Carlo mostró un percentil 5 aceptable. El walk-forward confirmó rendimientos fuera de muestra. Las tasas de financiación están contempladas, el análisis de meseta fue superado. La estrategia realmente funciona.

Pero opera el 15% del tiempo. El 85% restante tu capital permanece inactivo.

¿Ejecutar una segunda estrategia? ¿Una tercera? ¿Una décima? La idea es obvia. La implementación no lo es. Una cartera de estrategias crea problemas que no existen con un solo bot:

  • Conflictos: dos estrategias quieren abrir posiciones opuestas en el mismo par.
  • Restricciones: el exchange/gestión de riesgo no permite más de KK posiciones simultáneas.
  • Asignación: ¿qué fracción del capital dar a cada estrategia?
  • Correlación: 10 estrategias en pares de criptomonedas correlacionados no es una diversificación 10x.

La estrategia en cascada es un patrón arquitectónico que resuelve estos problemas: la estrategia primaria recibe el tamaño de posición completo, mientras la estrategia de respaldo llena el tiempo ocioso con una posición reducida.

El concepto de cascada: primaria + respaldo

Superposición de líneas de tiempo de la estrategia en cascada

Estrategia de alta convicción (Primaria)

La primaria es una estrategia con criterios de entrada estrictos. Por ejemplo, triple marco temporal con tres niveles de confirmación: señal en diario + 4 horas + horario, con filtrado de volatilidad y volumen.

Características:

  • Pocas operaciones (decenas durante el periodo de backtest)
  • Alto PnL por operación
  • Bajo tiempo en posición (5-15%)
  • Alta confianza en cada entrada

Estrategia de respaldo

El respaldo es una estrategia con criterios relajados. Doble marco temporal, menos filtros, tolerancias más amplias. Opera con mayor frecuencia, pero con menor edge por operación.

Características:

  • Más operaciones (cientos durante el periodo)
  • PnL moderado por operación
  • Alto tiempo en posición (30-50%)
  • Confianza moderada — compensada por un tamaño de posición reducido

Modo cascada

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

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

Cuando la primaria abre una posición, el respaldo guarda silencio (o cierra). Cuando la primaria está inactiva, el respaldo opera con posición reducida (dual_size). La prioridad es incondicional: la primaria siempre desplaza al respaldo.

Estrategias usadas en los ejemplos

A lo largo de la serie usamos tres estrategias. Aquí están sus parámetros para el periodo de 750 días:

Parámetro Estrategia A Estrategia B Estrategia C
PnL +55% +27% +300%
Operaciones ~500 ~40 ~400
Tiempo operando ~15% ~5% ~45%
MaxDD ~0.9% ~0.75% ~17%
PnL/día activo 0.49%/d 0.72%/d 0.89%/d
Carácter Actividad media Poco frecuente, alta convicción Frecuente, agresivo

Como mostramos en PnL por tiempo activo, clasificar por PnL bruto y por PnL/día activo produce resultados diferentes. Para la orquestación en cascada, lo que importa es la segunda métrica.

dual_size óptimo

Superficie de optimización de dual_size La búsqueda en cuadrícula sobre dual_size revela un pico del ratio de Sharpe — demasiado grande aumenta el drawdown, demasiado pequeño desperdicia tiempo ocioso

El problema de selección

dual_size es la fracción de la posición completa que recibe la estrategia de respaldo. Es el parámetro clave de la cascada:

  • Demasiado grande (p. ej., 0.5 = 50%): cuando la primaria y el respaldo están activos simultáneamente, la exposición total = 150% del objetivo. El drawdown se duplica. La asimetría pérdida-ganancia hace que esto sea desproporcionadamente costoso.

  • Demasiado pequeño (p. ej., 0.01 = 1%): el respaldo llena el 85% del tiempo ocioso pero gana centavos. El capital permanece efectivamente inactivo.

  • Óptimo: el respaldo aporta un PnL significativo sin aumentar críticamente el drawdown durante la operación simultánea con la primaria.

Formalización

Sea:

  • PpP_p — PnL de la primaria por unidad de tiempo
  • PfP_f — PnL del respaldo por unidad de tiempo
  • tpt_p — fracción de tiempo en posición (primaria)
  • tft_f — fracción de tiempo en posición (respaldo)
  • dd — dual_size (0..1)
  • toverlapt_{overlap} — fracción de tiempo en que ambas están en posición

PnL total de la cascada:

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

MaxDD total (peor caso — correlación total):

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

Si restringimos el drawdown total a DtargetD_{target}:

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

Búsqueda en cuadrícula

En la práctica, el dual_size óptimo se encuentra mediante búsqueda en cuadrícula sobre el backtest en cascada:

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)

Óptimo típico para estrategias cripto: dual_size en el rango 0.05-0.10 (5-10% de la posición completa). Con la Estrategia B como primaria (MaxDD 0.75%) y la Estrategia A como respaldo (MaxDD 0.9%):

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

La restricción de drawdown no es vinculante — el óptimo lo determina el Sharpe de la cascada. En la práctica, la búsqueda en cuadrícula suele arrojar d0.068d \approx 0.068 (6.8%).

Asignación basada en score

Ranking de estrategias basado en score Estrategias clasificadas por puntuación compuesta — el ajuste de confianza penaliza muestras pequeñas, los costos de financiación reducen el edge neto

Cuando hay más de dos estrategias, la cascada se generaliza a una asignación basada en score.

Clasificación por PnL por tiempo activo

Como se describe en detalle en PnL por tiempo activo, el score de la estrategia se calcula considerando:

  1. PnL por día activo — eficiencia en el uso del capital
  2. Ajuste de confianza — penalización por muestras pequeñas (distribución t)
  3. Costos de financiación — costo real del apalancamiento (Tasas de financiación)
  4. MaxLev — escalado considerando el drawdown (Asimetría pérdida-ganancia)

score=PnLnet/dayeficiencia×365ffillanualizar×MaxLevescala×cconffiabilidad\text{score} = \underbrace{\text{PnL}_{net/day}}_{\text{eficiencia}} \times \underbrace{365 \cdot f_{fill}}_{\text{anualizar}} \times \underbrace{\text{MaxLev}}_{\text{escala}} \times \underbrace{c_{conf}}_{\text{fiabilidad}}

Ajuste de confianza para estrategias poco frecuentes

La Estrategia B con 40 operaciones requiere una penalización seria. Usamos el límite inferior del intervalo de confianza:

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

Integración del costo de financiación

En los futuros perpetuos, la financiación se paga cada 8 horas. Con apalancamiento LL y tasa media rfr_f:

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

Para la Estrategia A con MaxLev = 55x y tasa media de financiación 0.01%:

Fundingdaily=3×0.0001×55=0.0165=1.65%/dıˊa\text{Funding}_{daily} = 3 \times 0.0001 \times 55 = 0.0165 = 1.65\%/\text{día}

Con PnL/día activo = 0.49%, el PnL neto es negativo: 0.49%1.65%=1.16%0.49\% - 1.65\% = -1.16\%/día. La estrategia no es rentable con apalancamiento total. Análisis detallado en Las tasas de financiación matan tu apalancamiento.

Orquestador multiestrategia

Asignación de slots y cola de prioridad del orquestador

Arquitectura

El orquestador gestiona NN estrategias en MM pares de trading. Número total de posiciones potenciales: N×MN \times M. Pero el capital es limitado — no se permiten más de KK posiciones 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    │
└─────────────────────────────────────────────┘

Gestión 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

Resolución de conflictos

Tres niveles de conflicto:

Nivel 1 — Mismo par, misma dirección. Gana la estrategia con mayor score. Si ambas son primarias — el score determina al ganador. Si una es primaria y la otra respaldo — la primaria gana incondicionalmente.

Nivel 2 — Mismo par, dirección opuesta. Prohibido: no se puede estar simultáneamente en largo y en corto en el mismo par. Gana la estrategia con el score más alto.

Nivel 3 — Competencia entre pares. Cuando todos los slots están ocupados, una nueva señal desaloja el slot con el score más bajo. Esto funciona como una cola de prioridad.

Backtesting en cascada: metodología

Simulación conjunta de estrategias en cascada Simulación conjunta: curvas de equity de la primaria y el respaldo con zonas de solapamiento y el resultado combinado de la cascada

Por qué no se puede simplemente sumar el PnL

El enfoque ingenuo: retroceder cada estrategia por separado, sumar el PnL. Esto produce un resultado inflado por tres razones:

  1. Solapamiento temporal. Cuando la primaria y el respaldo están activos simultáneamente, el respaldo no debería operar (o debería operar con dual_size). La suma simple ignora este solapamiento.

  2. Restricción de capital. La posición total es limitada. Si 5 estrategias quieren abrir simultáneamente pero solo hay 3 slots, dos estrategias no entrarán. Su PnL no puede contarse.

  3. Costos de transacción. El cambio de cascada (cerrar el respaldo, abrir la primaria) genera comisiones adicionales que no están presentes en los backtests individuales.

Simulación conjunta

El backtest correcto de cascada es una simulación conjunta de todas las estrategias sobre una línea de tiempo compartida:

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

Costo de transacción en el cambio

Cada cambio de cascada (respaldo -> primaria) requiere:

  1. Cerrar la posición de respaldo: comisión taker (0.04% en Binance futures)
  2. Abrir la posición primaria: comisión taker (0.04%)
  3. Spread: ~0.01-0.02%

Costo total de cambio: ~0.06-0.10% por cambio. Con 100 cambios durante el periodo:

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

Es una cantidad significativa. Una cascada con cambios frecuentes puede tener peor desempeño que una sola estrategia debido a los costos de transacción.

Extensión multipar: N estrategias en M pares

Red de estrategias multipar Red de N estrategias conectadas a M pares de trading — la fuerza de correlación determina la diversificación efectiva

Espacio de combinaciones

3 estrategias en 10 pares = 30 señales potenciales. Con max_slots = 5, el orquestador selecciona las 5 mejores por score. Este es un problema combinatorio: (305)=142506\binom{30}{5} = 142\,506 carteras posibles en cada momento.

En la práctica, un algoritmo voraz (ordenar por score, llenar de arriba hacia abajo) produce resultados casi óptimos en O(NMlogK)O(N \cdot M \cdot \log K).

Correlación entre pares

Los pares de criptomonedas están fuertemente correlacionados. BTC cae — ETH, SOL, AVAX caen juntos. Esto significa que 5 posiciones largas en 5 pares diferentes son efectivamente una gran posición en el "mercado cripto".

Como analizamos en detalle en Correlación de señales, el número efectivo de posiciones independientes es:

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

donde ρˉ\bar{\rho} es la correlación promedio entre pares.

Con ρˉ=0.7\bar{\rho} = 0.7 y 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 posiciones en pares correlacionados equivalen a 1.3 posiciones independientes. La diversificación es prácticamente inexistente.

Implicaciones prácticas para la cascada

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


El orquestador debe tener en cuenta la correlación al llenar los slots. Dos opciones:

  1. Bono de diversificación: al clasificar, añadir un bono al score de las estrategias en pares no correlacionados.
  2. Límite de correlación: limitar el número de posiciones en la misma dirección en pares correlacionados.

Pipeline de optimización de la cascada

Pipeline de optimización de ocho etapas Ocho etapas conectadas desde la preparación de datos hasta la validación y la orquestación en vivo — cada una se basa en la anterior

El pipeline completo desde los datos hasta la producción consta de 8 etapas:

Etapa 0: Preparación de datos

Cargar datos históricos, construir caché Parquet para acceso multi-timeframe. Sin un caché eficiente, las etapas siguientes son inaceptablemente lentas.

Etapa 1: TF + longitud (cuadrícula de ascenso de colina)

Seleccionar el timeframe base y las longitudes de ventana de los indicadores. Cuadrícula gruesa: TF de {1m, 5m, 15m, 1h, 4h}, longitud de {10, 20, 50, 100, 200}. Ascenso de colina desde el mejor punto de la cuadrícula.

Etapa 2: Separación (descenso por coordenadas, 12 parámetros)

Optimizar los parámetros de separación (entradas/salidas). Descenso por coordenadas sobre 12 parámetros — umbrales de indicadores, filtros, stop-losses, take-profits. El descenso por coordenadas es más económico que Optuna para funciones objetivo deterministas de alta dimensión.

Etapa 3: Metaparámetros (descenso por coordenadas)

Metaparámetros: tiempo máximo de retención, PnL mínimo para salida, configuración del trailing stop. De nuevo descenso por coordenadas. Verificar la robustez mediante análisis de meseta — si el óptimo es puntual, la estrategia está sobreoptimizada.

Etapa 4: Optimización de combinaciones

Búsqueda en cuadrícula sobre pares (primaria, respaldo). Para cada combinación: seleccionar dual_size, calcular el PnL de la cascada mediante simulación conjunta.

Etapa 5: Validación

Validación en múltiples niveles:

Etapa 6: Clasificación y selección

Clasificar las combinaciones de cascada por score. Las mejores K combinaciones avanzan a la Etapa 7. El score considera el ajuste de confianza, los costos de financiación y el fill_efficiency.

Etapa 7: Orquestación

Etapa final: lanzar el orquestador con NN estrategias y MM pares en modo cascada. Gestión de slots, cola de prioridad, resolución de conflictos — todo lo descrito anteriormente.

Análisis de rendimiento: cascada vs. individual

Rendimiento de cascada vs. estrategias individuales Comparación lado a lado: la cartera en cascada supera a las estrategias individuales gracias al aprovechamiento del tiempo ocioso

Ventaja teórica de la cascada

Supongamos que la primaria opera tp=15%t_p = 15\% del tiempo con PnL/día = 0.49%. El respaldo opera tf=45%t_f = 45\% con PnL/día = 0.89%. Solapamiento = tp×tf=6.75%t_p \times t_f = 6.75\% (asumiendo independencia).

Solo primaria (Estrategia A):

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

Cascada (A primaria + C respaldo):

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

Ganancia de la cascada: +31% de PnL gracias al respaldo, con un aumento mínimo del drawdown (0.068×17%=1.16%0.068 \times 17\% = 1.16\% añadido al MaxDD).

Cuándo la cascada no ayuda

La cascada es ineficaz cuando:

  1. La primaria está activa >80% del tiempo. Poco tiempo ocioso — no hay espacio para el respaldo.
  2. Las estrategias están muy correlacionadas. La primaria y el respaldo generan señales simultáneamente — el solapamiento es alto, y el respaldo está inactivo precisamente cuando la primaria también lo está.
  3. Los costos de cambio superan el PnL del respaldo. Con cambios frecuentes, las comisiones de la cascada se comen las ganancias del respaldo.
  4. dual_size es demasiado pequeño. Con d=0.01d = 0.01, el respaldo gana el 1% de su potencial — por debajo de las comisiones.

Tabla comparativa

Configuración PnL anual MaxDD Sharpe Costos de cambio
Estrategia A sola 26.8% 0.9% 1.42 0
Estrategia C sola 146.1% 17% 1.15 0
Cascada A+C (d=0.068) 35.2% 2.06% 1.58 ~1.2%
Cascada B+A (d=0.068) 19.4% 1.36% 1.71 ~0.3%
Orquestador de 3 estrategias 48.7% 3.1% 1.63 ~2.1%

Cascada A+C: la primaria A gana +8.4% gracias al respaldo C. El Sharpe aumenta por el aprovechamiento del tiempo ocioso. El MaxDD crece moderadamente (0.9%+0.068×17%2.06%0.9\% + 0.068 \times 17\% \approx 2.06\%).

La matemática de la diversificación temporal

La ventaja de la cascada descrita arriba — llenar el tiempo ocioso — es una de dos formas fundamentalmente distintas de diversificar una cartera de estrategias, y escalan de manera diferente. Entender bien la distinción indica exactamente qué puede y qué no puede comprar una cascada.

Sea una estrategia base que genera rendimientos i.i.d. con media μ\mu y volatilidad σ\sigma por periodo activo, de modo que su Sharpe por periodo es s=μ/σs = \mu/\sigma. Cada Sharpe a continuación se anualiza con 252\sqrt{252}; el ejemplo desarrollado usa s=0.05s = 0.05 por periodo (s252=0.79s\sqrt{252} = 0.79 anualizado), y cada número proviene de una simulación de 2,000,000 de periodos que coincide con la forma cerrada hasta dos decimales.

Diversificación temporal: llenar el tiempo ocioso

Una estrategia en el mercado solo una fracción pp del tiempo — plana (rendimiento cero, pero también riesgo cero) el resto — tiene un Sharpe de línea de tiempo completa

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

El tiempo ocioso diluye la media por pp pero la desviación estándar solo por p\sqrt{p}, así que la razón cae como p\sqrt{p}: con p=0.25p = 0.25 el Sharpe anualizado cae de 0.790.79 a 0.390.39. Por eso una primaria de alta convicción que opera el 15% del tiempo se ve mediocre en la curva de equity completa.

Una cascada llena ese tiempo ocioso. Al combinar kk estrategias temporalmente disjuntas de modo que juntas cubran toda la línea de tiempo — exactamente una activa en cada periodo — el flujo combinado vuelve a ser N(μ,σ)N(\mu,\sigma):

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

Pasar de una estrategia con cobertura 1/k1/k a kk estrategias con cobertura completa eleva el Sharpe en 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 — pero se satura en ss, el Sharpe por periodo de una sola estrategia. La diversificación temporal no puede elevar el rendimiento ajustado al riesgo de la cartera por encima de lo que gana una sola estrategia mientras opera, porque en cada instante se mantiene exactamente una estrategia: sin promediar, sin reducción de varianza. Lo que sí compra es reutilización de capital — una unidad de capital ejecuta las kk estrategias en secuencia. Para ejecutarlas simultáneamente se necesitarían kk unidades (o apalancamiento kk veces mayor); la cascada aprovecha las kk ventajas con una sola unidad. Una ganancia de eficiencia de capital, no una ganancia de riesgo.

Diversificación correlacional: el √N al que renuncia una cascada

El otro tipo ejecuta NN estrategias simultáneamente, divide el capital y las promedia en cada instante. Para estrategias equicorrelacionadas con correlación por pares ρ\rho,

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

que es Ns\sqrt{N}\,s cuando no están correlacionadas (ρ=0\rho = 0) y colapsa hacia ss cuando ρ1\rho \to 1. Verificado: 8 estrategias no correlacionadas alcanzan un anualizado de 2.252.25 (eso es 8×0.79\sqrt{8}\times 0.79), pero con ρ=0.5\rho = 0.5 las mismas ocho solo logran 1.041.04 — apenas mejor que una sola. Este es exactamente el descuento de diversificación efectiva de la sección multipar: la correlación es el impuesto sobre el N\sqrt{N}.

La asignación Kelly es el óptimo correlacional. Con el vector de medias μ\boldsymbol\mu y la covarianza Σ\Sigma, los pesos óptimos de crecimiento son w=Σ1μ\mathbf{w}^{*} = \Sigma^{-1}\boldsymbol\mu, y el Sharpe que logran es

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

que reproduce exactamente el escalado N/(1+(N1)ρ)s\sqrt{N/(1+(N-1)\rho)}\,s (la simulación y la forma cerrada coinciden: 2.252.25 para ocho no correlacionadas, 1.001.00 con ρ=0.5\rho=0.5). Σ1μ\Sigma^{-1}\boldsymbol\mu es lo que significa "promediar en cada instante" escrito en forma matemática.

Qué compra realmente una cascada, y su límite

Dimensión Temporal (cascada) Correlacional (paralela)
Cuándo se ejecutan las estrategias disjuntas en el tiempo simultáneamente
Capital reutilizado (1 unidad ejecuta todas) dividido / apalancado (NN unidades)
Escalado del Sharpe se satura en ss (llena el ocio, k\sqrt{k} hasta cobertura total) N/(1+(N1)ρ)s\sqrt{N/(1+(N-1)\rho)}\,\cdot s
Reducción de riesgo ninguna (una estrategia por instante) N\sqrt{N}, limitada por correlación
Qué compra eficiencia de capital, tiempo en mercado rendimiento ajustado al riesgo

Ambas se multiplican. Un orquestador realista ejecuta mm estrategias (débilmente correlacionadas) en cada instante — comprando el m\sqrt{m} por instante — y combina esas ventanas a lo largo del tiempo, reutilizando el capital. El Sharpe agregado lo determina la estructura por instante,

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

verificado en m=1,2,4m = 1, 2, 4 (anualizado 0.790.79, 1.141.14, 1.58=ms1.58 = \sqrt{m}\,s), mientras que el mosaico temporal multiplica la eficiencia de capital, no el Sharpe.

Dónde está el límite. Una cascada pura — una estrategia activa a la vez — tiene un Sharpe agregado exactamente igual a ss, sin importar cuántas estrategias esperen en la cola. Maximiza el tiempo en mercado y la eficiencia de capital, pero su rendimiento ajustado al riesgo está limitado al de una sola estrategia. Para superar ss hay que diversificar dentro del instante — ejecutar varias estrategias débilmente correlacionadas a la vez —, y esa ganancia está acotada por N\sqrt{N} y erosionada por la correlación. De ahí la regla de diseño: usar la cascada para recuperar el tiempo ocioso de manera económica con una sola unidad de capital, y gastar el escaso y costoso N\sqrt{N} de diversificación simultánea solo en estrategias genuinamente no correlacionadas. Apilar estrategias correlacionadas — en el tiempo o en paralelo — no aporta casi nada.

Orquestación: fill_efficiency en la práctica

Medidor y mapa de calor de fill efficiency Fill efficiency en ~78%: el mapa de calor muestra la utilización del tiempo entre estrategias y pares, las celdas brillantes indican operación activa

El parámetro fill_efficiency determina qué fracción del tiempo ocioso utiliza realmente el orquestador. Como se muestra en PnL por tiempo activo, puede estimarse de tres maneras:

  1. Constante fija (0.80) — aproximada pero universal
  2. Estimación analítica vía (1p)Neff(1-p)^{N_{eff}} — considera la correlación
  3. Simulación a partir de datos — la más precisa

Para una cascada con 3 estrategias en 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)

Recomendaciones prácticas

Lista de comprobación práctica de ingeniería Seis recomendaciones clave para el despliegue en cascada — desde empezar en pequeño hasta la recalibración adaptativa

1. Empieza con dos estrategias

No lances 10 estrategias en 20 pares de inmediato. Comienza con una primaria + una de respaldo en 3-5 pares. Asegúrate de que la simulación conjunta coincida con el comportamiento real. La paridad backtest-en vivo es crítica: si el backtest de la cascada diverge del rendimiento en vivo aunque sea un 5-10% — hay un error en la lógica del orquestador.

2. dual_size a partir de búsqueda en cuadrícula, no de la intuición

El dual_size óptimo depende del par específico de estrategias. 6.8% es una guía, no una constante universal. Ejecuta una búsqueda en cuadrícula del 1% al 30% en pasos de 0.5% y elige el máximo del Sharpe.

3. El límite de slots define la arquitectura

Con max_slots = 1, la cascada degenera en un simple cambio de estrategia. Con max_slots = 50, la restricción no es vinculante y el problema se reduce a una cartera independiente. La zona interesante: max_slots = 3-10, donde la gestión de slots realmente impacta los resultados.

4. Considera la latencia

En el trading en vivo, el cambio de cascada no es instantáneo. Cerrar una posición de respaldo + abrir la primaria = 2 llamadas API + latencia de red + emparejamiento del exchange. En un mercado volátil, el precio puede moverse en 200-500ms. Incluye un presupuesto de slippage.

5. Monitorea el fill_efficiency

Rastrea el fill_efficiency real en producción. Si es significativamente menor que en el backtest, el orquestador no está aprovechando el tiempo ocioso como se esperaba. Causas: retrasos de API, órdenes rechazadas, restricciones de margen.

6. Usa optimización adaptativa

Los parámetros de la cascada (dual_size, pesos del score, límites de slots) no deben ser estáticos. Usa el drill-down adaptativo para la recalibración periódica con datos frescos. El mercado cambia — los parámetros de la cascada deben seguirlo.

Resumen de la serie "Backtests sin ilusiones"

Mapa de conocimiento de la serie Arquitectura completa del sistema: 13 módulos interconectados desde las matemáticas hasta la validación y la orquestación en vivo

Este artículo es el final de una serie de 13+ artículos. Cada artículo abordó un problema específico en el camino del backtest a la producción. Así es como se conectan:

Fundamento: matemáticas del rendimiento

Asimetría pérdida-ganancia — la naturaleza multiplicativa de los rendimientos, el arrastre de volatilidad, el criterio de Kelly. Este es el fundamento matemático de todo lo que sigue: por qué el MaxDD determina el apalancamiento, por qué el Sharpe importa más que el PnL bruto, por qué una tasa de acierto del 50% con R:R simétrico no es rentable.

Validación: intervalos de confianza y robustez

Bootstrap de Monte Carlo — convertir una estimación puntual en una distribución con intervalos de confianza. Cualquier métrica (PnL, MaxDD, Sharpe) solo tiene sentido con un intervalo de confianza.

Optimización walk-forward — validación fuera de muestra. Un backtest sobre datos históricos es un resultado IS; el WFO muestra cómo se comporta la estrategia con datos nuevos.

Análisis de meseta — verificación de la robustez de los parámetros. Si el óptimo es puntual, la estrategia está sobreoptimizada.

Paridad backtest-en vivo — comparación del backtest con resultados reales. La verificación final antes de escalar.

Costos realistas: financiación y apalancamiento

Las tasas de financiación matan el apalancamiento — el costo oculto del apalancamiento en los futuros perpetuos. Sin contabilizar la financiación, un backtest hermoso se convierte en una pérdida.

Arbitraje de tasas de financiación — cómo convertir la financiación de un gasto en una fuente de ingresos mediante estrategias cross-exchange.

Métricas y clasificación

PnL por tiempo activo — la métrica para clasificar estrategias en una cartera. El PnL bruto no escala; el PnL/día activo sí.

Correlación de señales — diversificación efectiva en una cartera de pares correlacionados.

Infraestructura y optimización

Caché Parquet para backtests multi-timeframe — infraestructura de datos para iteraciones rápidas.

Drill-down adaptativo — optimización adaptativa: cuadrícula gruesa -> ajuste fino en zonas prometedoras.

Optuna vs. descenso por coordenadas — selección de optimizador: Optuna para dimensiones bajas con objetivos ruidosos, descenso por coordenadas para dimensiones altas con objetivos suaves.

Polars vs Pandas — rendimiento de operaciones de DataFrame para backtesting.

Orquestación (este artículo)

Estrategias en cascada — combinando todos los componentes anteriores en un sistema funcional. La asignación basada en score usa PnL/tiempo activo, ajuste de confianza, costos de financiación. El modo cascada llena el tiempo ocioso. La simulación conjunta valida la cartera. El bootstrap de Monte Carlo proporciona intervalos de confianza para el PnL de la cascada.

Cada artículo es un módulo independiente. Juntos forman un pipeline completo desde la carga de datos hasta la orquestación en vivo de una cartera de estrategias.

Conclusión

La cascada no es el único enfoque para las carteras de estrategias. Pero es uno de los más simples y prácticos: la estrategia primaria opera a plena capacidad, el respaldo llena el tiempo ocioso con una posición reducida. Dos parámetros clave (dual_size y max_slots) proporcionan suficiente flexibilidad para la mayoría de las configuraciones.

Tres conclusiones clave:

  1. La cascada solo debe retrocederse mediante simulación conjunta. Sumar el PnL individual infla los resultados. Los costos de cambio, el solapamiento, las restricciones de slots — todo esto solo se captura en la simulación conjunta.

  2. dual_size determina el equilibrio entre PnL y drawdown. El óptimo típico es 5-10%. La búsqueda en cuadrícula sobre el Sharpe es un método de selección confiable.

  3. El orquestador es una cola de prioridad basada en score. Todo se reduce a un solo número (score) para cada señal. Score = f(PnL/día activo, MaxLev, confianza, financiación). Las estrategias con el score más alto obtienen slots. El resto espera.

La serie "Backtests sin ilusiones" demuestra una cosa: entre un backtest hermoso y una ganancia real hay decenas de trampas. Cada artículo elimina una. La orquestación en cascada es el último paso: convertir un conjunto de estrategias validadas en una cartera funcional.


Enlaces útiles

  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

@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

Mantente a la vanguardia

Suscríbete a nuestro boletín para recibir información exclusiva sobre trading con IA, análisis de mercado y actualizaciones de la plataforma.

Respetamos tu privacidad. Puedes darte de baja en cualquier momento.