A Backtest Is Only as Useful as Its Risk Accounting
A strategy with a high historical return is not automatically a good strategy. The return may have required extreme position concentration, unrealistic fills, hidden leverage, or a drawdown that few investors could tolerate.
That is why the final layer of a backtester should not be a collection of flattering statistics. It should be an accounting system that connects portfolio risk, execution assumptions, and performance metrics to the same equity curve.
The earlier parts of this series established the sequence:
- Part 1 validates historical data and information timing.
- Part 2 separates point-in-time signals from later execution and records trades plus portfolio equity.
- Part 3 separates parameter selection from out-of-sample evaluation.
- Part 4 makes the resulting performance economically interpretable.
The examples are intentionally small, but the definitions are designed so you can extend them without changing what the metrics mean.
Position Sizing: Risk Is Not the Same as Capital Allocated
A common position-sizing mistake is to say, βI risk 1% per trade,β and then allocate 1% of portfolio value to the position. Those are different concepts.
If you intend to exit at a predefined stop, the approximate dollar risk before gaps, slippage, and fees is:
1Dollar Risk = Position Quantity Γ |Entry Price - Stop Price|A stop-based sizing function can therefore start with a portfolio-level risk budget and divide it by the planned loss per share:
1function sizePosition({
2 equity,
3 entryPrice,
4 stopPrice,
5 riskFraction = 0.01,
6 maxGrossFraction = 1,
7}) {
8 if (equity <= 0 || entryPrice <= 0 || stopPrice <= 0) {
9 throw new Error('Equity and prices must be positive');
10 }
11 if (riskFraction <= 0 || riskFraction >= 1) {
12 throw new Error('riskFraction must be between 0 and 1');
13 }
14
15 const riskPerShare = Math.abs(entryPrice - stopPrice);
16 if (riskPerShare === 0) {
17 throw new Error('Entry and stop prices must differ');
18 }
19
20 const riskBudget = equity * riskFraction;
21 const sharesByRisk = Math.floor(riskBudget / riskPerShare);
22
23 const grossBudget = equity * maxGrossFraction;
24 const sharesByGrossExposure = Math.floor(grossBudget / entryPrice);
25
26 return Math.max(0, Math.min(sharesByRisk, sharesByGrossExposure));
27}This is still an approximation. A stop order does not guarantee a fill at the stop price. Overnight gaps, thin liquidity, exchange halts, and market impact can make the realized loss larger than the planned loss.
For portfolios with multiple positions, you also need portfolio-level limits. Ten individually β1% riskβ positions can share the same underlying factor exposure and lose together.
Model Adverse Execution in the Correct Direction
A backtest should not assume every trade fills at the exact reference price unless that assumption is part of a deliberately idealized benchmark.
One simple stress model expresses slippage in basis points and always makes the fill worse for the strategy:
1function applySlippage(referencePrice, side, slippageBps) {
2 const adjustment = slippageBps / 10_000;
3
4 if (side === 'buy') return referencePrice * (1 + adjustment);
5 if (side === 'sell') return referencePrice * (1 - adjustment);
6
7 throw new Error(`Unsupported side: ${side}`);
8}Then calculate commissions from executed notional:
1function commission(notional, commissionBps) {
2 return Math.abs(notional) * (commissionBps / 10_000);
3}For a long trade, the simulated buy should generally become more expensive and the simulated sell less favorable as slippage increases. If increasing your cost assumption improves performance, inspect the sign convention before trusting anything else.
A fixed basis-point haircut is not a complete execution model. Real costs can depend on spread, volatility, order type, participation rate, asset liquidity, market session, and size. The important first step is to make cost assumptions explicit and stress them.
Keep Gross Return and Net Return Separate
It is useful to preserve both pre-cost and post-cost results. That tells you whether a strategy lacks raw edge or whether turnover consumes an otherwise interesting signal.
For each fill, record at least:
1const fill = {
2 timestamp,
3 side,
4 quantity,
5 referencePrice,
6 executedPrice,
7 commission,
8};The trade ledger can then reconstruct execution drag rather than hiding it inside one final return number.
For high-turnover strategies, small per-trade cost changes can dominate the result. That is not a reason to select a more convenient cost assumption. It is a reason to treat implementation capacity as part of the strategy thesis.
Build Performance Metrics from the Equity Curve
The equity curve is the common source of truth for portfolio-level performance. If every metric is calculated from a different shortcut, inconsistencies become almost inevitable.
Assume we have observations shaped like this:
1const equityCurve = [
2 { timestamp: '2024-01-02', equity: 100000 },
3 { timestamp: '2024-01-03', equity: 100800 },
4 { timestamp: '2024-01-04', equity: 99500 },
5];From that series we can calculate total return, periodic returns, drawdown, CAGR, volatility, and risk-adjusted metrics using consistent definitions.
Total Return
Total return is straightforward when there are no external cash flows:
1function calculateTotalReturn(equityCurve) {
2 if (equityCurve.length < 2) return null;
3
4 const initial = equityCurve[0].equity;
5 const final = equityCurve.at(-1).equity;
6
7 if (initial <= 0) return null;
8 return final / initial - 1;
9}If the simulated portfolio has deposits or withdrawals, this formula is no longer enough. You need a return convention that separates investment performance from external flows.
Maximum Drawdown
Maximum drawdown is the largest percentage decline from a prior equity peak.
1function calculateMaximumDrawdown(equityCurve) {
2 let peak = -Infinity;
3 let maxDrawdown = 0;
4
5 for (const { equity } of equityCurve) {
6 if (!Number.isFinite(equity) || equity <= 0) {
7 throw new Error('Equity values must be positive finite numbers');
8 }
9
10 peak = Math.max(peak, equity);
11 const currentDrawdown = (peak - equity) / peak;
12 maxDrawdown = Math.max(maxDrawdown, currentDrawdown);
13 }
14
15 return maxDrawdown;
16}The original version of this article treated a decline in cumulative profit as maximum drawdown. That loses the peak-to-trough definition and can materially misstate risk. Drawdown belongs on the marked-to-market equity curve.
For deeper analysis, also record drawdown duration: how long the portfolio remains below its previous high-water mark. Two strategies with the same 25% maximum drawdown can feel very different if one recovers in a month and the other remains underwater for years.
CAGR Should Use Elapsed Time
Compound annual growth rate standardizes growth over the elapsed years:
1CAGR = (Ending Equity / Starting Equity)^(1 / Years) - 1Use timestamps rather than manually typing the number of years:
1function calculateCagr(equityCurve) {
2 if (equityCurve.length < 2) return null;
3
4 const first = equityCurve[0];
5 const last = equityCurve.at(-1);
6 const initial = first.equity;
7 const final = last.equity;
8
9 if (initial <= 0 || final <= 0) return null;
10
11 const millisecondsPerYear = 365.2425 * 24 * 60 * 60 * 1000;
12 const years = (
13 new Date(last.timestamp).getTime() - new Date(first.timestamp).getTime()
14 ) / millisecondsPerYear;
15
16 if (!Number.isFinite(years) || years <= 0) return null;
17
18 return Math.pow(final / initial, 1 / years) - 1;
19}CAGR describes the endpoints. It does not describe the path, volatility, or drawdown required to get there, which is why it should never be the only performance metric.
Periodic Returns
Risk-adjusted statistics should normally start from a periodic return series rather than trade returns.
1function periodicReturns(equityCurve) {
2 const returns = [];
3
4 for (let i = 1; i < equityCurve.length; i += 1) {
5 const previous = equityCurve[i - 1].equity;
6 const current = equityCurve[i].equity;
7
8 if (previous <= 0) {
9 throw new Error('Previous equity must be positive');
10 }
11
12 returns.push(current / previous - 1);
13 }
14
15 return returns;
16}A daily strategy with a position held for 20 days should still contribute daily marked-to-market portfolio returns during that holding period. Using one return per closed trade would describe a different statistical process.
Annualized Volatility
Given equally spaced periodic returns, annualized volatility can be estimated from sample standard deviation:
1function mean(values) {
2 return values.reduce((sum, value) => sum + value, 0) / values.length;
3}
4
5function sampleStandardDeviation(values) {
6 if (values.length < 2) return null;
7
8 const avg = mean(values);
9 const variance = values.reduce(
10 (sum, value) => sum + Math.pow(value - avg, 2),
11 0
12 ) / (values.length - 1);
13
14 return Math.sqrt(variance);
15}
16
17function annualizedVolatility(returns, periodsPerYear = 252) {
18 const periodicVolatility = sampleStandardDeviation(returns);
19 if (periodicVolatility === null) return null;
20
21 return periodicVolatility * Math.sqrt(periodsPerYear);
22}252 is a conventional approximation for daily trading observations, not a universal constant. Hourly, weekly, crypto, and irregular series require an annualization convention appropriate to the data.
Sharpe Ratio with Frequency-Aligned Inputs
A frequent implementation bug is subtracting an annual risk-free rate from a daily average return. Both values need to refer to the same period before annualization.
1function calculateSharpeRatio(
2 returns,
3 { annualRiskFreeRate = 0, periodsPerYear = 252 } = {}
4) {
5 if (returns.length < 2) return null;
6 if (annualRiskFreeRate <= -1) return null;
7
8 const riskFreePerPeriod = (
9 Math.pow(1 + annualRiskFreeRate, 1 / periodsPerYear) - 1
10 );
11
12 const excessReturns = returns.map(
13 (value) => value - riskFreePerPeriod
14 );
15
16 const avgExcessReturn = mean(excessReturns);
17 const excessVolatility = sampleStandardDeviation(excessReturns);
18
19 if (!excessVolatility || excessVolatility === 0) return null;
20
21 return (
22 avgExcessReturn / excessVolatility
23 ) * Math.sqrt(periodsPerYear);
24}This is the conventional simple Sharpe calculation for equally spaced returns. It is not immune to statistical problems. Serial correlation, non-normal returns, stale marks, leverage, and short samples can all make Sharpe ratios look more precise than they are.
Treat Sharpe as one description of the return distribution, not a certification of strategy quality.
Exposure Helps Explain Where Return Came From
Two strategies can have the same CAGR even if one is invested nearly all the time and the other is exposed only occasionally. Recording exposure helps interpret that difference.
For a simple long-only system:
1function timeInMarket(positionStates) {
2 if (positionStates.length === 0) return null;
3
4 const invested = positionStates.filter(
5 (state) => state.shares > 0
6 ).length;
7
8 return invested / positionStates.length;
9}A more general portfolio can track gross and net exposure at each observation:
1Gross Exposure = Sum of Absolute Position Notionals / Equity
2Net Exposure = Sum of Signed Position Notionals / EquityThese series are especially important once the backtester supports shorts or leverage.
Turnover Makes Trading Cost Sensitivity Visible
Turnover measures how much portfolio notional is traded relative to capital. There are multiple conventions, so document the definition you use.
A simple research metric is total absolute traded notional divided by average equity:
1function turnover(fills, equityCurve) {
2 if (fills.length === 0 || equityCurve.length === 0) return 0;
3
4 const tradedNotional = fills.reduce(
5 (sum, fill) => sum + Math.abs(fill.quantity * fill.executedPrice),
6 0
7 );
8
9 const averageEquity = mean(
10 equityCurve.map((point) => point.equity)
11 );
12
13 return averageEquity <= 0 ? null : tradedNotional / averageEquity;
14}The period represented by this turnover figure depends on the backtest window. If you annualize it, state exactly how.
High turnover is not automatically bad, but it should trigger stronger scrutiny of spread, slippage, commissions, market impact, and capacity.
Report a Small, Coherent Metric Set
A useful first report might look like this:
1function performanceSummary({ equityCurve, fills, positionStates }) {
2 const returns = periodicReturns(equityCurve);
3
4 return {
5 totalReturn: calculateTotalReturn(equityCurve),
6 cagr: calculateCagr(equityCurve),
7 maxDrawdown: calculateMaximumDrawdown(equityCurve),
8 annualizedVolatility: annualizedVolatility(returns),
9 sharpe: calculateSharpeRatio(returns),
10 timeInMarket: timeInMarket(positionStates),
11 turnover: turnover(fills, equityCurve),
12 };
13}Do not add metrics merely because they are easy to calculate. Add a metric when it answers a research question and its definition is stable.
For example:
- CAGR: how quickly did capital compound between endpoints?
- maximum drawdown: how severe was the worst historical peak-to-trough decline?
- volatility: how variable were periodic returns?
- Sharpe ratio: how much average excess return accompanied that variability?
- exposure: how much market risk was carried?
- turnover: how much trading did the result require?
Stress the Result Before You Believe It
Once the metric pipeline is correct, vary assumptions that a live strategy cannot control perfectly.
Useful sensitivity tests include:
- double the assumed slippage;
- increase commissions or spread costs;
- delay execution by an additional bar;
- reduce maximum gross exposure;
- widen or tighten stop assumptions;
- start the test in different years;
- remove the largest winning trades;
- inspect performance during high-volatility and low-volatility regimes.
A strategy whose conclusion flips under a tiny, plausible assumption change deserves skepticism even if its base-case Sharpe ratio is high.
What a Production Backtester Still Needs
This four-part series builds the conceptual skeleton, not a broker-grade simulator. Depending on the strategy, production research may also need:
- point-in-time universe membership;
- corporate actions and delistings;
- dividends and financing;
- short borrow availability and borrow fees;
- limit-order and partial-fill logic;
- intrabar execution assumptions;
- futures rolls and contract multipliers;
- options exercises, assignments, and volatility surfaces;
- portfolio constraints across correlated positions;
- tax-aware accounting;
- deterministic data and strategy versioning.
The more complex the instrument or strategy, the more important it becomes to define the simulator as a set of explicit contracts instead of one large function.
The Backtesting Checklist
Before treating a historical result as decision-useful, ask:
- Data: Was every input actually available at the simulated decision time?
- Identity: Were symbols, corporate actions, and universe membership handled correctly?
- Execution: Did signals execute only when they could have executed in reality?
- Costs: Are commissions, spread, slippage, financing, and market impact reasonable?
- Validation: Was model selection separated from out-of-sample evaluation?
- Risk: Does position sizing represent capital at risk rather than an arbitrary allocation percentage?
- Accounting: Can every portfolio metric be reconstructed from trades, fills, positions, and equity?
- Robustness: Does the conclusion survive plausible changes to parameters and assumptions?
- Reproducibility: Can the result be recreated from a known dataset, strategy version, and configuration?
That checklist is a better definition of a mature backtester than the number of indicators or optimization algorithms it supports.
Grizzly Bulls applies the same general discipline to the methodology, provenance, and limitations in our original research. The code in this tutorial is educational, but the research standard should be the same: make the assumptions visible enough that another person can challenge the conclusion.
Disclaimer: The examples in this tutorial are educational and intentionally simplified. Historical backtests do not guarantee future results. Live trading can incur losses and costs that are not captured by a historical simulation.



