Custom candlestick charts development

We design and develop full-cycle blockchain solutions: from smart contract architecture to launching DeFi protocols, NFT marketplaces and crypto exchanges. Security audits, tokenomics, integration with existing infrastructure.
Showing 1 of 1 servicesAll 1306 services
Custom candlestick charts development
Complex
~5 business days
FAQ
Blockchain Development Services
Blockchain Development Stages
Latest works
  • image_website-b2b-advance_0.png
    B2B ADVANCE company website development
    1238
  • image_web-applications_feedme_466_0.webp
    Development of a web application for FEEDME
    1167
  • image_websites_belfingroup_462_0.webp
    Website development for BELFINGROUP
    867
  • image_ecommerce_furnoro_435_0.webp
    Development of an online store for the company FURNORO
    1080
  • image_logo-advance_0.png
    B2B Advance company logo design
    563
  • image_crm_enviok_479_0.webp
    Development of a web application for Enviok
    829

Custom Candlestick Charts Development

Standard TradingView widget solves the problem for most platforms. But when custom logic is needed — your own candle aggregation system, non-standard overlay indicators, specific branding — you need to build charts yourself. Let's go through from OHLCV data storage to browser rendering.

OHLCV Storage and Aggregation

Storage Structure

Raw data is trade events: each trade on an exchange. Candles are aggregated from trades.

-- Raw tick table (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);

-- Partition by month for manageable size
CREATE TABLE trades_2025_01 PARTITION OF trades
    FOR VALUES FROM ('2025-01-01') TO ('2025-02-01');

-- TimescaleDB instead of manual partitions — best choice for time-series
SELECT create_hypertable('trades', 'created_at');

For a crypto exchange with multiple pairs, TimescaleDB is significantly more convenient: automatic chunks, continuous aggregates, compression. Queries for 1-minute candles over a year run 10–100x faster than on regular PostgreSQL.

Continuous Aggregates in TimescaleDB

-- Automatic aggregation of 1-minute candles
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;

-- Update policy: refresh every minute, look at last 3 hours
SELECT add_continuous_aggregate_policy('candles_1m',
    start_offset => INTERVAL '3 hours',
    end_offset   => INTERVAL '1 minute',
    schedule_interval => INTERVAL '1 minute');

Higher timeframes aggregate on the fly from 1-minute candles:

-- 1-hour candles from 1-minute
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 Updates

When a new trade arrives, update the current unclosed candle:

type CandleAggregator struct {
    mu      sync.RWMutex
    current map[PairTimeframe]*Candle  // current unclosed candles
}

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 {
            // Close previous candle, publish to Pub/Sub
            if exists {
                ca.publishClosedCandle(candle)
            }
            // Open new one
            ca.current[key] = &Candle{
                Bucket: bucket,
                Open: trade.Price, High: trade.Price,
                Low: trade.Price, Close: trade.Price,
                Volume: trade.Quantity,
            }
        } else {
            // Update current
            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)
        }
        
        // Publish live update every N ticks or by timer
        ca.publishLiveCandle(ca.current[key])
    }
}

Frontend: TradingView Lightweight Charts

TradingView Lightweight Charts — free Apache 2.0 library from TradingView for embedding financial charts. Performant (WebGL rendering), customizable, supports all standard chart types.

Basic Setup

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;
}

Loading Historical Data and Streaming

const candleSeries = chart.addSeries(CandlestickSeries, {
  upColor: '#00b15e',
  downColor: '#e84242',
  borderVisible: false,
  wickUpColor: '#00b15e',
  wickDownColor: '#e84242',
});

// Load historical data
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 for live updates
const ws = new WebSocket(`wss://api.exchange.com/ws/candles/${pair}/${timeframe}`);
ws.onmessage = (event) => {
  const candle = JSON.parse(event.data);
  // update() updates current candle or creates new
  candleSeries.update({
    time: candle.bucket / 1000,
    open: parseFloat(candle.open),
    high: parseFloat(candle.high),
    low: parseFloat(candle.low),
    close: parseFloat(candle.close),
  });
};

Overlay Indicators

// EMA as LineSeries over candlestick chart
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 Histogram

// Volume bars in separate price pane
const volumeSeries = chart.addHistogramSeries({
  color: '#26a69a',
  priceFormat: { type: 'volume' },
  priceScaleId: 'volume',
});

chart.priceScale('volume').applyOptions({
  scaleMargins: { top: 0.8, bottom: 0 },  // 20% height at bottom
});

volumeSeries.setData(historical.map(c => ({
  time: c.bucket / 1000,
  value: parseFloat(c.volume),
  color: parseFloat(c.close) >= parseFloat(c.open) ? '#00b15e33' : '#e8424233',
})));

Custom Candle Types

Heikin-Ashi

Smoothed candles that filter noise:

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

Fixed-size candles not tied to time:

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 for Charts

Server pushes candle updates to subscribers:

// Hub manages subscriptions
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)
    }
}

Client subscribes:

{"action": "subscribe", "channel": "candles", "pair": "BTC/USDT", "timeframe": "1m"}

Receives updates:

{"type": "candle_update", "pair": "BTC/USDT", "tf": "1m", 
 "data": {"time": 1700000000, "open": "42000", "high": "42150", "low": "41950", "close": "42100", "volume": "12.5"}}

Timeframe Switching

When switching timeframes:

  1. Unsubscribe from current WebSocket channel
  2. Fetch historical data for new timeframe
  3. Set new data via setData()
  4. Subscribe to WebSocket of new timeframe
  5. Update indicators via recalculation
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));
  
  // Recalculate indicators
  emaSeries.setData(calculateEMA(data, 21));
  
  ws.send(JSON.stringify({ action: 'subscribe', channel: 'candles', timeframe: newTimeframe }));
  currentTf = newTimeframe;
}

Performance

With many candles (thousands of bars + several indicators) rendering can lag. Optimizations:

  • Windowing: load only visible range + buffer. Lightweight Charts does this automatically
  • Debounce WebSocket: with high-frequency trading, updates may arrive 10–20 times/sec. Throttle to 4–10 times/sec for smooth rendering
  • Web Workers: offload indicator calculations (especially heavy ones like Ichimoku, VP) to Worker to not block UI thread
Component Technology
Time-series DB TimescaleDB (PostgreSQL extension)
Real-time aggregation Go microservice
WebSocket push Go gorilla/websocket
Frontend charts TradingView Lightweight Charts v4
Indicators Custom TypeScript + ta-lib.wasm
State management Zustand

Development Timeline

  • Basic candlestick chart with live updates and 2–3 indicators: 4–6 weeks
  • Fully featured charting (all timeframes, 10+ indicators, drawing, Heikin-Ashi/Renko): 2–3 months
  • Server side (TimescaleDB + aggregator + WebSocket API): 3–5 weeks in parallel