← Back to Home
Volume Point of Control and Value Area Analysis

Volume Point of Control and Value Area Analysis

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.

1. Data Preparation

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
warnings.filterwarnings('ignore')

import yfinance as yf

# Download data
data = yf.download('BTC-USD', period='6mo', interval='1d', auto_adjust=False)
if data.columns.nlevels > 1:
    data = data.droplevel(axis=1, level=1)

highs = data['High'].values
lows = data['Low'].values
closes = data['Close'].values
volumes = data['Volume'].values
dates = data.index

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.

2. Building the Volume Profile

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:

  1. 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\} \]

  2. 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):
    lo, hi = np.min(lows), np.max(highs) # Use numpy min/max for efficiency
    levels = np.linspace(lo, hi, bins)
    profile = {p: 0 for p in levels}

    for H, L, V in zip(highs, lows, volumes):
        for p in levels:
            if L <= p <= H:
                profile[p] += V / bins # Distribute volume across covered bins

    return profile

# Build profile over last 60 bars for current analysis
profile = build_volume_profile(highs[-60:], lows[-60:], volumes[-60:], bins=30)

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.

3. Finding VPOC & Value Area

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.

VPOC (Volume Point of Control)

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) \]

vpoc = max(profile, key=profile.get)
print(f"VPOC = {vpoc:,.2f}")

Value Area (70% of Volume)

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.

  1. Total volume \(V_{\rm tot} = \sum_p \text{profile}[p]\).
  2. Target volume \(V_{\rm tgt} = 0.70 \times V_{\rm tot}\).
  3. Sort bins by descending volume and accumulate volume until the cumulative sum \(\sum \text{vol}\) is greater than or equal to \(V_{\rm tgt}\).
  4. The Value Area Low (\(\rm VAL\)) and High (\(\rm VAH\)) are the minimum and maximum prices, respectively, within the set of bins that contributed to the target volume.
def value_area(profile, pct=70):
    total = sum(profile.values())
    target = total * pct/100

    cum, area = 0, []
    # 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)
        cum += vol
        if cum >= target:
            break

    return min(area), max(area)

va_low, va_high = value_area(profile)
print(f"Value Area = {va_low:,.2f} to {va_high:,.2f}")

4. Adaptive Buffers via ATR

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:

  1. 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:

    • Current High minus current Low (\(H_i - L_i\))
    • Absolute value of Current High minus Previous Close (\(|H_i - C_{i-1}|\))
    • Absolute value of Current Low minus Previous Close (\(|L_i - C_{i-1}|\))

    \[ \text{TR}_i = \max\bigl(H_i - L_i,\;|H_i - C_{i-1}|,\;|L_i - C_{i-1}|\bigr) \]

  2. 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 \]

  3. 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
atr = simple_atr(highs, lows, closes)
current_price = closes[-1]
vpoc_buf = (atr / current_price) * 1.0  # k1=1.0×ATR
va_buf   = (atr / current_price) * 0.6  # k2=0.6×ATR

print(f"ATR: {atr:.2f}, VPOC buffer: {vpoc_buf:.2%}, VA buffer: {va_buf:.2%}")

5. Visualization

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.
prices_profile = list(profile.keys())
vols_profile   = list(profile.values())

fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(12, 5),
                               gridspec_kw={'width_ratios': [3, 1]})

# Left Chart: Price series + levels
ax1.plot(data.index, data['Close'], label='Close Price', color='blue', linewidth=0.8)
ax1.axhline(vpoc, color='red', linestyle='--', linewidth=1.5, label='VPOC')
ax1.axhline(va_high, color='green', linestyle='-.', linewidth=1, label='VAH')
ax1.axhline(va_low,  color='green', linestyle='-.', linewidth=1, label='VAL')
# Fill the value area for better visual representation
ax1.fill_between(data.index, va_low, va_high, color='lightgreen', alpha=0.15, label='Value Area (70%)')

ax1.set_title("Price & Volume Profile Levels (Last 60 Bars)", fontsize=14)
ax1.set_ylabel("Price ($)", fontsize=12)
ax1.set_xlabel("Date", fontsize=12)
ax1.legend(loc='best', fontsize=10)
ax1.grid(True, linestyle=':', alpha=0.6)
ax1.tick_params(axis='x', rotation=45)

# Right Chart: Volume Profile histogram
# Adjust bar height dynamically based on price range and number of bins
bar_height = (np.max(prices_profile) - np.min(prices_profile)) / len(prices_profile) * 0.8
ax2.barh(prices_profile, vols_profile, height=bar_height, color='lightblue', alpha=0.8)
ax2.set_title("Volume Profile Distribution", fontsize=14)
ax2.set_xlabel("Volume", fontsize=12)
ax2.set_ylabel("Price ($)", fontsize=12)
ax2.grid(axis='x', linestyle=':', alpha=0.6)

plt.tight_layout()
plt.show()

Pasted image 20250609151917.png ### 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
warnings.filterwarnings('ignore')

import yfinance as yf

# Download data
data = yf.download('BTC-USD', period='6mo', interval='1d')
if data.columns.nlevels > 1:
    data = data.droplevel(1, axis=1)

highs = data['High'].values
lows = data['Low'].values
closes = data['Close'].values
volumes = data['Volume'].values
dates = data.index

print(f"Downloaded {len(data)} bars of BTC-USD")

def build_volume_profile(highs, lows, volumes, bins=30):
    lo, hi = min(lows), max(highs)
    levels = np.linspace(lo, hi, bins)
    profile = {p: 0 for p in levels}

    for H, L, V in zip(highs, lows, volumes):
        for p in levels:
            if L <= p <= H:
                profile[p] += V / bins

    return profile

