Grizzly Bulls Research

Building an Algorithmic Trading Backtester with Node.js: Part 1

Build the data foundation for a Node.js trading backtester: a reproducible project, validated OHLC input, explicit timestamp rules, and defenses against common backtesting errors.

By Lee BaileyPublished Updated
Building an Algorithmic Trading Backtester with Node.js: Part 1

A backtester is not trustworthy because it can loop through historical prices. It is trustworthy only if the data available to each simulated decision matches what could actually have been known at that point in time, and if the inputs survive basic integrity checks before the strategy sees them.

This first part of the series builds that foundation in Node.js. We will create a small reproducible project, define an explicit daily OHLC data contract, validate a local CSV fixture, and separate data preparation from the trading logic we will add in Part 2.

2026 update: this tutorial was originally published in 2023. We kept the canonical URL and original publication date, but rewrote Part 1 around the stricter data and reproducibility standards we now use when thinking about backtesting at Grizzly Bulls. The old placeholder download example and several overly broad performance claims were removed.

What a backtester actually needs to prove

The useful question is not simply, "Would this rule have made money on this array of prices?" A serious backtest has to preserve a sequence of information and actions:

  1. load a defined historical dataset;
  2. validate and normalize it before simulation;
  3. expose only information available at each decision time;
  4. generate a signal from that information;
  5. translate the signal into a position under explicit execution assumptions;
  6. update cash, holdings, costs, and portfolio value; and
  7. measure the resulting path, not just the ending return.

That distinction matters because many spectacular backtests are really data bugs. A strategy can accidentally read tomorrow's close, trade at a price that was never available after its signal, ignore delisted securities, or compare raw prices across a stock split. The code may run perfectly while the conclusion is unusable.

Part 1 therefore spends most of its effort on the least glamorous component: the historical-data boundary.

Why Node.js is a reasonable backtesting choice

Node.js is a good fit when your research stack already uses JavaScript or TypeScript and you want one language across data ingestion, strategy code, APIs, visualization, and production services. Its package ecosystem and asynchronous I/O are useful when a research system has to read files or talk to external services.

That does not mean Node.js automatically makes a trading system low-latency or suitable for high-frequency trading. Backtesting speed depends on the workload, data layout, algorithm, amount of allocation, parallelization strategy, and hardware. Live execution adds a separate set of latency and reliability constraints.

For an end-of-day or hourly research engine, developer productivity and reproducibility often matter more than shaving microseconds from an event loop. Grizzly Bulls itself is a TypeScript-heavy application, and that shared language can make it easier to move a reviewed idea from research code into a product surface without rewriting every calculation in another ecosystem.

Set up a small reproducible project

Install a current supported Node.js release from the official Node.js download page and confirm the runtime is available:

bash
1node --version
2npm --version

Create the project:

bash
1mkdir algorithmic-trading-backtester
2cd algorithmic-trading-backtester
3npm init -y
4npm install papaparse

We will use Node's built-in file-system module and Papa Parse for CSV parsing. We do not need a date library for a daily YYYY-MM-DD fixture, which keeps the first example smaller and makes the timestamp assumptions visible.

Add this line to package.json so the examples can use ES modules:

json
1{
2  "type": "module"
3}

For a real project, commit your package lockfile and document the Node version used to run research. Reproducibility is easier when a future run can reconstruct both the data snapshot and the software environment.

Start with a local data fixture, not a mystery API

A common tutorial shortcut is to fetch an arbitrary public dataset and immediately start testing a strategy. That hides too much. Providers can revise files, change schemas, adjust historical prices differently, or enforce licensing rules that affect what you may store and redistribute.

For the first test, use a tiny local fixture whose meaning we control. Create data/sample-daily.csv:

csv
1Date,Open,High,Low,Close,Volume
22026-01-02,100.00,103.00,99.50,102.00,1250000
32026-01-05,102.50,104.00,101.25,103.25,1180000
42026-01-06,103.00,103.50,100.75,101.50,1400000
52026-01-07,101.25,102.75,100.50,102.25,1100000

These are illustrative values, not a real security. That is intentional. We are testing the loader before testing a market hypothesis.

For each row, our v1 contract is:

FieldMeaning
DateUTC calendar date for the daily bar
Openfirst price represented by the bar
Highmaximum price represented by the bar
Lowminimum price represented by the bar
Closefinal price represented by the bar
Volumenon-negative unit volume represented by the bar

A real provider needs a stronger contract: exchange calendar, timezone, adjusted-versus-unadjusted prices, split/dividend treatment, symbol identity, and exactly when the bar became available to the strategy.

Build a loader that fails loudly

Create src/market-data.js:

