Volumen y RVol (Relative Volume)

El Combustible del Movimiento

En small caps, el volumen es TODO. Sin volumen, no hay movimiento. Con volumen excesivo, hay oportunidad. RVol (Relative Volume) es mi indicador #1 para filtrar qué stocks mirar.

Cálculo de RVol

def calculate_rvol(df, lookback=10, time_based=True):
    """Calcular Relative Volume"""
    if time_based:
        # RVol por tiempo del día (más preciso)
        df['time'] = df.index.time
        
        # Volumen promedio para cada minuto de los últimos N días
        volume_by_time = {}
        for i in range(1, lookback + 1):
            date = df.index[-1].date() - pd.Timedelta(days=i)
            hist_data = df[df.index.date == date]
            for idx, row in hist_data.iterrows():
                time_key = idx.time()
                if time_key not in volume_by_time:
                    volume_by_time[time_key] = []
                volume_by_time[time_key].append(row['volume'])
        
        # Promedio por tiempo
        avg_volume_by_time = {
            time: np.mean(vols) for time, vols in volume_by_time.items()
        }
        
        # Calcular RVol
        df['avg_volume_time'] = df.index.time.map(avg_volume_by_time)
        df['rvol'] = df['volume'] / df['avg_volume_time']
        
    else:
        # RVol simple (menos preciso pero más rápido)
        df['avg_volume'] = df['volume'].rolling(lookback).mean().shift(1)
        df['rvol'] = df['volume'] / df['avg_volume']
    
    # Llenar NaN con 1 (volumen normal)
    df['rvol'] = df['rvol'].fillna(1)
    
    return df

RVol para Day Trading

def rvol_day_trading_setup(df, rvol_threshold=2):
    """Identificar setups basados en RVol"""
    df = calculate_rvol(df, time_based=True)
    
    # Clasificar niveles de RVol
    df['rvol_level'] = pd.cut(
        df['rvol'],
        bins=[0, 1, 2, 3, 5, 10, np.inf],
        labels=['low', 'normal', 'high', 'very_high', 'extreme', 'explosive']
    )
    
    # Detectar spikes de volumen
    df['volume_spike'] = df['rvol'] > rvol_threshold
    df['sustained_volume'] = df['volume_spike'].rolling(5).sum() >= 3  # 3 de 5 barras
    
    # Combinación con precio
    df['bullish_volume'] = df['volume_spike'] & (df['close'] > df['open'])
    df['bearish_volume'] = df['volume_spike'] & (df['close'] < df['open'])
    
    return df

Volume Profile

Dónde se ejecutó más volumen = niveles clave.

def calculate_volume_profile(df, bins=50):
    """Crear perfil de volumen"""
    # Definir bins de precio
    price_min = df['low'].min()
    price_max = df['high'].max()
    price_bins = np.linspace(price_min, price_max, bins)
    
    # Acumular volumen por nivel de precio
    volume_profile = np.zeros(len(price_bins) - 1)
    
    for idx, row in df.iterrows():
        # Distribuir volumen entre low y high
        for i in range(len(price_bins) - 1):
            if price_bins[i] <= row['high'] and price_bins[i+1] >= row['low']:
                # Porción del rango que cae en este bin
                overlap_low = max(row['low'], price_bins[i])
                overlap_high = min(row['high'], price_bins[i+1])
                overlap_pct = (overlap_high - overlap_low) / (row['high'] - row['low'])
                volume_profile[i] += row['volume'] * overlap_pct
    
    # Crear DataFrame
    vp_df = pd.DataFrame({
        'price': (price_bins[:-1] + price_bins[1:]) / 2,
        'volume': volume_profile
    })
    
    # Identificar POC (Point of Control)
    vp_df['poc'] = vp_df['volume'] == vp_df['volume'].max()
    
    # Value Area (70% del volumen)
    total_volume = vp_df['volume'].sum()
    vp_df = vp_df.sort_values('volume', ascending=False)
    vp_df['cum_volume'] = vp_df['volume'].cumsum()
    vp_df['in_value_area'] = vp_df['cum_volume'] <= total_volume * 0.7
    
    return vp_df.sort_values('price')

Volume Patterns

