Volume Cluster Analysis Visualization Development
Volume cluster analysis (also Volume Profile, Footprint chart) is a way to distribute trading volume by price levels rather than time. Instead of asking "how much volume was there at 14:00?" the question becomes "how much volume was there at price $43,500?". This is a fundamentally different view of the market, showing zones of real participant interest.
Theory: What Volume Cluster Shows
Volume Profile vs Regular Volume
A standard volume bar on a chart shows total volume for a period (candle). Volume profile distributes that volume by prices within the period:
Price $43,800 │████████████████████ 1,240 BTC
Price $43,750 │███████████████ 870 BTC
Price $43,700 │████████████████████████████ 2,100 BTC ← POC
Price $43,650 │████████████ 680 BTC
Price $43,600 │██████████████ 780 BTC
POC (Point of Control) — price level with maximum volume. Market spent the most time and/or volume here. Strong support/resistance level.
VAH / VAL (Value Area High / Low) — boundaries of the value zone where 70% of volume occurred (standard — one standard deviation from POC).
HVN / LVN (High Volume Node / Low Volume Node) — zones of attraction and zones of fast price movement respectively.
Footprint chart
Footprint (trace) is an improvement on volume profile, adding separation of buy volume and sell volume for each price cell:
$43,700 │ 890B × 1,210S │ delta: -320
$43,650 │ 1,450B × 680S │ delta: +770 ← seller absorption
$43,600 │ 340B × 1,890S │ delta: -1,550 ← aggressive sellers
Delta = Buy Volume - Sell Volume. Positive delta = buyers more aggressive. Divergences between price and delta — often precursors of reversals.
Imbalance — when bid/ask volume on adjacent levels differs by >300% (configurable parameter). Indicates aggressive absorption.
Data Sources
Problem: Public APIs Don't Provide Tick Data
Binance, Bybit, OKX publicly provide K-line (OHLCV) data, but not tick-by-tick trades with bid/ask split. For full footprint need aggTrades (aggregated trades) or raw trades.
Binance aggTrades WebSocket:
import asyncio
import websockets
import json
class FootprintCollector:
def __init__(self, symbol: str):
self.symbol = symbol.lower()
self.price_levels = {} # price -> {buy: 0, sell: 0}
self.tick_size = 10 # aggregate into $10 clusters
async def collect(self):
url = f"wss://stream.binance.com:9443/ws/{self.symbol}@aggTrade"
async with websockets.connect(url) as ws:
async for message in ws:
trade = json.loads(message)
await self.process_trade(trade)
async def process_trade(self, trade: dict):
price = float(trade["p"])
quantity = float(trade["q"])
is_buyer_maker = trade["m"] # True = aggressive seller
# Round to cluster
cluster_price = round(price / self.tick_size) * self.tick_size
if cluster_price not in self.price_levels:
self.price_levels[cluster_price] = {"buy": 0.0, "sell": 0.0}
if is_buyer_maker:
# Maker = limit order. Aggressive = market seller
self.price_levels[cluster_price]["sell"] += quantity
else:
self.price_levels[cluster_price]["buy"] += quantity
Important nuance: is_buyer_maker = True means buyer was in book (limit), seller came with market order. So aggressive side is seller. This is often confused.
Historical Data
For building historical volume profile:
| Source | Depth | Quality | Cost |
|---|---|---|---|
| Binance aggTrades REST | Up to 1000 records per request | Good | Free |
| Tardis.dev | Full history | Excellent (tick data) | $50-500/month |
| Kaiko | Up to 7 years | Institutional | $1,000+/month |
| Own collector | From start | Full control | Infrastructure |
For production: own data collector (aggTrades streaming → TimescaleDB) plus historical backfill via REST API on startup.
System Architecture
Backend: Collection and Aggregation
aggTrade WebSocket ──► Trade Collector ──► Kafka Topic (raw_trades)
│
┌────────────┤
▼ ▼
Volume Aggregator Footprint Builder
│ │
▼ ▼
TimescaleDB TimescaleDB
(volume_profile) (footprint_data)
│ │
└────────┬───┘
▼
WebSocket API ──► Frontend
TimescaleDB ideal for this: PostgreSQL with hypertables for time-series. Time partitioning + continuous aggregates for pre-computed timeframes.
-- Hypertable for raw trades
CREATE TABLE trades (
time TIMESTAMPTZ NOT NULL,
symbol VARCHAR(20) NOT NULL,
price NUMERIC(20, 8) NOT NULL,
quantity NUMERIC(20, 8) NOT NULL,
side VARCHAR(4) NOT NULL, -- 'buy' | 'sell'
trade_id BIGINT
);
SELECT create_hypertable('trades', 'time');
-- Materialized volume profile
CREATE MATERIALIZED VIEW volume_profile_1h AS
SELECT
time_bucket('1 hour', time) AS bucket,
symbol,
round(price / 10) * 10 AS price_cluster, -- $10 cluster
SUM(CASE WHEN side = 'buy' THEN quantity ELSE 0 END) AS buy_volume,
SUM(CASE WHEN side = 'sell' THEN quantity ELSE 0 END) AS sell_volume,
SUM(quantity) AS total_volume
FROM trades
GROUP BY bucket, symbol, price_cluster;
Continuous aggregate automatically updates aggregate on new data without full recalculation.
Volume Profile Metric Calculation
from dataclasses import dataclass
from typing import List, Dict
import statistics
@dataclass
class VolumeLevelData:
price: float
buy_volume: float
sell_volume: float
total_volume: float
@property
def delta(self) -> float:
return self.buy_volume - self.sell_volume
@property
def delta_percent(self) -> float:
if self.total_volume == 0:
return 0
return (self.delta / self.total_volume) * 100
class VolumeProfileCalculator:
def __init__(self, levels: List[VolumeLevelData]):
self.levels = sorted(levels, key=lambda x: x.price)
self._poc: VolumeLevelData = None
@property
def poc(self) -> VolumeLevelData:
"""Point of Control — level with max volume"""
if not self._poc:
self._poc = max(self.levels, key=lambda x: x.total_volume)
return self._poc
@property
def value_area(self) -> tuple:
"""Value Area — 70% volume around POC"""
total = sum(l.total_volume for l in self.levels)
target = total * 0.70
accumulated = self.poc.total_volume
poc_idx = self.levels.index(self.poc)
up_idx = poc_idx
down_idx = poc_idx
while accumulated < target:
can_up = up_idx < len(self.levels) - 1
can_down = down_idx > 0
up_vol = self.levels[up_idx + 1].total_volume if can_up else 0
down_vol = self.levels[down_idx - 1].total_volume if can_down else 0
if up_vol >= down_vol and can_up:
up_idx += 1
accumulated += up_vol
elif can_down:
down_idx -= 1
accumulated += down_vol
else:
break
return self.levels[up_idx].price, self.levels[down_idx].price # VAH, VAL
def get_hvn_lvn(self, threshold_percentile: float = 70) -> Dict:
"""High-volume and low-volume nodes"""
volumes = [l.total_volume for l in self.levels]
threshold_high = statistics.quantiles(volumes, n=100)[threshold_percentile - 1]
threshold_low = statistics.quantiles(volumes, n=100)[100 - threshold_percentile - 1]
return {
"hvn": [l for l in self.levels if l.total_volume >= threshold_high],
"lvn": [l for l in self.levels if l.total_volume <= threshold_low]
}
Frontend Visualization
Canvas-based Rendering
Standard libraries like Recharts or Chart.js can't handle rendering thousands of price levels in real-time. Need Canvas 2D or WebGL render.
Lightweight Charts (TradingView) supports custom series via Plugin API — preferred option if platform already uses this chart:
import { createChart, ISeriesApi } from 'lightweight-charts';
class VolumeProfilePlugin {
private canvas: HTMLCanvasElement;
private ctx: CanvasRenderingContext2D;
draw(data: VolumeProfileLevel[], priceRange: PriceRange) {
const maxVolume = Math.max(...data.map(d => d.totalVolume));
data.forEach(level => {
const y = this.priceToY(level.price, priceRange);
const barWidth = (level.totalVolume / maxVolume) * this.maxBarWidth;
// Buy volume — green
const buyWidth = barWidth * (level.buyVolume / level.totalVolume);
this.ctx.fillStyle = 'rgba(38, 166, 154, 0.6)';
this.ctx.fillRect(0, y, buyWidth, this.levelHeight);
// Sell volume — red
this.ctx.fillStyle = 'rgba(239, 83, 80, 0.6)';
this.ctx.fillRect(buyWidth, y, barWidth - buyWidth, this.levelHeight);
// POC highlight
if (level.isPoc) {
this.ctx.strokeStyle = '#FFD700';
this.ctx.lineWidth = 2;
this.ctx.strokeRect(0, y, barWidth, this.levelHeight);
}
});
}
}
Performance: with 1000+ levels use requestAnimationFrame with throttle to 60fps, redraw only changed levels (dirty checking). WebGL via PixiJS gives another 5-10x speedup with very high detail.
Real-time Updates
WebSocket architecture for live footprint:
class FootprintWebSocket {
private ws: WebSocket;
private pendingUpdates: Map<number, LevelUpdate> = new Map();
private renderScheduled = false;
onUpdate(update: LevelUpdate) {
// Buffer updates
const existing = this.pendingUpdates.get(update.price) || emptyLevel;
this.pendingUpdates.set(update.price, merge(existing, update));
if (!this.renderScheduled) {
this.renderScheduled = true;
requestAnimationFrame(() => {
this.flushUpdates();
this.renderScheduled = false;
});
}
}
private flushUpdates() {
// Apply all accumulated updates in one render frame
this.pendingUpdates.forEach((update, price) => {
this.chart.updateLevel(price, update);
});
this.pendingUpdates.clear();
}
}
Batching updates in requestAnimationFrame is critical: without it each trade triggers redraw, which at 1000 trades/sec kills browser performance.
Additional Volume Profile Indicators
Cumulative Volume Delta (CVD) — accumulated delta sum across all periods. CVD divergence with price = trend weakness signal.
VWAP (Volume Weighted Average Price) — volume-weighted average price:
VWAP = Σ(Price_i × Volume_i) / Σ(Volume_i)
Institutional algorithms often use VWAP as execution benchmark.
TPO (Time Price Opportunity) — each letter on TPO chart = 30 min trading at that price level. Classic Market Profile from CME Group.
Volume cluster analysis is serious analytical superstructure that attracts professional traders. Well-implemented footprint chart is competitive advantage hard to copy quickly.







