Pine Script Learning Roadmap: From First Indicator to Advanced Strategy Backtesting
Learning Pine Script efficiently requires a structured progression — jumping directly into complex strategies without mastering the execution model and type system leads to subtle bugs that are difficult to diagnose. This roadmap organizes the learning path into five discrete stages, each building on verified language mechanics, so every concept introduced is grounded in the official Pine Script v6 Reference Manual.
🗺️ Overview: The Five-Stage Learning Path
Each stage below is self-contained. Complete the checklist items before advancing to the next stage to avoid accumulating misconceptions about the execution model.
Stage 1 — Language Foundations & Execution Model
Before writing a single indicator, you must internalize how Pine Script executes. Unlike Python or JavaScript, Pine Script is a bar-by-bar reactive language: the entire script body re-executes on every bar, from the oldest historical bar to the current real-time bar.
1.1 The Mandatory Header
Every script must begin with the version declaration. Omitting it causes the compiler to fall back to legacy v1 behavior, producing immediate compile failures on modern syntax.
//@version=6
indicator("Stage 1 — Hello Chart", overlay = true)
// plot() renders a series value as a line on the chart.
// The first argument is the series to plot.
plot(close, title = "Close Price", color = color.blue)
1.2 Variable Declaration Keywords
| Keyword | Behavior | Typical Use Case |
|---|---|---|
= |
Declare and initialize. Resets on every bar. | Per-bar computed values |
:= |
Reassign only. Never use for first declaration. | Updating a previously declared variable |
var |
Initialize once on bar 0. Persists across all bars. | Accumulators, counters, state machines |
varip |
Initialize once on bar 0. Updates on every real-time tick. | Tick-level counters in live trading |
1.3 Critical Execution Timing Distinction
A common misconception is that indicator() and strategy() share the same execution timing. They do not:
indicator()— executes on every real-time tick by default. There is nocalc_on_every_tickparameter onindicator().strategy()— executes on bar close by default. Thecalc_on_every_tickparameter exists exclusively onstrategy().
Stage 2 — Type System & Built-in Series
Pine Script v6 introduced breaking changes to the type system. Understanding type qualifiers and the bool type restrictions is essential before writing any conditional logic.
2.1 The Four Type Qualifiers
Every value in Pine Script carries a qualifier that describes when it is known. The hierarchy from weakest to strongest is:
$$\text{const} \rightarrow \text{input} \rightarrow \text{simple} \rightarrow \text{series}$$
A parameter requiring a simple qualifier accepts const, input, or simple values — but not series. This is the most common source of compile errors for intermediate learners.
2.2 The bool Type — v6 Breaking Change
In Pine Script v6, bool is strictly true or false. It cannot hold na. The following patterns are illegal and will produce compile errors:
//@version=6
indicator("Type System Demo", overlay = true)
// ✅ LEGAL: bool declared with a boolean expression
bool crossedUp = ta.crossover(close, ta.sma(close, 20))
// ✅ LEGAL: converting bool to int using ternary
int crossSignal = crossedUp ? 1 : 0
// ✅ LEGAL: conditional check with explicit comparison
float myFloat = 1.5
if myFloat != 0
label.new(bar_index, high, "Non-zero")
// ❌ ILLEGAL (do not write): bool b = na
// ❌ ILLEGAL (do not write): int(myBool)
// ❌ ILLEGAL (do not write): if myFloat (implicit cast removed in v6)
2.3 Built-in Price Series
Pine Script provides the following built-in OHLCV series on every bar. These are all of type series float (except volume which may be series float depending on the instrument):
| Built-in | Description |
|---|---|
open | Bar open price |
high | Bar high price |
low | Bar low price |
close | Bar close price (also the default source for most indicators) |
volume | Bar volume |
hl2 | (high + low) / 2 |
hlc3 | (high + low + close) / 3 |
ohlc4 | (open + high + low + close) / 4 |
2.4 Historical Operator and the simple int Trap
The [] operator accesses historical values of a series. A critical trap for learners is passing a series int to functions that require simple int, such as ta.ema():
//@version=6
indicator("simple int Trap Demo", overlay = true)
// ✅ LEGAL: input.int() returns input int, which satisfies simple int
int emaLength = input.int(20, title = "EMA Length", minval = 1)
float emaValue = ta.ema(close, emaLength)
plot(emaValue, title = "EMA", color = color.orange)
// ❌ ILLEGAL pattern (do not use):
// var int myLen = 20
// if barstate.isfirst
// myLen := emaLength // forces myLen to series int
// ta.ema(close, myLen) // compile error: series int ≠ simple int
Stage 3 — Technical Analysis Functions & Plotting
With the execution model and type system understood, Stage 3 focuses on the ta.* namespace and the full range of plot*() functions.
3.1 Core ta.* Functions
🔽 [Click to expand] View Full Pine Script Code — Multi-Indicator Demo
//@version=6
indicator("Stage 3 — TA Functions", overlay = true)
// --- Inputs ---
// input.int() returns input int, satisfying simple int requirements.
int fastLen = input.int(12, title = "Fast EMA Length", minval = 1)
int slowLen = input.int(26, title = "Slow EMA Length", minval = 1)
int rsiLen = input.int(14, title = "RSI Length", minval = 1)
// --- Moving Averages ---
// ta.ema() requires simple int for the length parameter.
float fastEMA = ta.ema(close, fastLen)
float slowEMA = ta.ema(close, slowLen)
float smaVal = ta.sma(close, slowLen)
// --- Crossover Detection ---
// ta.crossover() returns series bool: true on the bar where
// series1 crosses above series2.
bool bullCross = ta.crossover(fastEMA, slowEMA)
bool bearCross = ta.crossunder(fastEMA, slowEMA)
// --- Plotting Moving Averages ---
plot(fastEMA, title = "Fast EMA", color = color.new(color.blue, 0), linewidth = 2)
plot(slowEMA, title = "Slow EMA", color = color.new(color.red, 0), linewidth = 2)
plot(smaVal, title = "SMA", color = color.new(color.gray, 40), linewidth = 1)
// --- Crossover Signals ---
// plotshape() renders a shape at a specific price level.
// The style parameter accepts shape constants from the shape.* namespace.
plotshape(bullCross, title = "Bull Cross",
style = shape.triangleup,
location = location.belowbar,
color = color.green,
size = size.small)
plotshape(bearCross, title = "Bear Cross",
style = shape.triangledown,
location = location.abovebar,
color = color.red,
size = size.small)
// --- Bollinger Bands ---
// ta.bb() returns [middle, upper, lower] as a tuple.
[bbMid, bbUpper, bbLower] = ta.bb(close, slowLen, 2.0)
plot(bbUpper, title = "BB Upper", color = color.new(color.purple, 60))
plot(bbLower, title = "BB Lower", color = color.new(color.purple, 60))
// fill() shades the area between two plot references.
// Both arguments are positional (no named parameter overloads for fill).
p1 = plot(bbUpper, display = display.none)
p2 = plot(bbLower, display = display.none)
fill(p1, p2, color = color.new(color.purple, 90), title = "BB Fill")
3.2 plot.style_* Constants Reference
| Constant | Visual Behavior |
|---|---|
plot.style_line | Continuous line; bridges na gaps (default) |
plot.style_linebr | Line that breaks at na gaps |
plot.style_stepline | Horizontal steps between values |
plot.style_stepline_diamond | Step line with diamond markers at data points |
plot.style_histogram | Vertical bars from zero baseline |
plot.style_area | Filled area from zero; bridges na |
plot.style_areabr | Filled area that breaks at na |
plot.style_circles | Circle at each data point |
plot.style_cross | Cross marker at each data point |
plot.style_columns | Column bars (similar to histogram but width-adjusted) |
Stage 4 — Arrays, User-Defined Types (UDTs), and Drawing Objects
Stage 4 introduces Pine Script's data structure capabilities. Arrays allow dynamic data storage, UDTs enable structured object modeling, and drawing objects (line, label, box) provide precise chart annotation.
4.1 Arrays — Core Operations
🔽 [Click to expand] View Full Pine Script Code — Array Rolling Window
//@version=6
indicator("Stage 4 — Array Rolling Window", overlay = false)
// --- Parameters ---
int windowSize = input.int(20, title = "Window Size", minval = 2)
// var preserves the array across bars (initialized once on bar 0).
// array.new() creates an empty float array.
var float[] priceWindow = array.new<float>()
// Push the current close price into the array on every bar.
array.push(priceWindow, close)
// Keep the array at a fixed size by removing the oldest element
// when the array exceeds the window size.
if array.size(priceWindow) > windowSize
array.shift(priceWindow) // removes the first (oldest) element
// --- Compute statistics from the array ---
// array.avg() returns the arithmetic mean of all elements.
float rollingMean = array.avg(priceWindow)
// array.stdev() returns the population standard deviation.
float rollingStdev = array.stdev(priceWindow)
// --- Compute a Z-score: how many std devs is close from the mean? ---
// Guard against division by zero when stdev is 0.
float zScore = rollingStdev != 0 ? (close - rollingMean) / rollingStdev : na
// --- Plot ---
plot(zScore, title = "Z-Score", color = color.blue, linewidth = 2)
hline(2, "Upper Band", color = color.red, linestyle = hline.style_dashed)
hline(-2, "Lower Band", color = color.green, linestyle = hline.style_dashed)
hline(0, "Zero", color = color.gray, linestyle = hline.style_solid)
4.2 User-Defined Types (UDTs)
UDTs allow you to group related fields into a named type, similar to a struct. The type keyword defines the UDT; instances are created with TypeName.new().
🔽 [Click to expand] View Full Pine Script Code — UDT Swing Point Tracker
//@version=6
indicator("Stage 4 — UDT Swing Tracker", overlay = true, max_labels_count = 100)
// --- Define a UDT for a swing point ---
//@type Represents a detected swing high or swing low on the chart.
type SwingPoint
//@field barIdx The bar_index where the swing occurred.
int barIdx
//@field price The price level of the swing.
float price
//@field isHigh True if this is a swing high; false if swing low.
bool isHigh
// --- Detect swing highs and lows using ta.pivothigh / ta.pivotlow ---
int leftBars = input.int(5, "Left Bars", minval = 1)
int rightBars = input.int(5, "Right Bars", minval = 1)
// ta.pivothigh() returns the high value if a pivot high is confirmed,
// otherwise returns na. Confirmation occurs rightBars bars after the pivot.
float pivHigh = ta.pivothigh(high, leftBars, rightBars)
float pivLow = ta.pivotlow(low, leftBars, rightBars)
// --- Create UDT instances when pivots are detected ---
if not na(pivHigh)
// Instantiate a SwingPoint UDT for the confirmed swing high.
SwingPoint sp = SwingPoint.new(
barIdx = bar_index - rightBars, // pivot occurred rightBars ago
price = pivHigh,
isHigh = true
)
// Draw a label at the swing high location.
label.new(
x = sp.barIdx,
y = sp.price,
text = "H",
style = label.style_label_down,
color = color.new(color.red, 20),
textcolor = color.white,
xloc = xloc.bar_index
)
if not na(pivLow)
SwingPoint sp = SwingPoint.new(
barIdx = bar_index - rightBars,
price = pivLow,
isHigh = false
)
label.new(
x = sp.barIdx,
y = sp.price,
text = "L",
style = label.style_label_up,
color = color.new(color.green, 20),
textcolor = color.white,
xloc = xloc.bar_index
)
4.3 UDT History Access — Critical Syntax Rule
Accessing historical fields of a UDT instance requires a specific syntax. The [] operator applies to the object reference, not to the field:
//@version=6
indicator("UDT History Syntax", overlay = true)
type Bar
float closeVal
// Create a new Bar UDT instance on every bar.
Bar currentBar = Bar.new(closeVal = close)
// ✅ LEGAL: apply [] to the object, then access the field.
float prevClose = na(currentBar[1]) ? na : (currentBar[1]).closeVal
// ❌ ILLEGAL (do not write): currentBar.closeVal[1]
plot(prevClose, title = "Prev Close via UDT", color = color.purple)
4.4 Drawing Object Limits
Drawing objects have default and maximum count limits. Exceeding the limit triggers garbage collection of the oldest objects:
| Object Type | Default Limit | Maximum Limit | Parameter to Set Maximum |
|---|---|---|---|
| line | ~50 | 500 | max_lines_count |
| label | ~50 | 500 | max_labels_count |
| box | ~50 | 500 | max_boxes_count |
| polyline | ~50 | 100 | max_polylines_count |
Stage 5 — Strategy Backtesting & request.*() Multi-Timeframe
The final stage covers strategy() for backtesting and request.security() for multi-timeframe analysis — the two most powerful and most commonly misused features in Pine Script.
5.1 strategy() — Key Differences from indicator()
| Feature | indicator() |
strategy() |
|---|---|---|
| Default execution timing | Every real-time tick | Bar close |
calc_on_every_tick parameter | ❌ Does not exist | ✅ Available |
| Order functions | ❌ Not available | ✅ strategy.entry(), strategy.close(), etc. |
| Performance tab | ❌ Not available | ✅ Shows equity curve, trade list |
🔽 [Click to expand] View Full Pine Script Code — EMA Crossover Strategy
//@version=6
// strategy() replaces indicator() for backtesting scripts.
// initial_capital: starting equity in account currency.
// commission_type: how commission is calculated.
// commission_value: commission amount per the chosen type.
strategy(
title = "Stage 5 — EMA Cross Strategy",
overlay = true,
initial_capital = 10000,
default_qty_type = strategy.percent_of_equity,
default_qty_value = 10,
commission_type = strategy.commission.percent,
commission_value = 0.1
)
// --- Inputs ---
int fastLen = input.int(12, "Fast EMA", minval = 1)
int slowLen = input.int(26, "Slow EMA", minval = 1)
// --- Calculations ---
float fastEMA = ta.ema(close, fastLen)
float slowEMA = ta.ema(close, slowLen)
bool longCondition = ta.crossover(fastEMA, slowEMA)
bool shortCondition = ta.crossunder(fastEMA, slowEMA)
// --- Orders ---
// strategy.entry() opens a position in the specified direction.
// In v6, the 'when' parameter is removed; use if blocks instead.
if longCondition
strategy.entry("Long", strategy.long)
if shortCondition
strategy.entry("Short", strategy.short)
// --- Plots ---
plot(fastEMA, "Fast EMA", color = color.blue, linewidth = 2)
plot(slowEMA, "Slow EMA", color = color.orange, linewidth = 2)
5.2 request.security() — Non-Repainting Pattern
The most critical concept in multi-timeframe analysis is avoiding repainting. The official non-repainting pattern uses close[1] with barmerge.lookahead_on:
🔽 [Click to expand] View Full Pine Script Code — MTF Non-Repainting
//@version=6
indicator("Stage 5 — MTF Non-Repainting", overlay = true)
// input.timeframe() returns input resolution, satisfying the
// timeframe parameter of request.security().
string htfTF = input.timeframe("60", title = "Higher Timeframe")
// Note: "60" = 60 minutes (1 hour). There is no "H" unit in timeframe strings.
// ✅ NON-REPAINTING PATTERN:
// close[1] requests the PREVIOUS bar's confirmed close.
// barmerge.lookahead_on maps it to the START of each HTF bar.
// This combination behaves identically on historical and real-time bars.
float htfClose = request.security(
syminfo.tickerid,
htfTF,
close[1],
lookahead = barmerge.lookahead_on
)
// ❌ REPAINTING patterns (do not use):
// request.security(syminfo.tickerid, htfTF, close[0], lookahead = barmerge.lookahead_on)
// request.security(syminfo.tickerid, htfTF, close[0], lookahead = barmerge.lookahead_off)
plot(htfClose, title = "HTF Confirmed Close",
style = plot.style_stepline,
color = color.new(color.teal, 0),
linewidth = 2)
5.3 Timeframe String Format Reference
| Timeframe | Correct String | Incorrect String |
|---|---|---|
| 1 minute | "1" | — |
| 1 hour | "60" | "1H" ❌ |
| 4 hours | "240" | "4H" ❌ |
| 1 day | "D" | — |
| 1 week | "W" | — |
| 1 month | "M" | — |
📋 Complete Learning Path Summary
✅ Conclusion — Three Core Facts
- Execution model first: The bar-by-bar reactive model and the distinction between
=,:=, andvarare the single most important concepts to master before writing any logic. Misunderstanding these causes state bugs that are invisible in backtests but catastrophic in live trading. - Type qualifiers are enforced at compile time: The
const → input → simple → serieshierarchy is strictly enforced. Passing aseries intto asimple intparameter (e.g.,ta.ema()length) produces a compile error — not a runtime warning. Usinginput.int()is the correct pattern for user-configurable lengths. - Non-repainting MTF requires
close[1]+barmerge.lookahead_on: This is the official pattern documented in the Pine Script v6 reference. Usingclose[0]withlookahead_onintroduces future data leakage on historical bars, invalidating all backtest results.
🚀 Ideas for Script Advancement
- Library Extraction: Once Stage 4 UDT patterns are mastered, refactor reusable UDTs and utility functions into a Pine Script
library()script. Libraries export functions and types without visual output, enabling modular code reuse across multiple indicator and strategy scripts. - Multi-Symbol Dashboard: Combine Stage 4 array techniques with Stage 5
request.security()calls to build a table-based dashboard that displays Z-scores, EMA alignment, and HTF trend direction for multiple symbols simultaneously using thetableobject namespace.
Related Posts
- 📄 Pine Script Tutorial for Beginners: Hello World, Pine Editor Setup & First Chart Script (2024)
- 📄 A Beginner's Guide to Pine Script: What It Is and How It Works in TradingView
- 📄 Pine Script v6: indicator() vs strategy() — Core Functional Differences Explained
- 📄 Pine Script v6 Basic Syntax Overview: Core Rules, Operators, and Clean Code Patterns
- 📄 Top 5 Pine Script Mistakes Beginners Make (And How to Fix Them) — v6 Guide
Comments
Post a Comment