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.
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:
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 core idea of this strategy is to:
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?
backtrader
StrategyThe 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.
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
'figure.figsize'] = (10, 6)
plt.rcParams[
# Download data and run backtest
= yf.download('SOL-USD', '2021-01-01', '2024-01-01')
data = data.columns.droplevel(1)
data.columns = bt.feeds.PandasData(dataname=data) data_feed
Analysis of this Snippet:
matplotlib.pyplot
is configured for plotting, with
%matplotlib qt
indicating an attempt to open plots in a
separate, interactive window.yfinance.download('SOL-USD', ...)
fetches historical data
for Solana. The subsequent data.columns.droplevel(1)
call
ensures that the column headers (like ‘Close’, ‘Volume’) are in a
single-level format suitable for backtrader
.bt.feeds.PandasData(dataname=data)
converts the prepared
pandas
DataFrame into a format that
backtrader
’s engine can consume bar by bar during the
backtest simulation.VSAStrategy
Initialization and Bar ClassifiersThis 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
= self.volume[0] / self.volume_ma[0]
volume_ratio
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
= self.spread[0] / self.spread_ma[0]
spread_ratio
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'
= self.close_position[0] # Value between 0 (low) and 1 (high)
close_pos
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:
params
: These parameters are the
adjustable controls for the VSA strategy. volume_period
and
spread_period
determine the lookback for calculating
“average” values. volume_threshold
,
spread_threshold
, climax_volume_mult
, and
test_volume_mult
define the specific quantitative criteria
for classifying a bar’s volume and spread. The impact of tweaking these
thresholds on the strategy’s signal generation is a key area for
research.__init__(self)
: This method
initializes the strategy. It establishes references to the raw OHLCV
(Open, High, Low, Close, Volume) data streams. Crucially, it calculates
self.spread
(the bar’s range) and
self.close_position
(the relative closing point within that
range), which are fundamental to VSA. Simple Moving Averages
(volume_ma
, spread_ma
, trend_ma
)
are also initialized as dynamic baselines for comparison.classify_volume()
,
classify_spread()
,
classify_close_position()
: These helper methods
automate the process of “reading” each bar. They categorize its volume,
spread, and close position based on the thresholds defined in
params
. This quantification is a necessary step in
translating qualitative VSA observations into a machine-readable
format.get_trend_direction()
: This function
provides a basic contextual filter: classifying the market trend as
“up,” “down,” or “sideways” by comparing the current closing price to a
moving average. VSA patterns often carry different implications
depending on the prevailing trend.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"""
= self.classify_volume()
volume_class = self.classify_spread()
spread_class = self.classify_close_position()
close_class = self.get_trend_direction()
trend
# Track if this is a down bar or up bar
= self.close[0] < self.open[0]
is_down_bar = self.close[0] > self.open[0]
is_up_bar
# 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
== 'low' and is_down_bar and trend == 'up'):
close_class 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
== 'high' and is_up_bar and trend == 'down'):
close_class return 'no_supply', 3
# 3. STOPPING VOLUME (Bullish) - Very high volume after decline
if (volume_class == 'climax' and trend == 'down' and
and close_class in ['middle', 'high']):
is_down_bar 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
== 'high' and is_up_bar and trend == 'up'):
close_class return 'climax', 4
# 5. WEAKNESS (Bearish) - High volume, narrow spread, closing down
if (volume_class == 'high' and spread_class == 'narrow' and
== 'low' and is_down_bar):
close_class return 'weakness', 2
# 6. STRENGTH (Bullish) - High volume, narrow spread, closing up
if (volume_class == 'high' and spread_class == 'narrow' and
== 'high' and is_up_bar):
close_class 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
and trend == 'up'):
is_up_bar 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
and trend == 'down'):
is_down_bar return 'effort_down', 1
return None, 0
Analysis of the Pattern Detective:
if
statements to identify specific VSA patterns
(e.g., “Stopping Volume,” “Climax,” “No Demand”). For instance, a “No
Demand” pattern is characterized by high/climax volume, a wide spread, a
low close, occurring on a down bar within an uptrend. These are concrete
interpretations of VSA rules.strength
score (ranging from 1 to 4). These
scores are arbitrary assignments; a key research question is how
accurately these scores reflect the true predictive power or
significance of each pattern.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
= 0
context_score
for i in range(1, 4):
if len(self.data) <= i:
continue
= self.volume[-i]
prev_volume = self.spread[-i]
prev_spread = self.close_position[-i]
prev_close_pos
# Add context scoring logic here
# This is simplified - in practice, you'd analyze the story
if prev_volume > self.volume_ma[-i]:
+= 1
context_score
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:
check_background_context()
: This
function introduces a basic form of “contextual analysis.” It simply
checks if high volume was observed in the immediate preceding bars. In a
full VSA approach, context is a deep, multi-bar “narrative” involving
accumulation phases, distribution phases, and re-tests. A critical
research question is how much this simplified approach impacts the
strategy’s overall effectiveness.notify_order()
: This is a crucial
backtrader
method that triggers whenever an order’s status
changes. When a main trade (buy()
or sell()
)
is filled, it places a trailing stop-loss order
(bt.Order.Stop
). This is a fundamental risk management
technique, designed to limit potential losses and protect profits by
moving the stop price as the trade moves favorably. The code
meticulously handles order references (order.ref
) to ensure
correct order tracking and prevent unintended actions.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:
= self.close[0]
current_price
if self.position.size > 0: # Long position
# Calculate new trailing stop (move up only)
= current_price * (1 - self.params.trail_stop_pct)
new_trail_stop
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)
= current_price * (1 + self.params.trail_stop_pct)
new_trail_stop
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
= self.detect_vsa_patterns()
pattern, strength
if pattern is None or strength < 2:
return
# Get background context
= self.check_background_context()
context = strength + context
total_strength
# 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):
if self.order is not None: return
ensures that only one
main trade order is active at a time, preventing over-trading or
conflicting signals.trail_stop_price
for open
positions. It recalculates the stop based on the current price and the
trail_stop_pct
and then cancels and re-places the
bt.Order.Stop
if the new stop is more favorable. This
represents a hands-on approach to implementing a trailing stop with a
simple stop order within backtrader
.if len(self.data) < max(...)
ensures that enough data
bars have passed for all the moving averages to be accurately calculated
before any trading decisions are made.next()
method
orchestrates the VSA analysis by calling
detect_vsa_patterns()
and
check_background_context()
. It then combines their
strength
scores into a total_strength
.if pattern is None or strength < 2:
filters out bars
where no strong VSA pattern is recognized.if total_strength < 3:
requires a minimum combined
strength from the pattern and context before considering a trade, aiming
to reduce noise.if len(self.data) - self.last_signal_bar < 5:
acts
as a “time-based” filter, preventing the strategy from opening new
trades too quickly after a previous signal, which can help in reducing
whipsaws.total_strength
, the strategy decides
whether to:
if total_strength >= 4 or pattern in [...]
imposes an
additional filter for new entries, requiring higher confidence for
initiating a trade.Finally, the backtrader
engine is configured, the
strategy and data are added, and the simulation is executed.
= bt.Cerebro()
cerebro
cerebro.addstrategy(VSAStrategy)
cerebro.adddata(data_feed)=95)
cerebro.addsizer(bt.sizers.PercentSizer, percents100000)
cerebro.broker.setcash(=0.001)
cerebro.broker.setcommission(commission
print(f'Start: ${cerebro.broker.getvalue():,.2f}')
= cerebro.run()
results print(f'End: ${cerebro.broker.getvalue():,.2f}')
print(f'Return: {((cerebro.broker.getvalue() / 100000) - 1) * 100:.2f}%')
# Fix matplotlib plotting issues
'figure.max_open_warning'] = 0
plt.rcParams['agg.path.chunksize'] = 10000
plt.rcParams[
try:
=False, style='candlestick', volume=False)
cerebro.plot(iplot
plt.show()except Exception as e:
print(f"Plotting error: {e}")
print("Strategy completed successfully - plotting skipped")
Analysis of the Backtest Execution:
cerebro = bt.Cerebro()
: This line
instantiates the central backtesting engine.cerebro.addstrategy(VSAStrategy)
and
cerebro.adddata(data_feed)
: These commands
register the defined VSA strategy and feed it the prepared Solana data
for the simulation.cerebro.addsizer(...)
,
cerebro.broker.setcash(...)
,
cerebro.broker.setcommission(...)
: These lines
configure the initial capital, the percentage of capital to use per
trade, and simulate trading commissions, all contributing to a more
realistic backtest environment.cerebro.run()
: This command initiates
the entire backtest simulation, allowing the strategy to execute its
logic sequentially through the historical data bars.print
statements provide a straightforward summary of the simulation,
displaying the starting and ending portfolio values, along with the
overall percentage return achieved by the strategy over the backtest
period.plt.rcParams
and
try...except
: These lines are used to configure
matplotlib
for plotting, potentially preventing warnings
with large datasets. The cerebro.plot()
call generates a
visual representation of the backtest. It is configured to use a
candlestick
style. Notably, the volume=False
setting for the plot means that the volume bars will not be displayed.
For a VSA strategy, which is fundamentally based on volume analysis,
visualizing volume directly on the plot is typically crucial for
understanding and debugging the signals. This might be an area to adjust
for future visual analysis, to fully observe the VSA signals in
action.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.
detect_vsa_patterns()
, consistently lead to profitable
outcomes within the tested dataset? Are some patterns more reliable or
exhibit stronger predictive power than others?check_background_context()
is a basic attempt to
incorporate context. How significant is the “background story” in VSA,
and how much does this simplification impact the strategy’s signal
quality? Could a more advanced, multi-bar contextual analysis be crucial
for unlocking greater insights?volume_threshold
, spread_threshold
, and
strength
scores? Are the results highly sensitive to these
specific numerical definitions, or is there a broader, more stable range
of parameters that yields consistent results across different assets or
market conditions?next()
protect capital and
profits in various market scenarios?volume=False
setting in cerebro.plot()
means
the volume bars are not displayed on the chart. For a VSA strategy,
which is fundamentally rooted in volume analysis, visualizing volume
directly alongside price is typically essential for deeper
understanding, debugging, and visual confirmation of the signals. This
might be a valuable adjustment for future visual analysis to fully
appreciate the VSA signals in action.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?