Розробка кастомних свічкових графіків

Проєктуємо та розробляємо блокчейн-рішення повного циклу: від архітектури смарт-контрактів до запуску DeFi-протоколів, NFT-маркетплейсів та криптобірж. Аудит безпеки, токеноміка, інтеграція з наявною інфраструктурою.
Показано 1 з 1Усі 1306 послуг
Розробка кастомних свічкових графіків
Складний
~5 днів
Часті запитання

Напрямки блокчейн-розробки

Етапи блокчейн-розробки

Останні роботи

  • image_website-b2b-advance_0.webp
    Розробка сайту компанії B2B ADVANCE
    1288
  • image_web-applications_feedme_466_0.webp
    Розробка веб-додатків для компанії FEEDME
    1198
  • image_websites_belfingroup_462_0.webp
    Розробка веб-сайту для компанії БЕЛФІНГРУП
    902
  • image_ecommerce_furnoro_435_0.webp
    Розробка інтернет магазину для компанії FURNORO
    1122
  • image_logo-advance_0.webp
    Розробка логотипу компанії B2B Advance
    589
  • image_crm_enviok_479_0.webp
    Розробка веб-додатків для компанії Enviok
    859

Розроблення користувацьких графіків свічок

Стандартний 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"}}

Зміна таймфреймів

При переключенні таймфрейму потрібно:

  1. Відписатися від поточного WebSocket каналу
  2. Зробити запит історичних даних для нового таймфрейму
  3. Встановити нові дані через setData()
  4. Підписатися на WebSocket нового таймфрейму
  5. Оновити індикатори пересчётом
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 тижнів паралельно