← Back to Home
Probing Market Narratives An Algorithmic Exploration of Volume Spread Analysis (VSA)

Probing Market Narratives An Algorithmic Exploration of Volume Spread Analysis (VSA)

What if market charts, beyond their seemingly random fluctuations, hold deeper clues about the forces at play? What if we could listen to a silent dialogue between the abstract forces of supply and demand? This is the fundamental premise of Volume Spread Analysis (VSA). It’s a methodology that attempts to discern the true intentions behind price action by meticulously examining the relationship between a bar’s price spread (its range), its closing price position within that range, and, crucially, the trading volume.

Unlike traditional technical indicators that often derive signals from price alone, VSA is rooted in the belief that major market movements are orchestrated by “Smart Money”—the large institutional traders and market makers who wield significant capital and influence. VSA is about reading their “footprints” on the chart. A sudden price rally might seem bullish, but if it occurs on very low volume, a VSA practitioner might ask: “Is there real conviction behind this move, or is it just a fleeting blip, perhaps even a trap?” Conversely, a sharp price drop on high volume that immediately recovers might suggest that powerful buyers are absorbing selling pressure.

The core intrigue of VSA lies in its qualitative nature. It is often described as an art, a skill honed through observation and intuition. But for those in quantitative finance, an even more fascinating question arises: Can this intricate, qualitative art be translated into a quantifiable, algorithmic framework? Can a system automatically identify these “footprints” and help explore their predictive power? This article is an exploration of that very challenge, using a backtrader strategy as our investigative tool.


The Theory: Decoding Each Bar’s Intent

VSA operates on the principle that every bar on a price chart tells a mini-story about the battle between buyers and sellers. It encourages scrutiny of three primary elements of each bar:

  1. The Bar’s Spread (Range): This is simply the distance between the bar’s high and low price.
    • Hypothesis: A wide spread on high volume suggests significant professional activity and strong conviction. A narrow spread on high volume might indicate an “effort vs. result” imbalance—a lot of activity (effort) but little price movement (result), implying a struggle between buyers and sellers. A narrow spread on low volume suggests a lack of interest or demand/supply.
  2. The Bar’s Closing Price Position: Where the closing price falls within the bar’s high-low range.
    • Hypothesis: A close near the high of the bar suggests buying strength or demand dominating. A close near the low indicates selling weakness or supply dominating. A close in the middle suggests indecision or equilibrium.
  3. The Bar’s Volume: The total amount of trading activity during that bar.
    • Hypothesis: High volume often confirms price movements, indicating significant participation. Low volume suggests a lack of interest or absorption of previous supply/demand.

By analyzing these three elements in combination with the overall market trend (e.g., an uptrend, a downtrend, or a sideways market), VSA attempts to identify specific “patterns” that can hint at future price direction. For example:


The Strategy Idea: Automating VSA Signals

The core idea of this strategy is to:

  1. Quantify Bar Attributes: For each bar, automatically classify its volume (e.g., “low,” “high,” “climax”), its spread (e.g., “narrow,” “wide”), and its closing position (e.g., “high,” “low”).
  2. Pattern Recognition: Use these quantified attributes, along with the bar’s direction (up or down) and the broader market trend, to identify specific VSA patterns.
  3. Strength and Context: Assign an arbitrary “strength” score to each pattern, and try to incorporate a rudimentary “background context” by looking at recent bars.
  4. Trading Hypothesis:
    • When a strong “bullish” VSA pattern (like Stopping Volume) appears, especially with supportive context, it is hypothesized that the market is likely to reverse upwards or continue a nascent uptrend. This would trigger a long entry.
    • When a strong “bearish” VSA pattern (like Buying Climax) appears, especially with bearish context, it is hypothesized that the market is likely to reverse downwards or continue a nascent downtrend. This would trigger a short entry.
    • A trailing stop-loss will be employed as a primary risk management tool, aiming to protect capital and lock in profits.

This approach represents an exciting attempt to translate the qualitative “art” of VSA into systematic “rules.” The central question for our research: how effectively can this translation capture the essence of VSA’s predictive power?


The Algorithmic Implementation: A backtrader Strategy

The following backtrader code provides a concrete implementation of the VSA strategy idea. Each snippet will be presented and analyzed to understand how the theoretical concepts are applied.

Step 1: Initial Setup and Data Loading

An algorithmic exploration always begins with data. This section prepares the environment and fetches historical price data.

import backtrader as bt
import yfinance as yf
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
%matplotlib qt
plt.rcParams['figure.figsize'] = (10, 6)

# Download data and run backtest
data = yf.download('SOL-USD', '2021-01-01', '2024-01-01')
data.columns = data.columns.droplevel(1)
data_feed = bt.feeds.PandasData(dataname=data)

Analysis of this Snippet:

