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:
- Unsubscribe from current WebSocket channel
- Fetch historical data for new timeframe
- Set new data via
setData() - Subscribe to WebSocket of new timeframe
- 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







