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.
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.
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)df.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.
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-ta | VP Script | Note |
|---|---|---|
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'] | close | open, 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 withrequest.securityand the host fetches it, which is also why a script cannot exfiltrate anything. - No
printto 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.
A worked port
A pandas-ta strategy condition, moved across in full:
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 atrAvgconst 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.