What a Backtest Loop Actually Has to Do
A useful backtester is not just a loop that asks a strategy whether to buy or sell. It has to preserve the information that would have been available at each point in time, translate signals into realistic executions, update portfolio state, and record enough history to explain the result later.
That distinction matters because a backtest can be perfectly valid JavaScript and still be financially wrong. A common example is generating a signal from today's closing price and then pretending the trade also executed at today's close. Unless your strategy truly knew the final close before the closing auction and had a realistic way to execute there, the simulation used information from the future.
In Part 1, we built the data layer and emphasized validation, point-in-time integrity, and reproducibility. Part 2 turns that clean historical data into an execution loop.
The design we will use is intentionally simple:
- Observe bar
tthrough its close. - Generate a signal using only information available through bar
t. - Store that signal as a pending order.
- Execute the order at bar
t + 1open. - Mark the portfolio to market at each close.
- Record trades and the equity curve separately.
This is not the only valid convention, but the timing rule is explicit and testable. That is much safer than allowing signal generation and execution assumptions to blur together.
For background on why this matters, see our backtesting definition and algorithmic trading definition.
Separate Strategy Logic from Execution Logic
A strategy should answer a narrow question: given the history available now, what action would it like to take?
The backtester should answer a different set of questions: when can that action execute, at what price, in what quantity, with what costs, and how does it change the portfolio?
Keeping those responsibilities separate makes the same strategy testable under different execution assumptions. It also makes unrealistic assumptions easier to spot.
For this tutorial, a strategy returns one of three values:
1'buy'
2'sell'
3'hold'The strategy receives only historical bars through the current close:
1function strategy(history) {
2 const current = history.at(-1);
3
4 // Replace this with your own point-in-time logic.
5 if (current.close > current.open) return 'buy';
6 if (current.close < current.open) return 'sell';
7 return 'hold';
8}This example has no claim to trading edge. Its only job is to make the backtest mechanics easy to inspect.
Build a Minimal Portfolio State
The simulator needs more than a position = 'long' flag. Even a basic long-only backtest should know how much cash it has, how many shares it owns, what the current position cost, and how portfolio equity changed over time.
1function createPortfolio(initialCash) {
2 return {
3 cash: initialCash,
4 shares: 0,
5 entryPrice: null,
6 entryFee: 0,
7 };
8}We will also maintain two independent records:
trades: completed round trips, useful for trade-level statistics.equityCurve: portfolio value through time, useful for total return and drawdown.
Do not try to derive portfolio risk from only the winning and losing trades. Open positions, position size, and the path between entries and exits matter.
Execute a Signal on the Next Bar
The execution function below fills a pending signal at the next bar's opening price. It supports a simple commission rate expressed in basis points so costs are part of the state transition rather than an afterthought.
1function executeSignal({ signal, bar, portfolio, commissionBps }) {
2 const feeRate = commissionBps / 10_000;
3
4 if (signal === 'buy' && portfolio.shares === 0) {
5 const maxShares = Math.floor(
6 portfolio.cash / (bar.open * (1 + feeRate))
7 );
8
9 if (maxShares <= 0) return null;
10
11 const grossCost = maxShares * bar.open;
12 const fee = grossCost * feeRate;
13
14 portfolio.cash -= grossCost + fee;
15 portfolio.shares = maxShares;
16 portfolio.entryPrice = bar.open;
17 portfolio.entryFee = fee;
18
19 return null;
20 }
21
22 if (signal === 'sell' && portfolio.shares > 0) {
23 const quantity = portfolio.shares;
24 const grossProceeds = quantity * bar.open;
25 const exitFee = grossProceeds * feeRate;
26 const entryPrice = portfolio.entryPrice;
27 const entryFee = portfolio.entryFee;
28
29 portfolio.cash += grossProceeds - exitFee;
30 portfolio.shares = 0;
31 portfolio.entryPrice = null;
32 portfolio.entryFee = 0;
33
34 return {
35 exitTimestamp: bar.timestamp,
36 quantity,
37 entryPrice,
38 exitPrice: bar.open,
39 fees: entryFee + exitFee,
40 pnl: (bar.open - entryPrice) * quantity - entryFee - exitFee,
41 };
42 }
43
44 return null;
45}This deliberately uses all available cash when entering. That keeps the example compact, but it is not a recommendation for real position sizing. In Part 4, we replace that shortcut with risk-based sizing and more realistic execution costs.
Put the Event Order Together
Now we can implement the core backtest. The important part is the order of operations inside the loop.
1function backtest(
2 bars,
3 strategy,
4 { initialCash = 100_000, commissionBps = 0 } = {}
5) {
6 if (bars.length < 2) {
7 throw new Error('Backtest requires at least two bars');
8 }
9
10 const portfolio = createPortfolio(initialCash);
11 const trades = [];
12 const equityCurve = [];
13 let pendingSignal = 'hold';
14
15 for (let i = 0; i < bars.length; i += 1) {
16 const bar = bars[i];
17
18 // 1. A signal generated on the previous bar may execute now.
19 const closedTrade = executeSignal({
20 signal: pendingSignal,
21 bar,
22 portfolio,
23 commissionBps,
24 });
25
26 if (closedTrade) trades.push(closedTrade);
27
28 // 2. Mark the portfolio using information available at this close.
29 const equity = portfolio.cash + portfolio.shares * bar.close;
30 equityCurve.push({ timestamp: bar.timestamp, equity });
31
32 // 3. Only now generate the signal that may execute next bar.
33 const history = bars.slice(0, i + 1);
34 pendingSignal = strategy(history);
35 }
36
37 const finalEquity = equityCurve.at(-1).equity;
38
39 return {
40 initialCash,
41 finalEquity,
42 openShares: portfolio.shares,
43 trades,
44 equityCurve,
45 };
46}Notice what this function does not do: it does not automatically force the final open position to close at the last observed price. That would be another modeling choice. Instead, final portfolio value includes the marked-to-market value of any open shares. If your production research requires liquidation at the end of the sample, implement that as an explicit rule and report it.
For large datasets, repeatedly calling slice() is wasteful. A production engine can maintain rolling features or pass an index into precomputed point-in-time indicators. The conceptual rule remains the same: the strategy must not read observations after the current decision time.
Calculate Returns from Equity, Not from the Last Trade
The original version of this tutorial tried to estimate total profit from the most recent buy price, sell price, and trade count. That does not work when trades have different prices or sizes.
Portfolio return should come from portfolio equity:
1function totalReturn(initialEquity, finalEquity) {
2 return finalEquity / initialEquity - 1;
3}Trade statistics should come from the actual trade ledger:
1function tradeStats(trades) {
2 const wins = trades.filter((trade) => trade.pnl > 0).length;
3 const losses = trades.filter((trade) => trade.pnl < 0).length;
4
5 return {
6 closedTrades: trades.length,
7 wins,
8 losses,
9 winRate: trades.length === 0 ? null : wins / trades.length,
10 netClosedTradePnl: trades.reduce((sum, trade) => sum + trade.pnl, 0),
11 };
12}A win rate is descriptive, not proof of quality. A strategy can win frequently and still lose money if its losing trades are much larger than its winners.
Calculate Drawdown from the Equity Curve
Maximum drawdown is a peak-to-trough decline in portfolio equity. It is not simply the most negative cumulative dollar profit.
1function maximumDrawdown(equityCurve) {
2 let peak = -Infinity;
3 let maxDrawdown = 0;
4
5 for (const { equity } of equityCurve) {
6 peak = Math.max(peak, equity);
7 const drawdown = peak === 0 ? 0 : (peak - equity) / peak;
8 maxDrawdown = Math.max(maxDrawdown, drawdown);
9 }
10
11 return maxDrawdown;
12}If equity moves from $100,000 to $120,000 and then falls to $90,000, the drawdown is measured from the $120,000 peak, not from the initial capital. In that example the drawdown is 25%.
That path dependence is exactly why the equity curve should be a first-class output of the simulator.
Add Invariants Before Adding Features
A backtester becomes much easier to trust when obvious impossible states fail loudly. Even in a simple long-only engine, useful checks include:
- cash and equity must remain finite numbers;
- share quantity must never be negative;
- timestamps must move forward;
- every executed sell must correspond to an open position;
- a trade cannot use a fill price from a bar earlier than its signal;
- every reported metric must be reconstructable from the trade ledger or equity curve.
A small assertion helper can make these assumptions executable:
1function assertPortfolio(portfolio) {
2 if (!Number.isFinite(portfolio.cash)) {
3 throw new Error('Portfolio cash is not finite');
4 }
5 if (!Number.isInteger(portfolio.shares) || portfolio.shares < 0) {
6 throw new Error('Portfolio share quantity is invalid');
7 }
8}Call it after each state transition during development. Silent impossible states are far more dangerous than a backtest that stops with an error.
What This Minimal Engine Still Leaves Out
This tutorial now has a defensible event order, but it is still a teaching engine. A serious research platform usually needs additional decisions around:
- splits, dividends, delistings, symbol changes, and other corporate actions;
- bid/ask spreads and market impact;
- partial fills and volume constraints;
- short sales, borrow availability, and financing costs;
- cash interest and margin;
- multiple simultaneous positions;
- market calendars and missing observations;
- taxes, if the research question requires them;
- point-in-time universe membership and fundamental data.
Those are not reasons to avoid backtesting. They are reasons to make the simulation contract explicit.
Next: Validate Instead of Merely Optimize
At this point we have the core separation we need:
historical data → point-in-time signal → later execution → portfolio state → equity curve → metrics
The next danger is optimization. Once we can run a backtest repeatedly, it becomes very easy to search hundreds or thousands of parameter combinations until history looks excellent.
Part 3 focuses on walk-forward validation, train/test separation, and parameter search without grading the strategy on the same observations used to choose it.
You can also browse Grizzly Bulls' original market research for examples of how we document data universes, methodology, limitations, and reproducible findings.
Disclaimer: The examples in this tutorial are educational and intentionally simplified. Historical backtests do not guarantee future results, and algorithmic trading can result in substantial losses.



