<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://cm45t3r.github.io/candlestick/feed.xml" rel="self" type="application/atom+xml" /><link href="https://cm45t3r.github.io/candlestick/" rel="alternate" type="text/html" /><updated>2026-09-15T05:34:52+00:00</updated><id>https://cm45t3r.github.io/candlestick/feed.xml</id><title type="html">candlestick</title><subtitle>Notes from building candlestick, an open-source candlestick pattern detection library for Node.js with zero runtime dependencies.</subtitle><author><name>cm45t3r</name></author><entry><title type="html">The Hammer and the Hanging Man Are the Same Candle</title><link href="https://cm45t3r.github.io/candlestick/blog/the-hammer-and-the-hanging-man/" rel="alternate" type="text/html" title="The Hammer and the Hanging Man Are the Same Candle" /><published>2026-09-14T00:00:00+00:00</published><updated>2026-09-14T00:00:00+00:00</updated><id>https://cm45t3r.github.io/candlestick/blog/the-hammer-and-the-hanging-man</id><content type="html" xml:base="https://cm45t3r.github.io/candlestick/blog/the-hammer-and-the-hanging-man/"><![CDATA[<p>A hammer is a small body with a long lower wick. It’s a <em>bullish</em> reversal signal.</p>

<p>A hanging man is a small body with a long lower wick. It’s a <em>bearish</em> reversal signal.</p>

<p>Geometrically, they are the same candle. Nothing about the shape distinguishes them. The only thing that separates a buy signal from a sell signal is what happened <em>before</em> it: a hammer means something after a downtrend, a hanging man after an uptrend.</p>

<p>Most pattern detection libraries will hand you both matches on the same candle and leave you to sort it out. That’s the problem I spent most of my time on in <a href="https://github.com/cm45t3r/candlestick"><code class="language-plaintext highlighter-rouge">candlestick</code></a>, and it’s the one worth writing about.</p>

<h2 id="why-a-static-confidence-score-is-worse-than-none">Why a static confidence score is worse than none</h2>

<p>The tempting fix is to attach a reliability number to each pattern — hammer is 0.7, doji is 0.4, and so on. I shipped exactly that, and it was a mistake.</p>

<p>A fixed score is context-blind by construction. The same hammer geometry is a strong signal after a sustained decline and nearly worthless in the middle of a rally, but a static 0.7 reports both identically. It reads as precision while carrying no information. The <code class="language-plaintext highlighter-rouge">confidence</code> field is still there, marked <code class="language-plaintext highlighter-rouge">@deprecated</code>, because removing it outright would break callers — but nothing in the library encourages its use any more.</p>

<p>What replaced it measures the actual preceding trend and scores how well each match fits the context that pattern requires:</p>

<div class="language-js highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="p">{</span> <span class="nx">patternChain</span><span class="p">,</span> <span class="nx">allPatterns</span><span class="p">,</span> <span class="nx">metadata</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">require</span><span class="p">(</span><span class="dl">"</span><span class="s2">candlestick</span><span class="dl">"</span><span class="p">);</span>

<span class="kd">const</span> <span class="nx">matches</span> <span class="o">=</span> <span class="nx">patternChain</span><span class="p">(</span><span class="nx">data</span><span class="p">,</span> <span class="nx">allPatterns</span><span class="p">,</span> <span class="p">{</span>
  <span class="na">trendContext</span><span class="p">:</span> <span class="p">{</span> <span class="na">trendMethod</span><span class="p">:</span> <span class="dl">"</span><span class="s2">sma-slope</span><span class="dl">"</span><span class="p">,</span> <span class="na">trendPeriod</span><span class="p">:</span> <span class="mi">10</span> <span class="p">},</span>
<span class="p">});</span>

<span class="c1">// 11: hangingMan     trendContext: "uptrend"  contextFit: 1.00</span>
<span class="c1">// 12: hammer         trendContext: "uptrend"  contextFit: 0.41</span>
<span class="c1">// 12: bearishHammer  trendContext: "uptrend"  contextFit: 0.41</span>
</code></pre></div></div>