Step 2: Defining the VSA “Interpreter”: VSAStrategy Initialization and Bar Classifiers

This core section defines the VSAStrategy class, including its parameters and the fundamental methods to classify each bar’s characteristics.

class VSAStrategy(bt.Strategy):
    params = (
        ('volume_period', 7),        # Period for volume averages
        ('volume_threshold', 1.2),   # High volume threshold (1.2x average)
        ('spread_period', 7),        # Period for spread averages
        ('spread_threshold', 1.2),   # Wide spread threshold (1.2x average)
        ('trend_period', 30),        # Trend determination period
        ('climax_volume_mult', 2.0), # Climax volume multiplier
        ('test_volume_mult', 0.5),   # Test volume multiplier (low volume)
        ('trail_stop_pct', 0.05),    # Trailing stop loss percentage
    )
    
    def __init__(self):
        # Price data references
        self.high = self.data.high
        self.low = self.data.low
        self.close = self.data.close
        self.open = self.data.open
        self.volume = self.data.volume
        
        # VSA components calculations
        self.spread = self.high - self.low  # Calculate the bar's range
        # Calculate where the close sits within the range (0=low, 1=high)
        self.close_position = (self.close - self.low) / (self.high - self.low)
        
        # Moving averages for comparison
        self.volume_ma = bt.indicators.SMA(self.volume, period=self.params.volume_period)
        self.spread_ma = bt.indicators.SMA(self.spread, period=self.params.spread_period)
        
        # Trend determination using a simple SMA
        self.trend_ma = bt.indicators.SMA(self.close, period=self.params.trend_period)
        
        # Internal variables to track VSA signal state
        self.vsa_signal = 0
        self.signal_strength = 0
        self.last_signal_bar = 0
        
        # Trailing stop tracking variables
        self.trail_stop_price = 0
        self.entry_price = 0
        
        # backtrader's order tracking variables
        self.order = None
        self.stop_order = None

    def classify_volume(self):
        """Classify current volume as high, normal, or low relative to its average."""
        if np.isnan(self.volume_ma[0]) or self.volume_ma[0] == 0:
            return 'normal' # Default if MA isn't ready
        
        volume_ratio = self.volume[0] / self.volume_ma[0]
        
        if volume_ratio >= self.params.climax_volume_mult:
            return 'climax' # Very high volume
        elif volume_ratio >= self.params.volume_threshold:
            return 'high'   # Above average
        elif volume_ratio <= self.params.test_volume_mult:
            return 'low'    # Below average
        else:
            return 'normal'

    def classify_spread(self):
        """Classify current bar's spread (range) as wide, normal, or narrow relative to its average."""
        if np.isnan(self.spread_ma[0]) or self.spread_ma[0] == 0:
            return 'normal' # Default if MA isn't ready
        
        spread_ratio = self.spread[0] / self.spread_ma[0]
        
        if spread_ratio >= self.params.spread_threshold:
            return 'wide'   # Large range
        # This condition classifies a "narrow" spread. If spread_threshold is 1.2,
        # then (2 - 1.2) = 0.8. So, a spread less than 80% of average is "narrow."
        elif spread_ratio <= (2 - self.params.spread_threshold):
            return 'narrow' # Small range
        else:
            return 'normal'

    def classify_close_position(self):
        """Classify where the close is within the bar's range (low, middle, high)."""
        if self.spread[0] == 0: # If the bar has no range (e.g., open=high=low=close)
            return 'middle'
        
        close_pos = self.close_position[0] # Value between 0 (low) and 1 (high)
        
        if close_pos >= 0.7:
            return 'high'   # Close is in the top 30%
        elif close_pos <= 0.3:
            return 'low'    # Close is in the bottom 30%
        else:
            return 'middle' # Close is in the middle 40%

    def get_trend_direction(self):
        """Determines the current trend direction based on a simple moving average."""
        if self.close[0] > self.trend_ma[0]:
            return 'up'
        elif self.close[0] < self.trend_ma[0]:
            return 'down'
        else:
            return 'sideways'

Analysis of the VSAStrategy’s Core:

Step 3: Attempting VSA Pattern Recognition

