Разработка визуализации кластерного анализа объемов
Кластерный анализ объёмов (volume cluster analysis, также Volume Profile, Footprint chart) — это способ распределить торговый объём по ценовым уровням, а не по времени. Вместо вопроса "сколько было объёма в 14:00?" задаётся вопрос "сколько было объёма по цене $43,500?". Это фундаментально иной взгляд на рынок, показывающий зоны реального интереса участников.
Теория: что показывает объёмный кластер
Volume Profile vs обычный объём
Стандартный volume bar на графике показывает суммарный объём за период (свечу). Volume profile распределяет этот объём по ценам внутри периода:
Цена $43,800 │████████████████████ 1,240 BTC
Цена $43,750 │███████████████ 870 BTC
Цена $43,700 │████████████████████████████ 2,100 BTC ← POC
Цена $43,650 │████████████ 680 BTC
Цена $43,600 │██████████████ 780 BTC
POC (Point of Control) — ценовой уровень с максимальным объёмом. Рынок провёл здесь больше всего времени и/или объёма. Сильный уровень поддержки/сопротивления.
VAH / VAL (Value Area High / Low) — границы зоны стоимости, где прошло 70% объёма (стандарт — одно стандартное отклонение от POC).
HVN / LVN (High Volume Node / Low Volume Node) — зоны притяжения и зоны быстрого прохождения цены соответственно.
Footprint chart
Footprint (отпечаток) — это улучшение volume profile, добавляющее разделение на buy volume и sell volume для каждой ценовой ячейки:
$43,700 │ 890B × 1,210S │ delta: -320
$43,650 │ 1,450B × 680S │ delta: +770 ← поглощение продавцов
$43,600 │ 340B × 1,890S │ delta: -1,550 ← агрессивные продавцы
Delta = Buy Volume - Sell Volume. Положительная дельта = покупатели агрессивнее. Дивергенции между ценой и дельтой — часто предвестники разворотов.
Imbalance — когда bid/ask объём на соседних уровнях отличается на >300% (настраиваемый параметр). Указывает на агрессивное поглощение.
Источники данных
Проблема: публичные API не дают tick data
Binance, Bybit, OKX публично предоставляют K-line (OHLCV) данные, но не tick-by-tick trades с bid/ask разбивкой. Для полноценного footprint нужны aggTrades (агрегированные сделки) или 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 # объединяем в кластеры по $10
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 = продавец агрессивный
# Округляем до кластера
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 = лимитный ордер. Агрессивный = рыночный продавец
self.price_levels[cluster_price]["sell"] += quantity
else:
self.price_levels[cluster_price]["buy"] += quantity
Важный нюанс: is_buyer_maker = True означает, что buyer был в book (лимитный), а seller пришёл с market ордером. То есть агрессивная сторона — продавец. Это часто путают.
Исторические данные
Для построения исторических volume profile:
| Источник | Глубина | Качество | Стоимость |
|---|---|---|---|
| Binance aggTrades REST | До 1000 записей за запрос | Хорошее | Бесплатно |
| Tardis.dev | Полная история | Отличное (tick data) | $50-500/мес |
| Kaiko | До 7 лет | Институциональное | $1,000+/мес |
| Собственный сбор | С момента старта | Полный контроль | Инфраструктура |
Для production системы рекомендуем: собственный сборщик данных (aggTrades streaming → TimescaleDB) плюс исторический бэкфилл через REST API при запуске.
Архитектура системы
Backend: сбор и агрегация
aggTrade WebSocket ──► Trade Collector ──► Kafka Topic (raw_trades)
│
┌────────────────┤
▼ ▼
Volume Aggregator Footprint Builder
│ │
▼ ▼
TimescaleDB TimescaleDB
(volume_profile) (footprint_data)
│ │
└────────┬───────┘
▼
WebSocket API ──► Frontend
TimescaleDB идеален для этой задачи: PostgreSQL с гипертаблицами для time-series. Партиционирование по времени + continuous aggregates для предвычисленных временных фреймов.
-- Гипертаблица для 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');
-- Материализованный 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
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 позволяет автоматически обновлять агрегат при добавлении новых данных без полного пересчёта.
Расчёт Volume Profile метрик
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 — уровень с максимальным объёмом"""
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% объёма вокруг 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:
"""Высокообъёмные и низкообъёмные узлы"""
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 визуализация
Canvas-based рендеринг
Стандартные библиотеки типа Recharts или Chart.js не справляются с рендерингом тысяч price levels в реальном времени. Нужен Canvas 2D или WebGL рендер.
Lightweight Charts (TradingView) поддерживает кастомные series через Plugin API — это предпочтительный вариант, если платформа уже использует этот чарт:
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 — зелёный
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 — красный
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);
}
});
}
}
Производительность: при 1000+ уровнях используем requestAnimationFrame с throttle на 60fps, перерисовываем только изменившиеся уровни (dirty checking). WebGL через PixiJS даёт ещё 5-10x прирост при очень высокой детализации.
Real-time обновления
WebSocket архитектура для live footprint:
class FootprintWebSocket {
private ws: WebSocket;
private pendingUpdates: Map<number, LevelUpdate> = new Map();
private renderScheduled = false;
onUpdate(update: LevelUpdate) {
// Буферизуем обновления
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() {
// Применяем все накопленные обновления за один render frame
this.pendingUpdates.forEach((update, price) => {
this.chart.updateLevel(price, update);
});
this.pendingUpdates.clear();
}
}
Батчинг обновлений в requestAnimationFrame критически важен: без него каждый trade вызывает перерисовку, что при 1000 trades/sec убьёт производительность браузера.
Дополнительные индикаторы на основе volume profile
Cumulative Volume Delta (CVD) — накопленная сумма delta по всем периодам. Дивергенция CVD с ценой = сигнал ослабления тренда.
VWAP (Volume Weighted Average Price) — средневзвешенная по объёму цена:
VWAP = Σ(Price_i × Volume_i) / Σ(Volume_i)
Институциональные алгоритмы часто используют VWAP как benchmark исполнения.
TPO (Time Price Opportunity) — каждая буква на TPO chart = 30 минут торговли на данном ценовом уровне. Классика Market Profile от CME Group.
Объёмный кластерный анализ — это серьёзная аналитическая надстройка, которая привлекает профессиональных трейдеров. Хорошо реализованный footprint chart — конкурентное преимущество платформы, которое сложно скопировать быстро.