<p>Same uptrend, same long-lower-wick geometry, and the library tells you which reading the context supports. <code class="language-plaintext highlighter-rouge">metadata.enrichWithMetadata</code> folds that into an <code class="language-plaintext highlighter-rouge">effectiveConfidence</code> of <code class="language-plaintext highlighter-rouge">confidence × contextFit</code>: the hammer drops to 0.29, because a bullish reversal signal in the middle of an uptrend isn’t worth much.</p>

<p>Passing <code class="language-plaintext highlighter-rouge">resolveConflicts: true</code> goes further and drops the weaker side of a same-candle, opposite-direction conflict outright. Built-in trend methods are <code class="language-plaintext highlighter-rouge">sma-slope</code> (default), <code class="language-plaintext highlighter-rouge">ema-slope</code> and <code class="language-plaintext highlighter-rouge">pct-change</code>, and you can supply your own <code class="language-plaintext highlighter-rouge">externalTrend</code> series or a <code class="language-plaintext highlighter-rouge">trendFn</code> callback if you already have a better regime model.</p>

<p>One implementation detail worth knowing: multi-candle patterns anchor their <code class="language-plaintext highlighter-rouge">index</code> at the <em>first</em> candle of the formation, while single-candle patterns anchor at the candle itself. In the output above, that’s why <code class="language-plaintext highlighter-rouge">hangingMan</code> reports at 11 and <code class="language-plaintext highlighter-rouge">hammer</code> at 12 for what is visually the same pair. Conflict resolution operates per anchor index, so overlapping formations of different lengths are surfaced rather than silently merged.</p>

<h2 id="the-rest-of-the-design">The rest of the design</h2>

<p>The trend-context work is the interesting part, but a few other decisions shaped the library enough to be worth naming.</p>

<p><strong>A consistent, dual-mode API.</strong> Every pattern ships two functions: a boolean check for a single candle or pair (<code class="language-plaintext highlighter-rouge">isHammer(candle)</code>), and an array scanner that returns match indices (<code class="language-plaintext highlighter-rouge">hammer(dataArray)</code>). Learn the shape once and you know it for all 18 patterns — 29 variants once you count the bullish/bearish splits.</p>

<p><strong>Pattern chaining instead of pattern-by-pattern loops.</strong> <code class="language-plaintext highlighter-rouge">patternChain(data, allPatterns)</code> scans for everything in a single pass and returns a normalized list of <code class="language-plaintext highlighter-rouge">{ index, pattern, match }</code> results, rather than making you run each detector separately over the same data. Pass your own list instead of <code class="language-plaintext highlighter-rouge">allPatterns</code> if you only care about a subset.</p>

<p><strong>TypeScript wasn’t bolted on.</strong> Full definitions ship in <code class="language-plaintext highlighter-rouge">types/index.d.ts</code>, so <code class="language-plaintext highlighter-rouge">OHLC</code> and <code class="language-plaintext highlighter-rouge">PatternMatch</code> give you real IntelliSense instead of <code class="language-plaintext highlighter-rouge">any</code>. The package dual-exports ESM and CommonJS, so <code class="language-plaintext highlighter-rouge">import</code> and <code class="language-plaintext highlighter-rouge">require</code> both work without a shim.</p>

<p><strong>A plugin system, because hardcoded pattern lists are a dead end.</strong> <code class="language-plaintext highlighter-rouge">plugins.registerPattern()</code> lets you define your own detection function, attach metadata — type, direction, expected trend context — and drop it into <code class="language-plaintext highlighter-rouge">patternChain</code> alongside the built-ins. Custom patterns get the same treatment as shipped ones, including trend context.</p>

