Розроблення візуалізації кластерного аналізу обсягів
Кластерний аналіз обсягів (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. Time partitioning + 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 виділяємо
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 критично важливий: без нього кожна сделка вызивает перерисовку, що при 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 — конкурентне переважання платформи, яке складно скопіювати швидко.







