Skip to content

Coming from another technology

From Pine Script

Most Pine indicators port almost line for line. The differences are concentrated in one place: Pine runs your script once per bar, while VP Script runs it once over every bar. Nearly everything else follows from that.

The one difference everything follows from

Pine executes your whole script at each bar, so cross-bar state needs var, every counter is one, and the current bar index is implicit. VP Script runs ONCE, with the whole bar array in hand: ta.* are pure transforms from series to series, loops are ordinary loops, and state is ordinary local variables.

// Pine
//@version=6
indicator("Count up bars")
var int up = 0
if close > close[1]
up := up + 1
plot(up)
// VP Script
indicator('Count up bars')
const up = new Array(barCount).fill(0)
let n = 0
for (let i = 1; i < barCount; i++) {
if (close[i]! > close[i - 1]!) n++
up[i] = n
}
plot(up)

What maps to what

PineVP Script
ta.sma(close, 20)ta.sma(close, 20), same call, but it returns the whole series
close[1]close[i - 1] inside a loop, or math.shift(close, 1)
var / :=ordinary let, because there is no per-bar re-entry
plot(x, offset=26)plot(x, { offset: 26 }), also drawing-only
plotshapemarker(condition, …), which takes a condition series
bgcolor / barcolorthe same names
fill(p1, p2, color=…)fill(p1, p2, { color }), and colour may be a series
input.int etc.input.int etc., plus group, tooltip and step
input.timeframeinput.timeframe, which refuses a resolution the feed cannot read
table.new + table.celltable(rows, { position }): one call, rows of cells, columns measured for you
label.new(…, text)label(barIndex, price, text, …); newlines in the text break as lines
hour(t, tz) / minute(t, tz)time.hour(t, tz) / time.minute(t, tz), or time.minuteOfDay
request.security_lower_tfthe same name, and it returns whole bars ({ time, open, high, low, close, volume }) rather than one array per field
xloc.bar_time{ time, price } anywhere a point is taken, or barAt(t) for the index itself
polyline.newpolyline(points, { color, closed, fillColor })
linefill.new(a, b, c)linefill(a, b, { color }), using the ids line() returns
str.format / str.tostringordinary JavaScript: a template literal, toFixed
request.security(…)request.security(…), always causal
timeframe.periodtimeframe.period
alertconditionalertcondition, closed bars only
nanull, and it propagates rather than becoming 0
nz(x)deliberately absent; use math.*, which keeps the gap
strategy.*not available; VP Script draws, it does not trade
lookahead_ondeliberately not reproduced

Sessions and time zones

A session is a statement about local time (the New York opening range is 09:30 America/New_York), and a bar carries a UTC millisecond. time.* is the bridge, and it is the only one, because a script cannot reach Intl and so cannot write its own. An unknown zone throws rather than quietly answering in UTC, because a range drawn five hours from where you put it looks completely normal.

// Pine
inBox = hour(time, "America/New_York") * 60 + minute(time, "America/New_York") >= 570
// VP Script
const TZ = input.string('Reference zone', 'America/New_York')
const inBox = bars.map((b) => time.minuteOfDay(b.time, TZ) >= 570)

Pair it with request.security_lower_tf when the session boundary is finer than the chart: the fine bars carry their own time, so a 09:30 box stays exact on a 4-hour chart. Bars the finer feed does not fully cover are null, never an empty array, because partial coverage would give you a high measured over half a bar.

Drawing in time, not in bars

Pine has xloc.bar_index and xloc.bar_time. So does VP Script: every point that takes { index, price } also takes { time, price }, and label takes a bar index, { index } or { time }. Reach for the timestamp whenever the anchor is a fact about the clock, because the bar index it lands on changes with the chart’s resolution.

The index a timestamp resolves to is FRACTIONAL, so a 09:30 edge sits exactly at 09:30 on a 4-hour chart rather than snapping to the 08:00 open. Past the last bar it extrapolates with the feed’s own bar span, which is what lets a session box reach its close before that bar exists. barAt(t) gives you the same number when you want it directly.

const close930 = 570 // minutes, New York
const tEnd = /* the session close, in ms */ 0
box({ time: tStart, price: high }, { time: tEnd, price: low }, { color: '#8FB8D4' })
label({ time: tEnd }, high, high.toFixed(2), { align: 'left' })

Curves and bands

A plot is one value per BAR, so it cannot draw a session VWAP sampled from 5-minute bars on a 4-hour chart: that curve has 48 points inside one bar and none inside the next. polyline(points) takes the points as they are. A fillColor closes the path.

line() returns an id, and linefill(a, b) shades the band between two of them, the way fill shades between two plots. The band is the quadrilateral between the two segments’ endpoints, so two lines that do not span the same range give a wedge rather than a guess about where either one continues.

const lo = line({ index: 0, price: level25 }, { index: barCount - 1, price: level25 })
const hi = line({ index: 0, price: level35 }, { index: barCount - 1, price: level35 })
linefill(lo, hi, { color: 'rgba(95,187,214,0.12)' })

Dashboards

Pine states a multi-timeframe read as a table. So does VP Script, so reach for table(rows) rather than a label full of newlines. A label is anchored to a bar, so it slides off screen when you pan, and its columns cannot line up because the chart’s font is proportional. (Newlines in a label DO break as lines; it is just the wrong shape for a table.)

table([
['NQSTRAT', { text: '0.72', bold: true }],
['breaks up', '69.8 %'],
['breaks down', '26.9 %'],
], { position: 'top-right', background: '#101820', border: '#2E3B47' })

The three that catch people

`na` is not zero. Pine authors reach for nz() reflexively. Here a gap propagates, and that is the feature: an average that reads 0 before it has warmed up draws a cliff that never happened. Use math.add and friends.

Higher timeframes arrive later. A weekly value appears once the week has closed, not during it. If your port looks "one period behind" TradingView, that is usually the original using lookahead_on and repainting.

Displacement is visual. offset moves the drawing; the series stays where it was computed, so alerts and fills still see it on the right bar. Reach for math.shift only when you genuinely want the DATA moved.

Writing the port with an assistant? Paste the language bundle into the chat first. Without it a model will confidently write Pine, because that is what it has seen a million of.