<p><strong>Gaps measured against volatility, not against zero.</strong> The same context problem shows up
in a second place. A kicker is defined by a gap between two candle bodies — but “any nonzero
gap” treats a $0.30 move on a $210 stock as a signal, when it’s indistinguishable from noise.
Passing <code class="language-plaintext highlighter-rouge">minGapVol</code> requires the gap to clear a volatility-relative threshold instead, using
ATR, standard deviation of returns, or a series you supply yourself. It’s opt-in: omit it and
the original behaviour is preserved exactly.</p>

<p>It’s the same argument as the hammer. Geometry alone will happily report a pattern that the
surrounding data says is meaningless.</p>

<p><strong>Validation that fails loudly.</strong> <code class="language-plaintext highlighter-rouge">validateOHLC</code> and <code class="language-plaintext highlighter-rouge">validateOHLCArray</code> throw on malformed data instead of letting a missing <code class="language-plaintext highlighter-rouge">high</code> field silently produce wrong results three functions downstream.</p>

<h2 id="what-zero-dependencies-is-actually-worth">What “zero dependencies” is actually worth</h2>

<p>This is the claim every library makes and few quantify, so here is the whole install:</p>

<table>
  <thead>
    <tr>
      <th> </th>
      <th>candlestick</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Runtime dependencies</td>
      <td>0 (0 transitive)</td>
    </tr>
    <tr>
      <td>Native build step</td>
      <td>none</td>
    </tr>
    <tr>
      <td>Install scripts</td>
      <td>none</td>
    </tr>
    <tr>
      <td>Platform-specific code</td>
      <td>none</td>
    </tr>
  </tbody>
</table>

<p>I had a size in kilobytes here and took it out, for a reason worth repeating: the
README ships inside the package, so writing the install size into it changes the
install size. Two attempts were both stale by the time they were committed. It’s
on the order of tens of kilobytes; <code class="language-plaintext highlighter-rouge">npm pack --dry-run</code> gives the exact figure at
any commit.</p>

<p>The kilobytes were never the interesting part anyway. The rows above are what
determines whether an install succeeds.</p>

<p>Several established libraries in this space are bindings to TA-Lib or Tulip — C libraries with decades of history and far more indicators than I’ll ever ship. The tradeoff is that installing them compiles a native addon: <code class="language-plaintext highlighter-rouge">node-gyp</code>, a toolchain, Python, and a build that can fail differently on every platform in your matrix. If you’ve ever watched a CI job go red on Windows only, you know the shape of it.</p>

<p>For reference, measured with <code class="language-plaintext highlighter-rouge">npm view &lt;pkg&gt; dist.unpackedSize</code>:</p>

<table>
  <thead>
    <tr>
      <th>Package</th>
      <th>Unpacked</th>
      <th>Runtime deps</th>
      <th>Native build</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><strong>candlestick</strong></td>
      <td><strong>171 kB</strong></td>
      <td><strong>0</strong></td>
      <td>no</td>
    </tr>
    <tr>
      <td>tulind</td>
      <td>233 kB</td>
      <td>4</td>
      <td><strong>yes</strong></td>
    </tr>
    <tr>
      <td>indicatorts</td>
      <td>563 kB</td>
      <td>0</td>
      <td>no</td>
    </tr>
    <tr>
      <td>trading-signals</td>
      <td>574 kB</td>
      <td>0</td>
      <td>no</td>
    </tr>
    <tr>
      <td>technicalindicators</td>
      <td>5.1 MB</td>
      <td>1</td>
      <td>no</td>
    </tr>
    <tr>
      <td>talib</td>
      <td>27.6 MB</td>
      <td>1</td>
      <td><strong>yes</strong></td>
    </tr>
  </tbody>
</table>

<p><em>(Figures as of September 2026 — they’ll drift as those projects release.)</em></p>

<p>This isn’t a claim that <code class="language-plaintext highlighter-rouge">candlestick</code> replaces TA-Lib. It doesn’t; TA-Lib does far more. It’s a claim about a narrower job: if what you need is candlestick pattern detection specifically, you can have it as plain JavaScript that installs identically on Linux, Windows and macOS, with nothing to compile and no supply-chain surface you didn’t ask for.</p>

