Skip to content
FactorQX
intermediatepine-scriptbacktesting

Backtesting a Strategy in Pine Script

How to declare a Pine v5 strategy, place and exit orders, configure commission and slippage, read the Strategy Tester, and avoid repainting.

3 min read

Education and engineering only. Nothing here is investment advice, a trading signal, or a recommendation. The moving-average cross used below is a placeholder chosen because it is easy to read — its only job is to exercise the strategy API, not to be a strategy worth running.

A Pine indicator computes and plots values. A Pine strategy does that and also simulates an account: it tracks position size, equity, commission, and fills against historical bars. This page covers the order-management API, the cost model, and the lookahead traps that quietly inflate results.

Declaring a strategy

Every strategy script begins with strategy() instead of indicator(). The declaration is where you set starting capital and the cost model — get this wrong and every number in the Strategy Tester is wrong.

strategy-declaration.pine
//@version=5
strategy(
     title              = "MA Cross (placeholder API demo)",
     overlay            = true,
     initial_capital    = 10000,
     default_qty_type   = strategy.percent_of_equity,
     default_qty_value  = 10,
     commission_type    = strategy.commission.percent,
     commission_value   = 0.05,
     slippage           = 2,
     process_orders_on_close = true,
     calc_on_every_tick = false)

slippage is expressed in ticks and is applied against you on every fill. commission_value here is 0.05% per trade. Together they are the difference between a curve that looks tradable and one that does not survive contact with a real broker.

Placing and closing orders

The core verbs are strategy.entry, strategy.close, and strategy.exit. Entries open or reverse a position; strategy.close flattens a named entry at market; strategy.exit attaches protective stop and limit orders.

order-logic.pine
//@version=5
strategy("MA Cross (placeholder API demo)", overlay = true,
     initial_capital = 10000, process_orders_on_close = true)
 
fastLen = input.int(10, "Fast MA")
slowLen = input.int(30, "Slow MA")
 
fast = ta.sma(close, fastLen)
slow = ta.sma(close, slowLen)
 
// Placeholder logic ONLY — demonstrates entry/exit calls.
longCondition  = ta.crossover(fast, slow)
shortCondition = ta.crossunder(fast, slow)
 
if longCondition
    strategy.entry("Long", strategy.long)
 
if shortCondition
    strategy.close("Long")
 
// Optional protective bracket on the open long.
strategy.exit("Long Exit", from_entry = "Long",
     stop  = strategy.position_avg_price * 0.97,
     limit = strategy.position_avg_price * 1.05)
 
plot(fast, "Fast", color.aqua)
plot(slow, "Slow", color.orange)

A few mechanics that trip people up:

  • strategy.entry with an existing opposite position reverses it (closes and opens) in one call. Use strategy.order if you want raw, non-reversing fills.
  • The id string is a handle. strategy.exit and strategy.close reference entries by that exact string, so keep them consistent.
  • strategy.exit does nothing if there is no matching open position — it is safe to call every bar.

When orders actually fill

By default, an order generated on bar N fills at the open of bar N+1. That one-bar delay is realistic: you cannot act on a close you have not seen yet. Setting process_orders_on_close = true fills at the current bar's close instead, which is appropriate when your signal genuinely uses only closed-bar data and you model the close as your fill price. Choose one model deliberately and document it; do not flip between them to make results look better.

Reading the Strategy Tester

Open the Strategy Tester panel at the bottom of the chart. The Overview tab shows the equity curve; Performance Summary breaks down net profit, max drawdown, profit factor, and commission paid; List of Trades shows every fill with its entry and exit price. Treat max drawdown and number of trades as first-class metrics — a curve built on twelve trades tells you almost nothing, and a low drawdown with high commission tells you the cost model is doing its job.

Avoiding repainting and lookahead

Repainting is when historical results use information that would not have been available live. The common causes:

  • calc_on_every_tick = true makes the in-progress bar recalculate on each tick, so signals can appear and vanish intrabar. Keep it false for reproducible backtests.
  • request.security with lookahead. Always pass lookahead = barmerge.lookahead_off. The _on variant pulls higher-timeframe values before they close — pure future leakage.
  • Indicators that reference the future, or if/plot logic keyed off the live bar, will paint differently on history than in real time.

A reliable habit: gate signals on confirmed bars only. barstate.isconfirmed is true once a bar has closed, so wrapping entry logic in it removes a whole class of intrabar artifacts.

Where to go next

Once the API is comfortable, the hard part is honesty about costs and data leakage rather than the syntax. Read Avoiding Lookahead Bias in Backtests for the framework-agnostic version of these traps, then Position Sizing in a Backtest to replace the fixed percent_of_equity default with sizing you actually control.

Educational content. This article covers software development and research methods only. It is not investment advice, a trading signal, or a recommendation. See our disclaimer.

More in Pine Script