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

graph TD S1["Stage 1 Language Foundations & Execution Model"] --> S2["Stage 2 Type System & Built-in Series"] S2 --> S3["Stage 3 TA Functions & Plotting"] S3 --> S4["Stage 4 Arrays, UDTs & Drawing Objects"] S4 --> S5["Stage 5 Strategy Backtesting & request.security()"] style S1 fill:#4a90d9,color:#fff,stroke:#2c6fad style S2 fill:#5ba85a,color:#fff,stroke:#3d7a3c style S3 fill:#e8a838,color:#fff,stroke:#b07a1a style S4 fill:#9b59b6,color:#fff,stroke:#6c3483 style S5 fill:#e74c3c,color:#fff,stroke:#a93226

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 no calc_on_every_tick parameter on indicator().
  • strategy() — executes on bar close by default. The calc_on_every_tick parameter exists exclusively on strategy().

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
openBar open price
highBar high price
lowBar low price
closeBar close price (also the default source for most indicators)
volumeBar 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_lineContinuous line; bridges na gaps (default)
plot.style_linebrLine that breaks at na gaps
plot.style_steplineHorizontal steps between values
plot.style_stepline_diamondStep line with diamond markers at data points
plot.style_histogramVertical bars from zero baseline
plot.style_areaFilled area from zero; bridges na
plot.style_areabrFilled area that breaks at na
plot.style_circlesCircle at each data point
plot.style_crossCross marker at each data point
plot.style_columnsColumn 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~50500max_lines_count
label~50500max_labels_count
box~50500max_boxes_count
polyline~50100max_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 timingEvery real-time tickBar 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

graph LR F1["✅ //@version=6 header var / := / = keywords Execution timing"] --> F2["✅ Type qualifiers const→input→simple→series bool restrictions"] F2 --> F3["✅ ta.* functions plot() / plotshape() fill() patterns"] F3 --> F4["✅ array.* UDT type keyword Drawing objects"] F4 --> F5["✅ strategy() request.security() Non-repainting MTF"] style F1 fill:#dbeafe,color:#1e3a5f,stroke:#93c5fd style F2 fill:#dcfce7,color:#14532d,stroke:#86efac style F3 fill:#fef9c3,color:#713f12,stroke:#fde047 style F4 fill:#f3e8ff,color:#4a044e,stroke:#d8b4fe style F5 fill:#fee2e2,color:#7f1d1d,stroke:#fca5a5

✅ Conclusion — Three Core Facts

  • Execution model first: The bar-by-bar reactive model and the distinction between =, :=, and var are 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 → series hierarchy is strictly enforced. Passing a series int to a simple int parameter (e.g., ta.ema() length) produces a compile error — not a runtime warning. Using input.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. Using close[0] with lookahead_on introduces future data leakage on historical bars, invalidating all backtest results.

🚀 Ideas for Script Advancement

  1. 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.
  2. 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 the table object namespace.

Related Posts

Comments

Popular posts from this blog

How to Build a Volume Indicator in Pine Script v6: Buying & Selling Pressure Analysis

Pine Script v6 plot() Function: Complete Guide to Lines, Histograms, and Circles

SuperTrend Indicator in Pine Script v6: Canonical Formula, Flip Logic, and Non-Repainting Signals