UNPKG

candlestick-chart-generator

Version:

A Node.js library for generating candlestick chart screenshots using financial data

356 lines (294 loc) 10.1 kB
const { createCanvas } = require("canvas"); class ChartRendererSimple { constructor(width = 1200, height = 600) { this.width = width; this.height = height; } /** * Render a candlestick chart using pure Canvas API * @param {Array} data - Chart data in OHLC format * @param {Object} options - Chart rendering options * @param {string} [options.symbol] - The symbol to display on the chart * @param {string} [options.timeframe] - The timeframe to display on the chart * @param {Object} [options.chartOptions] - Additional chart rendering options * @returns {Promise<Buffer>} Buffer containing the chart image */ async renderChart(data, options = {}) { const { chartOptions = {}, symbol, timeframe } = options; const canvas = createCanvas(this.width, this.height); const ctx = canvas.getContext("2d"); const bgColor = chartOptions.backgroundColor || "#131722"; ctx.fillStyle = bgColor; ctx.fillRect(0, 0, this.width, this.height); const currentPrice = data.length > 0 ? data[data.length - 1].close : 0; const currentTime = data.length > 0 ? new Date(data[data.length - 1].time * 1000) : new Date(); const buySignal = this._calculateBuySignal(data); const sellSignal = this._calculateSellSignal(data); this._drawHeader( ctx, data, symbol, timeframe, currentPrice, currentTime, buySignal, sellSignal ); const margin = { top: 140, right: 80, bottom: 120, left: 80 }; const chartWidth = this.width - margin.left - margin.right; const chartHeight = this.height - margin.top - margin.bottom; const prices = data.flatMap((d) => [d.open, d.high, d.low, d.close]); const minPrice = Math.min(...prices); const maxPrice = Math.max(...prices); const priceRange = maxPrice - minPrice; const padding = priceRange * 0.1; const xScale = (index) => margin.left + (index / (data.length - 1)) * chartWidth; const adjustedChartWidth = chartWidth * 0.95; const adjustedXScale = (index) => margin.left + chartWidth * 0.025 + (index / (data.length - 1)) * adjustedChartWidth; const yScale = (price) => margin.top + ((maxPrice + padding - price) / (priceRange + 2 * padding)) * chartHeight; ctx.strokeStyle = "rgba(42, 46, 57, 0.5)"; ctx.lineWidth = 1; for (let i = 0; i <= 5; i++) { const y = margin.top + (i / 5) * chartHeight; ctx.beginPath(); ctx.moveTo(margin.left, y); ctx.lineTo(margin.left + chartWidth, y); ctx.stroke(); } for (let i = 0; i <= 10; i++) { const x = margin.left + (i / 10) * chartWidth; ctx.beginPath(); ctx.moveTo(x, margin.top); ctx.lineTo(x, margin.top + chartHeight); ctx.stroke(); } const candleWidth = Math.max(2, (chartWidth / data.length) * 0.6); data.forEach((d, index) => { const x = adjustedXScale(index); const openY = yScale(d.open); const closeY = yScale(d.close); const highY = yScale(d.high); const lowY = yScale(d.low); const isUp = d.close >= d.open; const color = isUp ? "#26a69a" : "#ef5350"; ctx.strokeStyle = color; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(x, highY); ctx.lineTo(x, lowY); ctx.stroke(); ctx.fillStyle = color; const bodyTop = Math.min(openY, closeY); const bodyHeight = Math.abs(closeY - openY); if (bodyHeight > 0) { ctx.fillRect(x - candleWidth / 2, bodyTop, candleWidth, bodyHeight); } else { ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.beginPath(); ctx.moveTo(x - candleWidth / 2, openY); ctx.lineTo(x + candleWidth / 2, openY); ctx.stroke(); } }); ctx.fillStyle = "#d1d4dc"; ctx.font = "12px Arial"; ctx.textAlign = "left"; for (let i = 0; i <= 5; i++) { const price = maxPrice + padding - (i / 5) * (priceRange + 2 * padding); const y = margin.top + (i / 5) * chartHeight; ctx.fillText(price.toFixed(2), margin.left + chartWidth + 10, y + 4); } this._drawVolumeBars(ctx, data, margin, adjustedXScale); ctx.textAlign = "center"; const labelIndices = [ 0, Math.floor(data.length / 4), Math.floor(data.length / 2), Math.floor((3 * data.length) / 4), data.length - 1, ]; labelIndices.forEach((index) => { if (index < data.length) { const x = adjustedXScale(index); const date = new Date(data[index].time * 1000); const label = date.toLocaleDateString(); ctx.fillText(label, x, this.height - margin.bottom + 20); } }); ctx.strokeStyle = "#d1d4dc"; ctx.lineWidth = 1; ctx.strokeRect(margin.left, margin.top, chartWidth, chartHeight); return canvas.toBuffer("image/png"); } /** * Draw header information including symbol, timeframe, current price, time, and signals */ _drawHeader( ctx, data, symbol, timeframe, currentPrice, currentTime, buySignal, sellSignal ) { ctx.fillStyle = "#d1d4dc"; ctx.textAlign = "left"; let textX = 20; let textY = 30; if (symbol) { ctx.font = "bold 20px Arial"; ctx.fillText(symbol, textX, textY); textY += 25; } if (timeframe) { ctx.font = "16px Arial"; ctx.fillText(`Timeframe: ${timeframe}`, textX, textY); textY += 20; } ctx.font = "bold 18px Arial"; ctx.fillText(`Current Price: $${currentPrice.toFixed(2)}`, textX, textY); textY += 25; const timeString = currentTime.toLocaleString(); ctx.font = "16px Arial"; ctx.fillText(`Last Update: ${timeString}`, textX, textY); textY += 30; if (data && data.length > 0) { const latest = data[data.length - 1]; ctx.textAlign = "center"; ctx.font = "bold 16px Arial"; const centerX = this.width / 2; let ohlcY = 100; ctx.fillText(`O: $${latest.open.toFixed(2)}`, centerX - 250, ohlcY); ctx.fillText(`H: $${latest.high.toFixed(2)}`, centerX - 125, ohlcY); ctx.fillText(`L: $${latest.low.toFixed(2)}`, centerX, ohlcY); ctx.fillText(`C: $${latest.close.toFixed(2)}`, centerX + 125, ohlcY); ctx.fillText( `V: ${this._formatVolume(latest.volume || 0)}`, centerX + 250, ohlcY ); ctx.textAlign = "left"; } ctx.font = "bold 14px Arial"; if (buySignal) { ctx.fillStyle = "#26a69a"; // ctx.fillText("🟢 BUY SIGNAL", textX, textY); textY += 18; } if (sellSignal) { ctx.fillStyle = "#ef5350"; // ctx.fillText("🔴 SELL SIGNAL", textX, textY); } ctx.fillStyle = "#d1d4dc"; } /** * Calculate buy signal using simple moving average crossover */ _calculateBuySignal(data) { if (data.length < 20) return false; const shortMA = this._calculateSMA(data, 10); const longMA = this._calculateSMA(data, 20); return shortMA > longMA && data[data.length - 1].close > shortMA; } /** * Calculate sell signal using simple moving average crossover */ _calculateSellSignal(data) { if (data.length < 20) return false; const shortMA = this._calculateSMA(data, 10); const longMA = this._calculateSMA(data, 20); return shortMA < longMA && data[data.length - 1].close < shortMA; } /** * Calculate Simple Moving Average */ _calculateSMA(data, period) { if (data.length < period) return 0; const recentData = data.slice(-period); const sum = recentData.reduce((acc, d) => acc + d.close, 0); return sum / period; } /** * Draw volume bars below the main chart */ _drawVolumeBars(ctx, data, margin, xScale) { if (data.length === 0) return; const volumes = data.map((d) => d.volume || 0); const maxVolume = Math.max(...volumes); const volumeHeight = 100; const volumeScale = (volume) => { if (maxVolume === 0) return 0; return (volume / maxVolume) * volumeHeight; }; const volumeAreaTop = this.height - margin.bottom - volumeHeight; const volumeAreaHeight = volumeHeight; const barWidth = Math.max( 2, ((this.width - margin.left - margin.right) / data.length) * 0.6 ); data.forEach((d, index) => { const x = xScale(index); const volume = d.volume || 0; const barHeight = volumeScale(volume); if (barHeight > 0) { const isUp = d.close >= d.open; const color = isUp ? "rgba(38, 166, 154, 0.7)" : "rgba(239, 83, 80, 0.7)"; ctx.fillStyle = color; ctx.fillRect( x - barWidth / 2, volumeAreaTop + volumeAreaHeight - barHeight, barWidth, barHeight ); } }); ctx.fillStyle = "#d1d4dc"; ctx.font = "12px Arial"; ctx.textAlign = "left"; ctx.fillText("Volume", margin.left, volumeAreaTop - 10); ctx.textAlign = "right"; ctx.fillText( this._formatVolume(maxVolume), margin.left - 10, volumeAreaTop - 10 ); } /** * Format volume number for display */ _formatVolume(volume) { if (volume >= 1e9) { return (volume / 1e9).toFixed(1) + "B"; } else if (volume >= 1e6) { return (volume / 1e6).toFixed(1) + "M"; } else if (volume >= 1e3) { return (volume / 1e3).toFixed(1) + "K"; } else { return volume.toFixed(0); } } /** * Set the width and height for the chart * @param {number} width - Chart width in pixels * @param {number} height - Chart height in pixels */ setWidthAndHeight(width, height) { this.width = width; this.height = height; } } module.exports = ChartRendererSimple;