javascript
1import fs from 'node:fs';
2import Papa from 'papaparse';
3
4const REQUIRED_COLUMNS = ['Date', 'Open', 'High', 'Low', 'Close', 'Volume'];
5
6function parseFiniteNumber(value, label, rowNumber) {
7  const parsed = Number(value);
8  if (!Number.isFinite(parsed)) {
9    throw new Error(`Row ${rowNumber}: ${label} must be a finite number`);
10  }
11  return parsed;
12}
13
14function parseDailyDate(value, rowNumber) {
15  if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
16    throw new Error(`Row ${rowNumber}: Date must use YYYY-MM-DD`);
17  }
18
19  const date = new Date(`${value}T00:00:00.000Z`);
20  if (Number.isNaN(date.valueOf()) || date.toISOString().slice(0, 10) !== value) {
21    throw new Error(`Row ${rowNumber}: invalid calendar date ${value}`);
22  }
23  return date;
24}
25
26export function loadDailyBars(filePath) {
27  const csv = fs.readFileSync(filePath, 'utf8');
28  const parsed = Papa.parse(csv, {
29    header: true,
30    skipEmptyLines: true,
31  });
32
33  if (parsed.errors.length > 0) {
34    throw new Error(`CSV parse failed: ${parsed.errors[0].message}`);
35  }
36
37  const missingColumns = REQUIRED_COLUMNS.filter(
38    column => !parsed.meta.fields?.includes(column),
39  );
40  if (missingColumns.length > 0) {
41    throw new Error(`Missing required columns: ${missingColumns.join(', ')}`);
42  }
43
44  const bars = parsed.data.map((row, index) => {
45    const rowNumber = index + 2; // header is row 1
46    const date = parseDailyDate(row.Date, rowNumber);
47    const open = parseFiniteNumber(row.Open, 'Open', rowNumber);
48    const high = parseFiniteNumber(row.High, 'High', rowNumber);
49    const low = parseFiniteNumber(row.Low, 'Low', rowNumber);
50    const close = parseFiniteNumber(row.Close, 'Close', rowNumber);
51    const volume = parseFiniteNumber(row.Volume, 'Volume', rowNumber);
52
53    if (low > Math.min(open, close) || high < Math.max(open, close) || low > high) {
54      throw new Error(`Row ${rowNumber}: inconsistent OHLC range`);
55    }
56    if (volume < 0) {
57      throw new Error(`Row ${rowNumber}: Volume cannot be negative`);
58    }
59
60    return { date, open, high, low, close, volume };
61  });
62
63  bars.sort((a, b) => a.date - b.date);
64
65  for (let index = 1; index < bars.length; index += 1) {
66    if (bars[index].date.valueOf() === bars[index - 1].date.valueOf()) {
67      throw new Error(`Duplicate bar date: ${bars[index].date.toISOString().slice(0, 10)}`);
68    }
69  }
70
71  return bars;
72}

The validation is deliberately stricter than the 2023 version of this article. parseFloat('100oops') returns 100, which can quietly turn malformed input into apparently valid market data. Number() plus Number.isFinite() rejects that case. We also reject impossible OHLC relationships and duplicate dates before strategy logic can hide them.

Now create src/index.js:

javascript
1import { loadDailyBars } from './market-data.js';
2
3const bars = loadDailyBars('data/sample-daily.csv');
4
5console.table(bars.map(bar => ({
6  date: bar.date.toISOString().slice(0, 10),
7  open: bar.open,
8  high: bar.high,
9  low: bar.low,
10  close: bar.close,
11  volume: bar.volume,
12})));

Run it:

bash
1node src/index.js

At this point we still do not have a backtest. We have something more important: a deterministic boundary between raw input and the future simulation engine.

Five data mistakes that can invalidate the strategy

The loader above catches structural errors, but a valid-looking CSV can still encode bad research assumptions.

1. Look-ahead bias. If a rule uses today's closing price, decide whether the simulated order can execute at that same close. In many research designs it cannot. The safe implementation is to make the signal timestamp and execution timestamp explicit rather than letting array indexes imply timing.

2. Corporate actions. Raw and adjusted prices answer different questions. Splits can create fake returns if share quantities are not adjusted consistently. Dividends matter for total-return strategies. Do not mix adjusted closes with unadjusted OHLC values without understanding the provider's methodology.

3. Survivorship bias. Testing today's index constituents over ten years can omit companies that disappeared from the index or market. A clean price history is not proof that the security universe itself was historically knowable.

4. Missing bars and calendar assumptions. A missing trading day, suspended security, shortened session, or provider outage should not automatically be interpreted as a zero return. Validate expected calendars where the strategy depends on continuous observations.

5. Symbol and security identity. Tickers are labels, not permanent identifiers. Reused tickers, share-class changes, mergers, spin-offs, and conversions can break a long history even when every row passes numeric validation.

These issues are why Grizzly Bulls' newer research separates observation dates, source dates, security identity, and inference boundaries instead of treating every historical number as interchangeable. The same discipline belongs in a backtester.

Keep data authority separate from strategy logic

A useful project structure after Part 1 is:

text
1algorithmic-trading-backtester/
2  data/
3    sample-daily.csv
4  src/
5    index.js
6    market-data.js
7  package.json
8  package-lock.json

As the system grows, resist the temptation to let a strategy fetch, clean, and trade data inside one function. Separate responsibilities make it much easier to answer questions later:

  • Which source produced this observation?
  • Was the input adjusted?
  • What timestamp did the strategy see?
  • Did validation run before simulation?
  • Can the exact research run be reproduced?

That audit trail becomes especially important when a promising result survives long enough to influence real capital.

What comes next

Part 1 now gives us a reproducible input layer with explicit failure behavior. In Part 2, we build the simulation loop, position state, trades, and performance calculations on top of that data contract.

Later parts of the series cover more advanced testing ideas. As you progress, keep one principle fixed: adding sophistication to a strategy cannot repair historical data or timing assumptions that were wrong at the start.

Educational example only. Backtests are hypothetical and sensitive to data, execution, cost, and modeling assumptions. Historical simulated results do not guarantee future performance.