Estrategias en cascada: ejecución prioritaria con relleno de respaldo
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
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 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

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
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:
- — PnL de la primaria por unidad de tiempo
- — PnL del respaldo por unidad de tiempo
- — fracción de tiempo en posición (primaria)
- — fracción de tiempo en posición (respaldo)
- — dual_size (0..1)
- — fracción de tiempo en que ambas están en posición
PnL total de la cascada:
MaxDD total (peor caso — correlación total):
Si restringimos el drawdown total a :
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%):
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 (6.8%).
Asignación basada 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:
- PnL por día activo — eficiencia en el uso del capital
- Ajuste de confianza — penalización por muestras pequeñas (distribución t)
- Costos de financiación — costo real del apalancamiento (Tasas de financiación)
- MaxLev — escalado considerando el drawdown (Asimetría pérdida-ganancia)
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:
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 y tasa media :
Para la Estrategia A con MaxLev = 55x y tasa media de financiación 0.01%:
Con PnL/día activo = 0.49%, el PnL neto es negativo: /día. La estrategia no es rentable con apalancamiento total. Análisis detallado en Las tasas de financiación matan tu apalancamiento.
Orquestador multiestrategia

Arquitectura
El orquestador gestiona estrategias en pares de trading. Número total de posiciones potenciales: . Pero el capital es limitado — no se permiten más de 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: 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:
-
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.
-
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.
-
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:
- Cerrar la posición de respaldo: comisión taker (0.04% en Binance futures)
- Abrir la posición primaria: comisión taker (0.04%)
- Spread: ~0.01-0.02%
Costo total de cambio: ~0.06-0.10% por cambio. Con 100 cambios durante el periodo:
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 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: 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 .
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:
donde es la correlación promedio entre pares.
Con y :
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:
- Bono de diversificación: al clasificar, añadir un bono al score de las estrategias en pares no correlacionados.
- 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
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:
- Multi-símbolo: la estrategia se prueba en 10+ pares, no solo en el par de optimización
- Walk-forward: ventana deslizante IS/OOS
- Estabilidad de parámetros: análisis de meseta en cada etapa
- Bootstrap de Monte Carlo: intervalos de confianza para el PnL de la cascada
- Paridad backtest-en vivo: comparación del backtest con el paper trading
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 estrategias y 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
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 del tiempo con PnL/día = 0.49%. El respaldo opera con PnL/día = 0.89%. Solapamiento = (asumiendo independencia).
Solo primaria (Estrategia A):
Cascada (A primaria + C respaldo):
Ganancia de la cascada: +31% de PnL gracias al respaldo, con un aumento mínimo del drawdown ( añadido al MaxDD).
Cuándo la cascada no ayuda
La cascada es ineficaz cuando:
- La primaria está activa >80% del tiempo. Poco tiempo ocioso — no hay espacio para el respaldo.
- 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á.
- Los costos de cambio superan el PnL del respaldo. Con cambios frecuentes, las comisiones de la cascada se comen las ganancias del respaldo.
- dual_size es demasiado pequeño. Con , 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 ().
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 y volatilidad por periodo activo, de modo que su Sharpe por periodo es . Cada Sharpe a continuación se anualiza con ; el ejemplo desarrollado usa por periodo ( 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 del tiempo — plana (rendimiento cero, pero también riesgo cero) el resto — tiene un Sharpe de línea de tiempo completa
El tiempo ocioso diluye la media por pero la desviación estándar solo por , así que la razón cae como : con el Sharpe anualizado cae de a . 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 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 :
Pasar de una estrategia con cobertura a estrategias con cobertura completa eleva el Sharpe en — verificado: , , — pero se satura en , 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 estrategias en secuencia. Para ejecutarlas simultáneamente se necesitarían unidades (o apalancamiento veces mayor); la cascada aprovecha las 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 estrategias simultáneamente, divide el capital y las promedia en cada instante. Para estrategias equicorrelacionadas con correlación por pares ,
que es cuando no están correlacionadas () y colapsa hacia cuando . Verificado: 8 estrategias no correlacionadas alcanzan un anualizado de (eso es ), pero con las mismas ocho solo logran — 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 .
La asignación Kelly es el óptimo correlacional. Con el vector de medias y la covarianza , los pesos óptimos de crecimiento son , y el Sharpe que logran es
que reproduce exactamente el escalado (la simulación y la forma cerrada coinciden: para ocho no correlacionadas, con ). 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 ( unidades) |
| Escalado del Sharpe | se satura en (llena el ocio, hasta cobertura total) | |
| Reducción de riesgo | ninguna (una estrategia por instante) | , limitada por correlación |
| Qué compra | eficiencia de capital, tiempo en mercado | rendimiento ajustado al riesgo |
Ambas se multiplican. Un orquestador realista ejecuta estrategias (débilmente correlacionadas) en cada instante — comprando el por instante — y combina esas ventanas a lo largo del tiempo, reutilizando el capital. El Sharpe agregado lo determina la estructura por instante,
verificado en (anualizado , , ), 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 , 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 hay que diversificar dentro del instante — ejecutar varias estrategias débilmente correlacionadas a la vez —, y esa ganancia está acotada por 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 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
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:
- Constante fija (0.80) — aproximada pero universal
- Estimación analítica vía — considera la correlación
- 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
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"
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:
-
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.
-
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.
-
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
- López de Prado — Advances in Financial Machine Learning: Portfolio Construction
- Pardo, R. — The Evaluation and Optimization of Trading Strategies
- Ernest Chan — Algorithmic Trading: Winning Strategies and Their Rationale
- Perry Kaufman — Trading Systems and Methods, Chapter on Portfolio Allocation
- Tomasini, Jaekle — Trading Systems: A New Approach to System Development and Portfolio Optimisation
- Bailey, D.H. & López de Prado — The Deflated Sharpe Ratio
- Markowitz, H. — Portfolio Selection (1952)
- 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.}
}
Authors
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.