def detect_volume_patterns(df, window=20):
    """Detectar patrones de volumen importantes"""
    # Preparar datos
    df['volume_ma'] = df['volume'].rolling(window).mean()
    df['volume_std'] = df['volume'].rolling(window).std()
    
    # 1. Climax Volume
    df['climax_volume'] = df['volume'] > (df['volume_ma'] + 3 * df['volume_std'])
    
    # 2. Dry Up (secado de volumen)
    df['volume_dryup'] = df['volume'] < df['volume_ma'] * 0.5
    
    # 3. Volume Divergence
    df['price_up'] = df['close'] > df['close'].shift(5)
    df['volume_down'] = df['volume'] < df['volume'].shift(5)
    df['bearish_divergence'] = df['price_up'] & df['volume_down']
    
    # 4. Accumulation/Distribution
    df['ad_line'] = ((df['close'] - df['low']) - (df['high'] - df['close'])) / (df['high'] - df['low']) * df['volume']
    df['ad_cumulative'] = df['ad_line'].cumsum()
    
    # 5. Volume Rate of Change
    df['volume_roc'] = (df['volume'] - df['volume'].shift(window)) / df['volume'].shift(window) * 100
    
    return df

Smart Money Volume

Detectar volumen institucional vs retail.

def analyze_smart_money_volume(df, block_size=10000):
    """Identificar volumen institucional"""
    # Tamaño promedio de trade (aproximado)
    df['avg_trade_size'] = df['volume'] / df['tick_count'] if 'tick_count' in df else 100
    
    # Large blocks
    df['large_block'] = df['avg_trade_size'] > block_size
    
    # Volume en primera y última hora (institucionales)
    df['hour'] = df.index.hour
    df['institutional_hours'] = df['hour'].isin([9, 10, 15])
    
    # Dark pool prints (si tienes data)
    if 'dark_pool_volume' in df.columns:
        df['dark_pool_ratio'] = df['dark_pool_volume'] / df['volume']
        df['high_dark_pool'] = df['dark_pool_ratio'] > 0.3
    
    # Net buying pressure
    df['buying_pressure'] = (df['close'] - df['low']) / (df['high'] - df['low'])
    df['selling_pressure'] = 1 - df['buying_pressure']
    
    # Money Flow
    df['money_flow'] = df['typical_price'] * df['volume']
    df['positive_money_flow'] = np.where(df['typical_price'] > df['typical_price'].shift(1), 
                                         df['money_flow'], 0)
    df['negative_money_flow'] = np.where(df['typical_price'] < df['typical_price'].shift(1), 
                                         df['money_flow'], 0)
    
    return df

Volume Breakouts

def volume_breakout_signals(df, volume_multiplier=3, price_threshold=0.02):
    """Detectar breakouts con volumen"""
    df = calculate_rvol(df)
    
    # Preparar indicadores
    df['resistance'] = df['high'].rolling(20).max()
    df['support'] = df['low'].rolling(20).min()
    
    # Breakout alcista
    df['breakout_up'] = (
        (df['close'] > df['resistance'].shift(1)) &  # Rompe resistencia
        (df['rvol'] > volume_multiplier) &           # Con volumen alto
        (df['close'] > df['open'])                   # Vela verde
    )
    
    # Breakout bajista
    df['breakout_down'] = (
        (df['close'] < df['support'].shift(1)) &
        (df['rvol'] > volume_multiplier) &
        (df['close'] < df['open'])
    )
    
    # Fuerza del breakout
    df['breakout_strength'] = np.where(
        df['breakout_up'],
        (df['close'] - df['resistance'].shift(1)) / df['resistance'].shift(1) * 100,
        np.where(
            df['breakout_down'],
            (df['support'].shift(1) - df['close']) / df['support'].shift(1) * 100,
            0
        )
    )
    
    return df

OBV (On Balance Volume)

def calculate_obv_signals(df):
    """On Balance Volume con señales"""
    # OBV clásico
    df['obv'] = (df['volume'] * np.sign(df['close'] - df['close'].shift(1))).cumsum()
    
    # OBV suavizado
    df['obv_ema'] = df['obv'].ewm(span=20).mean()
    
    # Divergencias
    df['price_high'] = df['close'].rolling(20).max()
    df['obv_high'] = df['obv'].rolling(20).max()
    
    # Divergencia bajista: precio hace nuevo high, OBV no
    df['bearish_obv_divergence'] = (
        (df['close'] == df['price_high']) & 
        (df['obv'] < df['obv_high'].shift(20))
    )
    
    # Señal de confirmación
    df['obv_trend_up'] = df['obv'] > df['obv_ema']
    df['obv_breakout'] = df['obv'] > df['obv'].rolling(50).max().shift(1)
    
    return df

