Jayadev Rana Get a custom script
Free Pine Script Indicators / Walk-Forward Lab
Free indicator · Pine Script v6

Walk-Forward Lab

Overfitting shown rather than warned about: 154 parameter settings over 20,000 hourly candles, the in-sample best compared with an eight-fold walk-forward and with one fixed setting that was never re-optimised.

IndicatorPine Script v6OverlayFree

Get the codeDownload .pine.txt

Snapshots

The measured result: the in-sample best ends at +31R, the same approach walked forward out of sample ends at +13R, and a fixed 20/50 that was never tuned ends at +15R.
The measured result: the in-sample best ends at +31R, the same approach walked forward out of sample ends at +13R, and a fixed 20/50 that was never tuned ends at +15R.
Parameter stability across the grid, which is what separates a plateau from a lucky spike.
Parameter stability across the grid, which is what separates a plateau from a lucky spike.
The in-sample and out-of-sample split shaded on the chart, so the boundary is visible while the script runs.
The in-sample and out-of-sample split shaded on the chart, so the boundary is visible while the script runs.

How it works

  1. The grid is scored on data the selection never saw, which is the only comparison that means anything.
  2. Eight folds re-select in-sample and then trade forward, so the equity curve is a record of decisions, not of hindsight.
  3. The fixed 20/50 baseline is the control: if tuning cannot beat it out of sample, the tuning was noise.

Settings

SettingDefaultWhat it does
Fast average15Fast moving-average length
Slow average150Slow moving-average length
Stop = ATR x2.0Stop distance, in ATRs
Target = R x2.0Target distance, as a multiple of the risk
Shade in-sample and out-of-sampletrueTints the two halves so the split is visible

Source code

In TradingView: open the Pine Editor, create a new indicator, paste the code, then click "Add to chart".

walk-forward.pine.txtGitHubDownload
//@version=6
// Lesson 19. An indicator, not a strategy: the backtest is run by hand inside the
// script, so every trade it counts is visible in the code rather than hidden in
// the engine. table, plot, plotshape, fill and bgcolor render on every build.
indicator("Walk-Forward Lab", overlay = true)

// 1. Settings. The split date is the only input that matters in this lesson.
// Left of it is the data you fitted on. Right of it is the test.
splitT  = input.time(timestamp("2025-09-01T00:00 +0000"), "In-sample ends")
fastLen = input.int(15, "Fast average", minval = 2)
slowLen = input.int(150, "Slow average", minval = 5)
atrMult = input.float(2.0, "Stop = ATR x", step = 0.5)
rr      = input.float(2.0, "Target = R x", step = 0.5)
shade   = input.bool(true, "Shade in-sample and out-of-sample")

// 2. The line in time. One boolean decides which half a bar belongs to.
// Teal behind the bars means in sample: the settings have already seen this data.
// Yellow means out of sample: this is the only part that is evidence of anything.
inSample = time < splitT
bgcolor(not shade ? na : inSample ? color.new(color.teal, 90) :
  color.new(color.orange, 90), title = "Sample shading")

// 3. A deliberately ordinary entry, and one open trade tracked by hand. ta.ema,
// ta.atr and ta.crossover are called on every single bar, never inside an if, so
// they keep a correct running state no matter what the trade logic is doing. A
// trade ends at the stop for minus one R, or at the target for plus rr.
atr  = ta.atr(14)
fast = ta.ema(close, fastLen)
slow = ta.ema(close, slowLen)
xUp  = ta.crossover(fast, slow)
var float entry = na
var float stop  = na
var float targ  = na
var array<float> rs = array.new<float>()
var array<int>   ts = array.new<int>()
var float lastR  = na
var bool  opened = false
var bool  closed = false
opened := false
closed := false
if na(entry)
    if xUp and atr > 0
        entry  := close
        stop   := close - atrMult * atr
        targ   := close + atrMult * atr * rr
        opened := true
else
    if low <= stop or high >= targ
        lastR := low <= stop ? -1.0 : rr
        array.push(rs, lastR)
        array.push(ts, time)
        entry  := na
        closed := true

// 4. Two buckets, filled on the last bar from the trade list and the timestamp
// each trade carries. Slot 0 is the in-sample half, slot 1 is out of sample, so
// the same code measures both halves and nothing can leak from one to the other.
var array<float> nTrade = array.new_float(2, 0)
var array<float> nWin   = array.new_float(2, 0)
var array<float> sumR   = array.new_float(2, 0)
var array<float> grossW = array.new_float(2, 0)
var array<float> grossL = array.new_float(2, 0)
if barstate.islast
    array.fill(nTrade, 0.0)
    array.fill(nWin, 0.0)
    array.fill(sumR, 0.0)
    array.fill(grossW, 0.0)
    array.fill(grossL, 0.0)
    if array.size(rs) > 0
        for i = 0 to array.size(rs) - 1
            r = array.get(rs, i)
            b = array.get(ts, i) < splitT ? 0 : 1
            array.set(nTrade, b, array.get(nTrade, b) + 1)
            array.set(sumR, b, array.get(sumR, b) + r)
            if r > 0
                array.set(nWin, b, array.get(nWin, b) + 1)
                array.set(grossW, b, array.get(grossW, b) + r)
            else
                array.set(grossL, b, array.get(grossL, b) - r)

