//@version=6 // This Pine Script™ code is subject to the terms of the Mozilla Public License 2.0 at https://mozilla.org/MPL/2.0/ // © 2026 Logan's Grove Incorporated · logansgrove.com // Follow-Through Test, version 1.0, October 2026. Results in "Instruction Set Trading" by Rex Dale Johnson, Chapter 2. // How to run it and what I found: logansgrove.com/test-scripts // Education only, not financial advice. Run it yourself and check my work. // indicator("Follow-Through Test [Silver]", shorttitle = "Follow-Through", overlay = true, max_bars_back = 100) // Idea under test: once a trade is on, it works best when other orders follow right behind and push price // your way. You cannot see those orders in advance, but you can see whether price moves your way soon after // entry. If followers are there, price should move quickly. A trade that just sits there is evidence they // are not, so cutting it early should improve results. // // Every trade is managed two ways at once, on the same entries: // Baseline : stop 1 ATR away, target 2R, closed after 48 bars if neither is hit. // Follow-through rule: the same, except a trade that has not reached +0.5R by the close of bar 3 is // closed there. Bar 1 is the entry bar. // Every trade is also tagged Fast (reached +0.5R within 3 bars) or Slow, and scored the baseline way, to show // whether fast trades really are the winners. // // Entries are deliberately simple, because the test is about managing the trade, not finding it: // Breakout : a close above the highest high (long) or below the lowest low (short) of the last 20 // bars. Entry at the next bar's open. Same direction not retaken for 10 bars. // Random (control): entries on random bars in a random direction. If the rule also helps random entries, // the gain comes from trade management, not from the entries. // Costs: the spread is charged on every trade and stops slip. // // Pass criteria, decided in advance: over 100+ trades, the rule beats the baseline by +0.05R per trade or // more, with a t-stat of 2 or more on the per-trade difference, and in both halves of the period. // Missing any one counts as a fail. Do not tweak inputs and re-run to make it pass. // Trades can overlap, which flatters the t-stat a little, so treat 2 as the floor. // ─────────────────────────── Inputs ─────────────────────────── grpE = "Entries" entryMode = input.string("Breakout", "Entry type", options = ["Breakout", "Random (control)"], group = grpE) dirMode = input.string("Both", "Trade direction", options = ["Both", "Long only", "Short only"], group = grpE) brkLen = input.int(20, "Breakout: bars to beat", minval = 2, group = grpE) cooldown = input.int(10, "Breakout: same direction not retaken for (bars)", minval = 0, group = grpE) rndChance = input.float(3, "Random: chance of an entry on any bar (%)", minval = 0.1, maxval = 50, step = 0.5, group = grpE, tooltip = "Set this so the random run takes roughly as many trades as the breakout run.") rndSeed = input.int(1, "Random: seed", minval = 1, group = grpE, tooltip = "One random run is one draw of luck. Run seeds 1 to 5 and look at the spread of results.") grpX = "Exits" atrLen = input.int(14, "ATR length", minval = 1, group = grpX) stopAtr = input.float(1.0, "Stop (x ATR)", minval = 0.1, step = 0.1, group = grpX) rr = input.float(2.0, "Target (R)", minval = 0.5, step = 0.25, group = grpX) maxBars = input.int(48, "Close after (bars) if no stop or target", minval = 2, group = grpX) grpF = "Follow-through rule" ftBars = input.int(3, "Cut if not reached the threshold by the close of bar", minval = 1, group = grpF) ftR = input.float(0.5, "Threshold (R)", minval = 0.1, step = 0.1, group = grpF) grpC = "Costs" spreadPip = input.float(2.5, "Spread (pips, 1 pip = $0.01)", minval = 0, step = 0.5, group = grpC, tooltip = "Typical retail XAGUSD spread is 2 to 4 pips, wider overnight and around news. Use your broker's figure.") slipPip = input.float(1, "Stop slippage (pips)", minval = 0, step = 0.5, group = grpC) grpD = "Test period" testStart = input.time(timestamp("2015-01-01T00:00:00"), "Entries from", group = grpD) testEnd = input.time(timestamp("2026-12-31T23:59:00"), "Entries to", group = grpD) showTable = input.bool(true, "Show results table", group = "Display") // ───────────────────────── Guards ───────────────────────── tickerU = str.upper(syminfo.ticker) if barstate.isfirst if not (str.contains(tickerU, "XAG") or str.contains(tickerU, "SILVER") or (str.startswith(tickerU, "SI") and str.endswith(tickerU, "!"))) runtime.error("Follow-Through Test is built for silver only. Current symbol: " + syminfo.ticker) if timeframe.in_seconds() > 14400 runtime.error("Run this on a 4-hour chart or lower (4H and 1H are the ones to compare).") // ───────────────────────── Calculations ───────────────────────── // 1 pip = $0.01. Some feeds (FOREX.com SILVER) quote in cents, e.g. 5391.8 = $53.918, so there 1 pip = 1.0 var float pip = close > 500 ? 1.0 : 0.01 spr = spreadPip * pip slip = slipPip * pip isRandom = entryMode != "Breakout" allowLong = dirMode != "Short only" allowShort = dirMode != "Long only" atr = ta.atr(atrLen) hh = ta.highest(high, brkLen)[1] ll = ta.lowest(low, brkLen)[1] inRange = time >= testStart and time <= testEnd var int firstT = time startT = math.max(testStart, firstT) endT = math.min(testEnd, timenow) midTime = startT + (endT - startT) / 2 // Repeatable pseudo-random number in [0, 1) rnd(int s) => x = math.sin(s * 12.9898) * 43758.5453 x - math.floor(x) // ─────────────────── Trade store ─────────────────── type Trade int dir float entry float stop float tgt float risk int bar int half float mfe = 0.0 bool fast = false float rA float rB var array trades = array.new() var int pendDir = 0 var float pendRisk = na var int pendHalf = 5 var int lastL = -100000 var int lastS = -100000 // ─────────────────── Results tracking (in R, after costs) ─────────────────── // Categories: 0 baseline, 1 follow-through rule, 2 difference (rule - baseline), 3 fast trades, 4 slow trades, // 5 difference in first half, 6 difference in second half var array stN = array.new(7, 0) var array stSum = array.new(7, 0) var array stSq = array.new(7, 0) var array stWin = array.new(7, 0) addStat(int c, float r) => stN.set(c, stN.get(c) + 1) stSum.set(c, stSum.get(c) + r) stSq.set(c, stSq.get(c) + r * r) stWin.set(c, stWin.get(c) + (r > 0 ? 1 : 0)) // ─────────────────── Enter last bar's signal at this bar's open ─────────────────── if pendDir != 0 and not na(pendRisk) and pendRisk > 0 e = open trades.push(Trade.new(pendDir, e, e - pendDir * pendRisk, e + pendDir * rr * pendRisk, pendRisk, bar_index, pendHalf)) if trades.size() > 300 trades.remove(0) pendDir := 0 // ─────────────────── Manage open trades ─────────────────── // Stop is checked before target, so a bar that reaches both counts as a loss if trades.size() > 0 for i = trades.size() - 1 to 0 t = trades.get(i) held = bar_index - t.bar + 1 float ex = na bool stopped = false if t.dir == 1 if low <= t.stop ex := math.min(open, t.stop) - slip stopped := true else if high >= t.tgt ex := math.max(open, t.tgt) else if high >= t.stop ex := math.max(open, t.stop) + slip stopped := true else if low <= t.tgt ex := math.min(open, t.tgt) if not stopped fav = t.dir == 1 ? high - t.entry : t.entry - low t.mfe := math.max(t.mfe, fav / t.risk) if held <= ftBars and t.mfe >= ftR t.fast := true if na(ex) and held >= maxBars ex := close // Follow-through rule: no +ftR by the close of bar ftBars, so it is closed there if na(ex) and held == ftBars and not t.fast and na(t.rB) t.rB := (t.dir * (close - t.entry) - spr) / t.risk if not na(ex) t.rA := (t.dir * (ex - t.entry) - spr) / t.risk if na(t.rB) t.rB := t.rA addStat(0, t.rA) addStat(1, t.rB) addStat(2, t.rB - t.rA) addStat(t.fast ? 3 : 4, t.rA) addStat(t.half, t.rB - t.rA) trades.remove(i) // ─────────────────── New signals (entered at the next bar's open) ─────────────────── bool sigL = false bool sigS = false if inRange and not na(atr) and not na(hh) if isRandom if rnd(bar_index * 7 + rndSeed * 1000) < rndChance / 100 up = rnd(bar_index * 13 + rndSeed * 1000) < 0.5 sigL := up and allowLong sigS := not up and allowShort else sigL := allowLong and close > hh and bar_index - lastL > cooldown sigS := allowShort and close < ll and bar_index - lastS > cooldown if sigL lastL := bar_index if sigS lastS := bar_index // A bar that signals both ways is ambiguous: skip it if sigL != sigS pendDir := sigL ? 1 : -1 pendRisk := stopAtr * atr pendHalf := time < midTime ? 5 : 6 plotshape(sigL and not sigS, "Long signal", shape.triangleup, location.belowbar, color.new(#03B050, 0), size = size.tiny) plotshape(sigS and not sigL, "Short signal", shape.triangledown, location.abovebar, color.new(color.red, 0), size = size.tiny) // ─────────────────────────── Results table ─────────────────────────── var table tb = table.new(position.top_right, 5, 11, bgcolor = color.new(color.black, 15), frame_color = color.gray, frame_width = 1, border_color = color.new(color.gray, 60), border_width = 1) cell(int c, int r, string s) => table.cell(tb, c, r, s, text_color = color.white, text_size = size.small, text_halign = c == 0 ? text.align_left : text.align_right) meanOf(int c) => n = stN.get(c) n > 0 ? stSum.get(c) / n : na tOf(int c) => n = stN.get(c) mean = meanOf(c) sd = n > 1 ? math.sqrt(math.max(stSq.get(c) - n * mean * mean, 0) / (n - 1)) : na // Identical results have no spread to measure, so no t-stat n > 1 and sd > 1e-6 ? mean / (sd / math.sqrt(n)) : na yn(bool b) => b ? "yes" : "NO" if barstate.islast and showTable ftTxt = "+" + str.tostring(ftR, "#.##") + "R in " + str.tostring(ftBars) + " bars" cats = array.from("Baseline (stop, " + str.tostring(rr, "#.##") + "R target, " + str.tostring(maxBars) + " bars)", "Follow-through rule (cut if no " + ftTxt + ")", "Difference per trade (rule - baseline)", " First half", " Second half", "Fast trades (reached " + ftTxt + ") *", "Slow trades *") cmap = array.from(0, 1, 2, 5, 6, 3, 4) cell(0, 0, (isRandom ? "RANDOM entries (seed " + str.tostring(rndSeed) + ") " : "BREAKOUT entries ") + timeframe.period) cell(1, 0, "Trades") cell(2, 0, "Win %") cell(3, 0, "Avg R") cell(4, 0, "t-stat") for row = 0 to 6 c = cmap.get(row) n = stN.get(c) mean = meanOf(c) t = tOf(c) isDiff = c == 2 or c == 5 or c == 6 cell(0, row + 1, cats.get(row)) cell(1, row + 1, str.tostring(n, "#")) cell(2, row + 1, n > 0 and not isDiff ? str.tostring(100 * stWin.get(c) / n, "#") : "–") cell(3, row + 1, n > 0 ? (mean >= 0 ? "+" : "") + str.tostring(mean, "0.00") : "–") // A t-stat from a handful of trades means nothing, so it is only shown from 30 trades cell(4, row + 1, n >= 30 and not na(t) ? (t >= 0 ? "+" : "") + str.tostring(t, "0.0") : n > 1 ? "need 30" : "–") nAll = stN.get(0) slowPct = nAll > 0 ? 100 * stN.get(4) / nAll : na cell(0, 8, "Data from " + str.format_time(startT, "yyyy-MM-dd", syminfo.timezone) + " (" + str.tostring(bar_index + 1) + " bars) Slow trades " + (na(slowPct) ? "–" : str.tostring(slowPct, "#") + "% of all") + " * scored the baseline way Breakeven win % at " + str.tostring(rr, "#.##") + "R: " + str.tostring(100 / (1 + rr), "#")) table.merge_cells(tb, 0, 8, 4, 8) nD = stN.get(2) mD = meanOf(2) tD = tOf(2) h1 = meanOf(5) h2 = meanOf(6) cell(0, 9, "Pass checks: 100+ trades " + yn(nD >= 100) + " rule beats baseline by +0.05R " + yn(not na(mD) and mD >= 0.05) + " t-stat 2 " + yn(not na(tD) and tD >= 2) + " both halves + " + yn(not na(h1) and not na(h2) and h1 > 0 and h2 > 0)) table.merge_cells(tb, 0, 9, 4, 9) cell(0, 10, "Then run Random (control), seeds 1 to 5. If the rule helps random entries too, the gain is trade management.") table.merge_cells(tb, 0, 10, 4, 10)