Volume-Weighted Momentum

def volume_weighted_momentum(df, period=14):
    """Momentum ponderado por volumen"""
    # Price momentum
    df['price_change'] = df['close'] - df['close'].shift(period)
    
    # Volume-weighted price change
    weights = df['volume'].rolling(period).apply(lambda x: x / x.sum())
    df['vw_momentum'] = df['price_change'] * weights
    
    # Normalizar
    df['vw_momentum_normalized'] = df['vw_momentum'] / df['close'] * 100
    
    # Señales
    df['strong_bullish_momentum'] = (
        (df['vw_momentum_normalized'] > 5) & 
        (df['rvol'] > 2)
    )
    
    return df

Pre-Market Volume Analysis

def analyze_premarket_volume(ticker, date):
    """Analizar volumen pre-market"""
    # Obtener data pre-market
    pm_start = pd.Timestamp(date).replace(hour=4, minute=0)
    pm_end = pd.Timestamp(date).replace(hour=9, minute=29)
    
    pm_data = get_intraday_data(ticker, start=pm_start, end=pm_end)
    
    # Métricas
    analysis = {
        'total_pm_volume': pm_data['volume'].sum(),
        'pm_vwap': calculate_vwap(pm_data)['vwap'].iloc[-1],
        'pm_high': pm_data['high'].max(),
        'pm_low': pm_data['low'].min(),
        'pm_range': (pm_data['high'].max() - pm_data['low'].min()) / pm_data['low'].min() * 100,
        'volume_profile': pm_data.groupby(pd.cut(pm_data['close'], bins=10))['volume'].sum()
    }
    
    # Comparar con días anteriores
    historical_pm_volume = []
    for i in range(1, 11):
        hist_date = date - pd.Timedelta(days=i)
        hist_pm = get_intraday_data(ticker, start=hist_date.replace(hour=4), 
                                   end=hist_date.replace(hour=9, minute=29))
        historical_pm_volume.append(hist_pm['volume'].sum())
    
    analysis['pm_rvol'] = analysis['total_pm_volume'] / np.mean(historical_pm_volume)
    
    return analysis

Alertas de Volumen

class VolumeAlertSystem:
    def __init__(self, thresholds):
        self.thresholds = thresholds
        self.alerted = set()
        
    def check_alerts(self, ticker, current_data):
        alerts = []
        
        # RVol extremo
        if current_data['rvol'] > self.thresholds['rvol_extreme']:
            alert_key = f"{ticker}_rvol_{current_data.name}"
            if alert_key not in self.alerted:
                alerts.append(f"🚨 {ticker}: RVol {current_data['rvol']:.1f}x @ ${current_data['close']:.2f}")
                self.alerted.add(alert_key)
        
        # Climax volume
        if current_data.get('climax_volume', False):
            alerts.append(f"💥 {ticker}: CLIMAX VOLUME - Posible top/bottom")
        
        # Volume dry up en soporte
        if current_data.get('volume_dryup', False) and current_data['close'] > current_data['support']:
            alerts.append(f"📉 {ticker}: Volume dry up en soporte ${current_data['support']:.2f}")
        
        return alerts

Tips Prácticos

1. Morning Volume Rate

def morning_volume_rate(df):
    """Tasa de volumen en primera hora"""
    first_hour = df.between_time('09:30', '10:30')
    first_hour_vol = first_hour['volume'].sum()
    
    avg_daily_vol = df.groupby(df.index.date)['volume'].sum().mean()
    
    # Si primera hora > 30% del volumen diario promedio = día activo
    return first_hour_vol / avg_daily_vol

2. Volume Patterns por Hora

def hourly_volume_pattern(df):
    """Patrón típico de volumen por hora"""
    df['hour'] = df.index.hour
    hourly_avg = df.groupby('hour')['volume'].mean()
    
    # Normalizar (primera hora = 100)
    hourly_pattern = hourly_avg / hourly_avg[9] * 100
    return hourly_pattern

Siguiente Paso

Continuemos con Spike HOD/LOD, crítico para timing en small caps.