def value_area(profile, pct=70):
    total = sum(profile.values())
    target = total * pct/100

    cum, area = 0, []
    for price, vol in sorted(profile.items(), key=lambda x: x[1], reverse=True):
        area.append(price)
        cum += vol
        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
    prof = build_volume_profile(highs[-60:], lows[-60:], volumes[-60:])
    vpoc = max(prof, key=prof.get)
    va_lo, va_hi = value_area(prof)
    
    # ATR buffers
    atr = simple_atr(highs[-20:], lows[-20:], closes[-20:])
    P = closes[-1]
    vp_buf = (atr/P) * 1.0
    va_buf = (atr/P) * 0.6

    # Signal logic
    signal = "NO SIGNAL"
    if abs(P-vpoc)/vpoc <= vp_buf:
        signal = "BUY_VPOC"
    elif abs(P-va_hi)/va_hi <= va_buf:
        signal = "SELL_VAH" 
    elif abs(P-va_lo)/va_lo <= va_buf:
        signal = "BUY_VAL"
    elif P > va_hi * (1+va_buf):
        signal = "BUY_BREAKOUT"
    elif P < va_lo * (1-va_buf):
        signal = "SELL_BREAKDOWN"

    # Volume confirmation
    avg_vol = np.mean(volumes[-10:])
    if volumes[-1] < 1.2 * avg_vol:
        signal = "NO SIGNAL"

    return signal, vpoc, va_lo, va_hi

# Simple backtest
signals = []
position = 0  # 0=no position, 1=long, -1=short
entry_price = 0
equity = [10000]  # Starting with $10,000
cash = 10000

print("Running backtest...")

for i in range(60, len(data)):
    price = closes[i]
    
    # Get signal
    signal, vpoc, va_lo, va_hi = vp_strategy(
        highs[:i+1], lows[:i+1], closes[:i+1], volumes[:i+1]
    )
    
    # Execute trades
    if signal.startswith("BUY") and position <= 0:
        if position == -1:  # Close short
            pnl = (entry_price - price) / entry_price
            cash *= (1 + pnl)
        
        # Open long
        position = 1
        entry_price = price
        signals.append((dates[i], price, 'BUY', signal, vpoc, va_lo, va_hi))
        
    elif signal.startswith("SELL") and position >= 0:
        if position == 1:  # Close long
            pnl = (price - entry_price) / entry_price
            cash *= (1 + pnl)
        
        # Open short
        position = -1
        entry_price = price
        signals.append((dates[i], price, 'SELL', signal, vpoc, va_lo, va_hi))
    
    # Track equity
    if position == 1:
        current_value = cash * (price / entry_price)
    elif position == -1:
        current_value = cash * (entry_price / price)
    else:
        current_value = cash
    
    equity.append(current_value)

# Calculate performance
final_return = (equity[-1] - equity[0]) / equity[0]
total_signals = len(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
fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, figsize=(15, 10))

# 1. Price chart with signals
ax1.plot(dates, closes, 'k-', linewidth=1, label='BTC Price')

# Plot signals
for date, price, action, signal_type, vpoc, va_lo, va_hi in signals:
    color = 'green' if action == 'BUY' else 'red'
    marker = '^' if action == 'BUY' else 'v'
    ax1.scatter(date, price, color=color, marker=marker, s=100, 
               edgecolor='black', linewidth=1, zorder=5)

ax1.set_title('BTC Price with Volume Profile Signals')
ax1.set_ylabel('Price ($)')
ax1.legend()
ax1.grid(True, alpha=0.3)

# 2. Latest Volume Profile
if signals:
    last_signal = signals[-1]
    prof = build_volume_profile(highs[-60:], lows[-60:], volumes[-60:])
    prices = list(prof.keys())
    vols = list(prof.values())
    
    ax2.barh(prices, vols, height=(max(prices)-min(prices))/30*0.8, 
             color='lightblue', alpha=0.7)
    ax2.axhline(last_signal[4], 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.set_title('Current Volume Profile')
    ax2.set_xlabel('Volume')
    ax2.set_ylabel('Price ($)')
    ax2.legend()

# 3. Equity curve
ax3.plot(dates[:len(equity)], equity, 'b-', linewidth=2)
ax3.set_title(f'Portfolio Value (Return: {final_return:.2%})')
ax3.set_ylabel('Portfolio Value ($)')
ax3.grid(True, alpha=0.3)

# 4. Signal summary
signal_types = [s[3] for s in signals]
signal_counts = {}
for st in signal_types:
    signal_counts[st] = signal_counts.get(st, 0) + 1

if signal_counts:
    ax4.bar(range(len(signal_counts)), list(signal_counts.values()))
    ax4.set_xticks(range(len(signal_counts)))
    ax4.set_xticklabels(list(signal_counts.keys()), rotation=45, ha='right')
    ax4.set_title('Signal Type Distribution')
    ax4.set_ylabel('Count')

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:")
current_signal, current_vpoc, current_va_lo, current_va_hi = vp_strategy(
    highs, lows, closes, volumes
)
current_price = closes[-1]

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:

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:

  1. Price Chart with Signals: Displays BTC price action with overlaid buy/sell signals, offering a visual intuition of trade entry and exit points.
  2. Latest Volume Profile: A horizontal bar chart of the most recent volume profile, clearly showing the VPOC, VAH, and VAL for immediate market context.
  3. Equity Curve: A line chart showing the portfolio’s value over time, providing a clear picture of profitability and overall return.
  4. Signal Type Distribution: A bar chart summarizing the frequency of each specific signal type generated by the strategy.
Pasted image 20250609161528.png

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.