Estrategia de Trading de Ruptura con Confirmación de OBV y ATR en Python

El trading de rupturas es una estrategia popular que busca capitalizar movimientos significativos de precios cuando el precio de un activo rompe a través de un nivel de soporte o resistencia definido. Sin embargo, no todas las rupturas conducen a tendencias sostenidas; muchas resultan ser “rupturas falsas” o “falsas señales”. Para mejorar la confiabilidad de las señales de ruptura, los traders a menudo incorporan indicadores secundarios relacionados con el volumen y la volatilidad.
Este artículo explora una estrategia de ruptura específica aplicada a datos diarios de Ethereum (ETH-USD) desde el 1 de enero de 2020 hasta el 30 de abril de 2025. La estrategia identifica rupturas usando máximos/mínimos de precios recientes y las confirma con dos indicadores clave:
- On-Balance Volume (OBV): Un indicador de momentum que relaciona precio y volumen, sugiriendo acumulación (presión compradora) o distribución (presión vendedora).
- Average True Range (ATR): Una medida de la volatilidad del mercado.
El objetivo es generar señales de compra (UpBreak) o venta/corto (DownBreak) solo cuando la ruptura de precio sea confirmada por el momentum correspondiente del OBV y ocurra durante un período de volatilidad elevada (ATR significativamente por encima de su promedio reciente). Recorreremos la implementación en Python usando yfinance, pandas, talib y matplotlib, y analizaremos los resultados.
Lógica y reglas de la estrategia
La idea central detrás de este método de trading es filtrar posibles rupturas de precio para operaciones de mayor probabilidad:
- Ruptura de Precio: El precio de cierre debe moverse por encima del cierre más alto o por debajo del cierre más bajo observado durante un período retrospectivo definido definido (ej., 20 días).
- Confirmación del OBV:
- Para una ruptura alcista, el OBV también debe romper simultáneamente por encima de su propio máximo durante el mismo período retrospectivo, indicando que el volumen respalda el movimiento alcista del precio.
- Para una ruptura bajista, el OBV debe romper por debajo de su mínimo del periodo retrospectivo, sugiriendo que el volumen confirma la presión vendedora.
- Filtro de Volatilidad (ATR): La ruptura debe ocurrir cuando la volatilidad del mercado sea más alta de lo habitual. Requerimos que el ATR actual (período de 14 días) sea significativamente mayor (ej., 1.2 veces) que su promedio durante el período restrospectivo. Esto ayuda a filtrar rupturas que ocurren durante “ruido” de baja volatilidad.
Ahora vamos a mostrar como podemos implementar esta estrategia con Python y cómo podemos evaluarla usando sus múltiples paquetes de análisis.
Implementación de la estrategia de ruptura en Python
Mediante el siguiente código podemos aplicar esta estrategia:
1. Adquisición y Preparación de Datos
Primero, descargamos los datos históricos de precios diarios para ETH-USD usando la librería yfinance y manejamos las posibles columnas de múltiples niveles.
import numpy as np import pandas as pd import yfinance as yf import talib import matplotlib.pyplot as plt # --- Configuración --- ticker = 'ETH-USD' start_date = '2020-01-01' end_date = '2025-04-30' # Data up to Apr 30, 2025 used in the results atr_period = 14 lookback = 20 # Lookback window for highs/lows atr_thresh = 1.2 # ATR multiplier threshold horizon = 30 # Forward return horizon (days) # 1. Descarga y preparación de los datos print(f"Downloading data for {ticker}...") data = yf.download(ticker, start=start_date, end=end_date, progress=False) if isinstance(data.columns, pd.MultiIndex): data.columns = data.columns.droplevel(1) print(f"Data downloaded. Shape: {data.shape}") # Basic cleaning (ensure essential columns exist, drop NaNs) required_cols = ['High', 'Low', 'Close', 'Volume'] if not all(col in data.columns for col in required_cols): raise ValueError("Missing required columns") data.dropna(subset=required_cols, inplace=True)
Este código produce el siguiente resultado:
Downloading data for ETH-USD…
Data downloaded. Shape: (1946, 5)
2. Cálculo de Indicadores
Utilizamos la popular librería TA-Lib para calcular el OBV y el ATR.
# 2. Cómputo de los indicadores data['OBV'] = talib.OBV(data['Close'], data['Volume']) data['ATR'] = talib.ATR(data['High'], data['Low'], data['Close'], timeperiod=atr_period) # Drop initial rows where indicators couldn't be calculated data.dropna(inplace=True)
3. Definición de Condiciones de Ruptura
Calculamos los máximos/mínimos móviles del precio y OBV durante la ventana de lookback. Usamos .shift(1) para comparar los valores de la barra actual contra los máximos/mínimos establecidos por el período retrospectivo precedente. También calculamos la media móvil del ATR.
# 3. Calcular Máximos/Mínimos/Promedios Móviles para Condiciones de Ruptura data['PriceHigh'] = data['Close'].shift(1).rolling(lookback).max() data['OBVHigh'] = data['OBV'].shift(1).rolling(lookback).max() data['PriceLow'] = data['Close'].shift(1).rolling(lookback).min() data['OBVLow'] = data['OBV'].shift(1).rolling(lookback).min() data['ATRmean'] = data['ATR'].shift(1).rolling(lookback).mean() # Eliminar filas con NaNs de los cálculos móviles data.dropna(inplace=True)
4. Generación de Señales
Combinamos las condiciones de precio, OBV y ATR usando lógica booleana para crear las señales de ruptura alcista (UpBreak) y ruptura bajista (DownBreak) (1 si se cumplen las condiciones, 0 en caso contrario).
# 4. Generación de señales de compra/venta # Up Breakout: Close > Price High, OBV > OBV High, ATR > ATR Mean * Threshold data['UpBreak'] = ( (data['Close'] > data['PriceHigh']) & (data['OBV'] > data['OBVHigh']) & (data['ATR'] > data['ATRmean'] * atr_thresh) ).astype(int) # Down Breakout: Close < Price Low, OBV < OBV Low, ATR > ATR Mean * Threshold data['DownBreak'] = ( (data['Close'] < data['PriceLow']) & (data['OBV'] < data['OBVLow']) & (data['ATR'] > data['ATRmean'] * atr_thresh) ).astype(int)
5. Visualización de las señales
Graficar el precio, OBV y ATR junto con las señales generadas es crucial para inspeccionar visualmente el comportamiento de la estrategia. El código genera tres gráficos apilados:
- Superior: Precio de cierre ETH-USD con flechas verdes hacia arriba marcando las señales UpBreak y flechas rojas hacia abajo marcando las señales DownBreak.
- Medio: OBV con sus bandas de máximos/mínimos móviles y los marcadores de señales correspondientes.
- Inferior: ATR con su media móvil, el umbral de ATR calculado (media * 1.2), y los marcadores de señales correspondientes.
# 5. Visualización de las señales print("Generating visualization...") # Crear figura con subplots fig, (ax1, ax2, ax3) = plt.subplots(3, 1, figsize=(15, 12)) fig.suptitle(f'Estrategia de Ruptura {ticker} con Confirmación OBV y ATR', fontsize=16, fontweight='bold') # Obtener fechas donde ocurren las señales up_break_dates = data[data['UpBreak'] == 1].index down_break_dates = data[data['DownBreak'] == 1].index # Subplot 1: Precio con señales ax1.plot(data.index, data['Close'], label='Precio de Cierre', color='black', linewidth=1) ax1.plot(data.index, data['PriceHigh'], label='Máximo Móvil', color='blue', alpha=0.3, linestyle='--') ax1.plot(data.index, data['PriceLow'], label='Mínimo Móvil', color='red', alpha=0.3, linestyle='--') # Marcar señales de ruptura alcista (flechas verdes hacia arriba) if len(up_break_dates) > 0: up_prices = data.loc[up_break_dates, 'Close'] ax1.scatter(up_break_dates, up_prices, marker='^', color='green', s=100, label=f'Señales Alcistas ({len(up_break_dates)})', zorder=5) # Marcar señales de ruptura bajista (flechas rojas hacia abajo) if len(down_break_dates) > 0: down_prices = data.loc[down_break_dates, 'Close'] ax1.scatter(down_break_dates, down_prices, marker='v', color='red', s=100, label=f'Señales Bajistas ({len(down_break_dates)})', zorder=5) ax1.set_ylabel('Precio USD') ax1.set_title('Precio ETH-USD con Señales de Ruptura') ax1.legend() ax1.grid(True, alpha=0.3) # Subplot 2: OBV con bandas y señales ax2.plot(data.index, data['OBV'], label='OBV', color='purple', linewidth=1) ax2.plot(data.index, data['OBVHigh'], label='OBV Máximo Móvil', color='blue', alpha=0.5, linestyle='--') ax2.plot(data.index, data['OBVLow'], label='OBV Mínimo Móvil', color='red', alpha=0.5, linestyle='--') # Marcar señales en OBV if len(up_break_dates) > 0: up_obv = data.loc[up_break_dates, 'OBV'] ax2.scatter(up_break_dates, up_obv, marker='^', color='green', s=60, label='Señales Alcistas', zorder=5) if len(down_break_dates) > 0: down_obv = data.loc[down_break_dates, 'OBV'] ax2.scatter(down_break_dates, down_obv, marker='v', color='red', s=60, label='Señales Bajistas', zorder=5) ax2.set_ylabel('OBV') ax2.set_title('On-Balance Volume con Bandas Móviles') ax2.legend() ax2.grid(True, alpha=0.3) # Subplot 3: ATR con umbral y señales ax3.plot(data.index, data['ATR'], label='ATR', color='orange', linewidth=1) ax3.plot(data.index, data['ATRmean'], label='ATR Media Móvil', color='blue', alpha=0.7, linestyle='-') ax3.plot(data.index, data['ATRmean'] * atr_thresh, label=f'Umbral ATR (x{atr_thresh})', color='red', alpha=0.7, linestyle='--') # Marcar señales en ATR if len(up_break_dates) > 0: up_atr = data.loc[up_break_dates, 'ATR'] ax3.scatter(up_break_dates, up_atr, marker='^', color='green', s=60, label='Señales Alcistas', zorder=5) if len(down_break_dates) > 0: down_atr = data.loc[down_break_dates, 'ATR'] ax3.scatter(down_break_dates, down_atr, marker='v', color='red', s=60, label='Señales Bajistas', zorder=5) ax3.set_ylabel('ATR') ax3.set_xlabel('Fecha') ax3.set_title('Average True Range con Filtro de Volatilidad') ax3.legend() ax3.grid(True, alpha=0.3) # Ajustar layout plt.tight_layout() plt.show()
Evaluación del Rendimiento
Para evaluar el poder predictivo de estas señales, calculamos el retorno hacia adelante durante un horizonte fijo (30 días en este análisis). Esto nos dice el cambio porcentual en el precio 30 días después de que ocurrió una señal.
# 6. Calcular Retornos Hacia Adelante para Evaluación del Rendimiento data['FwdRet'] = data['Close'].shift(-horizon) / data['Close'] - 1 # 7. Evaluar Rendimiento de Señales (Tasa de Aciertos y Expectativa) up_signals = data[data['UpBreak'] == 1].dropna(subset=['FwdRet']) down_signals = data[data['DownBreak'] == 1].dropna(subset=['FwdRet']) # Calculate return for short trades (profit if price goes down) down_signals = down_signals.assign(ShortRet = -down_signals['FwdRet']) # ... [Cálculos para aciertos, tasas de aciertos, ganancia/pérdida promedio, expectativa] ...
Resultados: Tasa de Operaciones Ganadoras y Expectativa
El análisis durante el período especificado arrojó las siguientes métricas de rendimiento basadas en un retorno hacia adelante de 30 días:
Calculating signal performance... Up-break signals: 56 → Winners: 32 (57.1%) Up-break expectancy per trade (30-day horizon): 6.38% Down-break signals: 12 → Winners: 9 (75.0%) Down-break expectancy per trade (30-day horizon): 2.83%
- Rupturas Alcistas (Largos): Hubo 56 señales. El 57.1% de estas fueron “ganadoras” (el precio fue más alto 30 días después). El retorno esperado promedio (expectativa) por tomar cualquier señal de Ruptura Alcista dada, considerando tanto ganadoras como perdedoras, fue de +6.38% durante los siguientes 30 días.
- Rupturas Bajistas (Cortos): Hubo menos señales (12). Un porcentaje más alto, 75.0%, fueron “ganadoras” (el precio fue más bajo 30 días después). La expectativa por operación corta fue de +2.83% (ganancia por la disminución del precio) durante los siguientes 30 días.
Estos resultados sugieren que, históricamente durante este período y usando este horizonte fijo específico, ambos tipos de señales tuvieron una expectativa positiva, aunque la tasa de aciertos y el resultado promedio difirieron.
Retornos Generales de la Estrategia (Simplificados)
Para obtener una idea del potencial general, combinamos todas las operaciones y calculamos retornos agregados.
Advertencia Importante: Estos cálculos están altamente simplificados. Suman los retornos hacia adelante fijos de 30 días (total_simple) o calculan el producto compuesto (total_compound) asumiendo que cada operación es independiente y se mantiene por exactamente 30 días. Esto no representa una simulación realista de portafolio (que necesitaría manejar operaciones superpuestas, dimensionamiento de posiciones, capitalización dentro de las operaciones, costos de transacción, etc.).
# 8. Calcular Retornos Generales de la Estrategia (Simplificados) up_trades = up_signals.assign(Return=up_signals['FwdRet'], Type='Long') down_trades = down_signals.assign(Return=down_signals['ShortRet'], Type='Short') all_trades = pd.concat([up_trades, down_trades]).sort_index() # ... [Cálculos para total_simple y total_compound] ..
Resultados: Rendimiento General Simplificado
Calculating overall strategy return (simplified)... --- Simplified Strategy Performance (30-day horizon) --- Number of evaluated trades: 68 Simple sum total return: 391.46% Compound total return: 179.57% Average return per trade: 5.76% --- Performance by Trade Type --- count mean sum std Type Long 56 0.063841 3.575118 0.306096 Short 12 0.028293 0.339521 0.171162 Analysis complete.
Un total de 68 señales (56 largas, 12 cortas) fueron evaluadas.
- La suma simple de los retornos de 30 días para todas las operaciones fue de +391.46%.
- El retorno compuesto, calculado como (1 + R1) * (1 + R2) * … – 1, fue de +179.57%. La diferencia significativa entre retornos simples y compuestos resalta el impacto de grandes ganadores/perdedores y la naturaleza simplificada de esta métrica.
- El retorno promedio a través de las 68 operaciones fue de +5.76% durante el horizonte de 30 días.
- El desglose confirma que las señales largas tuvieron un retorno promedio más alto (6.38%) que las señales cortas (2.83%) durante este período, aunque los cortos tuvieron una tasa de aciertos más alta.
Conclusión
Este análisis demostró cómo implementar y probar una estrategia de trading de rupturas para ETH-USD usando Python, confirmando las rupturas de precio con OBV y filtrando mediante ATR. Los resultados históricos desde enero de 2020 hasta abril de 2025, basados en un retorno fijo a futuro de 30 días, mostraron expectativa positiva tanto para señales largas como cortas generadas por este conjunto específico de reglas. Las señales largas fueron más frecuentes y tuvieron retornos promedio más altos, mientras que las señales cortas fueron menos comunes pero tuvieron una tasa de aciertos más alta.
Es crucial recordar que:
- El rendimiento pasado no es indicativo de resultados futuros. Las condiciones del mercado cambian.
- Los cálculos de retorno mostrados están altamente simplificados y no representan retornos reales de portafolio. Un backtest apropiado requeriría un motor más sofisticado que maneje entradas, salidas (ej., stop-losses, take-profits, señales opuestas), dimensionamiento de posiciones y costos.
- Los parámetros (lookback, atr_thresh, horizon) fueron elegidos arbitrariamente para este ejemplo. La optimización podría arrojar resultados diferentes.
Este marco proporciona un punto de partida para explorar estrategias cuantitativas de rupturas. Investigaciones futuras podrían involucrar probar diferentes parámetros, aplicar la estrategia a otros activos o marcos temporales, incorporar reglas de gestión de riesgo, y construir un sistema de backtesting más robusto basado en eventos.
Pueden obtener más información sobre las principales librerías para finanzas en Python en: Los mejores paquetes de Python para finanzas