Because of lack of good algotrading tools in JavaScript, I've decided to build my own. It's also quite fast and nice to use. You can run a 5 year backtest on ALL stocks in 1 minute (on daily ticks).
- Clone the repository.
- Install dependencies:
npm install
- Go to stooq.com/db/h
- Download daily/hourly/5m data and place
nasdaq stocks, etc., indata/stooq/1d,data/stooq/1h,data/stooq/5m - Ingest into the store:
node scripts/stooq_ingest.js <1d|1h|5m>
- Re-run to update data.
Download klines and funding rates:
node scripts/binance_download.js <interval> [startMonth] [endMonth] [--no-daily] [--tail-only]- Example:
node scripts/binance_download.js 15m 2023-09 intervalcan be1m,5m,15m,1h,4h,1d- Re-run to update data. Only data that is not in the store yet is downloaded.
--tail-onlyonly gets the days after the last full month in the store, it's much faster.
The API serves only the latest 5000 candles per interval (4h ≈ 2.3 years, 1h ≈ 7 months, 15m ≈ 52 days).
node scripts/hyperliquid_download.js <interval> [--coins=BTC,ETH] [--hip3] [--no-funding] [--funding-from=YYYY-MM]- Example:
node scripts/hyperliquid_download.js 4h --hip3adds builder-dex markets (tickers likexyz:TSLA).- Funding starts at each coin's first stored candle unless
--funding-fromis set. - Re-run to update data.
- Get API key from massive.com
- Set
MASSIVE_KEYin.env - Run:
node scripts/massive_download.js <period> <startDate> <skip stored tickers>
- Example:
node scripts/massive_download.js 1d 2003-09-10 true periodcan be1d,1h,15m,5m,1mstartDatedefaults to the last timestamp in the store, or2003-09-10skip stored tickersskips tickers that are already in the store
- Example:
- Get API keys from alpaca.markets
- Set
APCA_API_KEY_IDandAPCA_API_SECRET_KEYin.env- Optional:
ALPACA_FEED(siporiex, defaultsip),ALPACA_RPM(requests per minute, default200),ALPACA_TRADING_URL(default paper API)
- Optional:
- Run:
node scripts/alpaca_download.js <period> <startDate> <skip stored tickers> <adjustment>
- Example:
node scripts/alpaca_download.js 15m 2023-01-01 true all periodcan be1d,1h,15m,5m,1mstartDatedefaults to the last timestamp in the store, or2016-01-01adjustmentcan beraw,split,dividend,all(defaultall)
- Example:
- Add a strategy file under
strategies/(see examples below). - Run it, e.g.:
node strategies/sma.js
import Strategy from '../src/backtest/strategy.js';
const strategy = new Strategy({
name: 'sma',
params: { short: 25, long: 50 },
warmup: 0,
intervals: {
'1d': { count: 50, main: true },
'1h': { count: 24, main: false },
},
onTick: async (context) => { /* ... */ },
});name- Used for forward test data folder. Default: script filename.params- Saved with forward runs.warmup- Bars to run beforestartDate. Orders are rejected during warmup.intervals- Timeframes your strategy uses. Keys:'1d','4h','1h','15m','5m','1m'.count- Number of bars to keep in lookback (≥ 1).main: true- Exactly one interval must be main; it drives the simulation (one tick per bar).
onTick- Called every bar (single-stock) or every bar across all stocks (all-stocks). Receives a context object (see below).
import Backtest from '../src/backtest/index.js';
const bt = new Backtest({
strategy,
startDate: new Date('2020-01-01'),
endDate: new Date('2025-01-01'),
capital: 10_000,
broker: new IBKR('tiered'),
logs: { swaps: false, trades: true },
features: [ // optional
{ name: 'volume', bucketSize: 1_000_000 },
],
});
const result = await bt.runOnTicker('AAPL'); // single symbol
// or
const result = await bt.runOnAllTickers(); // all symbols in the store
bt.logMetrics(result);runOnTicker(stockName)- Runs backtest on one ticker; returns metrics object.runOnAllTickers()- Runs on all tickers with data in the range; returns metrics object.logMetrics(metrics)- Prints summary (CAGR, Sharpe, max drawdown, win rate, etc.) and any open positions.buildReport(metrics)- Builds a HTML report with charts and tables.
Crypto options:
market-'stocks'or'crypto'. Crypto enables 24/7 trading. Default:broker.market.venue- Crypto data source:'binance'or'hyperliquid'.allowShort- Override shorting. Default:truefor crypto,falsefor stocks.maxLeverage- Maximum gross exposure / equity.
Metrics returned by getMetrics() / runOnTicker / runOnAllTickers:
| Field | Description |
|---|---|
period |
[startDate, endDate] |
trades |
Number of completed round-trip trades |
totalFees |
Sum of broker fees |
totalReturn |
(final equity / start cash) − 1 |
avgDaily |
Mean UTC-day return, from start cash |
geoDaily |
Geometric mean UTC-day return |
dailyWinRate |
Share of days with a positive return |
days |
Number of daily returns |
CAGR |
Compound annual growth rate |
sharpe |
Annualized Sharpe ratio |
maxDrawdown |
Worst peak-to-trough decline |
geoPeriodRet |
Geometric mean return per main-interval bar |
geoAnnualRet |
Geometric mean annualized return |
totalFunding |
Crypto: funding paid (positive) or received (negative) |
skippedOrders, skippedNotional |
Orders the broker's quantize rejected, and their requested notional |
quantizedOrders, quantizedDrift |
Orders rounded down to a lot boundary, and the notional lost to rounding |
ruined |
Crypto: equity hit zero and the run stopped |
Single-stock (runOnTicker):
stockName,candle(current bar),stockBalance,ctx(backtest instance)getCandles(intervalName, count, ts?)- Returns Promise of newest-first bars including the current bar, ornullwhen fewer thancountexist.buy(quantity, price),sell(quantity, price)- execute at given price (fees applied by broker).setFeatures(features)- set features for the trade. Used for calculating profit correlations. You must setfeaturesin Backtest options. for example:.setFeatures([0.1, 0.2, 0.3])
All-stocks (runOnAllTickers):
currentDate,ctx,stocks(array of per-stock objects)- Each element of
stockshas:stockName,candle,stockBalance,getCandles,buy,sell,setFeatures(see above). - Use
ctx.cashBalance,ctx.stockBalancesfor portfolio state. Delisted symbols are detected and positions cleared after missing bars.
ctx:
totalValue(),grossExposure(),cashBalance,stockBalances,stockPrices,isWarmuprecord(kind, data)- Savesdatato the forward journal.data.symboland numericdata.valueget their own columns. No-op in backtest.
-
Broker(base) - No fees, overridecalculateFees(quantity, price, side)for custom logic.quantize(symbol, signedQty, price, { reduceOnly })- Returns the signed quantity the venue would accept, or0to reject.splitMaxQty(symbol, signedQty)- Splits an order into venue-sized parts.prepareBacktest()- Awaited once before a backtest runs.executionPrice(quantity, price, side, candle)- Modelled fill pricetradingMode-'live'or'demo'
-
IBKR- Interactive Brokers:new IBKR('tiered')ornew IBKR('fixed')- Tiered: $0.0035/share, min $0.35, max 1% notional, plus a modelled $0.003/share liquidity-removal fee, $0.00020/share clearing, and commission pass-through assessments.
- Fixed: $0.005/share, min $1, max 1% notional
- Both: SEC $20.60/million sold, FINRA TAF $0.000195/sold share capped at $9.79, CAT $0.000003/share on buys and sells.
- Optional second argument: slippage (decimal, e.g.
0.001= 0.1%). - Optional third argument:
{ exchangeFeePerShare }. default0.003
-
Alpaca- Commission-free U.S. equity, regulatory fees only:new Alpaca({ environment, apiKey, apiSecret, feed, slippage, rpm })- Commission: $0. Sells: SEC $20.60 per $1M, FINRA TAF $0.000195/share (max $9.79). All: CAT $0.000003/share.
environment-paper(default) orlive.apiKey,apiSecret- needed for data and ordersfeed-sip(default) oriex.slippage- fraction, default0.rpm- requests per minute, default190.
-
BinanceFutures- USD-M futures. Supports forward testing:new BinanceFutures({ feeBps, slippage, impactCoef, depthRatio, environment, apiKey, apiSecret })feeBps- fee in basis points, default5(VIP0 taker).slippage- extra fraction of notional per fill, default0.impactCoef- multiplier on the book walk, default1depthRatio- depth within 1% of mid, as a fraction of the bar quote volumeenvironment-demo(default) orlive.apiKey,apiSecret- needed for forward testing.strictQuantization- throw instead of passing the order through when a symbol has no rules, defaultfalse.orderConcurrency- orders to send at once in forward runs, default8.quantizefloors toMARKET_LOT_SIZE/LOT_SIZEstep and rejects belowminQtyorMIN_NOTIONAL
-
Hyperliquid- Perps on Hyperliquid. Supports forward testing:new Hyperliquid({ feeBps, slippage, impactCoef, depthRatio, environment, privateKey, accountAddress, vaultAddress })feeBps- default4.5.slippage,impactCoef,depthRatio- asBinanceFutures;depthRatiodefault0.25.environment-testnet(default) ormainnet.privateKey- API wallet key, needed for orders.marketSlippage- IOC limit offset from mid for market orders, default0.05.quantizefloors toszDecimalsand rejects orders under $10 unless reduce-only.
import ForwardRunner from '../src/forward/index.js';
const runner = new ForwardRunner({
strategy,
broker: new BinanceFutures({ environment: 'demo', apiKey: process.env.BINANCE_API_KEY, apiSecret: process.env.BINANCE_API_SECRET }),
capital: 50_000,
maxLeverage: 1.1,
logs: { swaps: false, trades: true, ticks: false },
dryRun: false,
});
process.on('SIGINT', () => runner.stop());
await runner.run();capital- The strategy's allocation.adoptExisting/ignoreExisting- Required. Controls whether strategy takes over existing trades.maxLeverage- Max gross exposure / equity per batch. Default:3for crypto,1for stocks.dryRun- Run the strategy on live data without sending orders.maxNetExposure- Max|net| / equityper batch. Default: none.maxOrderNotional- Max notional of an opening order. Default: none.maxDailyLoss- Fraction of the strategy's own equity.symbols- Fixed universe. Default: all tradable symbols from the broker.logs.ticks- Print a line per bar.streamSettleMs- Ms after a bar's first close before the bar is released.- Order legs are placed
broker.orderConcurrencyat a time, reduce-only legs first. Brokers default to1. journalFile- Journal path, defaultoutput/journal.sqlite.journal- An existingRunJournalto write into instead of opening one.run()- Warms up onstrategy.warmupbars of history, then trades on each closed bar untilstop().
node scripts/forward_report.js --strategy=<name> [--run=N | --runs]Close-to-open books
import AuctionRunner from '../src/forward/auction.js';
const broker = new Alpaca({ environment: 'paper', apiKey, apiSecret });
const runner = new AuctionRunner({ strategy, broker, capital: 25_000, dryRun: false });
await runner.run();import { replayAuction } from '../src/forward/auction.js';
const { runId, metrics } = await replayAuction({ strategy, broker, capital: 25_000, from: '2026-08-01', to: '2026-09-25' });Data is saved in data/.
import { candles, dataset } from '../src/data/datasets.js';
const ds = candles('crypto', '15m', 'binance');
ds.series('BTCUSDT', { from, to }); // { ts, open, high, low, close, volume, ... }
ds.read({ symbols, from, to }); // Map of symbol -> data
ds.last('BTCUSDT', { at, count }); // last `count` bars up to `at`
ds.stats(); // first bar, last bar and bar count of every symbol- Values are
Float64Arrays and timestamps are candle close times in ms.
You can store your own data too:
const mine = dataset('custom/1h', { create: true, fields: ['close'], step: 3600000, partition: 'month' });
mine.write({ BTCUSDT: { ts, close } });partitionis how much time goes in one file:hour,day,week,month,quarteroryear.- Writing the same symbol and timestamp again replaces the old row.
import Strategy from '../src/backtest/strategy.js';
import Backtest from '../src/backtest/index.js';
import IBKR from '../src/brokers/ibkr.js';
const SHORT_LEN = 25;
const LONG_LEN = SHORT_LEN * 2;
const sma = candles => candles.reduce((sum, c) => sum + c.close, 0) / candles.length;
const smaCrossover = new Strategy({
intervals: {
'1d': { count: LONG_LEN, main: true },
},
onTick: async ({ candle, getCandles, buy, sell, stockBalance }) => {
const lastLong = await getCandles('1d', LONG_LEN);
const lastShort = await getCandles('1d', SHORT_LEN);
const longMA = sma(lastLong);
const shortMA = sma(lastShort);
const price = candle.close;
if (stockBalance === 0 && shortMA > longMA) {
buy(3, price);
}
else if (stockBalance > 0 && shortMA < longMA) {
sell(stockBalance, price);
}
}
});
const bt = new Backtest({
strategy: smaCrossover,
startDate: new Date('2020-07-14'),
endDate: new Date('2025-07-30'),
capital: 10_000,
broker: new IBKR('tiered'),
logs: { swaps: false, trades: true }
});
const result = await bt.runOnTicker('AAPL');
bt.logMetrics(result);Result:
import Strategy from '../src/backtest/strategy.js';
import Backtest from '../src/backtest/index.js';
import IBKR from '../src/brokers/ibkr.js';
const SHORT_LEN = 14;
const LONG_LEN = SHORT_LEN * 2;
const sma = candles => candles.reduce((sum, c) => sum + c.close, 0) / candles.length;
const smaCrossover = new Strategy({
intervals: {
'1d': { count: LONG_LEN, main: true },
},
onTick: async ({ stocks, currentDate, ctx }) => {
for (const s of stocks) {
const { stockName, candle, getCandles, buy, sell, stockBalance } = s;
try {
const lastLong = await getCandles('1d', LONG_LEN);
const lastShort = await getCandles('1d', SHORT_LEN);
if (!lastLong || !lastShort) {
continue;
}
const longMA = sma(lastLong);
const shortMA = sma(lastShort);
const price = candle.close;
if (stockBalance === 0 && shortMA > longMA) {
const perNameBudget = ctx.cashBalance / 10;
if (Object.values(ctx.stockBalances).length < 10) {
const qty = Math.floor(perNameBudget / price);
if (qty > 0) buy(qty, price);
}
}
else if (stockBalance > 0 && shortMA < longMA) {
sell(stockBalance, price);
}
} catch (_) {
continue;
}
}
}
});
const bt = new Backtest({
strategy: smaCrossover,
startDate: new Date('2024-07-14'),
endDate: new Date('2025-07-30'),
capital: 100_000,
broker: new IBKR('tiered'),
logs: { swaps: false, trades: true }
});
const result = await bt.runOnAllTickers();
bt.logMetrics(result);Result:

