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

Проектируем и разрабатываем блокчейн-решения полного цикла: от архитектуры смарт-контрактов до запуска 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-рендеринг), customizable, поддерживает все стандартные chart типы.

Базовая настройка

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 seconds
  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 microservice
WebSocket push Go gorilla/websocket
Frontend charts 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 недель параллельно