🇪🇸 Leer en Español 🇺🇸 English

Volume and RVol (Relative Volume)

The Fuel Behind the Move

In small caps, volume is EVERYTHING. Without volume, there is no movement. With excessive volume, there is opportunity. RVol (Relative Volume) is my #1 indicator for filtering which stocks to watch.

RVol Calculation

def calculate_rvol(df, lookback=10, time_based=True):
    """Calculate Relative Volume"""
    if time_based:
        # Time-of-day RVol (more precise)
        df['time'] = df.index.time
        
        # Average volume for each minute over the last N days
        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'])
        
        # Average by time
        avg_volume_by_time = {
            time: np.mean(vols) for time, vols in volume_by_time.items()
        }
        
        # Calculate RVol
        df['avg_volume_time'] = df.index.time.map(avg_volume_by_time)
        df['rvol'] = df['volume'] / df['avg_volume_time']
        
    else:
        # Simple RVol (less precise but faster)
        df['avg_volume'] = df['volume'].rolling(lookback).mean().shift(1)
        df['rvol'] = df['volume'] / df['avg_volume']
    
    # Fill NaN with 1 (normal volume)
    df['rvol'] = df['rvol'].fillna(1)
    
    return df

RVol for Day Trading

def rvol_day_trading_setup(df, rvol_threshold=2):
    """Identify setups based on RVol"""
    df = calculate_rvol(df, time_based=True)
    
    # Classify RVol levels
    df['rvol_level'] = pd.cut(
        df['rvol'],
        bins=[0, 1, 2, 3, 5, 10, np.inf],
        labels=['low', 'normal', 'high', 'very_high', 'extreme', 'explosive']
    )
    
    # Detect volume spikes
    df['volume_spike'] = df['rvol'] > rvol_threshold
    df['sustained_volume'] = df['volume_spike'].rolling(5).sum() >= 3  # 3 of 5 bars
    
    # Combination with price
    df['bullish_volume'] = df['volume_spike'] & (df['close'] > df['open'])
    df['bearish_volume'] = df['volume_spike'] & (df['close'] < df['open'])
    
    return df

Volume Profile

Where the most volume was executed = key levels.

def calculate_volume_profile(df, bins=50):
    """Create volume profile"""
    # Define price bins
    price_min = df['low'].min()
    price_max = df['high'].max()
    price_bins = np.linspace(price_min, price_max, bins)
    
    # Accumulate volume by price level
    volume_profile = np.zeros(len(price_bins) - 1)
    
    for idx, row in df.iterrows():
        # Distribute volume between low and high
        for i in range(len(price_bins) - 1):
            if price_bins[i] <= row['high'] and price_bins[i+1] >= row['low']:
                # Portion of the range that falls in this 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
    
    # Create DataFrame
    vp_df = pd.DataFrame({
        'price': (price_bins[:-1] + price_bins[1:]) / 2,
        'volume': volume_profile
    })
    
    # Identify POC (Point of Control)
    vp_df['poc'] = vp_df['volume'] == vp_df['volume'].max()
    
    # Value Area (70% of volume)
    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):
    """Detect important volume patterns"""
    # Prepare data
    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 (volume drying up)
    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

Detect institutional vs retail volume.

def analyze_smart_money_volume(df, block_size=10000):
    """Identify institutional volume"""
    # Approximate average trade size
    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 in first and last hour (institutional)
    df['hour'] = df.index.hour
    df['institutional_hours'] = df['hour'].isin([9, 10, 15])
    
    # Dark pool prints (if data available)
    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):
    """Detect breakouts with volume"""
    df = calculate_rvol(df)
    
    # Prepare indicators
    df['resistance'] = df['high'].rolling(20).max()
    df['support'] = df['low'].rolling(20).min()
    
    # Bullish breakout
    df['breakout_up'] = (
        (df['close'] > df['resistance'].shift(1)) &  # Breaks resistance
        (df['rvol'] > volume_multiplier) &           # With high volume
        (df['close'] > df['open'])                   # Green candle
    )
    
    # Bearish breakout
    df['breakout_down'] = (
        (df['close'] < df['support'].shift(1)) &
        (df['rvol'] > volume_multiplier) &
        (df['close'] < df['open'])
    )
    
    # Breakout strength
    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 with signals"""
    # Classic OBV
    df['obv'] = (df['volume'] * np.sign(df['close'] - df['close'].shift(1))).cumsum()
    
    # Smoothed OBV
    df['obv_ema'] = df['obv'].ewm(span=20).mean()
    
    # Divergences
    df['price_high'] = df['close'].rolling(20).max()
    df['obv_high'] = df['obv'].rolling(20).max()
    
    # Bearish divergence: price makes new high, OBV does not
    df['bearish_obv_divergence'] = (
        (df['close'] == df['price_high']) & 
        (df['obv'] < df['obv_high'].shift(20))
    )
    
    # Confirmation signal
    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):
    """Volume-weighted momentum"""
    # 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
    
    # Normalize
    df['vw_momentum_normalized'] = df['vw_momentum'] / df['close'] * 100
    
    # Signals
    df['strong_bullish_momentum'] = (
        (df['vw_momentum_normalized'] > 5) & 
        (df['rvol'] > 2)
    )
    
    return df

Pre-Market Volume Analysis

def analyze_premarket_volume(ticker, date):
    """Analyze pre-market volume"""
    # Get pre-market data
    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)
    
    # Metrics
    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()
    }
    
    # Compare with previous days
    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

Volume Alerts

class VolumeAlertSystem:
    def __init__(self, thresholds):
        self.thresholds = thresholds
        self.alerted = set()
        
    def check_alerts(self, ticker, current_data):
        alerts = []
        
        # Extreme RVol
        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 - Possible top/bottom")
        
        # Volume dry up at support
        if current_data.get('volume_dryup', False) and current_data['close'] > current_data['support']:
            alerts.append(f"📉 {ticker}: Volume dry up at support ${current_data['support']:.2f}")
        
        return alerts

Practical Tips

1. Morning Volume Rate

def morning_volume_rate(df):
    """First hour volume rate"""
    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()
    
    # If first hour > 30% of average daily volume = active day
    return first_hour_vol / avg_daily_vol

2. Volume Patterns by Hour

def hourly_volume_pattern(df):
    """Typical volume pattern by hour"""
    df['hour'] = df.index.hour
    hourly_avg = df.groupby('hour')['volume'].mean()
    
    # Normalize (first hour = 100)
    hourly_pattern = hourly_avg / hourly_avg[9] * 100
    return hourly_pattern

Next Step

Let’s continue with Spike HOD/LOD, critical for timing in small caps.