candlestick-chart-generator
Version: 
A Node.js library for generating candlestick chart screenshots using financial data
356 lines (294 loc) • 10.1 kB
JavaScript
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;