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.
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.
//@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.
//@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.entrywith an existing opposite position reverses it (closes and opens) in one call. Usestrategy.orderif you want raw, non-reversing fills.- The
idstring is a handle.strategy.exitandstrategy.closereference entries by that exact string, so keep them consistent. strategy.exitdoes 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 = truemakes the in-progress bar recalculate on each tick, so signals can appear and vanish intrabar. Keep itfalsefor reproducible backtests.request.securitywith lookahead. Always passlookahead = barmerge.lookahead_off. The_onvariant pulls higher-timeframe values before they close — pure future leakage.- Indicators that reference the future, or
if/plotlogic 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.