Guides

Optimizing Pine Script

How to make a Pine Script indicator or strategy run faster: measure where the time goes, use what Pine already does natively, and remove the work that does not change the result.

Updated Publisher Alien_AlgorithmsPine Script v6

A Pine script runs once on every historical bar, and an indicator runs again on every price update of the realtime bar. Whatever a line costs, it pays that cost tens of thousands of times. When a script takes too long, TradingView stops it with an error, so in large scripts optimization decides whether a feature can exist at all.

Measure first

Guessing where the time goes is usually wrong. TradingView's Pine Profiler, available from the Pine Editor, runs the script and reports how much of the total runtime each line and code block took.

  1. 1
    Profile on a realistic chart

    Use the symbol, timeframe and amount of history the script will normally run on. Costs that scale with bars or with collection size only show up there.

  2. 2
    Find the few lines that dominate

    Most of the time usually sits in a handful of places: a loop, an expensive function, a rebuild. Ignore everything else for now.

  3. 3
    Ask whether that work changes the result

    Work that repeats an unchanged answer, moves data that stays the same, or visits entries that did not change can be removed without changing any output.

  4. 4
    Change one thing, then profile again

    Confirm the time dropped and the plotted values are identical. Keep the change only if both hold.

Native Pine first

Built-in functions run inside TradingView's engine and are faster than the same logic written as a Pine loop. Before restructuring anything, check that the script uses them:

  • Series history is already O(1). close[50] reads a past value directly. Do not copy prices into an array to look back.
  • Prefer built-ins to loops. ta.highest(high, 50) instead of a loop over high[i]; array.sum(), array.max(), array.sort() and array.binary_search() instead of hand-written versions.
  • Call each indicator once. Every ta.sma(close, 20) call keeps its own state and computes separately. Assign the result to a variable and reuse it.
  • Combine requests. Several values from the same symbol and timeframe can come back from one request.security() call as a tuple.
One request, two valuespine
[dailyClose, dailyAtr] = request.security(syminfo.tickerid, "D", [close, ta.atr(14)])

Do less on historical bars

  • Tables and dashboards only show their final state, so fill them on the last bar with barstate.islast.
  • Drawings can be created once with var and moved with their setter functions, instead of being deleted and created again.
  • Event logic that only matters on closed bars can wait for barstate.isconfirmed, so it does not rerun on every realtime update.
  • Long histories that the script does not need can be cut with the calc_bars_count argument of indicator().
A table filled once, on the last barpine
var table stats = table.new(position.top_right, 2, 1)

// The ATR runs on every bar; only the table waits for the last one.
float atr14 = ta.atr(14)

if barstate.islast
    table.cell(stats, 0, 0, "ATR")
    table.cell(stats, 1, 0, str.tostring(atr14, format.mintick))

Work that adds nothing

Once the native basics are in place, what remains in a demanding script is usually structural: work around the calculation, repeated on every bar. Each pattern below has a replacement that produces the same result.

What the profiler showsWhat it usually meansReplace it with
An expensive pure function on every barIts inputs repeat, so it recomputes the same answerMemo or Watch
A model refit or summary on every barIt could run every few bars without harmCadenceGate
array.shift() on a full rolling arrayEvery new entry moves all the othersA row ring
array.remove() across parallel arraysRemoving one object shifts every later one, in every arrayStablePool
A loop over every entry to find the changed onesMost of the visits find nothingDirtySet
array.indexof() or a loop to find a keyA linear search per lookupA typed store or IntIndex
A map of wrapper objects that each hold an arrayOne small array per keyIntBuckets
array.new() inside the barA fresh allocation on every executionvar arrays, output arrays and double buffers

In our tests, these replacements commonly ran 15% to 40% faster than the conventional versions, and over 90% faster for sparse updates and keyed lookups against a full scan or search. A replacement only helps where the original work dominates, which is what the profiler is for.

Example: a rolling event history

A script keeps the last 200 pivot highs. The usual version pushes each new pivot and shifts the oldest one out:

Before: every new pivot shifts 199 valuespine
float pivot = ta.pivothigh(high, 5, 5)

var array<float> pivots = array.new<float>()

if not na(pivot)
    pivots.push(pivot)
    if pivots.size() > 200
        pivots.shift()

float latestPivot = pivots.size() > 0 ? pivots.last() : na

A ring holds the same 200 values and overwrites the oldest one in place:

After: one write per pivotpine
import Alien_Algorithms/OptiPine/1 as op

float pivot = ta.pivothigh(high, 5, 5)

var op.FloatRowRing pivots = op.floatRowRing(200, 1)

if not na(pivot)
    pivots.pushValue(pivot)

float latestPivot = pivots.newestAt(0, 0)

Reads stay in time order: pivots.at(0, 0) is the oldest retained pivot and newestAt(0, 0) the latest, or na before the first one. Pivots are events, which is why they belong in a ring; a price on every bar is already in the series history.

Loops

  • Move work that does not depend on the loop variable out of the loop.
  • Leave with break as soon as the answer is known.
  • Avoid a loop inside a loop over the same collection. A lookup structure usually turns the inner loop into one call.
  • Prefer batch calls such as getMany(), findMany() or markMany(), which dispatch once for the whole batch.
  • Guard counted loops over a range that may be empty: for i = 0 to n - 1 counts down when n is 0.

Stay correct while optimizing

Keep history-dependent calls on every bar

ta.* functions keep their own history. Inside a condition that is false on some bars, they miss those bars and return different values. Pine Script v6 also evaluates and and or lazily, so a call on the right of and can be skipped too. Compute these on every bar and pass the results into whatever is guarded.

Cache on what matters

A cache keyed on raw prices misses on almost every bar. Round the inputs to the precision the calculation actually responds to, and the expensive part runs only when that changes. Watch & Memo shows the pattern.

Prove the output is unchanged

Plot the old and new values together, or both to the Data Window, across the whole history. An optimization that changes a value is a different indicator.

Delayed work is a design choice
Running something every few bars with a CadenceGate does change when its result updates. Use it for periodic fitting or summaries, where that is acceptable, never for a value the current bar needs.

Checklist

  • Profile on a realistic chart and start with the costliest lines.
  • Use series history and built-in functions before writing loops.
  • Call each ta.* function once, on every bar, outside any condition.
  • Fill tables and final drawings on the last bar; reuse drawing objects.
  • Cache pure results on rounded inputs.
  • Replace shifting, removal, full scans and linear search with the structures above where the profiler shows them.
  • Create collections once with var and reuse output arrays.
  • Confirm the output is identical after every change.

The Quickstart sets up OptiPine in a script, and the complete examples combine several of these structures in working indicators.

OptiPine is a performance library for Pine Script. TradingView's memory, execution and collection limits still apply. © Alien_Algorithms.