Skip to content

Coming from another technology

From TA-Lib and pandas-ta

This is the easiest port of the three, because the model is already the same: you hand a whole array in and get a whole array back. What changes is what a missing value is called, who owns the warm-up, and the fact that this runs in the reader's browser rather than on your machine.

The shape already matches

TA-Lib and pandas-ta are vector libraries: talib.RSI(close, 14) takes the whole series and returns the whole series. So does VP Script. There is no per-bar loop to unwind, which is the entire difficulty of porting from Pine and none of the difficulty here.

Python
import talib
rsi = talib.RSI(close, timeperiod=14)
ma  = talib.SMA(close, timeperiod=20)
upper, mid, lower = talib.BBANDS(close, 20, 2, 2)
const rsi = ta.rsi(close, 14)
const ma = ta.sma(close, 20)
const [upper, mid, lower] = ta.bbands(close, 20, 2)

NaN becomes null, and it propagates

This is the difference that will bite, and it bites quietly. NumPy’s NaN is a float, so it survives arithmetic and comparison in ways that feel forgiving: nan > 5 is False rather than an error, and nan + 1 is nan. VP Script uses null for “no value here”, and it propagates through every ta.* transform the same way, but it is not a number, so a comparison you write by hand needs to say what it means.

Python: quietly False during warm-up
signal = rsi > 70
// null > 70 is not a question with an answer, so be explicit.
const signal = rsi.map(v => v === null ? null : v > 70)
Never fill a gap with zero. It is the reflex fromdf.fillna(0) and it is wrong on a price chart: a zero is a price, and an indicator that dips to zero for its warm-up period draws a cliff nobody traded. null renders as a break in the line, which is the truth.

Warm-up is yours, not the framework’s

In a notebook you usually slice the warm-up off (df.dropna()) before plotting or backtesting. Here you do not slice anything. The array must stay the same length as the bars, because index i is bar i. The leading nulls are how the chart knows not to draw there.

Python
df = df.dropna()          # 13 rows shorter
plot(df.index, df.rsi)
// Same length as `close`, with nulls at the front.
plot(rsi, { title: 'RSI' })

What maps to what

TA-Lib / pandas-taVP ScriptNote
talib.SMA(c, n)ta.sma(c, n)
talib.EMA(c, n)ta.ema(c, n)
talib.RSI(c, n)ta.rsi(c, n)
talib.ATR(h, l, c, n)ta.atr(n)Reads high/low/close itself.
talib.MACD(c)ta.macd(c, 12, 26, 9)Returns [macd, signal, hist].
talib.BBANDS(c, n, k, k)ta.bbands(c, n, k)One k; upper and lower are symmetric.
np.where(a, b, c)a.map((v, i) => v ? b[i] : c[i])Plain JavaScript; there is no vector select.
series.shift(n)ta.shift(series, n)Fills the front with null, not NaN.
series.rolling(n).max()ta.highest(series, n)
df['close']closeopen, high, low, close, volume, time are in scope.

The reference has every function with its signature. If something you use is missing, it is usually three lines of map over an array you already have. The arrays are plain JavaScript arrays and nothing stops you.

Where it runs, and what that changes

Your Python runs on your machine, against a dataframe you loaded, with the whole of PyPI available. A VP Script runs in the reader’s browser, in a sandbox with no network at all. Three consequences, and they are all consequences of that one fact:

  • No imports, no requests, no files. A script cannot fetch. If it needs another instrument or another timeframe, it declares one with request.security and the host fetches it, which is also why a script cannot exfiltrate anything.
  • No print to a notebook. Output is what you draw. That is a real adjustment coming from a REPL, and the visuals page is the substitute: plotting an intermediate series is how you debug here.
  • Determinism is enforced. random, the wall clock and locale are all absent. A backtest that seeded a random number would simply not run; an indicator that quietly depended on one would have been unreproducible anyway.

The thing that is easy to do in pandas and impossible here

Look-ahead. In a dataframe nothing stops you writing df['close'].shift(-1), tomorrow’s close on today’s row, and a backtest built on it produces a beautiful, meaningless equity curve. It is the single commonest way a Python strategy lies to its author.

Here it cannot happen by accident. Cross-timeframe and cross-symbol data goes through request.security, which is strictly causal: a foreign bar becomes visible only from the chart bar that opened after it closed, and there is no lookahead_on equivalent to switch that off. Within a series, ta.shift(series, -1) is refused rather than supported.

This is a constraint, and it is worth naming as one: if you are prototyping research where peeking is deliberate and understood, do it in Python. VP Script is for indicators that will be drawn on somebody’s live chart, where a look-ahead is not a shortcut, it is a wrong number on screen.

A worked port

A pandas-ta strategy condition, moved across in full:

pandas-ta
import pandas_ta as ta

df['ema_fast'] = ta.ema(df['close'], length=12)
df['ema_slow'] = ta.ema(df['close'], length=26)
df['atr']      = ta.atr(df['high'], df['low'], df['close'], length=14)

df['long'] = (df['ema_fast'] > df['ema_slow']) & (df['atr'] > df['atr'].rolling(50).mean())
indicator('Trend with volatility filter', { overlay: true })
const fast = ta.ema(close, 12)
const slow = ta.ema(close, 26)
const atr = ta.atr(14)
const atrAvg = ta.sma(atr, 50)
// null-safe, because the first 50 bars have no atrAvg
const long = fast.map((f, i) =>
f === null || slow[i] === null || atr[i] === null || atrAvg[i] === null
? null
: f > slow[i] && atr[i] > atrAvg[i])
plot(fast, { color: '#26a69a' })
plot(slow, { color: '#ef5350' })
plotShape(long, { style: 'triangleUp', location: 'belowBar', color: '#26a69a' })

The only line that grew is the condition, and it grew because it now says what should happen during warm-up instead of leaving it to a float convention.