Strategy Optimization 🎯
Introduction to Optimization​
Strategy optimization is the process of finding the best combination of parameters for a trading strategy. In Pine Script, this means systematically testing different values for inputs like moving average periods, stop-loss distances, or indicator thresholds to maximize performance metrics such as net profit, Sharpe ratio, or profit factor.
The Danger of Overfitting​
Overfitting is the single biggest risk in strategy optimization. Think of it like a student who memorizes every answer on a practice exam instead of learning the underlying concepts. On the exact same practice exam, they score 100%. On the real exam with different questions, they fail miserably.
When you optimize a strategy, you are adjusting parameters to fit historical data. If you push those parameters too far, the strategy learns the noise in the data rather than the signal. The result looks spectacular in backtesting but falls apart in live trading.
Signs of overfitting include:
- A strategy that works only on one specific symbol or timeframe
- Parameters that are extremely precise (e.g., a 17-bar MA outperforms everything, but 16 and 18 both lose money)
- Dramatic performance drops when you shift the test window by even a few weeks
- Strategies with dozens of finely-tuned parameters
The Goal: Robust Parameters​
The goal of optimization is not to find the parameters that produce the highest backtest profit. Instead, it is to find robust parameters -- values that perform reasonably well across a wide range of market conditions, symbols, and time periods. A robust parameter set might not be the absolute best on any single backtest, but it will be consistently profitable across many scenarios.
Throughout this chapter, we will build practical tools in Pine Script to help you optimize responsibly and validate your results before risking real capital.
Using TradingView's Input System for Optimization​
TradingView does not have a built-in automated optimizer that sweeps through parameter combinations. Instead, you use the input functions to manually test different values. There are two main approaches: range-based inputs with step values, and dropdown inputs with explicit option lists.
Range-Based Inputs​
The input.int() and input.float() functions accept minval, maxval, and step parameters. This creates a slider or number field that constrains the values you can test.
//@version=5
strategy("Range Input Example", overlay=true)
// Range-based inputs with min, max, and step
fastLength = input.int(10, "Fast MA", minval=5, maxval=50, step=5)
slowLength = input.int(50, "Slow MA", minval=20, maxval=200, step=10)
stopPct = input.float(2.0, "Stop Loss %", minval=0.5, maxval=5.0, step=0.5)
// Calculate MAs
fastMA = ta.sma(close, fastLength)
slowMA = ta.sma(close, slowLength)
// Trading logic
longCondition = ta.crossover(fastMA, slowMA)
shortCondition = ta.crossunder(fastMA, slowMA)
if longCondition
strategy.entry("Long", strategy.long)
strategy.exit("Long Exit", "Long", stop=close * (1 - stopPct / 100))
if shortCondition
strategy.entry("Short", strategy.short)
strategy.exit("Short Exit", "Short", stop=close * (1 + stopPct / 100))
plot(fastMA, "Fast MA", color.blue)
plot(slowMA, "Slow MA", color.red)
With this setup, you can quickly change fastLength between 5 and 50 in steps of 5, observing how each value affects the strategy's performance in the Strategy Tester panel.
Dropdown Inputs with Options​
When you want to test a specific set of values rather than a continuous range, use the options parameter. This creates a dropdown menu. The options parameter requires a tuple literal written directly in the function call -- you cannot pass an array variable.
//@version=5
strategy("Dropdown Input Example", overlay=true)
// Dropdown inputs using tuple literals -- NOT array variables
maPeriod = input.int(20, "MA Period", options=[10, 20, 50, 100, 200])
maType = input.string("SMA", "MA Type", options=["SMA", "EMA", "WMA"])
riskPct = input.float(1.0, "Risk %", options=[0.5, 1.0, 1.5, 2.0, 3.0])
// Select MA type
float maValue = switch maType
"SMA" => ta.sma(close, maPeriod)
"EMA" => ta.ema(close, maPeriod)
"WMA" => ta.wma(close, maPeriod)
=> ta.sma(close, maPeriod)
// Simple trend-following logic
longCondition = close > maValue and close[1] <= maValue[1]
shortCondition = close < maValue and close[1] >= maValue[1]
if longCondition
strategy.entry("Long", strategy.long)
if shortCondition
strategy.close("Long")
plot(maValue, "MA", color.orange, linewidth=2)
You can now quickly switch between MA types and periods using the dropdown, comparing results in the Strategy Tester after each change.
Parameter Sensitivity Analysis​
A hallmark of a robust strategy is that small changes in parameters do not destroy its performance. If your strategy earns $10,000 with a 20-period MA but loses $5,000 with a 19-period or 21-period MA, the 20-period result is likely an artifact of overfitting.
Parameter sensitivity analysis tests a range of values and visualizes the results so you can identify stable "plateaus" of good performance versus fragile "spikes" that should not be trusted.
The following script tests five different MA periods simultaneously by running the strategy logic for a single selected period while displaying a table summarizing what you should test:
//@version=5
strategy("Parameter Sensitivity", overlay=true)
// Select which period to actively trade with
activePeriod = input.int(20, "Active MA Period", options=[10, 20, 50, 100, 200])
// Calculate MA for the active period
ma = ta.sma(close, activePeriod)
// Trading logic
longCondition = ta.crossover(close, ma)
shortCondition = ta.crossunder(close, ma)
if longCondition
strategy.entry("Long", strategy.long)
if shortCondition
strategy.close("Long")
plot(ma, "MA", color.blue, linewidth=2)
// Display a sensitivity analysis guide table
var table sensTable = table.new(position.top_right, 3, 7, bgcolor=color.new(color.gray, 90))
if barstate.islast
// Header row
table.cell(sensTable, 0, 0, "Period", bgcolor=color.blue, text_color=color.white)
table.cell(sensTable, 1, 0, "Status", bgcolor=color.blue, text_color=color.white)
table.cell(sensTable, 2, 0, "Net Profit", bgcolor=color.blue, text_color=color.white)
// List periods to test
table.cell(sensTable, 0, 1, "10")
table.cell(sensTable, 0, 2, "20")
table.cell(sensTable, 0, 3, "50")
table.cell(sensTable, 0, 4, "100")
table.cell(sensTable, 0, 5, "200")
// Mark the active period
activeRow = switch activePeriod
10 => 1
20 => 2
50 => 3
100 => 4
200 => 5
=> 2
for i = 1 to 5
if i == activeRow
table.cell(sensTable, 1, i, "ACTIVE", text_color=color.green)
table.cell(sensTable, 2, i, str.tostring(strategy.netprofit, "#.##"), text_color=color.green)
else
table.cell(sensTable, 1, i, "Switch to test", text_color=color.gray)
table.cell(sensTable, 2, i, "---", text_color=color.gray)
table.cell(sensTable, 0, 6, "Tip:", text_color=color.orange)
table.cell(sensTable, 1, 6, "Change dropdown, note each profit", text_color=color.orange)
table.cell(sensTable, 2, 6, "Look for stable plateaus", text_color=color.orange)
How to use this: Change the "Active MA Period" dropdown one value at a time. Write down the net profit for each. If periods 20, 50, and 100 all produce similar positive results, that is a robust zone. If only one period is profitable, the strategy is fragile.
Walk-Forward Analysis​
Walk-forward analysis is the gold standard for strategy validation. The idea is simple: optimize your strategy on one period of data (the in-sample or training period), then test it on a subsequent unseen period (the out-of-sample or validation period). If the strategy performs well out-of-sample, you have evidence that the parameters generalize beyond the data they were fitted to.
Theory​
- Split your data into two segments using a date boundary
- Develop and optimize your strategy using only the in-sample segment
- Apply the same parameters to the out-of-sample segment without changes
- Compare performance metrics between the two periods
If in-sample performance is spectacular but out-of-sample performance is terrible, you have overfitted. Ideally, out-of-sample performance should be at least 50-60% as good as in-sample performance.
Implementation​
//@version=5
strategy("Walk-Forward Analysis", overlay=true, initial_capital=10000)
// Date boundary input
splitYear = input.int(2023, "Split Year", minval=2015, maxval=2026)
splitMonth = input.int(1, "Split Month", minval=1, maxval=12)
// Strategy parameters (optimized on in-sample)
fastLen = input.int(10, "Fast MA", minval=5, maxval=50, step=5)
slowLen = input.int(30, "Slow MA", minval=10, maxval=100, step=10)
// Define split timestamp
splitTime = timestamp(splitYear, splitMonth, 1, 0, 0)
// Period detection
isInSample = time < splitTime
isOutOfSample = time >= splitTime
// Indicators
fastMA = ta.sma(close, fastLen)
slowMA = ta.sma(close, slowLen)
// Trading signals
longSignal = ta.crossover(fastMA, slowMA)
shortSignal = ta.crossunder(fastMA, slowMA)
// Execute trades in both periods
if longSignal
strategy.entry("Long", strategy.long)
if shortSignal
strategy.close("Long")
// Track performance by period
var float inSampleProfit = 0.0
var float inSampleMaxEquity = 10000.0
var float inSampleMaxDD = 0.0
var int inSampleTrades = 0
var float outSampleProfit = 0.0
var float outSampleMaxEquity = 0.0
var float outSampleMaxDD = 0.0
var int outSampleTrades = 0
var float equityAtSplit = 0.0
// Record equity at the split point
if isInSample[1] and isOutOfSample
equityAtSplit := strategy.equity
// Track in-sample metrics
if isInSample
inSampleProfit := strategy.netprofit
inSampleMaxEquity := math.max(inSampleMaxEquity, strategy.equity)
currentDD = (inSampleMaxEquity - strategy.equity) / inSampleMaxEquity * 100
inSampleMaxDD := math.max(inSampleMaxDD, currentDD)
inSampleTrades := strategy.closedtrades
// Track out-of-sample metrics
if isOutOfSample and equityAtSplit > 0
outSampleProfit := strategy.netprofit - inSampleProfit
outSampleMaxEquity := math.max(outSampleMaxEquity, strategy.equity)
currentDD2 = (outSampleMaxEquity - strategy.equity) / outSampleMaxEquity * 100
outSampleMaxDD := math.max(outSampleMaxDD, currentDD2)
outSampleTrades := strategy.closedtrades - inSampleTrades
// Background color to show periods
bgcolor(isInSample ? color.new(color.blue, 93) : color.new(color.green, 93))
// Results table
var table wfTable = table.new(position.top_right, 3, 6, bgcolor=color.new(color.gray, 90))
if barstate.islast
table.cell(wfTable, 0, 0, "Metric", bgcolor=color.teal, text_color=color.white)
table.cell(wfTable, 1, 0, "In-Sample", bgcolor=color.blue, text_color=color.white)
table.cell(wfTable, 2, 0, "Out-of-Sample", bgcolor=color.green, text_color=color.white)
table.cell(wfTable, 0, 1, "Net Profit")
table.cell(wfTable, 1, 1, str.tostring(inSampleProfit, "#.##"))
table.cell(wfTable, 2, 1, str.tostring(outSampleProfit, "#.##"))
table.cell(wfTable, 0, 2, "Max Drawdown %")
table.cell(wfTable, 1, 2, str.tostring(inSampleMaxDD, "#.##") + "%")
table.cell(wfTable, 2, 2, str.tostring(outSampleMaxDD, "#.##") + "%")
table.cell(wfTable, 0, 3, "Trades")
table.cell(wfTable, 1, 3, str.tostring(inSampleTrades))
table.cell(wfTable, 2, 3, str.tostring(outSampleTrades))
// Efficiency ratio
float efficiency = outSampleProfit != 0 and inSampleProfit != 0 ? (outSampleProfit / inSampleProfit) * 100 : 0.0
table.cell(wfTable, 0, 4, "OOS/IS Ratio")
table.cell(wfTable, 1, 4, "---")
effColor = efficiency > 50 ? color.green : color.red
table.cell(wfTable, 2, 4, str.tostring(efficiency, "#.#") + "%", text_color=effColor)
table.cell(wfTable, 0, 5, "Verdict")
table.cell(wfTable, 1, 5, "---")
verdict = efficiency > 50 ? "ROBUST" : "OVERFITTED"
table.cell(wfTable, 2, 5, verdict, text_color=efficiency > 50 ? color.green : color.red)
plot(fastMA, "Fast MA", color.blue)
plot(slowMA, "Slow MA", color.red)
How to read the results: The table shows side-by-side performance for both periods. The "OOS/IS Ratio" tells you what percentage of your in-sample performance carried over to out-of-sample data. A ratio above 50% generally indicates a robust parameter set. Below that, you may be overfitting.
Monte Carlo Simulation​
Monte Carlo simulation answers a critical question: Is your strategy's performance the result of a genuine edge, or could it be explained by luck?
The technique works by taking your actual trade results, randomly shuffling the order those trades occurred, and observing how the equity curve changes across many shuffled sequences. If your strategy is robust, most random orderings should still produce a positive result. If many orderings produce losses, the original positive result may have been a fortunate sequence of trades.
Implementation​
The following script collects closed trade profits, performs a Fisher-Yates shuffle using a deterministic seed based on bar_index, and calculates statistics across the shuffled results.
//@version=5
strategy("Monte Carlo Analysis", overlay=true, initial_capital=10000)
// Strategy parameters
maLen = input.int(20, "MA Length", minval=5, maxval=100)
atrLen = input.int(14, "ATR Length", minval=5, maxval=50)
atrMult = input.float(2.0, "ATR Stop Multiplier", minval=0.5, maxval=5.0, step=0.5)
// Simple MA strategy
ma = ta.sma(close, maLen)
atr = ta.atr(atrLen)
longCondition = ta.crossover(close, ma)
shortCondition = ta.crossunder(close, ma)
if longCondition
strategy.entry("Long", strategy.long)
strategy.exit("Long SL", "Long", stop=close - atr * atrMult)
if shortCondition
strategy.close("Long")
plot(ma, "MA", color.blue, linewidth=2)
// --- Monte Carlo Analysis ---
var float[] tradeReturns = array.new_float(0)
var int lastTradeCount = 0
// Collect trade profits as they close
if strategy.closedtrades > lastTradeCount
for i = lastTradeCount to strategy.closedtrades - 1
float tradeProfit = strategy.closedtrades.profit(i)
array.push(tradeReturns, tradeProfit)
lastTradeCount := strategy.closedtrades
// Monte Carlo on the last bar
var table mcTable = table.new(position.top_right, 2, 9, bgcolor=color.new(color.gray, 90))
if barstate.islast and array.size(tradeReturns) > 2
int tradeCount = array.size(tradeReturns)
// Original statistics
float originalTotal = array.sum(tradeReturns)
float originalAvg = originalTotal / tradeCount
float originalStdev = array.stdev(tradeReturns)
// Create a shuffled copy using Fisher-Yates with bar_index as seed
float[] shuffled = array.copy(tradeReturns)
int seed = bar_index
// Fisher-Yates shuffle
for i = tradeCount - 1 to 1
// Simple deterministic pseudo-random index using seed
seed := (seed * 1103515245 + 12345) % 2147483647
int j = math.abs(seed) % (i + 1)
// Swap elements i and j
float temp = array.get(shuffled, i)
array.set(shuffled, i, array.get(shuffled, j))
array.set(shuffled, j, temp)
// Calculate shuffled equity curve stats
float shuffledTotal = 0.0
float shuffledPeak = 0.0
float shuffledMaxDD = 0.0
float[] shuffledEquity = array.new_float(0)
for i = 0 to array.size(shuffled) - 1
shuffledTotal := shuffledTotal + array.get(shuffled, i)
array.push(shuffledEquity, shuffledTotal)
shuffledPeak := math.max(shuffledPeak, shuffledTotal)
float dd = shuffledPeak - shuffledTotal
shuffledMaxDD := math.max(shuffledMaxDD, dd)
// Confidence metrics
float worstCase = originalAvg - 2 * originalStdev
float bestCase = originalAvg + 2 * originalStdev
// Count winning trades
int winCount = 0
for i = 0 to tradeCount - 1
if array.get(tradeReturns, i) > 0
winCount += 1
float winRate = (winCount / tradeCount) * 100
// Display results
table.cell(mcTable, 0, 0, "Monte Carlo Analysis", bgcolor=color.purple, text_color=color.white)
table.cell(mcTable, 1, 0, "Value", bgcolor=color.purple, text_color=color.white)
table.cell(mcTable, 0, 1, "Total Trades")
table.cell(mcTable, 1, 1, str.tostring(tradeCount))
table.cell(mcTable, 0, 2, "Total Profit")
table.cell(mcTable, 1, 2, str.tostring(originalTotal, "#.##"))
table.cell(mcTable, 0, 3, "Avg Trade")
table.cell(mcTable, 1, 3, str.tostring(originalAvg, "#.##"))
table.cell(mcTable, 0, 4, "Std Deviation")
table.cell(mcTable, 1, 4, str.tostring(originalStdev, "#.##"))
table.cell(mcTable, 0, 5, "Win Rate")
table.cell(mcTable, 1, 5, str.tostring(winRate, "#.#") + "%")
table.cell(mcTable, 0, 6, "95% Worst (per trade)")
worstColor = worstCase > 0 ? color.green : color.red
table.cell(mcTable, 1, 6, str.tostring(worstCase, "#.##"), text_color=worstColor)
table.cell(mcTable, 0, 7, "95% Best (per trade)")
table.cell(mcTable, 1, 7, str.tostring(bestCase, "#.##"), text_color=color.green)
table.cell(mcTable, 0, 8, "Shuffled Max DD")
table.cell(mcTable, 1, 8, str.tostring(shuffledMaxDD, "#.##"), text_color=color.red)
What the code does:
- Collects trade returns -- each time a trade closes,
strategy.closedtrades.profit(i)retrieves the dollar profit for that trade and stores it in an array. - Shuffles the returns -- the Fisher-Yates algorithm reorders the trades using a deterministic seed derived from
bar_index. This simulates an alternative history where the same trades occurred in a different order. - Calculates statistics --
array.stdev()computes the standard deviation of returns. The 95% confidence interval (mean plus/minus 2 standard deviations) tells you the range of per-trade outcomes you should expect. - Displays results -- a table shows key metrics. If the "95% Worst" value is positive, you can be fairly confident the strategy has a genuine edge. If it is negative, the strategy may not be profitable under less favorable trade sequencing.
Market Regime Detection​
Markets alternate between trending, ranging, and volatile environments. A strategy optimized for trending markets will lose money in a range-bound market. Regime detection allows your strategy to adapt its behavior -- or sit out entirely -- when conditions are unfavorable.
ATR and ADX Regime Detection​
//@version=5
strategy("Regime-Aware Strategy", overlay=true, initial_capital=10000)
// Strategy parameters
maLen = input.int(20, "MA Length", minval=5, maxval=100)
// Regime detection parameters
atrLen = input.int(14, "ATR Length", minval=5, maxval=50)
adxLen = input.int(14, "ADX Length", minval=5, maxval=50)
adxSmooth = input.int(14, "ADX Smoothing", minval=5, maxval=50)
volLookback = input.int(100, "Volatility Lookback", minval=50, maxval=500)
// Calculate indicators
ma = ta.sma(close, maLen)
atr = ta.atr(atrLen)
// ADX calculation
[diPlus, diMinus, adxValue] = ta.dmi(adxLen, adxSmooth)
// Volatility regime: compare current ATR to its historical average
atrNorm = atr / close * 100
atrAvg = ta.sma(atrNorm, volLookback)
atrStd = ta.stdev(atrNorm, volLookback)
isHighVol = atrNorm > atrAvg + atrStd
isLowVol = atrNorm < atrAvg - atrStd * 0.5
isNormalVol = not isHighVol and not isLowVol
// Trend regime: ADX above 25 suggests trending
isTrending = adxValue > 25
isRanging = adxValue < 20
// Determine position size multiplier based on regime
float sizeMult = 1.0
if isTrending and isNormalVol
sizeMult := 1.0 // Full size in trending, normal volatility
else if isTrending and isHighVol
sizeMult := 0.5 // Reduced size in trending, high volatility
else if isRanging and isLowVol
sizeMult := 0.25 // Minimal size in ranging, low volatility
else if isRanging and isHighVol
sizeMult := 0.0 // No trading in choppy, volatile markets
else
sizeMult := 0.5 // Default half size
// Base quantity
float baseQty = strategy.equity / close * 0.95
float adjustedQty = baseQty * sizeMult
// Trading logic
longSignal = ta.crossover(close, ma) and sizeMult > 0
shortSignal = ta.crossunder(close, ma)
if longSignal and adjustedQty > 0
strategy.entry("Long", strategy.long, qty=adjustedQty)
if shortSignal
strategy.close("Long")
// Visual regime display
regimeColor = isTrending and isNormalVol ? color.new(color.green, 85) :
isTrending and isHighVol ? color.new(color.orange, 85) :
isRanging and isLowVol ? color.new(color.blue, 85) :
isRanging and isHighVol ? color.new(color.red, 85) :
color.new(color.gray, 90)
bgcolor(regimeColor)
plot(ma, "MA", color.white, linewidth=2)
// Regime info table
var table regimeTable = table.new(position.bottom_right, 2, 4, bgcolor=color.new(color.gray, 85))
if barstate.islast
table.cell(regimeTable, 0, 0, "Regime", bgcolor=color.teal, text_color=color.white)
table.cell(regimeTable, 1, 0, "Value", bgcolor=color.teal, text_color=color.white)
trendStr = isTrending ? "Trending" : isRanging ? "Ranging" : "Neutral"
table.cell(regimeTable, 0, 1, "Trend")
table.cell(regimeTable, 1, 1, trendStr)
volStr = isHighVol ? "High" : isLowVol ? "Low" : "Normal"
table.cell(regimeTable, 0, 2, "Volatility")
table.cell(regimeTable, 1, 2, volStr)
table.cell(regimeTable, 0, 3, "Position Size")
table.cell(regimeTable, 1, 3, str.tostring(sizeMult * 100, "#") + "%")
What the code does: The strategy classifies each bar into a regime using two dimensions -- trend strength (ADX) and volatility (ATR relative to its historical average). Position sizing automatically scales down in unfavorable conditions and goes to zero in the worst regime (ranging + high volatility). The background color changes to visually show which regime is active.
Robustness Testing​
A truly robust strategy should work across multiple symbols and timeframes. If your strategy only works on one ticker on one timeframe, it is likely overfit to that specific data.
Multi-Timeframe Validation​
Use request.security() to test whether your signal holds on a different timeframe. If a daily signal also looks valid on a weekly chart, you have additional confidence.
//@version=5
strategy("Multi-TF Robustness", overlay=true, initial_capital=10000)
// Parameters
maLen = input.int(20, "MA Length", minval=5, maxval=100)
higherTF = input.timeframe("W", "Confirmation Timeframe")
// Current timeframe signal
ma = ta.sma(close, maLen)
currentLong = close > ma
currentShort = close < ma
// Higher timeframe confirmation
higherMA = request.security(syminfo.tickerid, higherTF, ta.sma(close, maLen))
higherLong = request.security(syminfo.tickerid, higherTF, close > ta.sma(close, maLen))
// Only trade when both timeframes agree
confirmedLong = currentLong and higherLong
confirmedShort = currentShort and not higherLong
longEntry = confirmedLong and not confirmedLong[1]
shortEntry = confirmedShort and not confirmedShort[1]
if longEntry
strategy.entry("Long", strategy.long)
if shortEntry
strategy.close("Long")
// Visual
plot(ma, "Current TF MA", color.blue, linewidth=2)
plot(higherMA, "Higher TF MA", color.orange, linewidth=2)
bgcolor(confirmedLong ? color.new(color.green, 93) : confirmedShort ? color.new(color.red, 93) : na)
Cross-market validation approach: After developing a strategy on one symbol (e.g., AAPL), apply the exact same parameters to related symbols (e.g., MSFT, GOOGL) and unrelated ones (e.g., GLD, EURUSD). If the strategy works across several markets without changing parameters, it is capturing a genuine market behavior pattern rather than a quirk of one symbol's history.
Performance Metrics Deep Dive​
Understanding what your performance metrics actually mean is essential for evaluating optimization results. A single metric like net profit tells a very incomplete story. Here we build a comprehensive dashboard that calculates the most important metrics from scratch.
Key Metrics Explained​
- Sharpe Ratio: Measures risk-adjusted return. Calculated as the mean return divided by the standard deviation of returns. A Sharpe above 1.0 is generally considered good; above 2.0 is excellent.
- Maximum Drawdown: The largest peak-to-trough decline in equity. Tells you the worst period you would have experienced.
- Recovery Factor: Net profit divided by maximum drawdown. Indicates how efficiently the strategy recovers from losses. Values above 3.0 are strong.
- Profit Factor: Gross profit divided by gross loss. Values above 1.5 are solid; below 1.2 are fragile.
- Win Rate: Percentage of trades that are profitable. Important to evaluate alongside average win size vs. average loss size.
Performance Dashboard​
//@version=5
strategy("Performance Dashboard", overlay=true, initial_capital=10000)
// Simple strategy for demonstration
fastLen = input.int(10, "Fast MA", minval=5, maxval=50)
slowLen = input.int(30, "Slow MA", minval=10, maxval=100)
fastMA = ta.sma(close, fastLen)
slowMA = ta.sma(close, slowLen)
if ta.crossover(fastMA, slowMA)
strategy.entry("Long", strategy.long)
if ta.crossunder(fastMA, slowMA)
strategy.close("Long")
plot(fastMA, "Fast", color.blue)
plot(slowMA, "Slow", color.red)
// --- Performance Dashboard ---
var float[] tradeReturns = array.new_float(0)
var float maxEquity = 10000.0
var float maxDrawdown = 0.0
var int lastTradeCount = 0
// Track equity drawdown
maxEquity := math.max(maxEquity, strategy.equity)
currentDD = maxEquity - strategy.equity
maxDrawdown := math.max(maxDrawdown, currentDD)
// Collect trade returns
if strategy.closedtrades > lastTradeCount
for i = lastTradeCount to strategy.closedtrades - 1
array.push(tradeReturns, strategy.closedtrades.profit(i))
lastTradeCount := strategy.closedtrades
// Performance table
var table dashTable = table.new(position.top_right, 2, 10, bgcolor=color.new(color.gray, 90))
if barstate.islast
int totalTrades = strategy.closedtrades
int winTrades = strategy.wintrades
int lossTrades = strategy.losstrades
float netProfit = strategy.netprofit
float grossProfit = strategy.grossprofit
float grossLoss = math.abs(strategy.grossloss)
// Calculate derived metrics
float winRate = totalTrades > 0 ? (winTrades / totalTrades) * 100 : 0.0
float profitFactor = grossLoss > 0 ? grossProfit / grossLoss : 0.0
float recoveryFac = maxDrawdown > 0 ? netProfit / maxDrawdown : 0.0
float avgWin = winTrades > 0 ? grossProfit / winTrades : 0.0
float avgLoss = lossTrades > 0 ? grossLoss / lossTrades : 0.0
float payoffRatio = avgLoss > 0 ? avgWin / avgLoss : 0.0
// Sharpe Ratio (annualized, approximate)
float meanReturn = array.size(tradeReturns) > 0 ? array.avg(tradeReturns) : 0.0
float stdReturn = array.size(tradeReturns) > 1 ? array.stdev(tradeReturns) : 1.0
float sharpe = stdReturn != 0 ? (meanReturn / stdReturn) * math.sqrt(252) : 0.0
// Header
table.cell(dashTable, 0, 0, "Metric", bgcolor=color.teal, text_color=color.white)
table.cell(dashTable, 1, 0, "Value", bgcolor=color.teal, text_color=color.white)
// Rows
table.cell(dashTable, 0, 1, "Net Profit")
profitColor = netProfit >= 0 ? color.green : color.red
table.cell(dashTable, 1, 1, "$" + str.tostring(netProfit, "#.##"), text_color=profitColor)
table.cell(dashTable, 0, 2, "Total Trades")
table.cell(dashTable, 1, 2, str.tostring(totalTrades))
table.cell(dashTable, 0, 3, "Win Rate")
wrColor = winRate >= 50 ? color.green : color.orange
table.cell(dashTable, 1, 3, str.tostring(winRate, "#.#") + "%", text_color=wrColor)
table.cell(dashTable, 0, 4, "Profit Factor")
pfColor = profitFactor >= 1.5 ? color.green : profitFactor >= 1.0 ? color.orange : color.red
table.cell(dashTable, 1, 4, str.tostring(profitFactor, "#.##"), text_color=pfColor)
table.cell(dashTable, 0, 5, "Sharpe Ratio")
shColor = sharpe >= 1.0 ? color.green : sharpe >= 0.5 ? color.orange : color.red
table.cell(dashTable, 1, 5, str.tostring(sharpe, "#.##"), text_color=shColor)
table.cell(dashTable, 0, 6, "Max Drawdown")
table.cell(dashTable, 1, 6, "$" + str.tostring(maxDrawdown, "#.##"), text_color=color.red)
table.cell(dashTable, 0, 7, "Recovery Factor")
rfColor = recoveryFac >= 3.0 ? color.green : recoveryFac >= 1.0 ? color.orange : color.red
table.cell(dashTable, 1, 7, str.tostring(recoveryFac, "#.##"), text_color=rfColor)
table.cell(dashTable, 0, 8, "Avg Win / Avg Loss")
table.cell(dashTable, 1, 8, str.tostring(payoffRatio, "#.##"))
table.cell(dashTable, 0, 9, "Expectancy/Trade")
float expectancy = meanReturn
expColor = expectancy > 0 ? color.green : color.red
table.cell(dashTable, 1, 9, "$" + str.tostring(expectancy, "#.##"), text_color=expColor)
What the code does: This script runs a simple MA crossover strategy and then builds a comprehensive 10-row performance dashboard on the last bar. It manually calculates Sharpe ratio using array.stdev() on collected trade returns, tracks maximum drawdown bar by bar, and derives metrics like recovery factor and payoff ratio from the built-in strategy variables. Color-coding makes it easy to spot which metrics are strong (green), marginal (orange), or weak (red).
Practice Exercises​
Exercise 1: Optimization Dashboard​
Create a strategy with a dropdown for five different MA period combinations (e.g., 10/30, 20/50, 20/100, 50/150, 50/200 for fast/slow). Display a table that shows the active combination and its net profit, win rate, and profit factor. Switch between combinations and record results to find the most robust pair.
Exercise 2: Walk-Forward Equity Tracking​
Extend the walk-forward analysis script to plot separate equity curves for in-sample and out-of-sample periods. Use plot() with color conditional on whether the current bar is in-sample or out-of-sample. Add a horizontal line at the split date using vline or a label.
Exercise 3: Regime-Aware Position Sizing​
Build a strategy that uses the ADX-based regime detection to:
- Trade full size when ADX > 30 (strong trend)
- Trade half size when ADX is between 20 and 30 (weak trend)
- Stop trading entirely when ADX < 20 (no trend)
Track the total equity with and without regime filtering (use a var float variable to track what would have happened without the filter) and compare results in a table.
Pro Tips and Common Pitfalls​
- Start with wide parameter ranges and narrow down. If the best performance cluster is at one extreme end of your range, extend the range in that direction.
- Use at least 30 trades for any statistical conclusion. Fewer trades means your results are dominated by randomness.
- Optimize for Sharpe ratio or profit factor rather than net profit. Net profit optimization tends to favor high-risk parameter sets.
- Always reserve at least 30% of your data for out-of-sample validation. Never peek at the out-of-sample results while tuning parameters.
- Test parameter sensitivity before committing. If a tiny parameter change flips the strategy from profitable to unprofitable, the parameter is not robust.
- Document every optimization run. Keep a log of which parameters you tested, on which symbol and timeframe, and what results you got. This prevents you from accidentally re-testing the same things.
- Curve fitting: Adding more and more parameters to fit historical data perfectly. Every additional parameter increases overfitting risk exponentially.
- Survivorship bias: Testing only on stocks that still exist today. Companies that went bankrupt are not in your dataset, which inflates historical returns.
- Look-ahead bias: Using information that would not have been available at the time of the trade. Pine Script handles this correctly for most built-in functions, but be careful with
request.security()and custom calculations. - Ignoring transaction costs: A strategy that trades 500 times per year needs to overcome significant commission and slippage costs. Always set realistic commission values in
strategy(). - Ignoring market regime changes: Parameters optimized during a bull market will likely fail during a bear market. Always test across multiple market regimes.
- Optimization on too little data: Using only six months of daily data gives you around 125 bars. That is not enough for meaningful optimization of most strategies.
Next Steps​
Ready to learn about deploying your strategies? Move on to the next chapter! 🚀