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=6indicator("Count up bars")var int up = 0if close > close[1] up := up + 1plot(up) // VP Scriptindicator('Count up bars')const up = new Array(barCount).fill(0)let n = 0for (let i = 1; i < barCount; i++) { if (close[i]! > close[i - 1]!) n++ up[i] = n}plot(up)What maps to what
| Pine | VP 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 |
plotshape | marker(condition, …), which takes a condition series |
bgcolor / barcolor | the 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.timeframe | input.timeframe, which refuses a resolution the feed cannot read |
table.new + table.cell | table(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_tf | the 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.new | polyline(points, { color, closed, fillColor }) |
linefill.new(a, b, c) | linefill(a, b, { color }), using the ids line() returns |
str.format / str.tostring | ordinary JavaScript: a template literal, toFixed |
request.security(…) | request.security(…), always causal |
timeframe.period | timeframe.period |
alertcondition | alertcondition, closed bars only |
na | null, 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_on | deliberately 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.
// PineinBox = hour(time, "America/New_York") * 60 + minute(time, "America/New_York") >= 570 // VP Scriptconst 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 Yorkconst tEnd = /* the session close, in ms */ 0box({ 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.