This section explicitly defines the logical conditions for recognizing various VSA patterns, assigning an arbitrary “strength” score to each.

    def detect_vsa_patterns(self):
        """Detect key VSA patterns"""
        volume_class = self.classify_volume()
        spread_class = self.classify_spread()
        close_class = self.classify_close_position()
        trend = self.get_trend_direction()
        
        # Track if this is a down bar or up bar
        is_down_bar = self.close[0] < self.open[0]
        is_up_bar = self.close[0] > self.open[0]
        
        # VSA Pattern Detection
        
        # 1. NO DEMAND (Bullish) - High volume, wide spread, closing down but in uptrend
        if (volume_class in ['high', 'climax'] and spread_class == 'wide' and 
            close_class == 'low' and is_down_bar and trend == 'up'):
            return 'no_demand', 3
        
        # 2. NO SUPPLY (Bullish) - High volume, wide spread, closing up after decline
        if (volume_class in ['high', 'climax'] and spread_class == 'wide' and 
            close_class == 'high' and is_up_bar and trend == 'down'):
            return 'no_supply', 3
        
        # 3. STOPPING VOLUME (Bullish) - Very high volume after decline
        if (volume_class == 'climax' and trend == 'down' and 
            is_down_bar and close_class in ['middle', 'high']):
            return 'stopping_volume', 4
        
        # 4. CLIMAX (Bearish) - Very high volume, wide spread, closing up in uptrend
        if (volume_class == 'climax' and spread_class == 'wide' and 
            close_class == 'high' and is_up_bar and trend == 'up'):
            return 'climax', 4
        
        # 5. WEAKNESS (Bearish) - High volume, narrow spread, closing down
        if (volume_class == 'high' and spread_class == 'narrow' and 
            close_class == 'low' and is_down_bar):
            return 'weakness', 2
        
        # 6. STRENGTH (Bullish) - High volume, narrow spread, closing up
        if (volume_class == 'high' and spread_class == 'narrow' and 
            close_class == 'high' and is_up_bar):
            return 'strength', 2
        
        # 7. TEST (Context dependent) - Low volume retest of previous levels
        if volume_class == 'low':
            # Test of support (Bullish if holds)
            if (trend == 'down' and close_class in ['middle', 'high'] and 
                not is_down_bar):
                return 'test_support', 2
            # Test of resistance (Bearish if rejected)
            elif (trend == 'up' and close_class in ['middle', 'low'] and 
                  not is_up_bar):
                return 'test_resistance', 2
        
        # 8. EFFORT TO MOVE UP (Bearish) - High volume but narrow spread up
        if (volume_class == 'high' and spread_class == 'narrow' and 
            is_up_bar and trend == 'up'):
            return 'effort_up', 1
        
        # 9. EFFORT TO MOVE DOWN (Bullish) - High volume but narrow spread down
        if (volume_class == 'high' and spread_class == 'narrow' and 
            is_down_bar and trend == 'down'):
            return 'effort_down', 1
        
        return None, 0

Analysis of the Pattern Detective:


Step 4: Incorporating Context and Managing Risk

VSA places strong emphasis on “background context”—a pattern’s meaning is often dictated by the broader market narrative. This section also includes the crucial risk management logic.

    def check_background_context(self):
        """Check previous bars for context"""
        # Look at previous 3 bars for context
        context_score = 0
        
        for i in range(1, 4):
            if len(self.data) <= i:
                continue
            
            prev_volume = self.volume[-i]
            prev_spread = self.spread[-i]
            prev_close_pos = self.close_position[-i]
            
            # Add context scoring logic here
            # This is simplified - in practice, you'd analyze the story
            if prev_volume > self.volume_ma[-i]:
                context_score += 1
        
        return context_score

    def notify_order(self, order):
        if order.status in [order.Completed]:
            if order.isbuy() and self.position.size > 0:
                # Long position opened - set initial trailing stop
                self.entry_price = order.executed.price
                self.trail_stop_price = order.executed.price * (1 - self.params.trail_stop_pct)
                self.stop_order = self.sell(exectype=bt.Order.Stop, price=self.trail_stop_price)
            elif order.issell() and self.position.size < 0:
                # Short position opened - set initial trailing stop
                self.entry_price = order.executed.price
                self.trail_stop_price = order.executed.price * (1 + self.params.trail_stop_pct)
                self.stop_order = self.buy(exectype=bt.Order.Stop, price=self.trail_stop_price)
        
        if order.status in [order.Completed, order.Canceled, order.Rejected]:
            if hasattr(order, 'ref') and hasattr(self.order, 'ref') and order.ref == self.order.ref:
                self.order = None
            elif order is self.order:
                self.order = None
            
            if hasattr(order, 'ref') and hasattr(self.stop_order, 'ref') and order.ref == self.stop_order.ref:
                self.stop_order = None
                # Reset trailing stop tracking when stop order is filled
                if order.status == order.Completed:
                    self.trail_stop_price = 0
                    self.entry_price = 0
            elif order is self.stop_order:
                self.stop_order = None
                # Reset trailing stop tracking when stop order is filled
                if order.status == order.Completed:
                    self.trail_stop_price = 0
                    self.entry_price = 0

Analysis of Context and Risk Management:


Step 5: The Trading Logic: Orchestrating VSA Signals