// 5. What you actually see on the chart. The ribbon between the two averages is the
// signal, tinted by which half it is in, and the little markers are the entries and
// the closed trades, so you can watch winners thin out after the line.
p1 = plot(fast, "Fast average", color = color.new(color.silver, 30), linewidth = 2)
p2 = plot(slow, "Slow average", color = color.new(color.gray, 30), linewidth = 2)
fill(p1, p2, color = inSample ? color.new(color.teal, 80) :
  color.new(color.orange, 80), title = "Trend ribbon")
plotshape(opened, "Entry", shape.circle, location.belowbar,
  color.new(color.blue, 0), size = size.tiny)
plotshape(closed and lastR > 0, "Winner", shape.triangleup, location.belowbar,
  color.new(color.teal, 0), size = size.tiny)
plotshape(closed and lastR <= 0, "Loser", shape.xcross, location.abovebar,
  color.new(color.red, 0), size = size.tiny)

// 6. Small helpers for the report. A function may not assign to a global in Pine,
// so each of these only reads its arguments and hands back a value or a cell.
var table t = table.new(position.top_right, 3, 8, border_width = 1)
put(c, r, s, col, bg) =>
    table.cell(t, c, r, s, text_color = col, text_size = size.normal, bgcolor = bg)
pf(b) =>
    l = array.get(grossL, b)
    l > 0 ? array.get(grossW, b) / l : na
per(b) =>
    n = array.get(nTrade, b)
    n > 0 ? array.get(sumR, b) / n : na
txt(v, d) =>
    na(v) ? "-" : str.tostring(v, d == 0 ? "#" : d == 1 ? "#.#" : "#.##")

// 7. The report, drawn once on the last bar. Read it left to right: the middle
// column is the settings on the data they were fitted to, the right column is them
// on data they never saw, and the bottom row is how much of the edge survived.
if barstate.islast
    head = color.new(color.blue, 20)
    put(0, 0, "metric", color.white, head)
    put(1, 0, "IN SAMPLE", color.white, color.new(color.teal, 40))
    put(2, 0, "OUT OF SAMPLE", color.white, color.new(color.orange, 40))
    for b = 0 to 1
        n  = array.get(nTrade, b)
        w  = array.get(nWin, b)
        bg = b == 0 ? color.new(color.teal, 80) : color.new(color.orange, 80)
        put(b + 1, 1, txt(n, 0), color.white, bg)
        put(b + 1, 2, n > 0 ? txt(w / n * 100, 1) + "%" : "-", color.white, bg)
        put(b + 1, 3, txt(array.get(sumR, b), 1) + "R", color.white, bg)
        put(b + 1, 4, txt(per(b), 2) + "R", color.white, bg)
        put(b + 1, 5, txt(pf(b), 2), color.white, bg)
    put(0, 1, "trades", color.silver, color.new(color.black, 60))
    put(0, 2, "win rate", color.silver, color.new(color.black, 60))
    put(0, 3, "total R", color.silver, color.new(color.black, 60))
    put(0, 4, "R per trade", color.silver, color.new(color.black, 60))
    put(0, 5, "profit factor", color.silver, color.new(color.black, 60))
    keep = na(per(0)) or per(0) == 0 or na(per(1)) ? na : per(1) / per(0) * 100
    kcol = na(keep) ? color.silver : keep >= 100 ? color.teal : color.red
    put(0, 6, "edge kept out of sample", color.white, color.new(color.black, 40))
    put(1, 6, "", color.white, color.new(color.black, 40))
    put(2, 6, txt(keep, 0) + "%", kcol, color.new(color.black, 40))
    put(0, 7, "fitted on the left of the line, tested on the right", color.silver,
      color.new(color.black, 60))
    put(1, 7, "", color.silver, color.new(color.black, 60))
    put(2, 7, "", color.silver, color.new(color.black, 60))

Watch it built

This script is written and explained step by step in the video lesson.

Need a custom indicator or strategy?

Non-repainting Pine Script v6, backtested with real costs, alert and webhook ready. Fixed quote within 24 hours.

Free and open source under the Mozilla Public License 2.0. Educational content only, not financial advice. Backtest results are historical and include the costs stated; past performance does not predict future results. © Jayadev Rana · Privacy · Terms