<p>When I first wrote this, there was a caveat here: the package was not usefully
tree-shakeable. The ESM entry re-exported the CommonJS module by destructuring it
at runtime, which is opaque to a bundler’s static analysis, so importing one
pattern pulled in the same code as importing all eighteen. <code class="language-plaintext highlighter-rouge">sideEffects: false</code>
in the manifest was writing a cheque the entry point could not cash.</p>

<p>It’s fixed in v3.0.0. The entry now names each export against its own module,
which a bundler can follow. <code class="language-plaintext highlighter-rouge">import { hammer }</code> measures <strong>1.5 kB</strong> minified and
gzipped, against 8.1 kB for the whole library. The default export still pulls
everything in, because that is what it means.</p>

<p>I’m leaving the original paragraph’s substance here rather than quietly swapping
the numbers, because the interesting part is not the fix — it’s that measuring
the claim is what turned up the defect. Writing “modular, tree-shakeable” in a
README costs nothing. Running esbuild over a single import is what tells you
whether it was true.</p>

<h2 id="streaming-and-where-the-savings-actually-come-from">Streaming, and where the savings actually come from</h2>

<p>Once you’re past a few hundred thousand candles, holding the whole series in memory while running pattern checks over it stops being free. <code class="language-plaintext highlighter-rouge">createStream</code> takes candles in configurable chunks and fires an <code class="language-plaintext highlighter-rouge">onMatch</code> callback per hit, so you can drive it from a file or a socket without ever materializing the full array.</p>

<p>Measured on 200,000 candles with five patterns: <strong>41.9 MB live heap for <code class="language-plaintext highlighter-rouge">patternChain</code> against 0.2 MB for the stream</strong> — producing the same 61,866 matches in both cases.</p>

<p>That’s a much larger reduction than I used to claim for this API, and I want to be precise about why, because the number is easy to misread. Resident memory is bounded by <code class="language-plaintext highlighter-rouge">chunkSize</code> instead of scaling with the dataset. The saving is not the streaming machinery — it’s <em>never holding the whole dataset</em>.</p>

<p>Two conditions are doing all the work, and both are easy to lose by accident:</p>

<ul>
  <li><strong>Consume matches in <code class="language-plaintext highlighter-rouge">onMatch</code> rather than collecting them.</strong> Pushing every match into an array puts the result set straight back into memory, and at high match counts that dominates whatever the buffering saved.</li>
  <li><strong>Feed the stream incrementally.</strong> Passing <code class="language-plaintext highlighter-rouge">process()</code> slices of an array you already built in memory saves you nothing at all. The win comes from a generator, a file read, or a socket.</li>
</ul>

<p>Streaming a pre-built array is a no-op dressed up as an optimization. Worth stating plainly, because it’s the mistake I’d expect most people to make first.</p>

<h2 id="theres-a-cli-too">There’s a CLI, too</h2>

<p>Not everything needs to be a <code class="language-plaintext highlighter-rouge">require</code>. A fair amount of this work is exploratory — you have a
CSV, you want to know what’s in it, and writing a script first is friction.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npx candlestick <span class="nt">-i</span> data.csv <span class="nt">--patterns</span> hammer,doji <span class="nt">--output</span> table
<span class="nb">cat </span>data.json | candlestick <span class="nt">--output</span> csv <span class="nt">--confidence</span> 0.8
</code></pre></div></div>

<p>It reads CSV or JSON from a file or stdin, filters by pattern, minimum confidence, type
(reversal / continuation / neutral) or direction, and prints JSON, CSV, or a formatted table.
Table and CSV output always carry the pattern metadata — type, direction, confidence,
strength — so the columns are never empty.</p>

<p>It’s the same detection code the library exposes, so whatever you confirm at the terminal is
what you’ll get in your program.</p>

<h2 id="proving-it-works">Proving it works</h2>

