<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[TA Platform — Trading Tools Notes]]></title><description><![CDATA[TA Platform — Trading Tools Notes]]></description><link>https://taplatform-notes.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sun, 01 Nov 2026 04:50:25 GMT</lastBuildDate><atom:link href="https://taplatform-notes.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building a candlestick chart engine on raw Canvas2D (no chart library)]]></title><description><![CDATA[How I plotted candlesticks on raw Canvas2D: three performance lessons, indicator functions over typed arrays, and hit-testing in chart space. No chart…]]></description><link>https://taplatform-notes.hashnode.dev/building-a-candlestick-chart-engine-on-raw-canvas2d-no-chart-library</link><guid isPermaLink="true">https://taplatform-notes.hashnode.dev/building-a-candlestick-chart-engine-on-raw-canvas2d-no-chart-library</guid><category><![CDATA[canvas]]></category><category><![CDATA[crypto]]></category><category><![CDATA[performance]]></category><category><![CDATA[JavaScript]]></category><dc:creator><![CDATA[hacikanprofessor035-prog]]></dc:creator><pubDate>Sat, 17 Oct 2026 17:19:54 GMT</pubDate><content:encoded><![CDATA[<blockquote>
<blockquote>
<p>Originally published on <a href="https://dev.to/hacikan39622p/building-a-candlestick-chart-engine-on-raw-canvas2d-no-chart-library-34a2">Dev.to</a>.</p>
</blockquote>
<p>Every charting tutorial reaches for a library. I ended up hand-writing the engine instead, and it turned out to be less code than I expected — plus three performance lessons I hadn't anticipated.</p>
<p>This is what I learned plotting candlesticks on raw Canvas2D, with the naive version first so you can see exactly what goes wrong.</p>
<img src="https://files.catbox.moe/k23ft8.png" alt="TA Platform — график BTC/USDT: свечи, RSI и объёмы на живом рынке" style="display:block;margin:0 auto" />

<img src="https://files.catbox.moe/8vngro.png" alt="TA Platform — выбор индикаторов: SMA 20, RSI 14 и Volume из 38 доступных" style="display:block;margin:0 auto" />

<h2>The naive version</h2>
<p>The obvious approach is to redraw everything on every frame:</p>
<pre><code class="language-js">function draw() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  for (const candle of visible) drawCandle(ctx, candle);
  for (const obj of drawings) drawDrawing(ctx, obj);
  requestAnimationFrame(draw);
}
</code></pre>
<p>It works. It's also redrawing 1500 candles, 3 overlays and every drawing object 60 times a second, while nothing has changed.</p>
<h2>Lesson 1: only redraw when something changed</h2>
<p>Live crypto data doesn't tick at 60Hz. It ticks maybe a few times a second, and usually only the <em>last</em> candle changes. So separate the render from the loop:</p>
<pre><code class="language-js">let dirty = true;

function markDirty() { dirty = true; }

function frame() {
  if (dirty) {
    render();
    dirty = false;
  }
  requestAnimationFrame(frame);
}

// data arrives
ws.onmessage = (msg) =&gt; {
  updateLastCandle(msg);
  markDirty();
};

// user pans or zooms
canvas.addEventListener('wheel', (e) =&gt; {
  zoom(e.deltaY);
  markDirty();
});
</code></pre>
<p>Panning and zooming <em>do</em> change everything, so those set the flag. Price ticks usually don't. That one change took my idle CPU from ~15% to near zero.</p>
<h2>Lesson 2: cache what doesn't move</h2>
<p>The background grid, axis labels and the historical candles (everything except the last one) are static between data ticks. Draw them once into an offscreen canvas, then blit:</p>
<pre><code class="language-js">const back = document.createElement('canvas');

function rebuildBackBuffer() {
  back.width = canvas.width;
  back.height = canvas.height;
  const bctx = back.getContext('2d');
  drawGrid(bctx);
  drawAxes(bctx);
  drawCandles(bctx, candles.slice(0, -1));   // everything but the live candle
}

function render() {
  ctx.drawImage(back, 0, 0);                 // one call instead of thousands
  drawCandle(ctx, candles[candles.length - 1]);  // just the live one
  drawings.forEach((o) =&gt; drawDrawing(ctx, o));
}
</code></pre>
<p>Rebuild the back buffer only on pan/zoom/timeframe change. On a price tick you're now drawing one candle, not 1500.</p>
<h2>Lesson 3: indicators as pure functions over typed arrays</h2>
<p>Indicator code gets tangled when it reads from the chart's data model. Keep it pure — take arrays in, return arrays out:</p>
<pre><code class="language-js">function sma(values, period) {
  const out = new Float64Array(values.length).fill(NaN);
  let sum = 0;
  for (let i = 0; i &lt; values.length; i++) {
    sum += values[i];
    if (i &gt;= period) sum -= values[i - period];
    if (i &gt;= period - 1) out[i] = sum / period;
  }
  return out;
}

function ema(values, period) {
  const out = new Float64Array(values.length);
  const k = 2 / (period + 1);
  out[0] = values[0];
  for (let i = 1; i &lt; values.length; i++) {
    out[i] = values[i] * k + out[i - 1] * (1 - k);
  }
  return out;
}
</code></pre>
<p>Typed arrays matter here — with 1500 bars and 38 indicators recomputed on every timeframe switch, <code>Float64Array</code> vs a plain <code>Array</code> was a measurable difference, and it keeps the GC quiet.</p>
<p>Because they're pure, they're also trivially testable:</p>
<pre><code class="language-js">test('sma of a constant series is that constant', () =&gt; {
  const out = sma([5, 5, 5, 5, 5], 3);
  assert.equal(out[4], 5);
});
</code></pre>
<h2>Lesson 4: hit-testing drawing objects in chart space</h2>
<p>Drawing tools (trend lines, Fibonacci, rectangles) have to survive pan and zoom. The trick is to do hit-testing in <em>chart coordinates</em>, not pixels — convert the mouse position once, then everything is plain geometry:</p>
<pre><code class="language-js">function toChartCoords(mouseX, mouseY) {
  return {
    x: viewStartIndex + mouseX / candleWidth,
    y: priceMin + (canvas.height - mouseY) / canvas.height * (priceMax - priceMin),
  };
}

function hitTest(line, chartPoint, toleranceInBars) {
  // distance from point to segment, in chart units
  const d = distanceToSegment(chartPoint, line.a, line.b);
  return d &lt; toleranceInBars;
}
</code></pre>
<p>Because the objects live in chart space, zooming doesn't move them relative to the data — which is exactly what a trader expects a trend line to do.</p>
<h2>The one trading thing that surprised me</h2>
<img src="https://files.catbox.moe/85nkdw.png" alt="TA Platform — уровни, трендовые линии и осцилляторы на живом графике" style="display:block;margin:0 auto" />

<p>I built this to put Binance, Bybit and OKX on one chart, mostly to see the spread between them. What I didn't expect: the <em>absolute</em> spread is boring (a few basis points on BTC), but the <em>first tick where it jumps</em> is informative — the venue spread usually widens before the candles react.</p>
<p>That's a property of the data, not the canvas, but it's the reason the project exists at all.</p>
<h2>Takeaway</h2>
<p>You don't need a chart library for a charting app. You need:</p>
<ol>
<li><p>a dirty flag, so you render on change rather than on frame,</p>
</li>
<li><p>an offscreen back buffer for everything that didn't change,</p>
</li>
<li><p>pure indicator functions over typed arrays,</p>
</li>
<li><p>drawing objects in chart space.</p>
</li>
</ol>
<p>That's the whole engine.</p>
<p>If you want to see the result (38 indicators, 3 exchanges, one canvas, no signup): <a href="https://cryptota.duckdns.org">cryptota.duckdns.org</a>. Happy to go deeper on any of the four pieces in the comments.</p>
</blockquote>
]]></content:encoded></item></channel></rss>