Stock Runner
Fast JS stock algotrading backtester
Install / Use
npx skills add dimdenGD/stock-runnerInstalls into whichever agent you are using.
README
Stock Runner
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).
Installation
- 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
Getting the data
Stooq (free, only last 6 months for 5m, 2 years for 1h, 20 years for 1d)
- 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.
Massive (paid)
- 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:
Running a backtest
- Add a strategy file under
strategies/(see examples below). - Run it, e.g.:
node strategies/sma.js
API reference
Strategy
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).
Backtest
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 |
onTick context
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.
Brokers
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.
Strategies
Single stock - SMA crossover
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:

All stocks - SMA crossover
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:

Related Skills
node-connect
385.5kDiagnose OpenClaw Android, iOS, or macOS node pairing, QR/setup code, route, auth, and connection failures.
blender-python-addon
40.5kBlender Python add-on rules for operators, panels, properties, registration, testing, and API-safe scripting
flutter-development-guidelines-cursorrules-prompt-file
40.5kCursor rules for Flutter development with MVVM architecture, Riverpod state management, Material widgets, and Dart style guidelines.
commit-push-pr
140.7kCommit, push, and open a PR
