Because of lack of good backtesting tools in JavaScript, I've decided to build my own. It uses QuestDB to efficiently store and query the data. 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 - QuestDB
- Download from questdb.com/download
- Run:
./questdb(or./questdb.exeon Windows) - Default:
admin:quest@localhost:8812/qdb. For custom setup, set:QUESTDB_USERNAME,QUESTDB_PASSWORDQUESTDB_HOST,QUESTDB_PORT,QUESTDB_DATABASE
- 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 QuestDB:
node scripts/stooq_ingest.js <1d|1h|5m>
- Re-run to update data.
- Get API key from massive.com
- Set
MASSIVE_KEYin.env - Run:
node scripts/massive_download.js <period> <startDate> <skip downloaded tickers>
- Example:
node scripts/massive_download.js 1d 2003-09-10 true periodcan be1d,1h,5m,1mstartDateis the date to start downloading fromskip downloaded tickersis a boolean flag to skip already downloaded tickers
- Example:
- Ingest into QuestDB:
node scripts/massive_ingest.js <period>
- Example:
node scripts/massive_ingest.js 1d periodcan be1d,1h,5m,1m
- 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({
intervals: {
'1d': { count: 50, main: true },
'1h': { count: 24, main: false, preload: true },
},
onTick: async (context) => { /* ... */ },
});intervals- Timeframes your strategy uses. Keys:'1d','1h','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).preload- Iftrue, bars are preloaded for speed; non-main intervals can set this to avoid on-demand DB reads.
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'),
startCashBalance: 10_000,
broker: new IBKR('tiered'),
logs: { swaps: false, trades: true },
features: [ // optional
{ name: 'volume', bucketSize: 1_000_000 },
],
});
const result = await bt.runOnStock('AAPL'); // single symbol
// or
const result = await bt.runOnAllStocks(); // all symbols in DB
bt.logMetrics(result);runOnStock(stockName)- Runs backtest on one ticker; returns metrics object.runOnAllStocks()- 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.
Metrics returned by getMetrics() / runOnStock / runOnAllStocks:
| Field | Description |
|---|---|
period |
[startDate, endDate] |
trades |
Number of completed round-trip trades |
totalFees |
Sum of broker fees |
totalReturn |
(final equity / start cash) − 1 |
avgDaily |
Average period return |
CAGR |
Compound annual growth rate |
sharpe |
Annualized Sharpe ratio |
maxDrawdown |
Worst peak-to-trough decline |
geoPeriodRet |
Geometric mean period return |
geoAnnualRet |
Geometric mean annualized return |
Single-stock (runOnStock):
stockName,candle(current bar),stockBalance,ctx(backtest instance)getCandles(intervalName, count, ts?)- returnsPromise<Array>of bars (newest to oldest), includes the current bar;tsdefaults to current bar.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 (runOnAllStocks):
currentDate,ctx,stocks(array of per-stock objects),raw(all loaded symbols)- 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.
Broker(base) - No fees; overridecalculateFees(quantity, price, side)for custom logic.IBKR- Interactive Brokers:new IBKR('tiered')ornew IBKR('fixed')- Tiered: $0.0035/share, min $0.35, max 1% notional + clearing/regulatory.
- Fixed: $0.005/share, min $1, max 1% notional.
- Optional second argument: slippage (decimal, e.g.
0.001= 0.1%).
Alpaca- Commission-free U.S. equity; regulatory fees only:new Alpaca(slippage?)- Commission: $0. Sells: FINRA TAF $0.000195/share (max $9.79, qty cap 50,205). All: CAT $0.0000265/share. Rounded up to nearest penny.
slippage- fraction (e.g.0.001= 0.1%), default0.
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'),
startCashBalance: 10_000,
broker: new IBKR('tiered'),
logs: { swaps: false, trades: true }
});
const result = await bt.runOnStock('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'),
startCashBalance: 100_000,
broker: new IBKR('tiered'),
logs: { swaps: false, trades: true }
});
const result = await bt.runOnAllStocks();
bt.logMetrics(result);Result:

