Skip to main content

Backtesting Trading Strategies 📊

Introduction to Backtesting​

Backtesting is the process of testing a trading strategy against historical price data to evaluate how it would have performed in the past. It is one of the most important steps in strategy development because it lets you measure profitability, risk, and robustness before risking real capital.

Pine Script's strategy engine replays historical data bar by bar, simulating trade entries, exits, position sizing, commissions, and slippage exactly as if you were trading live. On every historical bar, the engine:

  1. Receives the bar's OHLCV data.
  2. Evaluates your script's conditions.
  3. Submits any orders generated by strategy.entry(), strategy.exit(), or strategy.close().
  4. Fills pending orders against the bar's price action.
  5. Updates equity, drawdown, and all performance metrics.

Understanding this bar-by-bar execution model is critical. Your script never "sees" future bars -- it only has access to the current bar and all bars that came before it, just like real trading.

Basic Strategy Structure​

Every Pine Script strategy starts with a strategy() declaration instead of indicator(). Here is a complete moving average crossover strategy:

//@version=5
strategy("MA Crossover Strategy", overlay=true, initial_capital=10000,
default_qty_type=strategy.percent_of_equity, default_qty_value=100)

// Input parameters
fastLen = input.int(10, "Fast MA Period", minval=1)
slowLen = input.int(20, "Slow MA Period", minval=1)

// Calculate moving averages
fastMA = ta.sma(close, fastLen)
slowMA = ta.sma(close, slowLen)

// Define entry conditions
longCondition = ta.crossover(fastMA, slowMA)
shortCondition = ta.crossunder(fastMA, slowMA)

// Execute trades
if longCondition
strategy.entry("Long", strategy.long)

if shortCondition
strategy.entry("Short", strategy.short)

// Plot moving averages on the chart
plot(fastMA, "Fast MA", color=color.blue, linewidth=2)
plot(slowMA, "Slow MA", color=color.red, linewidth=2)

The strategy() declaration accepts many parameters that control how the backtester behaves. The most important ones are:

  • overlay -- whether the strategy is drawn on the price chart or in a separate pane.
  • initial_capital -- the starting account balance for the simulation.
  • default_qty_type -- how the default order size is interpreted (strategy.fixed, strategy.cash, or strategy.percent_of_equity).
  • default_qty_value -- the numeric value for the default order size.

How the Strategy Engine Works​

Three parameters on the strategy() declaration fundamentally change how orders are processed:

  • calc_on_every_tick -- when true, the script recalculates on every real-time tick instead of only on bar close. This has no effect on historical bars. Default is false.
  • calc_on_order_fills -- when true, the script recalculates immediately after an order is filled, even in the middle of a bar. This lets subsequent logic react to fills within the same bar.
  • process_orders_on_close -- when true, orders are executed at the current bar's close price instead of the next bar's open. This simulates market-on-close orders.

Here is a concrete example showing how process_orders_on_close changes fill prices:

//@version=5
strategy("Order Processing Demo", overlay=true, initial_capital=10000,
default_qty_type=strategy.percent_of_equity, default_qty_value=100,
process_orders_on_close=true)

// Simple condition: buy when RSI crosses above 30
rsiValue = ta.rsi(close, 14)
longCondition = ta.crossover(rsiValue, 30)

if longCondition
strategy.entry("Long", strategy.long)

// With process_orders_on_close=true, this fill happens at THIS bar's close.
// With process_orders_on_close=false (default), the fill would happen
// at the NEXT bar's open price.

// Close position when RSI crosses above 70
if ta.crossover(rsiValue, 70)
strategy.close("Long")

plot(rsiValue, "RSI", color=color.purple, display=display.data_window)

In most cases you should leave process_orders_on_close set to false (the default) because real orders cannot fill at the exact close price. Setting it to true can introduce a slight look-ahead bias.

Position Sizing​

Fixed Position Size​

The simplest approach is to specify a fixed number of shares or contracts on every trade:

//@version=5
strategy("Fixed Size Strategy", overlay=true, initial_capital=10000,
default_qty_type=strategy.fixed, default_qty_value=100)

fastMA = ta.sma(close, 10)
slowMA = ta.sma(close, 20)

longCondition = ta.crossover(fastMA, slowMA)
shortCondition = ta.crossunder(fastMA, slowMA)

// qty= overrides the default; here we buy exactly 50 shares regardless of price
if longCondition
strategy.entry("Long", strategy.long, qty=50)

if shortCondition
strategy.close("Long")

plot(fastMA, "Fast MA", color=color.blue)
plot(slowMA, "Slow MA", color=color.red)

Dynamic Position Sizing​

Professional strategies size positions based on risk. The idea is to risk a fixed percentage of equity on each trade by dividing the dollar risk amount by the per-share risk (measured with ATR):

//@version=5
strategy("ATR Risk Sizing", overlay=true, initial_capital=50000,
default_qty_type=strategy.fixed, default_qty_value=1)

// Inputs
riskPercent = input.float(1.0, "Risk Per Trade %", minval=0.1, maxval=10.0)
atrLen = input.int(14, "ATR Period", minval=1)
atrMult = input.float(2.0, "ATR Multiplier for Stop", minval=0.5)

// Calculate ATR
atrValue = ta.atr(atrLen)

// Position sizing: risk 1% of equity, stop is 2x ATR away
riskAmount = strategy.equity * (riskPercent / 100.0)
stopDistance = atrValue * atrMult
positionSize = stopDistance > 0 ? riskAmount / stopDistance : 0

// Simple trend-following entry
fastMA = ta.ema(close, 20)
slowMA = ta.ema(close, 50)
longCondition = ta.crossover(fastMA, slowMA)
exitCondition = ta.crossunder(fastMA, slowMA)

if longCondition and positionSize > 0
strategy.entry("Long", strategy.long, qty=positionSize)

if exitCondition
strategy.close("Long")

plot(fastMA, "EMA 20", color=color.blue)
plot(slowMA, "EMA 50", color=color.red)

With this approach, volatile markets automatically result in smaller position sizes (because the stop distance is wider), while calm markets allow larger positions. This keeps dollar risk consistent across different market conditions.

Stop Loss and Take Profit​

Percentage-Based Stops​

The most straightforward exit management uses strategy.exit() with fixed percentage levels:

//@version=5
strategy("Percentage SL/TP", overlay=true, initial_capital=10000,
default_qty_type=strategy.percent_of_equity, default_qty_value=100)

// Inputs
slPercent = input.float(2.0, "Stop Loss %", minval=0.1)
tpPercent = input.float(4.0, "Take Profit %", minval=0.1)
maLen = input.int(20, "MA Length", minval=1)

// Simple MA crossover entry
maValue = ta.sma(close, maLen)
longCondition = ta.crossover(close, maValue)
shortCondition = ta.crossunder(close, maValue)

if longCondition
strategy.entry("Long", strategy.long)
strategy.exit("Long Exit", "Long",
stop = close * (1 - slPercent / 100),
limit = close * (1 + tpPercent / 100))

if shortCondition
strategy.close("Long")

plot(maValue, "SMA", color=color.orange, linewidth=2)

ATR-Based Stops​

ATR-based stops adapt to current volatility, which is generally more robust than fixed percentages:

//@version=5
strategy("ATR SL/TP", overlay=true, initial_capital=10000,
default_qty_type=strategy.percent_of_equity, default_qty_value=100)

// Inputs
atrLen = input.int(14, "ATR Period", minval=1)
slMult = input.float(1.5, "Stop Loss ATR Multiplier", minval=0.5)
tpMult = input.float(3.0, "Take Profit ATR Multiplier", minval=0.5)

// Indicators
atrValue = ta.atr(atrLen)
fastMA = ta.ema(close, 10)
slowMA = ta.ema(close, 30)

longCondition = ta.crossover(fastMA, slowMA)

if longCondition
stopPrice = close - atrValue * slMult
limitPrice = close + atrValue * tpMult
strategy.entry("Long", strategy.long)
strategy.exit("Long Exit", "Long", stop=stopPrice, limit=limitPrice)

// Plot stop and target levels for visualization
plot(fastMA, "Fast EMA", color=color.blue)
plot(slowMA, "Slow EMA", color=color.red)

Trailing Stop Loss​

A trailing stop follows price upward and locks in profit. Pine Script's strategy.exit() supports trailing via trail_price and trail_offset:

//@version=5
strategy("Trailing Stop", overlay=true, initial_capital=10000,
default_qty_type=strategy.percent_of_equity, default_qty_value=100)

atrLen = input.int(14, "ATR Period", minval=1)
trailAtrMult = input.float(2.0, "Trail ATR Multiplier", minval=0.5)

atrValue = ta.atr(atrLen)
fastMA = ta.ema(close, 10)
slowMA = ta.ema(close, 30)

longCondition = ta.crossover(fastMA, slowMA)

if longCondition
strategy.entry("Long", strategy.long)
// trail_price: trailing starts when price reaches this level
// trail_offset: distance (in ticks) the stop trails behind price
trailOffsetTicks = math.round(atrValue * trailAtrMult / syminfo.mintick)
strategy.exit("Trail Exit", "Long",
trail_price = close + atrValue,
trail_offset = trailOffsetTicks)

if ta.crossunder(fastMA, slowMA)
strategy.close("Long")

plot(fastMA, "Fast EMA", color=color.blue)
plot(slowMA, "Slow EMA", color=color.red)

The trail_offset parameter is specified in ticks (the minimum price increment for the instrument), not in dollars or points. Dividing by syminfo.mintick converts a dollar amount into ticks.

Strategy Properties​

A realistic strategy declaration includes commission, slippage, and capital settings. Here is a complete example with all major properties:

//@version=5
strategy("Realistic Strategy Setup",
overlay = true,
initial_capital = 25000,
default_qty_type = strategy.percent_of_equity,
default_qty_value = 10,
commission_type = strategy.commission.percent,
commission_value = 0.1,
slippage = 2,
pyramiding = 0,
calc_on_every_tick = false,
process_orders_on_close = false)

// With these settings:
// - Start with $25,000
// - Each trade uses 10% of current equity
// - 0.1% commission per trade (round trip = 0.2%)
// - 2 ticks of slippage per fill
// - No pyramiding (only one position at a time)

rsiValue = ta.rsi(close, 14)

if ta.crossover(rsiValue, 30)
strategy.entry("Long", strategy.long)

if ta.crossover(rsiValue, 70)
strategy.close("Long")

Key parameters explained:

ParameterPurpose
initial_capitalStarting equity for the backtest
default_qty_typeHow order size is calculated (fixed, cash, or percent)
default_qty_valueThe numeric order size value
commission_typePercent-based or fixed per-contract
commission_valueThe commission amount
slippageNumber of ticks of adverse price slippage per fill
pyramidingMaximum number of entries in the same direction

Understanding Slippage and Commission​

Many beginners build strategies that look profitable but fall apart once realistic trading costs are applied. Let us compare the same strategy with zero costs versus realistic costs:

//@version=5
// VERSION 1: No costs (unrealistic)
strategy("Zero Cost Strategy", overlay=true, initial_capital=10000,
default_qty_type=strategy.percent_of_equity, default_qty_value=100,
commission_type=strategy.commission.percent, commission_value=0.0,
slippage=0)

fastMA = ta.ema(close, 5)
slowMA = ta.ema(close, 20)

longCondition = ta.crossover(fastMA, slowMA)
shortCondition = ta.crossunder(fastMA, slowMA)

if longCondition
strategy.entry("Long", strategy.long)

if shortCondition
strategy.close("Long")

plot(fastMA, "Fast EMA", color=color.blue)
plot(slowMA, "Slow EMA", color=color.red)

Now the same strategy with realistic costs:

//@version=5
// VERSION 2: Realistic costs
strategy("Realistic Cost Strategy", overlay=true, initial_capital=10000,
default_qty_type=strategy.percent_of_equity, default_qty_value=100,
commission_type=strategy.commission.percent, commission_value=0.1,
slippage=3)

fastMA = ta.ema(close, 5)
slowMA = ta.ema(close, 20)

longCondition = ta.crossover(fastMA, slowMA)
shortCondition = ta.crossunder(fastMA, slowMA)