<p>Design decisions are cheap to write about and expensive to get right, so the test suite does the real talking: <strong>484 tests across 107 suites, 99.94% line coverage, 100% function coverage, and branch coverage a hair over 99%.</strong> Property-based tests via <code class="language-plaintext highlighter-rouge">fast-check</code> generate randomized OHLC scenarios per invariant, which is how most of the interesting edge cases surfaced — hand-written examples tend to test the cases you already thought of.</p>

<p>That randomness is also why I won’t quote a branch-coverage decimal: the generated cases reach slightly different branches on every run, so the figure moves between 99.1% and 99.3% run to run. A precise number there would be one nobody could reproduce, including me.</p>

<p>CI runs the full suite on Node 22, 24 and 26 across Linux, Windows and macOS.</p>

<p>Throughput for the full 29-pattern chain, measured 2026-09-11 on Node v24.21.0, Intel Core i7-9750H, 16 GB RAM, macOS 26.6:</p>

<table>
  <thead>
    <tr>
      <th>Candles</th>
      <th>Chain time</th>
      <th>Throughput</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>1,000</td>
      <td>2.7 ms</td>
      <td>370K candles/s</td>
    </tr>
    <tr>
      <td>10,000</td>
      <td>21.1 ms</td>
      <td>474K candles/s</td>
    </tr>
    <tr>
      <td>100,000</td>
      <td>227.1 ms</td>
      <td>440K candles/s</td>
    </tr>
    <tr>
      <td>1,000,000</td>
      <td>2,436.9 ms</td>
      <td>410K candles/s</td>
    </tr>
  </tbody>
</table>

<p>Single-run figures on one machine — treat them as an order of magnitude, not a guarantee. <code class="language-plaintext highlighter-rouge">npm run bench</code> reproduces them on your own hardware, which is the only number that should matter to you.</p>

<h2 id="nine-years-and-whats-next">Nine years, and what’s next</h2>

<p>The package has been on npm since 2016. The trend-context work is recent; a lot of the rest is the accumulated result of using it, finding it wrong, and fixing it — which is why the roadmap carries corrections to claims earlier versions made, including a memory figure that turned out to be unsubstantiated.</p>

<p>Next up: visual examples for each pattern (they’re text-only descriptions today), and a few more multi-candle formations. If there’s a pattern you rely on that isn’t covered, <a href="https://github.com/cm45t3r/candlestick/issues">issues and PRs are open</a>.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm <span class="nb">install </span>candlestick
</code></pre></div></div>

<p>Requires Node.js &gt;= 22. Node 20 reached end of life on 2026-04-30 and was dropped in v3.0.0.</p>

<hr />

<h2 id="a-quick-aside-hardware-wallets">A quick aside: hardware wallets</h2>

<p><em>Disclosure: the link below is an affiliate link. I earn a commission if you buy through it, at no extra cost to you. It doesn’t change what the library does or what I’ve written above.</em></p>

<p>A caveat before the recommendation: this is a detour from the topic. If you’re here for OHLC tooling and don’t hold crypto, skip it — nothing below is about the library.</p>

<p>Some of the people running this library are analyzing crypto markets rather than equities, and if you’re holding assets rather than just charting them, a hardware wallet is the baseline I’d suggest over leaving anything on an exchange. I use <strong><a href="https://onekey.so/r/KFTH04">OneKey</a></strong> — open-source firmware, an EAL6+ certified secure element, and backing from Coinbase Ventures and Dragonfly. Worth a look if you don’t already have cold storage sorted.</p>

<hr />

<p><em>The full source, docs and examples are on <a href="https://github.com/cm45t3r/candlestick">GitHub</a>. A video walkthrough of the trend-context design is in progress — I’ll link it here when it’s up.</em></p>]]></content><author><name>cm45t3r</name></author><summary type="html"><![CDATA[Two candlestick patterns with identical geometry and opposite meanings, and what it took to tell them apart in a pattern detection library.]]></summary></entry></feed>