I will present the idea of Volume Profile and how we might use it in trading in this article. We’ll delve into the core concepts, mathematical formulas, and Python implementations for each component, including building the volume profile, identifying key levels, creating adaptive buffers, generating signals, and visualizing the results.
Before we dive into the strategy, we need to acquire and prepare our
historical market data. We’ll use yfinance
to download
Bitcoin (BTC-USD) data.
import numpy as np
import matplotlib.pyplot as plt
import warnings
'ignore')
warnings.filterwarnings(
import yfinance as yf
# Download data
= yf.download('BTC-USD', period='6mo', interval='1d', auto_adjust=False)
data if data.columns.nlevels > 1:
= data.droplevel(axis=1, level=1)
data
= data['High'].values
highs = data['Low'].values
lows = data['Close'].values
closes = data['Volume'].values
volumes = data.index
dates
print(f"Downloaded {len(data)} bars of BTC-USD")
This code snippet downloads daily historical data for BTC-USD for the
last 6 months. It then extracts the ‘High’, ‘Low’, ‘Close’, and ‘Volume’
arrays, along with the DateTimeIndex
.
The Volume Profile is a powerful analytical tool that displays trading volume at different price levels over a specified period. Unlike traditional volume indicators that show total volume over time, the Volume Profile reveals where the most significant trading activity occurred in terms of price. This can help identify potential support and resistance levels.
Concept & Formula:
Define B equally spaced price bins between the minimum low (\(L\)) and maximum high (\(H\)) of the data period:
\[ \text{bins} = \{\,p_i\mid p_i = L + i\cdot\frac{H - L}{B-1},\;i=0,\dots,B-1\} \]
For each bar j with range \([L_j, H_j]\) (low and high) and volume \(V_j\), distribute its volume equally across every price bin \(p_i\) that satisfies \(L_j \le p_i \le H_j\):
\[
\text{profile}[p_i] \;+\!=\; \frac{V_j}{\text{number of bins that
satisfy } L_j \le p_i \le H_j}
\] Correction: The provided formula
profile[p_i] += V_j / B
implies that each bar’s volume is
distributed across all bins, which is not correct. The volume
\(V_j\) should be distributed only
across the bins that fall within the specific bar’s high-low range. A
more accurate distribution would be \(V_j /
(\text{number of bins covered by } [L_j, H_j])\). However, for
simplicity and common practice in many implementations, the
V_j / bins
(where ‘bins’ is the total number of price
levels defined, acting as a normalization factor) can be used, implying
that each unit of volume is ‘spread’ evenly over the price levels it
touches. The provided Python code implements the latter.
def build_volume_profile(highs, lows, volumes, bins=30):
= np.min(lows), np.max(highs) # Use numpy min/max for efficiency
lo, hi = np.linspace(lo, hi, bins)
levels = {p: 0 for p in levels}
profile
for H, L, V in zip(highs, lows, volumes):
for p in levels:
if L <= p <= H:
+= V / bins # Distribute volume across covered bins
profile[p]
return profile
# Build profile over last 60 bars for current analysis
= build_volume_profile(highs[-60:], lows[-60:], volumes[-60:], bins=30) profile
The build_volume_profile
function takes arrays of high,
low, and volume data, along with the desired number of price bins. It
iterates through each price bar, distributing its volume across the
price levels that fall within that bar’s range.
Once the Volume Profile is constructed, we can identify two critical levels: the Volume Point of Control (VPOC) and the Value Area (VA). These levels represent areas of significant market agreement and are often watched by traders.
Concept & Formula: The VPOC is the price bin \(p^*\) with the maximum accumulated volume within the profile. It represents the price level where the most trading activity occurred, indicating a significant area of market agreement.
\[ p^* = \arg\max_{p} \bigl(\text{profile}[p]\bigr) \]
= max(profile, key=profile.get)
vpoc print(f"VPOC = {vpoc:,.2f}")
Concept & Formula: The Value Area is the price range where a specified percentage (commonly 70%) of the total volume for the period was traded. It signifies the prices at which the majority of market participants agreed on value.
def value_area(profile, pct=70):
= sum(profile.values())
total = total * pct/100
target
= 0, []
cum, area # Sort by volume in descending order to find the highest volume nodes first
for price, vol in sorted(profile.items(), key=lambda x: x[1], reverse=True):
area.append(price)+= vol
cum if cum >= target:
break
return min(area), max(area)
= value_area(profile)
va_low, va_high print(f"Value Area = {va_low:,.2f} to {va_high:,.2f}")
To make our strategy more robust and adapt to changing market volatility, we use the Average True Range (ATR) to define adaptive buffers around the VPOC and Value Area boundaries. This prevents signals from being triggered by minor price fluctuations.
Concept & Formulas:
True Range (\(\text{TR}_i\)) for bar i: This measures the total range a security traded during a specific period. It is the greatest of:
\[ \text{TR}_i = \max\bigl(H_i - L_i,\;|H_i - C_{i-1}|,\;|L_i - C_{i-1}|\bigr) \]
ATR over period P: The ATR is typically a simple moving average of the True Ranges over a specified period.
\[ \mathrm{ATR} = \frac1P \sum_{i=n-P+1}^n \mathrm{TR}_i \]
Adaptive buffer as a fraction of the current price: These buffers adjust dynamically with the market’s volatility.
\[ \text{buffer}_{\rm vpoc} = \frac{\mathrm{ATR}}{C_n}\times k_1,\quad \text{buffer}_{\rm va} = \frac{\mathrm{ATR}}{C_n}\times k_2 \] where \(C_n\) is the current closing price, and \(k_1, k_2\) are scaling constants (e.g., 1.0 for VPOC and 0.6 for Value Area).
def simple_atr(highs, lows, closes, period=14):
# Ensure there are enough bars for ATR calculation
if len(highs) < period + 1:
return 0 # Or handle error appropriately
= [
TR max(h - l, abs(h - c0), abs(l - c0))
for h, l, c0 in zip(highs[1:], lows[1:], closes[:-1])
]return np.mean(TR[-period:])
# Use the full downloaded data for ATR calculation for current context
= simple_atr(highs, lows, closes)
atr = closes[-1]
current_price = (atr / current_price) * 1.0 # k1=1.0×ATR
vpoc_buf = (atr / current_price) * 0.6 # k2=0.6×ATR
va_buf
print(f"ATR: {atr:.2f}, VPOC buffer: {vpoc_buf:.2%}, VA buffer: {va_buf:.2%}")
Visualizing the Volume Profile and its key levels is crucial for understanding the strategy’s dynamics and for manual analysis.
Concept:
# Rebuild the profile for the visualization, often for the most recent period.
# Using the profile from the last 60 bars calculated earlier.
= list(profile.keys())
prices_profile = list(profile.values())
vols_profile
= plt.subplots(1, 2, figsize=(12, 5),
fig, (ax1, ax2) ={'width_ratios': [3, 1]})
gridspec_kw
# Left Chart: Price series + levels
'Close'], label='Close Price', color='blue', linewidth=0.8)
ax1.plot(data.index, data[='red', linestyle='--', linewidth=1.5, label='VPOC')
ax1.axhline(vpoc, color='green', linestyle='-.', linewidth=1, label='VAH')
ax1.axhline(va_high, color='green', linestyle='-.', linewidth=1, label='VAL')
ax1.axhline(va_low, color# Fill the value area for better visual representation
='lightgreen', alpha=0.15, label='Value Area (70%)')
ax1.fill_between(data.index, va_low, va_high, color
"Price & Volume Profile Levels (Last 60 Bars)", fontsize=14)
ax1.set_title("Price ($)", fontsize=12)
ax1.set_ylabel("Date", fontsize=12)
ax1.set_xlabel(='best', fontsize=10)
ax1.legend(locTrue, linestyle=':', alpha=0.6)
ax1.grid(='x', rotation=45)
ax1.tick_params(axis
# Right Chart: Volume Profile histogram
# Adjust bar height dynamically based on price range and number of bins
= (np.max(prices_profile) - np.min(prices_profile)) / len(prices_profile) * 0.8
bar_height =bar_height, color='lightblue', alpha=0.8)
ax2.barh(prices_profile, vols_profile, height"Volume Profile Distribution", fontsize=14)
ax2.set_title("Volume", fontsize=12)
ax2.set_xlabel("Price ($)", fontsize=12)
ax2.set_ylabel(='x', linestyle=':', alpha=0.6)
ax2.grid(axis
plt.tight_layout() plt.show()
### Volume Profile Trading
Strategy with Backtesting and Visualization
Now, let’s try a simple Python script for exploring a Volume Profile (VP) based trading strategy:
import numpy as np
import matplotlib.pyplot as plt
import warnings
'ignore')
warnings.filterwarnings(
import yfinance as yf
# Download data
= yf.download('BTC-USD', period='6mo', interval='1d')
data if data.columns.nlevels > 1:
= data.droplevel(1, axis=1)
data
= data['High'].values
highs = data['Low'].values
lows = data['Close'].values
closes = data['Volume'].values
volumes = data.index
dates
print(f"Downloaded {len(data)} bars of BTC-USD")
def build_volume_profile(highs, lows, volumes, bins=30):
= min(lows), max(highs)
lo, hi = np.linspace(lo, hi, bins)
levels = {p: 0 for p in levels}
profile
for H, L, V in zip(highs, lows, volumes):
for p in levels:
if L <= p <= H:
+= V / bins
profile[p]
return profile
def value_area(profile, pct=70):
= sum(profile.values())
total = total * pct/100
target
= 0, []
cum, area for price, vol in sorted(profile.items(), key=lambda x: x[1], reverse=True):
area.append(price)+= vol
cum if cum >= target:
break
return min(area), max(area)
def simple_atr(highs, lows, closes, period=14):
= [
TR max(h - l, abs(h - c0), abs(l - c0))
for h, l, c0 in zip(highs[1:], lows[1:], closes[:-1])
]return np.mean(TR[-period:])
def vp_strategy(highs, lows, closes, volumes):
"""Simple VP strategy logic"""
if len(highs) < 60:
return "NO SIGNAL", 0, 0, 0
# Build profile from last 60 bars
= build_volume_profile(highs[-60:], lows[-60:], volumes[-60:])
prof = max(prof, key=prof.get)
vpoc = value_area(prof)
va_lo, va_hi
# ATR buffers
= simple_atr(highs[-20:], lows[-20:], closes[-20:])
atr = closes[-1]
P = (atr/P) * 1.0
vp_buf = (atr/P) * 0.6
va_buf
# Signal logic
= "NO SIGNAL"
signal if abs(P-vpoc)/vpoc <= vp_buf:
= "BUY_VPOC"
signal elif abs(P-va_hi)/va_hi <= va_buf:
= "SELL_VAH"
signal elif abs(P-va_lo)/va_lo <= va_buf:
= "BUY_VAL"
signal elif P > va_hi * (1+va_buf):
= "BUY_BREAKOUT"
signal elif P < va_lo * (1-va_buf):
= "SELL_BREAKDOWN"
signal
# Volume confirmation
= np.mean(volumes[-10:])
avg_vol if volumes[-1] < 1.2 * avg_vol:
= "NO SIGNAL"
signal
return signal, vpoc, va_lo, va_hi
# Simple backtest
= []
signals = 0 # 0=no position, 1=long, -1=short
position = 0
entry_price = [10000] # Starting with $10,000
equity = 10000
cash
print("Running backtest...")
for i in range(60, len(data)):
= closes[i]
price
# Get signal
= vp_strategy(
signal, vpoc, va_lo, va_hi +1], lows[:i+1], closes[:i+1], volumes[:i+1]
highs[:i
)
# Execute trades
if signal.startswith("BUY") and position <= 0:
if position == -1: # Close short
= (entry_price - price) / entry_price
pnl *= (1 + pnl)
cash
# Open long
= 1
position = price
entry_price 'BUY', signal, vpoc, va_lo, va_hi))
signals.append((dates[i], price,
elif signal.startswith("SELL") and position >= 0:
if position == 1: # Close long
= (price - entry_price) / entry_price
pnl *= (1 + pnl)
cash
# Open short
= -1
position = price
entry_price 'SELL', signal, vpoc, va_lo, va_hi))
signals.append((dates[i], price,
# Track equity
if position == 1:
= cash * (price / entry_price)
current_value elif position == -1:
= cash * (entry_price / price)
current_value else:
= cash
current_value
equity.append(current_value)
# Calculate performance
= (equity[-1] - equity[0]) / equity[0]
final_return = len(signals)
total_signals
print(f"\nBacktest Results:")
print(f"Total Signals: {total_signals}")
print(f"Final Return: {final_return:.2%}")
print(f"Starting Value: ${equity[0]:,.0f}")
print(f"Ending Value: ${equity[-1]:,.0f}")
# Plot results
= plt.subplots(2, 2, figsize=(15, 10))
fig, ((ax1, ax2), (ax3, ax4))
# 1. Price chart with signals
'k-', linewidth=1, label='BTC Price')
ax1.plot(dates, closes,
# Plot signals
for date, price, action, signal_type, vpoc, va_lo, va_hi in signals:
= 'green' if action == 'BUY' else 'red'
color = '^' if action == 'BUY' else 'v'
marker =color, marker=marker, s=100,
ax1.scatter(date, price, color='black', linewidth=1, zorder=5)
edgecolor
'BTC Price with Volume Profile Signals')
ax1.set_title('Price ($)')
ax1.set_ylabel(
ax1.legend()True, alpha=0.3)
ax1.grid(
# 2. Latest Volume Profile
if signals:
= signals[-1]
last_signal = build_volume_profile(highs[-60:], lows[-60:], volumes[-60:])
prof = list(prof.keys())
prices = list(prof.values())
vols
=(max(prices)-min(prices))/30*0.8,
ax2.barh(prices, vols, height='lightblue', alpha=0.7)
color4], color='red', linestyle='--', linewidth=2, label='VPOC')
ax2.axhline(last_signal[6], color='green', linestyle='-.', label='VAH')
ax2.axhline(last_signal[5], color='green', linestyle='-.', label='VAL')
ax2.axhline(last_signal['Current Volume Profile')
ax2.set_title('Volume')
ax2.set_xlabel('Price ($)')
ax2.set_ylabel(
ax2.legend()
# 3. Equity curve
len(equity)], equity, 'b-', linewidth=2)
ax3.plot(dates[:f'Portfolio Value (Return: {final_return:.2%})')
ax3.set_title('Portfolio Value ($)')
ax3.set_ylabel(True, alpha=0.3)
ax3.grid(
# 4. Signal summary
= [s[3] for s in signals]
signal_types = {}
signal_counts for st in signal_types:
= signal_counts.get(st, 0) + 1
signal_counts[st]
if signal_counts:
range(len(signal_counts)), list(signal_counts.values()))
ax4.bar(range(len(signal_counts)))
ax4.set_xticks(list(signal_counts.keys()), rotation=45, ha='right')
ax4.set_xticklabels('Signal Type Distribution')
ax4.set_title('Count')
ax4.set_ylabel(
plt.tight_layout()
plt.show()
# Print recent signals
print(f"\nRecent Signals:")
for date, price, action, signal_type, vpoc, va_lo, va_hi in signals[-5:]:
print(f"{date.strftime('%Y-%m-%d')}: {action} at ${price:,.0f} ({signal_type})")
# Current market analysis
print(f"\nCurrent Market Analysis:")
= vp_strategy(
current_signal, current_vpoc, current_va_lo, current_va_hi
highs, lows, closes, volumes
)= closes[-1]
current_price
print(f"Current Price: ${current_price:,.0f}")
print(f"VPOC: ${current_vpoc:,.0f}")
print(f"Value Area: ${current_va_lo:,.0f} - ${current_va_hi:,.0f}")
print(f"Current Signal: {current_signal}")
if current_va_lo <= current_price <= current_va_hi:
print("✅ Price is INSIDE Value Area (Fair Value)")
elif current_price > current_va_hi:
print("🚀 Price is ABOVE Value Area (Bullish Territory)")
else:
print("📉 Price is BELOW Value Area (Bearish Territory)")
The script starts by downloading 6 months of daily Bitcoin
(BTC-USD) data using yfinance
. It then defines
functions for the strategy’s building blocks:
build_volume_profile
: This function
calculates the volume traded at different price levels over a specified
period. It effectively creates a histogram of volume across price bins,
revealing areas of high trading activity.value_area
: Derived from the volume
profile, this function identifies the Value Area (VA),
which is the price range where 70% of the total volume was traded. It
also pinpoints the Volume Point of Control (VPOC), the
single price level with the highest volume. These levels act as key
support and resistance zones.simple_atr
: This calculates the
Average True Range (ATR), a volatility measure. ATR is
crucial for creating adaptive buffers around the VPOC and VA boundaries,
making the strategy robust to changing market conditions.The vp_strategy
function is the core of the trading
logic. It uses the calculated VPOC, VA (VAH and VAL), and ATR-based
buffers to generate trading signals:
Crucially, every signal is subject to a volume confirmation. If the current day’s volume is too low (less than 1.2 times the average of the last 10 days), the signal is invalidated to filter out low-conviction moves.
The script includes a straightforward backtesting engine that simulates trades on historical data based on the generated signals. It tracks the portfolio’s equity over time, allowing for a quantitative assessment of the strategy’s performance.
The visualization section provides four key plots:
This framework provides a solid foundation for understanding and experimenting with Volume Profile-based trading strategies. It’s an excellent starting point for further customization and optimization. ### Conclusion
We explored the idea for a Volume Profile trading strategy, moving beyond a simple backtest to understand the underlying concepts and their implementation. From building the profile and identifying critical levels like VPOC and Value Area, to creating adaptive buffers with ATR and generating actionable signals with volume confirmation, we have a solid foundation. While this exploration focuses on the mechanics and individual components, remember that successful trading also involves robust risk management, position sizing, and continuous adaptation to market conditions. I will implement an enhanced proper strategy and backtesting code later.