if longCondition
strategy.entry("Long", strategy.long)

if shortCondition
strategy.close("Long")

plot(fastMA, "Fast EMA", color=color.blue)
plot(slowMA, "Slow EMA", color=color.red)

Run both on the same chart and compare the Strategy Tester results. High-frequency crossover strategies that trade often are especially sensitive to costs. A strategy that shows +40% return with zero costs might show -5% with 0.1% commission and 3 ticks of slippage. Always test with realistic costs from the start.

Performance Analysis​

Equity Curve​

Plotting the equity curve helps you visualize how the strategy's account balance evolves over time:

//@version=5
strategy("Equity Curve Display", overlay=false, initial_capital=10000,
default_qty_type=strategy.percent_of_equity, default_qty_value=100)

// Simple strategy logic
rsiValue = ta.rsi(close, 14)

if ta.crossover(rsiValue, 30)
strategy.entry("Long", strategy.long)
if ta.crossover(rsiValue, 70)
strategy.close("Long")

// Plot equity curve
plot(strategy.equity, "Equity", color=color.blue, linewidth=2)

// Plot the initial capital as a reference line
hline(10000, "Initial Capital", color=color.gray, linestyle=hline.style_dashed)

// Plot profit/loss relative to starting capital
plot(strategy.equity - strategy.initial_capital, "P&L", color=strategy.equity >= strategy.initial_capital ? color.green : color.red)

Performance Metrics Table​

A comprehensive dashboard shows all the key metrics at a glance:

//@version=5
strategy("Performance Dashboard", overlay=true, initial_capital=10000,
default_qty_type=strategy.percent_of_equity, default_qty_value=100,
commission_type=strategy.commission.percent, commission_value=0.1,
slippage=2)

// Strategy logic
fastMA = ta.sma(close, 10)
slowMA = ta.sma(close, 30)
if ta.crossover(fastMA, slowMA)
strategy.entry("Long", strategy.long)
if ta.crossunder(fastMA, slowMA)
strategy.close("Long")

plot(fastMA, "Fast MA", color=color.blue)
plot(slowMA, "Slow MA", color=color.red)

// Performance table
var table perfTable = table.new(position.top_right, 2, 7, bgcolor=color.white, border_width=1)

if barstate.islast
// Headers
table.cell(perfTable, 0, 0, "Metric", bgcolor=color.new(color.blue, 20), text_color=color.white)
table.cell(perfTable, 1, 0, "Value", bgcolor=color.new(color.blue, 20), text_color=color.white)

// Net Profit
netProfit = strategy.netprofit
table.cell(perfTable, 0, 1, "Net Profit")
table.cell(perfTable, 1, 1, "$" + str.tostring(netProfit, "#.##"),
text_color = netProfit >= 0 ? color.green : color.red)

// Win Rate
winRate = strategy.closedtrades > 0 ? strategy.wintrades / strategy.closedtrades * 100 : 0.0
table.cell(perfTable, 0, 2, "Win Rate")
table.cell(perfTable, 1, 2, str.tostring(winRate, "#.#") + "%")

// Max Drawdown
table.cell(perfTable, 0, 3, "Max Drawdown")
table.cell(perfTable, 1, 3, "$" + str.tostring(strategy.max_drawdown, "#.##"), text_color=color.red)

// Profit Factor
profitFactor = strategy.grossloss != 0 ? strategy.grossprofit / math.abs(strategy.grossloss) : 0.0
table.cell(perfTable, 0, 4, "Profit Factor")
table.cell(perfTable, 1, 4, str.tostring(profitFactor, "#.##"))

// Total Trades
table.cell(perfTable, 0, 5, "Total Trades")
table.cell(perfTable, 1, 5, str.tostring(strategy.closedtrades))

// Return on Capital
returnPct = strategy.initial_capital > 0 ? (strategy.netprofit / strategy.initial_capital) * 100 : 0.0
table.cell(perfTable, 0, 6, "Return %")
table.cell(perfTable, 1, 6, str.tostring(returnPct, "#.##") + "%",
text_color = returnPct >= 0 ? color.green : color.red)