This is where the classified VSA patterns and context scores are translated into concrete trading decisions.

    def next(self):
        if self.order is not None:
            return
        
        # Update trailing stop if we have a position
        if self.position and self.trail_stop_price > 0:
            current_price = self.close[0]
            
            if self.position.size > 0:  # Long position
                # Calculate new trailing stop (move up only)
                new_trail_stop = current_price * (1 - self.params.trail_stop_pct)
                
                if new_trail_stop > self.trail_stop_price:
                    # Cancel old stop order
                    if self.stop_order is not None:
                        self.cancel(self.stop_order)
                        self.stop_order = None
                    
                    # Update trailing stop price
                    self.trail_stop_price = new_trail_stop
                    
                    # Place new stop order
                    self.stop_order = self.sell(exectype=bt.Order.Stop, price=self.trail_stop_price)
            
            elif self.position.size < 0:  # Short position
                # Calculate new trailing stop (move down only)
                new_trail_stop = current_price * (1 + self.params.trail_stop_pct)
                
                if new_trail_stop < self.trail_stop_price:
                    # Cancel old stop order
                    if self.stop_order is not None:
                        self.cancel(self.stop_order)
                        self.stop_order = None
                    
                    # Update trailing stop price
                    self.trail_stop_price = new_trail_stop
                    
                    # Place new stop order
                    self.stop_order = self.buy(exectype=bt.Order.Stop, price=self.trail_stop_price)
        
        # Skip if not enough data for indicators to warm up
        if len(self.data) < max(self.params.volume_period, self.params.spread_period):
            return
        
        # Detect VSA patterns
        pattern, strength = self.detect_vsa_patterns()
        
        if pattern is None or strength < 2:
            return
        
        # Get background context
        context = self.check_background_context()
        total_strength = strength + context
        
        # Minimum strength threshold for trading
        if total_strength < 3:
            return
        
        # Prevent multiple signals too close together
        if len(self.data) - self.last_signal_bar < 5:
            return
        
        # Trading logic based on VSA patterns
        
        # BULLISH SIGNALS
        if pattern in ['no_demand', 'no_supply', 'stopping_volume', 'strength', 
                       'test_support', 'effort_down']:
            
            if self.position.size < 0:  # Close short position
                if self.stop_order is not None:
                    self.cancel(self.stop_order)
                self.order = self.close()
                self.last_signal_bar = len(self.data)
                # Reset trailing stop tracking
                self.trail_stop_price = 0
                self.entry_price = 0
            elif not self.position:  # Open long position
                # Only take high-confidence signals
                if total_strength >= 4 or pattern in ['stopping_volume', 'no_supply']:
                    self.order = self.buy()
                    self.last_signal_bar = len(self.data)
        
        # BEARISH SIGNALS
        elif pattern in ['climax', 'weakness', 'test_resistance', 'effort_up']:
            
            if self.position.size > 0:  # Close long position
                if self.stop_order is not None:
                    self.cancel(self.stop_order)
                self.order = self.close()
                self.last_signal_bar = len(self.data)
                # Reset trailing stop tracking
                self.trail_stop_price = 0
                self.entry_price = 0
            elif not self.position:  # Open short position
                # Only take high-confidence signals
                if total_strength >= 4 or pattern in ['climax', 'weakness']:
                    self.order = self.sell()
                    self.last_signal_bar = len(self.data)

Analysis of next() (The Trade Orchestrator):


Step 6: Running the VSA Experiment: The Backtest Execution

Finally, the backtrader engine is configured, the strategy and data are added, and the simulation is executed.

cerebro = bt.Cerebro()
cerebro.addstrategy(VSAStrategy)
cerebro.adddata(data_feed)
cerebro.addsizer(bt.sizers.PercentSizer, percents=95)
cerebro.broker.setcash(100000)
cerebro.broker.setcommission(commission=0.001)

print(f'Start: ${cerebro.broker.getvalue():,.2f}')
results = cerebro.run()
print(f'End: ${cerebro.broker.getvalue():,.2f}')
print(f'Return: {((cerebro.broker.getvalue() / 100000) - 1) * 100:.2f}%')

# Fix matplotlib plotting issues
plt.rcParams['figure.max_open_warning'] = 0
plt.rcParams['agg.path.chunksize'] = 10000

try:
    cerebro.plot(iplot=False, style='candlestick', volume=False)
    plt.show()
except Exception as e:
    print(f"Plotting error: {e}")
    print("Strategy completed successfully - plotting skipped")
Pasted image 20250609061029.png

Analysis of the Backtest Execution:


Reflecting on Our VSA Expedition

Upon running this code, the output will present a quantitative result (the final return) and a visual chart of the trades. This forms the starting point for our deeper exploration into VSA.

This backtrader strategy offers a fascinating lens through which to explore the enduring ideas of Volume Spread Analysis. It challenges us to quantify the qualitative, to test hypotheses about market behavior, and to continuously ask: Are these market whispers truly guiding us, and can an algorithm learn to listen?