Backtesting Costs & Walk-Forward Testing

Why backtests without commission and slippage are fiction, and why parameters optimised on historical data don't survive forward testing — with practical fixes for both.

Two assumptions silently destroy backtest credibility: that trades execute at exactly the signal price, and that the parameters found by optimisation will continue to work on unseen data.

The first assumption is broken by commission and slippage. Every real trade incurs execution costs — the bid/ask spread, brokerage commission, and market impact. On a strategy that takes many trades, these costs compound into a substantial drag. A strategy with a profit factor of 1.4 in a zero-cost backtest may have a profit factor of 0.9 after realistic costs — a profitable-looking system that actually loses money.

The second assumption is broken by curve-fitting. When you test 50 parameter combinations and keep the best one, you have essentially found the settings that happened to fit the historical data best. Those settings often fail on new data because they captured noise rather than genuine market structure. Walk-forward testing addresses this by separating the data used to find parameters from the data used to evaluate them.

Commission: the compounding drag

Commission applies to every trade — twice. Once when you enter and once when you exit. A 0.1% commission per side is 0.2% round-trip. On 100 trades per year, that is 20% of the traded capital consumed by commissions, regardless of the strategy's gross performance.

The `commission_type` parameter in Pine Script's `strategy()` declaration accepts several formats:

Setting `commission_value = 0.1` with `strategy.commission.percent` means 0.1% of the trade's notional value is deducted on each fill. A $10,000 trade incurs $10 commission per fill, $20 round trip.

The practical test: run your strategy first with no commission, then with realistic commission. If the profit factor drops significantly (say, from 1.8 to 1.1), the strategy's raw edge is too thin to survive real-world execution costs and needs refinement.

Slippage: fills never happen at the signal price

Slippage is the difference between the price your script signals an entry and the price the order actually fills at. In liquid markets during normal hours, slippage is modest — often just 1–2 ticks. In thin markets, at market open, or during news events, slippage can be substantial.

A realistic starting value is 1–2 ticks for liquid instruments. For less liquid instruments, 3–5 ticks or more may be appropriate.

Walk-forward testing: separating discovery from validation

Walk-forward testing is a methodology for testing whether optimised strategy parameters generalise to unseen data. The process:

  1. Divide your history into segments — e.g. 12 months of training data followed by 3 months of test data
  2. Optimise on the training period — find the parameter set (EMA lengths, ATR multipliers, etc.) that performs best on those 12 months
  3. Apply to the test period — run the strategy with those exact parameters on the 3 months that were not part of the optimisation. No further adjustments.
  4. Record the test-period result — this is your out-of-sample performance
  5. Slide forward — move the window forward 3 months and repeat
  6. Aggregate — the combined out-of-sample results give a realistic estimate of live performance

If the out-of-sample results are reasonably close to the in-sample results, the strategy has some robustness. If the out-of-sample results are dramatically worse, the in-sample optimisation was largely curve-fitting.

In TradingView, you cannot automate walk-forward testing from Pine Script alone, but you can perform it manually:

  1. Fix a start and end date using the strategy settings panel
  2. Run the optimisation on the training window
  3. Note the best parameters
  4. Change the date range to the test window
  5. Manually input the parameters found in step 3 — do not re-optimise
  6. Record the result

Key metrics that expose fragility

A strategy that is genuinely robust tends to show these characteristics:

A strategy that fails these checks is not necessarily worthless, but it warrants more investigation before trading it live.

//@version=6
strategy("Realistic Backtest — Commission & Slippage",
         overlay=true,
         commission_type  = strategy.commission.percent,
         commission_value = 0.1,    // 0.1% per side = 0.2% round trip
         slippage         = 2,      // 2 ticks of slippage per fill
         default_qty_type = strategy.percent_of_equity,
         default_qty_value = 10)    // risk 10% of equity per trade

fastEMA = ta.ema(close, 9)
slowEMA = ta.ema(close, 21)
atrVal  = ta.atr(14)

longEntry  = ta.crossover(fastEMA, slowEMA)  and barstate.isconfirmed
shortEntry = ta.crossunder(fastEMA, slowEMA) and barstate.isconfirmed

if longEntry
    strategy.entry("Long", strategy.long)
    strategy.exit("Long Exit", "Long",
                  stop=close - atrVal * 2, limit=close + atrVal * 4)

if shortEntry
    strategy.entry("Short", strategy.short)
    strategy.exit("Short Exit", "Short",
                  stop=close + atrVal * 2, limit=close - atrVal * 4)

plot(fastEMA, "Fast EMA", color=color.blue)
plot(slowEMA, "Slow EMA", color=color.orange)
plotshape(longEntry,  style=shape.triangleup,   location=location.belowbar, color=color.green, size=size.small)
plotshape(shortEntry, style=shape.triangledown, location=location.abovebar, color=color.red,   size=size.small)

// ── Performance summary table ─────────────────────────────────────────
var table t = table.new(position.top_right, 2, 6,
                         bgcolor=color.new(color.black, 75), border_width=1)

if barstate.islast
    winRate = strategy.wintrades / math.max(strategy.closedtrades, 1) * 100
    pf      = strategy.grossprofit / math.max(strategy.grossloss, 1)

    table.cell(t, 0, 0, "Net Profit",    text_color=color.white, text_size=size.small)
    table.cell(t, 1, 0, str.tostring(strategy.netprofit, "#.##"),
               text_color=strategy.netprofit > 0 ? color.green : color.red, text_size=size.small)
    table.cell(t, 0, 1, "Win Rate",      text_color=color.white, text_size=size.small)
    table.cell(t, 1, 1, str.tostring(winRate, "#.#") + "%", text_color=color.white, text_size=size.small)
    table.cell(t, 0, 2, "Profit Factor", text_color=color.white, text_size=size.small)
    table.cell(t, 1, 2, str.tostring(pf, "#.##"),
               text_color=pf >= 1.5 ? color.green : pf >= 1.0 ? color.yellow : color.red, text_size=size.small)
    table.cell(t, 0, 3, "Max Drawdown",  text_color=color.white, text_size=size.small)
    table.cell(t, 1, 3, str.tostring(strategy.max_drawdown, "#.##"), text_color=color.red, text_size=size.small)
    table.cell(t, 0, 4, "Total Trades",  text_color=color.white, text_size=size.small)
    table.cell(t, 1, 4, str.tostring(strategy.closedtrades), text_color=color.white, text_size=size.small)
    table.cell(t, 0, 5, "Commission",    text_color=color.white, text_size=size.small)
    table.cell(t, 1, 5, "0.1%/side",     text_color=color.gray,  text_size=size.small)