Here is what each metric means and what "good" values look like:

MetricWhat It MeasuresGood Range
Net ProfitTotal P&L after all costsPositive, obviously
Win RatePercentage of winning trades40-60% for trend-following; 60%+ for mean-reversion
Max DrawdownLargest peak-to-trough equity declineUnder 20% of initial capital
Profit FactorGross profit divided by gross lossAbove 1.5 is solid; above 2.0 is excellent
Sharpe RatioRisk-adjusted return (available in TradingView's Strategy Tester tab)Above 1.0 is acceptable; above 2.0 is very good

Multi-Timeframe Strategy​

Combining higher-timeframe trend filters with lower-timeframe entries is a powerful technique. Use request.security() to fetch data from a different timeframe:

//@version=5
strategy("Multi-TF Strategy", overlay=true, initial_capital=10000,
default_qty_type=strategy.percent_of_equity, default_qty_value=100,
commission_type=strategy.commission.percent, commission_value=0.1)

// Higher timeframe trend filter (Daily)
dailyMA50 = request.security(syminfo.tickerid, "D", ta.sma(close, 50))
dailyMA200 = request.security(syminfo.tickerid, "D", ta.sma(close, 200))
dailyUptrend = dailyMA50 > dailyMA200

// Current timeframe entry signals
fastMA = ta.ema(close, 9)
slowMA = ta.ema(close, 21)

longCondition = ta.crossover(fastMA, slowMA) and dailyUptrend
shortCondition = ta.crossunder(fastMA, slowMA)

if longCondition
strategy.entry("Long", strategy.long)

if shortCondition
strategy.close("Long")

// Visual feedback
plot(fastMA, "EMA 9", color=color.blue, linewidth=1)
plot(slowMA, "EMA 21", color=color.red, linewidth=1)
bgcolor(dailyUptrend ? color.new(color.green, 90) : color.new(color.red, 90))

The strategy only takes long entries when the daily trend is bullish (50-day MA above 200-day MA). This filters out many losing trades that occur during downtrends. The background color provides visual feedback about the current trend regime.

Time-Based Filters​

Many instruments have better trading conditions during specific hours. For example, equity markets tend to have the most liquidity and tightest spreads during regular trading hours.

//@version=5
strategy("Session Filter Strategy", overlay=true, initial_capital=10000,
default_qty_type=strategy.percent_of_equity, default_qty_value=100)

// Session filter: only trade during US market hours (9:30 AM - 4:00 PM ET)
inSession = not na(time(timeframe.period, "0930-1600"))

// Strategy logic
rsiValue = ta.rsi(close, 14)
maValue = ta.sma(close, 20)

longCondition = ta.crossover(rsiValue, 30) and close > maValue and inSession
exitCondition = ta.crossover(rsiValue, 70)

if longCondition
strategy.entry("Long", strategy.long)

if exitCondition
strategy.close("Long")

// Close all positions at end of session
if not inSession and strategy.position_size > 0
strategy.close_all("Session End")

// Highlight the active trading session
bgcolor(inSession ? color.new(color.blue, 95) : na)
plot(maValue, "SMA 20", color=color.orange)

The key line is not na(time(timeframe.period, "0930-1600")). The time() function returns na when the current bar is outside the specified session, so wrapping it with not na(...) gives a boolean that is true during the session. This is the correct Pine Script v5 approach for session detection.

Risk Management​

Maximum Drawdown Protection (Circuit Breaker)​

A circuit breaker stops all trading when the account suffers a drawdown beyond a defined threshold. This prevents catastrophic losses during abnormal market conditions:

//@version=5
strategy("Drawdown Circuit Breaker", overlay=true, initial_capital=10000,
default_qty_type=strategy.percent_of_equity, default_qty_value=100,
commission_type=strategy.commission.percent, commission_value=0.1)

// Risk parameters
maxDrawdownPct = input.float(15.0, "Max Drawdown %", minval=1.0, maxval=50.0)

// Track peak equity and current drawdown
var float peakEquity = strategy.initial_capital
peakEquity := math.max(peakEquity, strategy.equity)
currentDrawdownPct = (peakEquity - strategy.equity) / peakEquity * 100

// Circuit breaker: stop trading if drawdown exceeds threshold
circuitBreakerActive = currentDrawdownPct >= maxDrawdownPct

// Strategy logic
fastMA = ta.ema(close, 10)
slowMA = ta.ema(close, 30)
longCondition = ta.crossover(fastMA, slowMA)

if longCondition and not circuitBreakerActive
strategy.entry("Long", strategy.long)

if ta.crossunder(fastMA, slowMA)
strategy.close("Long")

// Force close everything if circuit breaker triggers
if circuitBreakerActive and strategy.position_size > 0
strategy.close_all("Circuit Breaker")

// Visual warning
bgcolor(circuitBreakerActive ? color.new(color.red, 80) : na)
plot(fastMA, "Fast EMA", color=color.blue)
plot(slowMA, "Slow EMA", color=color.red)

Maximum Consecutive Losses Limit​

Another safeguard pauses trading after a streak of consecutive losing trades:

//@version=5
strategy("Consecutive Loss Limiter", overlay=true, initial_capital=10000,
default_qty_type=strategy.percent_of_equity, default_qty_value=100)

// Risk parameter
maxConsecLosses = input.int(5, "Max Consecutive Losses", minval=1, maxval=20)

// Track consecutive losses
var int consecLosses = 0
var int prevClosedTrades = 0

if strategy.closedtrades > prevClosedTrades
// A new trade just closed -- check if it was a loss
lastTradeProfit = strategy.closedtrades.profit(strategy.closedtrades - 1)
if lastTradeProfit < 0
consecLosses := consecLosses + 1
else
consecLosses := 0
prevClosedTrades := strategy.closedtrades

// Pause trading if too many consecutive losses
tradingPaused = consecLosses >= maxConsecLosses

// Strategy logic
rsiValue = ta.rsi(close, 14)
longCondition = ta.crossover(rsiValue, 30)

if longCondition and not tradingPaused
strategy.entry("Long", strategy.long)

if ta.crossover(rsiValue, 70)
strategy.close("Long")

bgcolor(tradingPaused ? color.new(color.orange, 85) : na)

When the consecutive loss counter reaches the threshold, the strategy stops opening new positions (shown by the orange background) until a winning trade resets the counter.

Practice Exercises​

1. RSI Mean-Reversion Strategy with SL/TP​

Build a strategy that buys when RSI drops below 30 and sells when it rises above 70, with percentage-based stop loss and take profit:

//@version=5
strategy("RSI Mean Reversion", overlay=true, initial_capital=10000,
default_qty_type=strategy.percent_of_equity, default_qty_value=100,
commission_type=strategy.commission.percent, commission_value=0.1,
slippage=2)

// Inputs
rsiLen = input.int(14, "RSI Period", minval=2)
slPct = input.float(3.0, "Stop Loss %", minval=0.1)
tpPct = input.float(6.0, "Take Profit %", minval=0.1)

// Indicators
rsiValue = ta.rsi(close, rsiLen)

// Entry: RSI crosses above 30 (oversold bounce)
longCondition = ta.crossover(rsiValue, 30)

// Exit: RSI crosses above 70 (overbought)
exitCondition = ta.crossover(rsiValue, 70)

if longCondition
stopPrice = close * (1 - slPct / 100)
takePrice = close * (1 + tpPct / 100)
strategy.entry("Long", strategy.long)
strategy.exit("SL/TP", "Long", stop=stopPrice, limit=takePrice)

if exitCondition
strategy.close("Long")

// Visualization
hline(30, "Oversold", color=color.green, linestyle=hline.style_dashed, display=display.none)
hline(70, "Overbought", color=color.red, linestyle=hline.style_dashed, display=display.none)

2. ATR-Based Position Sizing on an Existing Strategy​

Take a basic MACD strategy and add ATR-based position sizing so you risk exactly 2% of equity per trade:

//@version=5
strategy("MACD with ATR Sizing", overlay=true, initial_capital=25000,
default_qty_type=strategy.fixed, default_qty_value=1,
commission_type=strategy.commission.percent, commission_value=0.1)

// Inputs
riskPct = input.float(2.0, "Risk %", minval=0.1, maxval=10.0)
atrLen = input.int(14, "ATR Length", minval=1)
atrMult = input.float(2.0, "ATR Stop Multiplier", minval=0.5)

// MACD calculation
[macdLine, signalLine, histLine] = ta.macd(close, 12, 26, 9)

// ATR for position sizing and stops
atrValue = ta.atr(atrLen)
stopDistance = atrValue * atrMult
riskAmount = strategy.equity * (riskPct / 100)
positionSize = stopDistance > 0 ? riskAmount / stopDistance : 0

// Entry and exit
longCondition = ta.crossover(macdLine, signalLine) and macdLine < 0
exitCondition = ta.crossunder(macdLine, signalLine)

if longCondition and positionSize > 0
strategy.entry("Long", strategy.long, qty=positionSize)
strategy.exit("ATR Stop", "Long", stop=close - stopDistance)

if exitCondition
strategy.close("Long")

3. Drawdown-Based Kill Switch​

Build a strategy that permanently stops trading once a 20% drawdown is hit:

//@version=5
strategy("Kill Switch Strategy", overlay=true, initial_capital=10000,
default_qty_type=strategy.percent_of_equity, default_qty_value=100,
commission_type=strategy.commission.percent, commission_value=0.1)

// Inputs
killDrawdownPct = input.float(20.0, "Kill Switch Drawdown %", minval=5.0, maxval=50.0)

// Track peak equity and whether kill switch has been triggered
var float peakEquity = strategy.initial_capital
var bool killSwitch = false

peakEquity := math.max(peakEquity, strategy.equity)
currentDDPct = peakEquity > 0 ? (peakEquity - strategy.equity) / peakEquity * 100 : 0.0

// Once triggered, the kill switch stays on permanently
if currentDDPct >= killDrawdownPct
killSwitch := true

// Strategy logic (only if kill switch is not active)
fastMA = ta.sma(close, 10)
slowMA = ta.sma(close, 30)

if ta.crossover(fastMA, slowMA) and not killSwitch
strategy.entry("Long", strategy.long)

if ta.crossunder(fastMA, slowMA)
strategy.close("Long")

// Close all positions immediately when kill switch triggers
if killSwitch and strategy.position_size > 0
strategy.close_all("KILL SWITCH")

// Visual feedback
bgcolor(killSwitch ? color.new(color.red, 80) : na)
plot(fastMA, "Fast MA", color=color.blue)
plot(slowMA, "Slow MA", color=color.red)

The key difference from the circuit breaker exercise is that the kill switch uses a var bool that, once set to true, never resets. The strategy permanently stops trading for the remainder of the backtest.

Pro Tips
  • Always include realistic commissions and slippage from the very first backtest. A strategy that only works with zero costs is not a real strategy.
  • Test across multiple symbols and timeframes. A strategy that only works on one chart is likely overfit.
  • Use at least 2-3 years of historical data to capture different market regimes (trending, ranging, volatile, calm).
  • Compare your strategy to a buy-and-hold benchmark. If your strategy cannot beat buy-and-hold on the same instrument, it may not be worth the complexity.
  • Check the trade count. Fewer than 30 closed trades makes statistical conclusions unreliable. Aim for 100+ trades for meaningful results.
Common Pitfalls
  • Look-ahead bias: Using process_orders_on_close=true or referencing future data through incorrect request.security() calls inflates results. Orders should fill at the next bar's open (the default).
  • Overfitting: Optimizing 10 parameters to perfection on historical data almost guarantees poor live performance. Keep strategies simple with 2-4 key parameters.
  • Survivorship bias: Backtesting only on stocks that exist today ignores all the companies that delisted or went bankrupt. Your results will be overly optimistic.
  • Ignoring slippage on illiquid instruments: A strategy that trades low-volume stocks or penny stocks will face enormous real-world slippage that the backtester underestimates.
  • Not accounting for position sizing limits: Backtesting with 100% of equity per trade is unrealistic. Real accounts face margin requirements and buying power limits.

Next Steps​

Ready to learn about strategy optimization? Move on to the next chapter! 🚀