Розроблення користувацьких графіків свічок
Стандартний TradingView віджет розв'язує завдання для більшості платформ. Але коли потрібна користувацька логіка — власна система агрегації свічок, нестандартні overlay-індикатори, специфічний брендинг — потрібно будувати графіки самостійно. Розберемось від зберігання OHLCV даних до рендерингу в браузері.
Зберігання та агрегація OHLCV
Структура зберігання
Сирі дані — це trade events: кожна сделка на біржі. Свічки агрегуються з trades.
-- Таблиця сирих тиків (сделок)
CREATE TABLE trades (
id BIGSERIAL,
pair_id SMALLINT NOT NULL,
price NUMERIC(36,18) NOT NULL,
quantity NUMERIC(36,18) NOT NULL,
side SMALLINT NOT NULL, -- 0=buy, 1=sell
created_at TIMESTAMPTZ NOT NULL
) PARTITION BY RANGE (created_at);
-- Партиція по місяцях для керованого розміру
CREATE TABLE trades_2025_01 PARTITION OF trades
FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');
-- TimescaleDB замість партицій вручну — найкращий вибір для time-series
SELECT create_hypertable('trades', 'created_at');
Для крипто-біржі з кількома парами — TimescaleDB значно зручніше: автоматичні chunk, continuous aggregates, compression. Запити на 1-хвилинні свічки за рік працюють у 10–100x швидше ніж на звичайному PostgreSQL.
Continuous aggregates в TimescaleDB
-- Автоматична агрегація 1-хвилинних свічок
CREATE MATERIALIZED VIEW candles_1m
WITH (timescaledb.continuous) AS
SELECT
time_bucket('1 minute', created_at) AS bucket,
pair_id,
first(price, created_at) AS open,
max(price) AS high,
min(price) AS low,
last(price, created_at) AS close,
sum(quantity) AS volume,
count(*) AS trades_count
FROM trades
GROUP BY bucket, pair_id;
-- Політика оновлення: оновлювати кожну хвилину, дивитися останні 3 години
SELECT add_continuous_aggregate_policy('candles_1m',
start_offset => INTERVAL '3 hours',
end_offset => INTERVAL '1 minute',
schedule_interval => INTERVAL '1 minute');
Вищі таймфрейми агрегуються на льоту з 1-хвилинних свічок:
-- 1-годинні свічки з 1-хвилинних
SELECT
time_bucket('1 hour', bucket) AS hour_bucket,
first(open, bucket) AS open,
max(high) AS high,
min(low) AS low,
last(close, bucket) AS close,
sum(volume) AS volume
FROM candles_1m
WHERE pair_id = $1 AND bucket >= NOW() - INTERVAL '7 days'
GROUP BY hour_bucket
ORDER BY hour_bucket;
Real-time оновлення
При поступленні нової сделки оновити поточну незакриту свічку:
type CandleAggregator struct {
mu sync.RWMutex
current map[PairTimeframe]*Candle // поточні незакриті свічки
}
func (ca *CandleAggregator) OnTrade(trade Trade) {
ca.mu.Lock()
defer ca.mu.Unlock()
for _, tf := range TIMEFRAMES {
key := PairTimeframe{trade.PairID, tf}
bucket := truncateToTimeframe(trade.Time, tf)
candle, exists := ca.current[key]
if !exists || candle.Bucket != bucket {
// Закриваємо попередню свічку, публікуємо в Pub/Sub
if exists {
ca.publishClosedCandle(candle)
}
// Відкриваємо нову
ca.current[key] = &Candle{
Bucket: bucket,
Open: trade.Price, High: trade.Price,
Low: trade.Price, Close: trade.Price,
Volume: trade.Quantity,
}
} else {
// Оновлюємо поточну
if trade.Price > candle.High { candle.High = trade.Price }
if trade.Price < candle.Low { candle.Low = trade.Price }
candle.Close = trade.Price
candle.Volume = candle.Volume.Add(trade.Quantity)
}
// Публікуємо live оновлення кожні N тиків або за таймером
ca.publishLiveCandle(ca.current[key])
}
}
Frontend: TradingView Lightweight Charts
TradingView Lightweight Charts — безплатна бібліотека Apache 2.0 від TradingView для вбудовування фінансових графіків. Продуктивна (WebGL-рендеринг), можна кастомізувати, підтримує всі стандартні типи графіків.
Базова настройка
import { createChart, ColorType, CandlestickSeries } from 'lightweight-charts';
function initChart(container: HTMLElement) {
const chart = createChart(container, {
width: container.clientWidth,
height: 400,
layout: {
background: { type: ColorType.Solid, color: '#0d0d0f' },
textColor: '#9b9ea8',
},
grid: {
vertLines: { color: '#1e2030' },
horzLines: { color: '#1e2030' },
},
crosshair: { mode: 1 }, // CrosshairMode.Magnet
rightPriceScale: {
borderColor: '#2a2d3a',
scaleMargins: { top: 0.1, bottom: 0.2 },
},
timeScale: {
borderColor: '#2a2d3a',
timeVisible: true,
secondsVisible: false,
},
});
return chart;
}
Завантаження історичних даних та стриминг
const candleSeries = chart.addSeries(CandlestickSeries, {
upColor: '#00b15e',
downColor: '#e84242',
borderVisible: false,
wickUpColor: '#00b15e',
wickDownColor: '#e84242',
});
// Завантаження історичних даних
const historical = await fetchCandles(pair, timeframe, 500);
candleSeries.setData(historical.map(c => ({
time: c.bucket / 1000, // unix секунди
open: parseFloat(c.open),
high: parseFloat(c.high),
low: parseFloat(c.low),
close: parseFloat(c.close),
})));
// WebSocket для live оновлень
const ws = new WebSocket(`wss://api.exchange.com/ws/candles/${pair}/${timeframe}`);
ws.onmessage = (event) => {
const candle = JSON.parse(event.data);
// update() оновлює поточну свічку або створює нову
candleSeries.update({
time: candle.bucket / 1000,
open: parseFloat(candle.open),
high: parseFloat(candle.high),
low: parseFloat(candle.low),
close: parseFloat(candle.close),
});
};
Overlay індикатори
// EMA як LineSeries над свічковим графіком
const emaSeries = chart.addLineSeries({
color: '#f5a623',
lineWidth: 1,
priceLineVisible: false,
lastValueVisible: false,
});
function calculateEMA(data: CandleData[], period: number): LineData[] {
const k = 2 / (period + 1);
let ema = data[0].close;
return data.map((candle, i) => {
if (i === 0) {
ema = candle.close;
} else {
ema = candle.close * k + ema * (1 - k);
}
return { time: candle.time, value: ema };
});
}
emaSeries.setData(calculateEMA(historicalData, 21));
Volume гістограма
// Volume bars у окремій price pane
const volumeSeries = chart.addHistogramSeries({
color: '#26a69a',
priceFormat: { type: 'volume' },
priceScaleId: 'volume',
});
chart.priceScale('volume').applyOptions({
scaleMargins: { top: 0.8, bottom: 0 }, // 20% висоти внизу
});
volumeSeries.setData(historical.map(c => ({
time: c.bucket / 1000,
value: parseFloat(c.volume),
color: parseFloat(c.close) >= parseFloat(c.open) ? '#00b15e33' : '#e8424233',
})));
Кастомні типи свічок
Heikin-Ashi
Сгладжені свічки, які фільтрують шум:
function toHeikinAshi(candles: OHLCV[]): OHLCV[] {
return candles.map((c, i) => {
const prev = i > 0 ? candles[i - 1] : c;
const haClose = (c.open + c.high + c.low + c.close) / 4;
const haOpen = i === 0 ? (c.open + c.close) / 2 : (prev.open + prev.close) / 2;
return {
time: c.time,
open: haOpen,
high: Math.max(c.high, haOpen, haClose),
low: Math.min(c.low, haOpen, haClose),
close: haClose,
};
});
}
Renko Chart
Свічки фіксованого розміру, не прив'язані до часу:
function toRenko(candles: OHLCV[], brickSize: number): RenkoCandle[] {
const bricks: RenkoCandle[] = [];
let lastBrick = candles[0].close;
for (const candle of candles) {
while (candle.close >= lastBrick + brickSize) {
bricks.push({ open: lastBrick, close: lastBrick + brickSize, direction: 'up' });
lastBrick += brickSize;
}
while (candle.close <= lastBrick - brickSize) {
bricks.push({ open: lastBrick, close: lastBrick - brickSize, direction: 'down' });
lastBrick -= brickSize;
}
}
return bricks;
}
WebSocket API для графіків
Сервер пушить оновлення свічок підписчикам:
// Hub управляє підписками
type CandleHub struct {
subscriptions map[string]map[*websocket.Conn]bool // pair+tf -> clients
mu sync.RWMutex
}
func (h *CandleHub) BroadcastCandle(pair, timeframe string, candle CandleUpdate) {
key := pair + "_" + timeframe
h.mu.RLock()
clients := h.subscriptions[key]
h.mu.RUnlock()
data, _ := json.Marshal(candle)
for conn := range clients {
conn.WriteMessage(websocket.TextMessage, data)
}
}
Клієнт підписується:
{"action": "subscribe", "channel": "candles", "pair": "BTC/USDT", "timeframe": "1m"}
Отримує оновлення:
{"type": "candle_update", "pair": "BTC/USDT", "tf": "1m",
"data": {"time": 1700000000, "open": "42000", "high": "42150", "low": "41950", "close": "42100", "volume": "12.5"}}
Зміна таймфреймів
При переключенні таймфрейму потрібно:
- Відписатися від поточного WebSocket каналу
- Зробити запит історичних даних для нового таймфрейму
- Встановити нові дані через
setData() - Підписатися на WebSocket нового таймфрейму
- Оновити індикатори пересчётом
async function switchTimeframe(newTimeframe: string) {
ws.send(JSON.stringify({ action: 'unsubscribe', channel: 'candles', timeframe: currentTf }));
const data = await fetchCandles(currentPair, newTimeframe, 500);
candleSeries.setData(formatCandles(data));
volumeSeries.setData(formatVolume(data));
// Пересчітуємо індикатори
emaSeries.setData(calculateEMA(data, 21));
ws.send(JSON.stringify({ action: 'subscribe', channel: 'candles', timeframe: newTimeframe }));
currentTf = newTimeframe;
}
Продуктивність
При великій кількості свічок (тисячі барів + кілька індикаторів) рендеринг може тормозити. Optimizations:
- Windowing: завантажувати тільки видимий діапазон + буфер. Lightweight Charts робить це автоматично
- Debounce WebSocket: при високочастотному трейдингу update може приходити 10–20 разів/сек. Throttle до 4–10 разів/сек для smooth rendering
- Web Workers: вичислення індикаторів (особливо важких — Ichimoku, VP) виносити в Worker, щоб не блокувати UI thread
| Компонент | Технологія |
|---|---|
| Time-series DB | TimescaleDB (PostgreSQL extension) |
| Real-time агрегація | Go мікросервіс |
| WebSocket push | Go gorilla/websocket |
| Frontend графіки | TradingView Lightweight Charts v4 |
| Індикатори | Custom TypeScript + ta-lib.wasm |
| State management | Zustand |
Сроки розроблення
- Базовий свічний графік з live оновленнями та 2–3 індикаторами: 4–6 тижнів
- Повнофункціональний charting (всі таймфрейми, 10+ індикаторів, малювання, Heikin-Ashi/Renko): 2–3 місяці
- Серверна частина (TimescaleDB + агрегатор + WebSocket API): 3–5 тижнів паралельно







