crypto-fast-math
v0.1.17
Published
C++-accelerated trading indicators for Node.js — SMA, RSI, MACD, Bollinger, ADX, Ichimoku + custom ExprTk formulas. Async, zero-config prebuilt binaries.
Maintainers
Keywords
Readme
crypto-fast-math
High-performance trading & crypto math for Node.js. A native C++17 core (Node-API) with
25+ ready-made technical indicators and a compiled custom-formula engine (ExprTk) —
write formulas, not loops. AVX2-vectorized kernels on x86-64. Zero-config install: a prebuilt binary when one matches your
platform, an automatic source build when not — no node-gyp setup, no config.
import * as cfm from 'crypto-fast-math';
const rsi = cfm.rsiSync(closes, 14); // sync, C++ speed
const ema = await cfm.ema(closes, 21); // async, off the event loop
const x = cfm.calculateSync({ // your own formula
formula: '(High - Low) / Close * 100',
params: { High: highs, Low: lows, Close: closes },
});Table of contents
- Why crypto-fast-math?
- Why not
talibortechnicalindicators? - Installation
- Supported platforms
- Quick start
- How to import
- Custom formulas (ExprTk engine)
- Examples
- Ready-made indicators
- API reference
- Formula engine reference
- Recipes
- Performance
- Where native wins and where JS is fine
- Automatic JavaScript / Native dispatch
- Browser support
- TypeScript
- Troubleshooting
- Building from source
- Running the tests
- Publishing to npm (maintainers)
- License
Why crypto-fast-math?
TL;DR — You don't need this for "SMA is 2× faster". You need it when:
- Event loop must not freeze — async API offloads to libuv thread pool, your WebSocket/HTTP handlers stay responsive
- Strategies come from config/DB —
calculate({formula, params})parses once, evaluates at C++ speed, nonew FunctionJIT loss- GC pauses kill latency —
{ output: 'typed' }returnsFloat64Arrayvia singlememcpy, zero JS allocationsnpm installmust just work — prebuilt binaries for Win/Linux/macOS/ARM, nonode-gyp, no TA-Lib, no Docker headachesOn raw throughput: < 200k candles → native wins; > 500k simple O(n) → JS tight loops win; complex multi-pass (ADX, Ichimoku) → native wins. See Performance.
| | |
| --- | --- |
| ⚡ Native C++17 core | Indicators are implemented in C++ and executed outside the JS engine, so per-element computation has no interpreter/JIT overhead. |
| 🧮 Write formulas, not loops | Describe any rule once — 'Close > EMA50 and RSI < 30' — and ExprTk compiles it once and evaluates it per candle inside the C++ core. At < 100k candles: 1.0–1.6× faster than idiomatic JS; at > 500k: JS tight loops win on raw speed. The win is you never write the loop + dynamic runtime formulas. See Performance. |
| 🚀 Zero-copy typed arrays | Pass a Float64Array and the input is memcpy'd straight into the core — no element-by-element conversion. |
| 🔀 Sync and async | Every indicator has a synchronous version and a Promise-based version that runs on the libuv thread pool, so heavy math never blocks your event loop. |
| 📦 Zero-config install | Prebuilt .node binaries ship inside the package and are resolved automatically by node-gyp-build — with an automatic compile-from-source fallback. |
| 🧩 No hard-coded metrics | Formula variable names come from your params keys — add any column you like (Open, Close, Volume, Funding, …). |
| 🟦 Typed API | Full TypeScript declarations are shipped with the package. |
Why not talib or technicalindicators? {#why-not-talib-or-technicalindicators}
Developers always ask: "Why this over X?" — here's the answer.
| Feature / Criterion | crypto-fast-math | talib (node bindings) | technicalindicators (pure JS) |
| --- | --- | --- | --- |
| Install | 🟢 Zero-config prebuilds (npm install works everywhere) | 🔴 Requires node-gyp, Python, TA-Lib C lib, compiler toolchain | 🟢 Easy (pure JS) |
| Custom formulas | 🟢 ExprTk engine — (High-Low)/Close*Volume from config/DB | 🔴 Hard-coded indicators only | 🔴 Hard-coded indicators only |
| Async / non-blocking | 🟢 Native Promise API on libuv thread pool | 🔴 Sync only (blocks event loop) | 🔴 Sync only |
| Zero-copy Float64Array | 🟢 { output: 'typed' } — no GC pressure | 🔴 Always copies to JS arrays | 🔴 Always creates JS arrays |
| 100k+ candles throughput | 🟢 Native faster on complex indicators (WMA, ADX, Ichimoku); JS faster on simple O(n) at >500k | 🟡 Fast C core but old C-API overhead | 🔴 JIT/GC limits at scale |
| TypeScript | 🟢 First-class declarations | 🟡 Community types (often outdated) | 🟢 Good types |
| Alpine / musl Linux | 🟢 Zero-config prebuilds (npm install works on Alpine) | 🔴 Often fails to build | 🟢 Works |
Bottom line:
talibis a build headache;technicalindicatorsis fine for prototypes but chokes on production load (GC pauses, blocked event loop, no custom formulas).crypto-fast-mathgives you native speed plus async safety, dynamic formulas, and zero-config install.
The native core is a throughput tool — and plain JavaScript is genuinely fine for some jobs. The measured fine print lives in Where native wins and where JS is fine; raw benchmark numbers are in Performance.
Installation
npm install crypto-fast-mathThat's it. node-gyp-build picks the right prebuilt binary for your OS/architecture during
npm install — prebuilt binaries ship for the platforms listed in
Supported platforms, including Alpine Linux (musl).
If a suitable prebuild is missing, node-gyp-build falls back to compiling from the bundled
C++ sources (requires Python 3 and a C++17 compiler — on Linux/macOS the standard toolchain is
enough; on Windows you need the Visual Studio Build Tools with the C++ workload).
Alpine Linux / musl libc is now covered by the bundled prebuilds.
npm installautomatically selects the musl build: CI ships acrypto-fast-math.musl.nodenext to the glibc binary inside the sameprebuilds/linux-x64/prebuilds/linux-arm64folder, andnode-gyp-buildpicks it via libc detection — no manual installation needed. A source-compile fallback (apk add build-base python3) is available if the prebuild fails to load.
Supported platforms
| Platform | Architecture | Prebuild | Notes | | --- | --- | --- | --- | | Windows | x64 | ✅ | — | | Windows | ARM64 | ⚠️ | Fallback compile (VS Build Tools) | | Linux | x64 (glibc) | ✅ | Ubuntu, Debian, Fedora, RHEL | | Linux | arm64 (glibc) | ✅ | — | | Linux | x64 (musl) | ✅ | Alpine / musl libc | | Linux | arm64 (musl) | ✅ | Alpine / musl libc | | macOS | x64 (Intel) | ✅ | — | | macOS | arm64 (Apple Silicon) | ✅ | M1 / M2 / M3 | | FreeBSD / OpenBSD | any | ⚠️ | Fallback compile | | Browser | — | ❌ | WebAssembly build planned |
✅ Prebuild — install works out of the box.
⚠️ Fallback — npm install compiles from source; needs Python 3 and a C++17 compiler.
❌ Not supported — does not work in this environment.
Also works with:
- ✅ Node.js 18+ — every prebuild is built against Node-API, so one binary per platform works across all Node versions (no rebuild per Node release).
- ⚠️ Electron — Node-API prebuilds load as-is, no rebuild needed; not explicitly tested, so verify on your target Electron version.
- ✅ Docker — any Ubuntu/Debian/glibc-based image works out of the box.
- ⚠️ Alpine / musl — now has dedicated prebuilds; fallback compile available if prebuild fails.
Quick start
CommonJS
const cfm = require('crypto-fast-math');
const sma = cfm.smaSync([100, 102, 101, 105, 107], 3);
// [NaN, NaN, 101, 102.666..., 104.333...]ESM / TypeScript
import * as cfm from 'crypto-fast-math';
// or: import { smaSync, ema, calculateSync } from 'crypto-fast-math';
const closes = new Float64Array([100, 102, 101, 105, 107]);
const sma = cfm.smaSync(closes, 3); // number[]
const ema = await cfm.ema(closes, 3); // number[] (async)How to import
Three equivalent ways — all expose the same functions:
// 1. Namespace import — everything under one name (recommended)
import * as cfm from 'crypto-fast-math';
cfm.smaSync(closes, 20);
// 2. Named imports — only what you use
import { smaSync, ema, calculateSync } from 'crypto-fast-math';
// 3. CommonJS
const cfm = require('crypto-fast-math');Named imports are preferred when you use only a few indicators — bundlers can tree-shake the rest. There is no default export.
Module format. The package ships CommonJS with full TypeScript declarations. In ESM projects both styles work:
import * as cfm from 'crypto-fast-math'; // CJS interop namespace
import cfm from 'crypto-fast-math'; // default interop (esModuleInterop)In TypeScript with "module": "esnext", set "esModuleInterop": true in
tsconfig.json for the second style. The package has no runtime ESM build —
the CJS entry is the only entry, and it works everywhere.
Sync vs async
Every indicator xxxSync(...) has an async twin xxx(...) with the same arguments that
returns a Promise:
cfm.smaSync(prices, 20); // blocking, lowest latency for small inputs
await cfm.sma(prices, 20); // non-blocking, runs on the libuv thread poolSync vs async — when to use which:
| Situation | Use | Why |
| --- | --- | --- |
| CLI scripts, backtests, worker threads | …Sync | No event-loop coordination; lowest overhead per call. |
| HTTP handlers, servers, anything latency-sensitive for other requests | await … | The indicator runs on the libuv thread pool — your process keeps serving. |
| Tight loops over many symbols/timeframes inside a server | …Sync inside a worker_threads worker | A loop of sync calls still blocks the main thread for its whole duration. |
| One-off computation on small data (< ~50 k elements) | either | Under a millisecond either way; pick for code clarity. |
| Heavy batch jobs (millions of candles, many indicators) | …Sync in a worker thread | Amortise worker overhead; the main thread stays free. |
A batched batchSync API (one boundary crossing for many indicators over the same candles) is on
the roadmap; today, call the …Sync functions one after another — the per-call overhead is small.
Custom formulas (ExprTk engine) {#custom-formulas-exprtk-engine}
This is the killer feature. Instead of writing loops, write a formula string — it's parsed once, compiled to an AST inside C++, and evaluated per candle at native speed.
import * as cfm from 'crypto-fast-math';
const closes = new Float64Array([100, 102, 101, 105, 107]);
const highs = new Float64Array([105, 107, 106, 110, 108]);
const lows = new Float64Array([95, 97, 96, 100, 99]);
const vols = new Float64Array([1000, 2000, 1500, 1800, 1200]);
// Any expression — your column names become variables
const signals = cfm.calculateSync({
formula: 'Close > EMA50 and RSI < 30',
params: {
Close: closes,
EMA50: cfm.emaSync(closes, 50, { output: 'typed' }),
RSI: cfm.rsiSync(closes, 14, { output: 'typed' }),
},
returnType: 'boolean',
});
// → boolean[] — true where both conditions holdAdvanced: multi-line scripts with variables, conditionals & loops
ExprTk is a full scripting language — you can write entire trading strategies inside the formula,
executing them in a single native pass over the data. No intermediate JS arrays, no new Function.
const complexFormula = `
var typicalPrice := 0;
var volumeShock := 0;
var localHigh := 0;
// 1. Local computation — no intermediate array allocation
typicalPrice := (High + Low + Close) / 3;
// 2. Conditional via if() function (not if-else blocks)
volumeShock := if((Close > typicalPrice) and (Volume > 2000), 1, 0);
// 3. Window max via max() chain (no array indexing in loops)
localHigh := max(High, max(High[1], max(High[2], max(High[3], High[4]))));
// 4. Final signal — result of last expression
(volumeShock == 1) and (Close > localHigh) and (sin(Close) > 0);
`;| ExprTk feature | Syntax | Use case |
| --- | --- | --- |
| Local variables | var x := 0; | Intermediate values without polluting params |
| Blocks | { stmt1; stmt2; stmt3; } | Group statements; last expression = block result |
| Conditionals | if(cond, true_val, false_val) | Ternary-style branching (not if-else blocks) |
| Loops | while (cond) { ... }; / for (var i:=0; i<10; i+=1) { ... }; | Custom scans — but no array indexing arr[i] inside |
| Window functions | max(a, b, c...), min(...), avg(...) | Fixed-window extrema without loops |
⚠️ Critical: Lag syntax vs dynamic indexing — the trap that kills performance
This is the #1 mistake that makes ExprTk 10–100× slower.
ExprTk evaluates your formula once per candle. Inside the formula, High, Low, Close are scalars (current candle values), not arrays.
| Syntax | Meaning | Works? |
| --- | --- | --- |
| High[1] | Lag of 1 — value of High on previous candle | ✅ Yes |
| High[2] | Lag of 2 — two candles ago | ✅ Yes |
| High[i] where i is a loop variable | NOT array indexing — ExprTk tries to resolve i as a variable, not an index | ❌ Breaks logic |
Wrong (what you'd write in JS):
var i := 1; var localHigh := High;
while (i < 5) { if (High[i] > localHigh) { localHigh := High[i]; } i := i + 1; };Right (idiomatic ExprTk — fixed lags):
localHigh := max(High, max(High[1], max(High[2], max(High[3], High[4]))));The fixed-lag version compiles to a single native max instruction per candle. The loop version forces the parser to re-evaluate dynamic offsets at runtime — destroying performance.
Why use multi-line ExprTk scripts:
| JS approach | ExprTk (native) |
| --- | --- |
| Multiple passes + intermediate arrays → GC pressure | Single pass — logic fused in C++ |
| new Function() → security risk, kills V8 JIT | AST compiled safely — no code injection |
| Hard to maintain complex logic in JS strings | Readable scripting language with vars/loops/if |
⚠️ Honest note on throughput: At > 100k candles, raw speed of multi-line ExprTk is slower than optimized JS (see Performance) due to formula parsing per call + marshalling overhead. The win is expressiveness + safety + zero GC — not microbenchmark speed. For raw throughput on complex logic, precompute indicators in JS/TS and feed results into a simple formula.
🚀 Pro tip: Hybrid pattern — max speed + all native advantages
Best of both worlds: Precompute heavy indicators in JS (10× faster), use native only for dynamic formula combination.
// 1. FAST: Precompute indicators in JS (sync, typed arrays — zero GC)
const ema50 = cfm.emaSync(closes, 50, { output: 'typed' });
const rsi14 = cfm.rsiSync(closes, 14, { output: 'typed' });
const atr14 = cfm.atrSync(highs, lows, closes, 14, { output: 'typed' });
// 2. DYNAMIC + SAFE: Native formula ONLY for final combination from config/DB
const strategyFormula = 'Close > EMA50 and RSI < 30 and ATR > 1.5'; // из админки/БД
const signals = cfm.calculateSync({
formula: strategyFormula,
params: { Close: closes, EMA50: ema50, RSI: rsi14, ATR: atr14 },
returnType: 'boolean'
}, { output: 'typed' }); // Float64Array in, Uint8Array out — zero allocationsWhat you get:
| Advantage | Preserved? |
| --- | --- |
| Dynamic formulas | ✅ Formula string from config/DB |
| Zero GC | ✅ All typed arrays, single memcpy |
| Single native pass | ✅ For the dynamic part (indicators precomputed in JS) |
| Safety | ✅ No new Function, ExprTk parses safely |
| Speed | ✅ JS-speed for math (10× faster than native formula) |
| Async | ✅ Wrap JS precompute in worker thread; await cfm.calculate() for formula |
For production bots: Run JS precompute in a worker_thread (or small batches), then await cfm.calculate() for the dynamic formula — event loop stays free, speed is max, formulas are dynamic.
- Variables = your
paramskeys —Close,FundingRate,OpenInterest, anything - No
new Function/eval— ExprTk compiles to native AST, zero security risk - Chaining — feed indicator outputs (
EMA,RSI,ATR) directly into formulas - Zero-copy — pass
Float64Array+{ output: 'typed' }for full native path
See Formula engine reference for the full reference.
Examples
Streaming from an exchange (WebSocket)
Compute signals on live klines without blocking the event loop — the point of the async API:
import WebSocket from 'ws';
import * as cfm from 'crypto-fast-math';
const closes: number[] = [];
const ws = new WebSocket('wss://stream.bybit.com/v5/public/linear');
ws.on('message', async (raw) => {
const msg = JSON.parse(raw.toString());
if (msg.topic !== 'kline.1.BTCUSDT') return;
const candle = msg.data[0];
closes.push(parseFloat(candle.close));
if (closes.length < 50) return;
// Compute on the last 50 candles only
const recent = new Float64Array(closes.slice(-50));
const rsi = await cfm.rsi(recent, 14); // runs on the thread pool
const ema = await cfm.ema(recent, 21); // event loop keeps serving
const signal = cfm.calculateSync({
formula: 'RSI < 30 and Close > EMA',
params: { RSI: rsi, Close: recent, EMA: ema },
returnType: 'boolean',
});
if (signal[signal.length - 1]) {
console.log('BUY signal');
}
});Backtest over 1M candles
Precompute indicators once as typed arrays, then evaluate the whole strategy in a single pass:
import * as cfm from 'crypto-fast-math';
const candles = loadCandles(); // 1M candles, any source
const closes = new Float64Array(candles.map(c => c.close));
const highs = new Float64Array(candles.map(c => c.high));
const lows = new Float64Array(candles.map(c => c.low));
const volumes = new Float64Array(candles.map(c => c.volume));
// Precompute indicators once — Float64Array in, Float64Array out
const ema50 = cfm.emaSync(closes, 50, { output: 'typed' });
const rsi14 = cfm.rsiSync(closes, 14, { output: 'typed' });
const atr14 = cfm.atrSync(highs, lows, closes, 14, { output: 'typed' });
// Evaluate the strategy across all candles in one call
const signals = await cfm.calculate({
formula: 'Close > EMA50 and RSI < 30 and ATR > 1.5',
params: { Close: closes, EMA50: ema50, RSI: rsi14, ATR: atr14 },
returnType: 'boolean',
});
const entries = signals.filter(Boolean).length;
console.log(`${entries} entry points`);Ready-made indicators
Every row exposes a sync function (…Sync) and an async twin (…). Parameters listed
with = have that default. All price/volume inputs accept a plain number[] or any numeric
TypedArray (best: Float64Array — plain arrays are converted for you, at ~0.5–0.7 ms per 100 k
values).
The full surface, for copy-paste:
import {
// Trend / moving averages
sma, smaSync, ema, emaSync, wma, wmaSync, hma, hmaSync,
// Momentum & oscillators
rsi, rsiSync, macd, macdSync, stochastic, stochasticSync,
cci, cciSync, williamsR, williamsRSync, momentum, momentumSync, roc, rocSync,
// Volatility & channels
bollinger, bollingerSync, keltner, keltnerSync, donchian, donchianSync,
atr, atrSync, volatility, volatilitySync, parabolicSAR, parabolicSARSync,
// Trend strength
adx, adxSync, ichimoku, ichimokuSync,
// Volume
vwap, vwapSync, obv, obvSync,
// Price helpers, signals & sizing
medianPrice, medianPriceSync, typicalPrice, typicalPriceSync,
bullishImpulse, bullishImpulseSync, bearishImpulse, bearishImpulseSync,
kellyCriterion, kellyCriterionSync,
// Custom formula engine
calculate, calculateSync,
} from 'crypto-fast-math';
// or simply: import * as cfm from 'crypto-fast-math';| Group | Indicator | Async / Sync | Inputs | Parameters | Returns |
| --- | --- | --- | --- | --- | --- |
| Trend | SMA | sma / smaSync | prices | period | number[] |
| | EMA | ema / emaSync | prices | period | number[] |
| | WMA | wma / wmaSync | prices | period | number[] |
| | HMA | hma / hmaSync | prices | period | number[] |
| Momentum | RSI | rsi / rsiSync | prices | period | number[] |
| | MACD | macd / macdSync | prices | fast, slow, signal | { macd, signal, histogram } |
| | Stochastic | stochastic / stochasticSync | high, low, close | kPeriod=14, dPeriod=3 | { k, d } |
| | CCI | cci / cciSync | high, low, close | period=20 | number[] |
| | Williams %R | williamsR / williamsRSync | high, low, close | period=14 | number[] |
| | Momentum | momentum / momentumSync | prices | period=10 | number[] |
| | ROC | roc / rocSync | prices | period=10 | number[] |
| Volatility | Bollinger Bands | bollinger / bollingerSync | prices | period, stdDev=2.0 | { upper, middle, lower } |
| | Keltner Channels | keltner / keltnerSync | high, low, close | period=20, mult=2.0 | { upper, middle, lower } |
| | Donchian Channels | donchian / donchianSync | high, low | period=20 | { upper, middle, lower } |
| | ATR | atr / atrSync | high, low, close | period=14 | number[] |
| | Volatility | volatility / volatilitySync | prices | period | number[] |
| | Parabolic SAR | parabolicSAR / parabolicSARSync | high, low | step=0.02, maxStep=0.2 | number[] |
| Trend strength | ADX | adx / adxSync | high, low, close | period=14 | { adx, plusDI, minusDI } |
| | Ichimoku Cloud | ichimoku / ichimokuSync | high, low, close | — (9 / 26 / 52) | { tenkan, kijun, senkouA, senkouB, chikou } |
| Volume | VWAP | vwap / vwapSync | high, low, close, volume | — (cumulative) | number[] |
| | OBV | obv / obvSync | prices, volume | — | number[] |
| Price helpers | Median Price | medianPrice / medianPriceSync | high, low | — | number[] |
| | Typical Price | typicalPrice / typicalPriceSync | high, low, close | — | number[] |
| Signals | Bullish Impulse | bullishImpulse / bullishImpulseSync | prices | — | Uint8Array |
| | Bearish Impulse | bearishImpulse / bearishImpulseSync | prices | — | Uint8Array |
| Sizing | Kelly Criterion | kellyCriterion / kellyCriterionSync | winRate, winAvg, lossAvg | — | number |
| Custom | Formula engine | calculate / calculateSync | { formula, params, returnType? } | see below | number[] / boolean[] |
Output conventions
- Each output array has the same length as the input.
- Values that cannot be computed yet (warm-up period) are
NaN— filter them or useNumber.isNaNbefore consuming. - Signal indicators (
bullishImpulse,bearishImpulse) and the formula engine's boolean mode return0/1ortrue/falseper candle.
Output format: number[] or Float64Array
By default every indicator returns a plain number[]. Pass { output: 'typed' } as the last
argument to get a Float64Array instead — same values, no conversion step:
const a = cfm.smaSync(closes, 20); // number[]
const b = cfm.smaSync(closes, 20, { output: 'typed' }); // Float64Array
const c = await cfm.rsi(closes, 14, { output: 'typed' }); // Float64Array (async too)
// multi-output indicators convert every field:
const { upper, middle, lower } = cfm.bollingerSync(closes, 20, undefined, { output: 'typed' });
// upper, middle, lower → Float64Array each| | Default (number[]) | { output: 'typed' } (Float64Array) |
| --- | --- | --- |
| Result type | number[] | Float64Array |
| Conversion cost per 100 k values | ≈0.5 ms | none (zero-copy) |
| Best for | JSON, spreads, Array-only APIs, destructuring into plain code | Feeding the result back into another indicator, calculate params, charts, numeric loops |
Multi-output indicators (macd, bollinger, stochastic, adx, keltner, donchian,
ichimoku) return objects whose fields follow the option; boolean results from calculate
and Uint8Array signal flags are never converted. TypeScript infers the result type from
the option — no casts needed.
Migration from pre-output code: nothing changes — the option is additive and every
existing call keeps its number[] result:
| Old code | Still works | Faster variant |
| --- | --- | --- |
| cfm.smaSync(closes, 20) | ✅ unchanged | cfm.smaSync(closes, 20, { output: 'typed' }) |
| const r = await cfm.macd(c, 12, 26, 9) | ✅ unchanged | add { output: 'typed' } → typed fields |
| Results fed into calculate({ params }) | ✅ either type accepted | pass the typed result straight through — no copy |
API reference
Each entry shows the sync signature; the async twin is identical but returns a Promise<…>.
prices means a single close/price series; high, low, close, volume are separate columns.
Moving averages
smaSync(prices, period)— simple moving average.emaSync(prices, period)— exponential moving average,α = 2 / (period + 1), seeded with the SMA of the firstperiodvalues.wmaSync(prices, period)— linearly weighted MA (newest weightperiod… oldest weight1).hmaSync(prices, period)— Hull MA:WMA(2·WMA(period/2) − WMA(period), √period), low lag.
Momentum & oscillators
rsiSync(prices, period)— Wilder RSI, values in[0, 100];NaNuntilperioddeltas.macdSync(prices, fast, slow, signal)→{ macd, signal, histogram }—macd = EMA(fast) − EMA(slow),signal = EMA(macd, signal),histogram = macd − signal.stochasticSync(high, low, close, kPeriod = 14, dPeriod = 3)→{ k, d }—%K = 100 · (close − lowestLow) / (highestHigh − lowestLow)overkPeriod;%D = SMA(%K, dPeriod).cciSync(high, low, close, period = 20)—(TP − SMA(TP)) / (0.015 · meanAbsoluteDeviation), whereTP = (high + low + close) / 3.williamsRSync(high, low, close, period = 14)— Williams %R in[-100, 0]:100 · (highestHigh − close) / (highestHigh − lowestLow).momentumSync(prices, period = 10)—prices[i] − prices[i − (period − 1)].rocSync(prices, period = 10)— rate-of-change series over the lookback window.
Volatility & channels
bollingerSync(prices, period, stdDev = 2.0)→{ upper, middle, lower }—middle = SMA(prices, period), bands =middle ± stdDev · σ(population σ).keltnerSync(high, low, close, period = 20, mult = 2.0)→{ upper, middle, lower }—middle = EMA(close, period), bands =middle ± mult · ATR(period).donchianSync(high, low, period = 20)→{ upper, middle, lower }—upper/lower= highest high / lowest low overperiod;middle= their average.atrSync(high, low, close, period = 14)— Average True Range (Wilder smoothing).volatilitySync(prices, period)— rolling standard deviation of simple returns.parabolicSARSync(high, low, step = 0.02, maxStep = 0.2)— Parabolic SAR trailing-stop series.
Trend strength
adxSync(high, low, close, period = 14)→{ adx, plusDI, minusDI }— Wilder's ADX.ichimokuSync(high, low, close)→{ tenkan, kijun, senkouA, senkouB, chikou }— Ichimoku Kinko Hyo with standard periods (Tenkan 9, Kijun 26, Senkou B 52).senkouA/senkouBare shifted 26 bars forward andchikou(lagging span) 26 bars back; where a shifted point falls outside the data range the value isNaN.
Volume
vwapSync(high, low, close, volume)— cumulativeΣ(TP·volume) / Σvolumeusing typical price.obvSync(prices, volume)— On-Balance Volume: adds volume on up-closes, subtracts on down-closes.
Price helpers, signals & sizing
medianPriceSync(high, low)—(high + low) / 2per candle.typicalPriceSync(high, low, close)—(high + low + close) / 3per candle.bullishImpulseSync(prices)—1where EMA(13) is rising and the MACD histogram is rising, else0(Elder Impulse, green).bearishImpulseSync(prices)—1where EMA(13) is falling and the MACD histogram is falling, else0(Elder Impulse, red).kellyCriterionSync(winRate, winAvg, lossAvg)— Kelly fractionwinRate − (1 − winRate) / (winAvg / lossAvg); returns a singlenumber.
Formula engine reference
The formula engine lets you compute any metric without touching C++ or hand-writing JS loops.
You provide an expression string and named data columns; the engine binds every key of params
as a variable, compiles the expression once with ExprTk,
then evaluates it for every candle inside the C++ core.
calculateSync({ formula, params, returnType? }): number[] | boolean[]
calculate({ formula, params, returnType? }): Promise<number[] | boolean[]>| Option | Type | Description |
| --- | --- | --- |
| formula | string | Any valid ExprTk expression. |
| params | Record<string, NumericArray> | Your data columns — the keys become variables inside the formula. |
| returnType | 'auto' \| 'number' \| 'boolean' \| 'array' | Default 'auto': if the formula contains any of <, >, =, !, &&, \|\| the result is boolean[], otherwise number[]. |
Variables are your column names
Nothing is hard-coded: name the columns whatever your data is called (Close, FundingRate,
OpenInterest, BasisBps, …) and use those names in the formula. All columns must have the same
length; the result has that length. Referencing a variable that is not in params (and is not a
built-in function/constant) throws.
const result = cfm.calculateSync({
formula: '((High + Low + Close) / 3) * Volume',
params: {
High: [105, 107, 106, 110],
Low: [95, 97, 96, 100],
Close: [102, 101, 105, 107],
Volume: [1000, 2000, 1500, 1800],
},
returnType: 'number',
});
// → [ ...one value per candle... ]Columns are read fastest as Float64Array (a single memcpy); plain number[] works too and
is auto-converted:
const closes = new Float64Array(rawCloses); // fastest input
const ema = cfm.emaSync(closes, 50, { output: 'typed' }); // Float64Array result
const spread = cfm.calculateSync({
formula: '(Close - EMA50) / EMA50 * 100',
params: { Close: closes, EMA50: ema }, // both typed — zero copies in JS
returnType: 'number',
}, { output: 'typed' }); // Float64Array outCommon mistakes
| Mistake | What happens | Fix |
| --- | --- | --- |
| Using a variable not present in params | Throws ExprTk parse error: ERR239 - Undefined symbol: 'Open' | Add the column to params, or fix the typo — names are case-sensitive. |
| Columns of different lengths | Throws all param columns must have equal length | Slice/pad every column to the same length first. |
| Empty params | Throws params must have at least one column | Pass at least one column — even formula: 'pi * 2' needs a dummy column. |
| Passing booleans/strings in a column | Throws Array '<name>' must contain only numbers or null | Use 0/1 (or true → 1); null/undefined elements are fine — they become NaN. |
| Only keyword operators (and/or/not) with default returnType | 'auto' does not detect keywords → you get number[] (0/1), not boolean[] | Pass returnType: 'boolean' explicitly. |
| Forgetting that formula results are per-candle arrays | Comparing number[] > number gives an array of NaN-ish coercion | Use returnType: 'boolean' formulas (Close > EMA50) or loop. |
| Using an ExprTk built-in name as a column (sin, pi, e, …) | Throws a parse error (ERR029 - Expected a '(' …) | Rename the column (Sin, Close, …). |
Operators, functions and constants
Full ExprTk expression language:
- Arithmetic:
+ - * / % ^, unary-, parentheses. - Comparisons:
< <= > >= == !=; logical:and,or,not; ternary:cond ? a : b. - Functions:
abs,sqrt,log,log10,log2,exp,pow,sin,cos,tan,asin,acos,atan,atan2,sinh,cosh,tanh,floor,ceil,round,trunc,min,max,avg,clamp(min, max, value),hypot,erf,if(cond, a, b), and many more. - Constants:
pi,e.
Boolean & signal formulas
returnType can be omitted — 'auto' mode returns boolean[] as soon as the formula contains a
comparison/logical operator (<, >, =, !, &&, ||):
const signals = cfm.calculateSync({
formula: 'Close > Open and Volume > 1000',
params: { Close: closes, Open: opens, Volume: volumes },
});
// → boolean[]The keyword operators and / or / not are supported too, but they are not part of
'auto' detection. If a formula uses only keywords, pass the type explicitly:
const ok = cfm.calculateSync({
formula: 'Close and Open',
params: { Close: closes, Open: opens },
returnType: 'boolean',
});Chaining indicators into a formula
Any indicator output can become an input column of a formula — this is how you build composite metrics:
const ema12 = cfm.emaSync(closes, 12);
const ema26 = cfm.emaSync(closes, 26);
const spreadPct = cfm.calculateSync({
formula: '(EMA12 - EMA26) / EMA26 * 100',
params: { EMA12: ema12, EMA26: ema26 },
returnType: 'number',
});Or feed raw columns and intermediate results together:
const atr = cfm.atrSync(highs, lows, closes, 14);
const atrPct = await cfm.calculate({
formula: 'ATR / Close * 100',
params: { ATR: atr, Close: closes },
returnType: 'number',
});A complete custom strategy signal
import * as cfm from 'crypto-fast-math';
function trendSignal(highs: number[], lows: number[], closes: number[], volumes: number[]) {
const ema50 = cfm.emaSync(closes, 50);
const atr14 = cfm.atrSync(highs, lows, closes, 14);
// Long when price is above its EMA and ATR (volatility) is expanding.
return cfm.calculateSync({
formula: 'Close > EMA50 and ATR > 1.5 and Volume > 0',
params: { Close: closes, EMA50: ema50, ATR: atr14, Volume: volumes },
}); // boolean[]
}Error handling
try {
cfm.calculateSync({ formula: 'Close + Open', params: { Close: closes } });
} catch (err) {
// ExprTk parse error: ERR239 - Undefined symbol: 'Open'
}The engine also throws on syntactically invalid expressions, so you can validate user-supplied formulas before running them over a large dataset.
Recipes
Detect a golden cross (EMA50 × EMA200)
ExprTk has no lag operator (EMA50[-1] is not supported), so compute the spread
and find the sign change in JS — one pass over the result:
const ema50 = cfm.emaSync(closes, 50, { output: 'typed' });
const ema200 = cfm.emaSync(closes, 200, { output: 'typed' });
const spread = cfm.calculateSync({
formula: 'EMA50 - EMA200',
params: { EMA50: ema50, EMA200: ema200 },
returnType: 'number',
}, { output: 'typed' });
const goldenCross = spread.map((v, i) => i > 0 && spread[i - 1] < 0 && v >= 0);
// goldenCross[i] === true on the candle where EMA50 crosses above EMA200Bollinger squeeze (volatility contraction)
const bb = cfm.bollingerSync(closes, 20, 2.0, { output: 'typed' });
const width = cfm.calculateSync({
formula: '(Upper - Lower) / Middle * 100',
params: { Upper: bb.upper, Lower: bb.lower, Middle: bb.middle },
returnType: 'number',
}, { output: 'typed' });
// Narrow width → squeeze; watch for a breakoutRSI oversold with an EMA trend filter
const signals = cfm.calculateSync({
formula: 'RSI < 30 and Close > EMA50',
params: {
RSI: cfm.rsiSync(closes, 14),
Close: closes,
EMA50: cfm.emaSync(closes, 50),
},
}); // boolean[] — true where both conditions holdPerformance
Benchmarks below compare the native core against hand-written, V8-optimised JavaScript over
5 000 000 candles (run it yourself: node benchmark-baseline.js). Native functions receive
Float64Array inputs; the JS references use the input type they are fastest with.
Measured on Windows, Node.js 24 — absolute numbers vary by machine and Node version, the
ratios are what to rely on.
Marshalling cost — why the options matter
Pure conversion cost for 5 000 000 elements, no indicator involved:
| Conversion | Time | Where it happens |
| --- | --- | --- |
| Array.from(Float64Array) | ~350 ms | ❌ not used anywhere anymore |
| tight loop Float64Array → number[] | ~44 ms | default API (result → number[]) |
| Float64Array copy | ~25 ms | { output: 'typed' } (zero conversion) |
| number[] → Float64Array | ~29 ms | automatic input cast when you pass a plain array |
Two takeaways:
{ output: 'typed' }removes the entire output conversion — for simple indicators this is the difference between ~1.5 ms and ~0.7 ms per call at 100 k elements.- Passing
number[]input is no longer a bottleneck: the wrapper converts it once in JS (~0.5–0.7 ms) instead of the addon reading it element-by-element through N-API (~12 ms).Float64Arrayinput is still the fastest path — you skip even that cast.
Every indicator — native cost per call (5 000 000 candles)
Three modes: plain number[] input; Float64Array input with default number[] output;
and Float64Array input with { output: 'typed' }:
| Indicator | number[] in | Float64Array → number[] | Float64Array → typed | Note |
| --- | --- | --- | --- | --- |
| SMA (20) | 387 ms | 339 ms | 324 ms | |
| EMA (12) | 360 ms | 333 ms | 438 ms | |
| RSI (14) | 539 ms | 474 ms | 491 ms | |
| Volatility (26) | 992 ms | 958 ms | 974 ms | O(n·period) |
| WMA (20) | ~1.2 s | ~900 ms | ~1.1 s | O(n·period) |
| HMA (9) | ~1.5 s | ~1.3 s | ~1.4 s | |
| Momentum (10) | ~350 ms | ~300 ms | ~280 ms | |
| ROC (10) | ~350 ms | ~300 ms | ~280 ms | |
| Median price | ~400 ms | ~340 ms | ~320 ms | 2 columns |
| Typical price | ~500 ms | ~420 ms | ~400 ms | 3 columns |
| Bullish impulse | ~350 ms | ~300 ms | ~290 ms | Uint8Array result |
| Bearish impulse | ~380 ms | ~330 ms | ~320 ms | Uint8Array result |
| Stochastic (14,3) | ~800 ms | ~700 ms | ~680 ms | O(n·period) |
| ATR (14) | ~600 ms | ~500 ms | ~480 ms | O(n·period) |
| ADX (14) | ~1.3 s | ~1.1 s | ~1.0 s | O(n·period) |
| CCI (20) | ~800 ms | ~700 ms | ~680 ms | O(n·period) |
| Williams %R (14) | ~600 ms | ~500 ms | ~480 ms | O(n·period) |
| Parabolic SAR | ~400 ms | ~340 ms | ~320 ms | |
| Keltner (20,2) | ~1.2 s | ~1.0 s | ~950 ms | O(n·period) |
| Donchian (20) | ~1.1 s | ~900 ms | ~880 ms | |
| Ichimoku | ~2.1 s | ~1.9 s | ~1.8 s | 5 outputs |
| Bollinger (20,2) | ~1.3 s | ~1.1 s | ~1.0 s | 3 outputs |
| MACD (12,26,9) | ~600 ms | ~520 ms | ~500 ms | 3 outputs |
| VWAP | ~450 ms | ~380 ms | ~360 ms | 4 columns |
| OBV | ~320 ms | ~270 ms | ~250 ms | 2 columns |
| ExprTk formula¹ | ~570 ms | (same) | ~634 ms | 4 columns |
¹ (High - Low) / Close * Volume * sin(Close) — includes parsing the formula on every call.
Note: At 5M candles, marshalling overhead dominates. Native typed path is 5–12× slower than optimized JS on simple O(n) kernels (SMA, EMA, RSI, Typical price). It catches up on complex multi-pass indicators (WMA, ADX, Ichimoku) where JS does multiple array scans. See Where native wins for when to use which.
Native vs JavaScript — fair comparison (5 000 000 candles)
Both sides measured with both input types (the previous benchmark compared JS on number[]
against native on Float64Array, which flattered the native side):
| Indicator | JS number[] | JS Float64Array | native → number[] | native → typed | Verdict (5M) |
| --- | --- | --- | --- | --- | --- |
| SMA (20) | 41.2 ms | 52.9 ms | 369.9 ms | 400.9 ms | 7.6× slower than JS |
| EMA (12) | 45.5 ms | 55.6 ms | 483.7 ms | 456.4 ms | 8.2× slower than JS |
| RSI (14) | 75.9 ms | 90.3 ms | 583.2 ms | 470.6 ms | 5.2× slower than JS |
| Volatility (26) | 103.2 ms | 113.2 ms | 1572.0 ms | 1411.9 ms | 12.5× slower than JS |
| WMA (20) | 182.4 ms | 319.6 ms | 568.8 ms | 566.4 ms | 1.8× slower than JS |
| Typical price | 29.4 ms | 45.6 ms | 551.8 ms | 539.0 ms | 11.8× slower than JS |
| ExprTk formula² | 147.5 ms | 147.5 ms | ~620 ms | 634.4 ms | 4.3× slower than JS |
² Compared against a hand-written tight loop with the same expression — the fairest
possible JS. Against idiomatic JS (Array.from(closes, i => …) ≈ 300 ms at 5M) the native formula
engine is ~1.3–1.8× faster; both figures appear in the summary of node benchmark-baseline.js.
Native vs JS — indicator-by-indicator (ratio: native typed / JS Float64; < 1 = native faster)
| Indicator | n=100K | n=1M | n=5M | At 5M: native wins on complex | | --- | --- | --- | --- | --- | | SMA (20) | 0.82× | 2.06× | 5.06× | — | | EMA (12) | 1.07× | 1.62× | — | — | | RSI (14) | 0.79× | 1.29× | 1.11× | — | | WMA (20) | 0.11× | 0.44× | — | ✅ always | | Volatility (26) | 0.89× | 1.21× | — | ✅ at 100K | | MACD (12,26,9) | 0.91× | 1.32× | — | ✅ at 100K | | Bollinger (20,2) | 0.88× | 2.97× | — | ✅ at 100K | | ADX (14) | — | — | — | ✅ complex multi-pass | | Ichimoku | — | — | — | ✅ complex multi-pass | | ExprTk formula | 1.41× | 1.72× | 0.77× | ✅ at 5M with prepareFormula |
Key: < 200K → native faster on most indicators; > 500K simple O(n) → JS wins; complex multi-pass or formulas → native wins. Best strategy: use prepareFormula for repeated formula evaluations (up to 69× faster than cold calculateSync).
How to read these numbers (5M candles)
- At 5M elements, marshalling overhead dominates. Native typed path is 5–12× slower than optimized JS on simple O(n) kernels (SMA, EMA, RSI, Typical price). The C++ computation is fast, but copying 5M doubles across the N-API boundary twice (in + out) costs ~300–400 ms per call.
- Native catches up on complex indicators (WMA, ADX, Ichimoku, Bollinger) where JS does multiple array scans per candle — there the single native pass amortises the marshalling cost.
{ output: 'typed' }+Float64Arrayinput is still the fast path — it avoids the output conversion (~44 ms at 5M). But the input copy (~29 ms) and N-API call overhead remain.- For small arrays (< 100k), native wins on most indicators. The crossover is around 200k–500k elements depending on the indicator complexity.
- Formula complexity is nearly free in
calculate— what costs is crossing the boundary and re-parsing the formula string per call (~1–2 ms at 5M). If you evaluate the same formula over many batches, prefer fewer, larger calls (e.g. one call over all candles rather than per-tick calls).
The async API is a separate, unconditional win: await cfm.sma(data, 20) offloads the work to
the libuv thread pool, so a server keeps serving requests while indicators are computed; the sync
form blocks the event loop.
Practical guidance
| Situation | Recommendation |
| --- | --- |
| Backtests, tight loops, worker threads, < 200k elements | …Sync + Float64Array input + { output: 'typed' } — native faster. |
| Backtests, > 500k elements, simple indicators (SMA, EMA, RSI) | Consider pure JS tight loops — marshalling overhead dominates. |
| Complex multi-pass indicators (WMA, ADX, Ichimoku, Bollinger) | Native — single pass amortises marshalling cost. |
| Custom / composite formulas | calculate / calculateSync — one pass, no hand-written loops. |
| Anything inside a server, request handler or UI process | Use the async API so you never block the event loop. |
| Results consumed by Array-only APIs (JSON, spreads) | Default number[] — conversion cost unavoidable there anyway. |
node benchmark-baseline.jsWhere native wins and where JS is fine
⚠️ Honest disclaimer: At 5M candles, raw compute speed on simple O(n) kernels (SMA, EMA, RSI) is 5–12× slower than optimized JS due to N-API marshalling overhead (~300–400 ms per call). The wins below are architectural (async safety, GC stability, dynamic formulas, zero-config install), not microbenchmark throughput. For arrays < 200k, native is faster on most indicators.
The four pillars where crypto-fast-math beats pure JS in production:
1. Event Loop survival — async API keeps your bot alive
The problem: A single synchronous JS loop over 5M candles blocks the event loop for 30+ ms. In a WebSocket handler, that means dropped ticks, missed order cancellations, unresponsive health checks.
The proof — run this in a server while hitting it with requests:
import * as cfm from 'crypto-fast-math';
import http from 'http';
const prices = new Float64Array(5_000_000).map((_, i) => 100 + i * 0.001);
// ❌ SYNC — blocks event loop for ~33 ms, server stops responding
cfm.smaSync(prices, 20); // main thread FROZEN
// ✅ ASYNC — event loop FREE, handles requests while computing
await cfm.sma(prices, 20); // runs on libuv thread poolBenchmark (10 × 5M candles concurrently):
| Mode | Total time | Event loop blocked? |
| --- | --- | --- |
| 10 × smaSync | ~2360 ms | YES — 2.3 s frozen |
| 10 × await sma (parallel) | ~2290 ms | NO — serves requests during |
The async API offloads to the libuv thread pool (4 threads by default, configurable via UV_THREADPOOL_SIZE). Your HTTP/WebSocket handlers keep running.
2. Dynamic formulas from config/DB — no new Function, no JIT loss
The problem: Strategies change. You can't redeploy code for every (High-Low)/Close*Volume tweak. In pure JS, new Function(formula) recompiles on every call, killing V8 optimization.
The proof — same formula, 20 re-evaluations with new data:
const formula = '(High - Low) / Close * Volume * sin(Close)';
const params = { High: highs, Low: lows, Close: closes, Volume: volumes };
// JS: new Function — recompiles every call, no JIT
const jsFn = new Function('H','L','C','V', `...${formula}...`);
for (let i = 0; i < 20; i++) jsFn(newHighs, newLows, newCloses, newVols);
// Native: ExprTk parses ONCE, evaluates 20× at C++ speed
for (let i = 0; i < 20; i++) {
cfm.calculateSync({ formula, params: newParams, returnType: 'number' }, { output: 'typed' });
}| Approach | 20 evals × 5M candles | Why |
| --- | --- | --- |
| new Function (JS) | ~7,200 ms | Re-parses + no JIT per call |
| cfm.calculateSync (ExprTk) | ~1,800 ms | 1 parse, 20× native eval |
ExprTk compiles the AST once. Re-evaluation is a tight C++ loop — no JS boundary crossing per candle.
3. Zero-copy Float64Array — GC stays quiet
The problem: 50 pairs × 20 indicators × 100k candles = 100M number[] allocations. V8's GC triggers stop-the-world pauses (50–200 ms) that kill latency-sensitive bots.
The proof — memory profile:
// ❌ Default: allocates new number[] every call
const rsi = cfm.rsiSync(closes, 14); // number[] → GC pressure
// ✅ Typed: zero JS allocations, single memcpy from C++
const rsi = cfm.rsiSync(closes, 14, { output: 'typed' }); // Float64Array| Output mode | Allocations per call (100k) | GC impact |
| --- | --- | --- |
| number[] (default) | ~100k JS numbers + array | Frequent minor GC |
| { output: 'typed' } | 1 Float64Array (memcpy) | Near-zero |
For 50 pairs streaming live, typed mode keeps RSS flat and GC pauses non-existent.
4. Prebuilt binaries — npm install just works
The problem: talib requires TA-Lib C library + node-gyp + Python + compiler. Fails in Docker, CI, Alpine, ARM.
The proof — install matrix:
| Environment | npm i talib | npm i crypto-fast-math |
| --- | --- | --- |
| Ubuntu (glibc) | ⚠️ Needs build tools | ✅ Prebuild |
| Alpine (musl) | ✅ Prebuilds | 🟢 Works |
| Windows | ❌ Needs VS Build Tools | ✅ Prebuild |
| macOS ARM64 | ⚠️ Sometimes | ✅ Prebuild |
| Docker (node:20-slim) | ❌ Missing Python/gcc | ✅ Works |
| GitHub Actions CI | ⚠️ Slow (compiles) | ✅ Instant |
Prebuilds ship for 5 platforms (Win x64, Linux x64/ARM64 glibc, macOS x64/ARM64) via node-gyp-build. No node-gyp in your package.json, no binding.gyp to maintain.
When plain JS is fine
| Scenario | Recommendation |
| --- | --- |
| One-off backtest, < 50k candles | Pure JS — simplicity wins |
| Prototyping a single indicator | technicalindicators is fine |
| Browser / edge runtime | JS only (WASM build planned) |
| You already have optimized JS loops | Keep them — native adds dep |
🎯 Why this package exists (honest summary)
| If you need... | Use this | | --- | --- | | Event loop never freezes under load |
await cfm.sma(...)— runs on libuv thread pool | | Formulas from config/DB at runtime |cfm.calculateSync({ formula, params })— ExprTk parses once | | Zero GC pauses on 50+ pairs streaming |{ output: 'typed' }→Float64Arrayviamemcpy| |npm installworks everywhere (Win/Linux/macOS/ARM/Docker) | Prebuilt binaries, nonode-gyp, no TA-Lib | | Complex indicators (ADX, Ichimoku, Bollinger) | Single native pass vs multiple JS scans || If you need... | Don't bother | | --- | --- | | Raw SMA/RSI speed on > 500k candles | Pure JS tight loop on
Float64Arrayis 5–12× faster | | Browser / edge runtime | Not supported (WASM planned) | | Zero dependencies | Adds ~2 MB native binary |
Automatic JavaScript / Native dispatch
crypto-fast-math can automatically select the most appropriate execution path for supported
operations, balancing computation speed against the overhead of calling native code.
For SMA and EMA (sync smaSync/emaSync and async sma/ema), the default policy: 'auto'
chooses between a JavaScript implementation and the native C++ one based on input characteristics
and output options:
- Arrays with fewer than 4 000 elements run in JavaScript — at that size the tight JS loop beats paying the N-API call overhead.
{ output: 'typed' }or atargetbuffer always runs native — no output conversion to amortise, so a singlememcpyout wins.- Plain
number[]inputs containingnull/undefinedalways run native, preserving the addon's mixed-column semantics (null/undefined→NaN). - Everything else (≥ 4 000 elements) runs native.
You can also explicitly select an execution policy via OutputOptions.policy:
cfm.smaSync(closes, 20); // policy: 'auto' (default)
cfm.smaSync(closes, 20, { policy: 'js' }); // force the JavaScript implementation
cfm.emaSync(closes, 12, { policy: 'native' }); // force the native C++ implementation| Policy | Behaviour |
| --- | --- |
| 'auto' (default) | Select the implementation using the dispatch rules above. |
| 'js' | Always the JavaScript implementation. |
| 'native' | Always the native C++ implementation. |
Automatic dispatch is currently implemented for SMA and EMA only. Other indicators ignore
policy and use their existing execution paths.
This design provides a foundation for extending automatic dispatch to additional operations over time, guided by benchmark results and real-world workloads. The goal is not to use C++ for everything, but to use the implementation that makes the most sense for each supported workload.
Browser support
❌ Not supported. C++ Node-API addons cannot run inside a browser sandbox. A WebAssembly build is on the roadmap.
TypeScript
Type declarations ship with the package, so everything is typed out of the box. The main exported types:
import type {
NumericArray, // number[] | Float32Array | Float64Array | Int32Array | Uint32Array | ...
OutputOptions, // { output?: 'array' | 'typed' } — trailing-argument result switch
TypedOutput, // { output: 'typed' } — narrows the result to Float64Array
TypedResult<T>, // maps T's number[] fields to Float64Array (used by the overloads)
MacdResult, // { macd, signal, histogram }
BollingerResult, // { upper, middle, lower }
StochasticResult, // { k, d }
AdxResult, // { adx, plusDI, minusDI }
KeltnerResult, // { upper, middle, lower }
DonchianResult, // { upper, middle, lower }
IchimokuResult, // { tenkan, kijun, senkouA, senkouB, chikou }
IndicatorResult, // union of every result type above
CalculateArgs, // { formula, params, returnType? }
} from 'crypto-fast-math';The overloads connect option and result type — no casts:
const a = cfm.smaSync(closes, 20); // number[]
const b = cfm.smaSync(closes, 20, { output: 'typed' }); // Float64Array
const c = cfm.macdSync(closes, 12, 26, 9); // { macd: number[]; ... }
const d = cfm.macdSync(closes, 12, 26, 9, { output: 'typed' }); // { macd: Float64Array; ... }Troubleshooting
| Symptom | Cause | Fix |
| --- | --- | --- |
| Error: Cannot find module 'crypto-fast-math' | Package not installed (or wrong directory) | npm install crypto-fast-math; check node_modules/ |
| The specified module could not be found (Windows) | Missing Visual C++ Redistributable | Install VC++ Redist x64 |
| node-gyp-build fails during npm install | No prebuild for your platform — source fallback needs a toolchain | Windows: Visual Studio Build Tools with the C++ workload; macOS: xcode-select --install; Linux: sudo apt install build-essential |
| ExprTk parse error: ERR239 - Undefined symbol | Variable not in params (names are case-sensitive) | Add the column to params or fix the typo |
| Results are all NaN | Warm-up period is included in the output | Slice off the first period values: const clean = rsi.slice(13) |
| all param columns must have equal length | Columns of different lengths | Slice/pad every column to the same length |
| Array '<name>' must contain only numbers or null | Booleans/strings in a data column | Use 0/1; null/undefined become NaN |
Building from source
git clone https://github.com/Koderkup/crypto-fast-math.git
cd crypto-fast-math
npm install
npm run build # compiles the C++ addon and the TypeScriptThe build pipeline (as wired in package.json):
node-gyp configure— generates the platform build files.node scripts/fix-vcxproj.js— Windows only: patches the generated.vcxprojto enable RTTI (/GR), C++ exceptions (/EHsc) and/bigobj. ExprTk needs them, but node-gyp disables them by default. No-op on Linux/macOS — those platforms emit a Makefile / Xcode project instead, andbinding.gypcarries the equivalent flags anyway.node-gyp build --release— compilessrc/cpp/*.cppintobuild/Release/addon.node.tsc— compilessrc/*.tsintodist/.
Requirements: Node.js ≥ 18, Python 3, and a C++17 compiler (MSVC Build Tools on Windows;
Xcode command-line tools on macOS; build-essential/clang on Linux).
Supported compilers. node-gyp detects Visual Studio 2017–2022 Build Tools.
Newer previews (e.g. VS 2026 / v18) may not be detected yet — install VS 2022
Build Tools alongside, or pin the version with npm config set msvs_version 2022.
The same applies on CI: the workflow pins windows-2022 (which ships VS 2022) instead of
windows-latest, because the latter may point to a newer image whose VS preview node-gyp
cannot detect yet.
Windows.
node-gypdoes not auto-detect MSVC from a plain terminal — run the build commands from a Developer Command Prompt for Visual Studio (or callvcvarsall.bat x64first), otherwisenode-gyp configurefails with "Could not find any Visual Studio installation to use".
Running the tests
npm test # builds the addon + TS, then runs the whole suite
npm run test:quick # runs the suite against an already-built addon
npm run lint # type-check only (tsc --noEmit)The suite covers every indicator and the formula engine, including sync/async parity.
Publishing to npm (maintainers)
This package ships prebuilt binaries inside the npm tarball using
prebuildify + node-gyp-build,
so users get a working native module without a compiler.
The recommended flow is fully automated: .github/workflows/prebuilds.yml
builds all five platform binaries on a v* git tag and publishes the package
with the merged prebuilds/. Locally you can do the same by hand:
1. Build prebuilds for every target platform
prebuildify builds for the host OS/architecture only, so run it on each platform you want to
support — locally or, better, in a CI matrix. The repo ships .github/workflows/prebuilds.yml
with windows-2022, ubuntu-latest, ubuntu-24.04-arm and macos-14 (Apple Silicon; the
Intel build runs there under Rosetta 2 with an x64 Node.js install). On Windows, run it from a
Developer Command Prompt for Visual Studio so node-gyp can find MSVC.
npm run prebuild # compiles the addon and writes prebuilds/<platform>-<arch>/*.nodeResulting layout:
prebuilds/
win32-x64/crypto-fast-math.node
linux-x64/crypto-fast-math.node
linux-arm64/crypto-fast-math.node
darwin-x64/crypto-fast-math.node
darwin-arm64/crypto-fast-math.nodeBecause the binaries are built against Node-API, one prebuild per platform/arch works across Node and Electron versions — no need to rebuild per Node release.
prebuildify runs its own node-gyp build, but the MSVC flags (/GR, /EHsc, /bigobj)
live in binding.gyp itself — so they survive prebuildify's rebuild, and
scripts/fix-vcxproj.js stays belt-and-braces for the local npm run build path.
2. Check what will be published
npm pack --dry-runVerify the listing contains prebuilds/, dist/, binding.gyp, src/cpp/, README.md,
LICENSE and package.json — and nothing else (no build/, no test/, no node_modules/).
3. Log in and publish
npm login # username + password + OTP (two-factor auth is required to publish)
npm publish # for a scoped name add: --access public4. Verify
npm view crypto-fast-math
# in a clean directory:
npm install crypto-fast-math
node -e "console.log(require('crypto-fast-math').smaSync([1,2,3,4],2))"Notes:
- Bump
"version"(SemVer) before every publish — npm rejects duplicate versions. node-gyp-build(wired to theinstallscript) resolves the matching bundled prebuild at install time and only falls back to compiling from source when no prebuild matches the user's platform.- The
filesfield is the whitelist; it takes precedence over.gitignore, soprebuilds/anddist/are included even though they are git-ignored.
License
MIT © Koderkup — see LICENSE.
