Pine Script is a great place to sketch an idea: the data is already there and the Strategy Tester is one click away. Python is where you go when you want more: exchange data you control, your own analysis, or a bot that places orders through an exchange's API. There's no reliable one-click converter between the two, but the concepts map cleanly, and a handful of details decide whether your Python numbers match TradingView's.
This guide maps Pine concepts to pandas, converts a complete strategy, and shows a backtest loop that fills orders the way Pine does. The strategy is an example for learning the conversion, not a recommendation.
Bar by bar versus whole columns
A Pine script runs once per bar, from the oldest to the newest. close means "the close of the bar being processed" and close[1] reaches one bar back.
pandas works on whole columns at once. df["close"].shift(1) is the entire previous-close column in one operation. That's perfect for indicators, which only depend on past values. It's awkward for trade logic, which depends on what happened before: whether a position is open, where its stop was set. So the usual split is:
- Indicators and signals: vectorised columns.
- Positions, stops and fills: a plain loop over the bars.
Concept map
| Pine Script v6 | Python (pandas) |
|---|---|
close, close[1] | df["close"], df["close"].shift(1) |
na, nz(x) | NaN, x.fillna(0) |
ta.change(x) | x.diff() |
ta.sma(x, n) | x.rolling(n).mean() |
ta.ema(x, n) | x.ewm(span=n, adjust=False).mean() (first values depend on seeding) |
ta.rma(x, n) | Wilder's average: an SMA seed, then alpha = 1/n (function below) |
ta.rsi(x, n), ta.atr(n) | Built on ta.rma(), not on a simple average |
ta.highest(high, n) | df["high"].rolling(n).max() |
ta.crossover(a, b) | (a > b) & (a.shift(1) <= b.shift(1)) |
var | A variable defined before the loop |
input.int() | A function argument or a config value |
request.security() on a higher timeframe | resample(), then shift(1) and forward-fill |
strategy.entry(), strategy.exit() | Your own loop, or a backtesting library |
The Pine Script we'll convert
An RSI pullback in an uptrend: buy when RSI crosses back above 30 while the close is above the 200 SMA, exit when RSI crosses above 55, with an ATR stop set at entry.
//@version=6
strategy("RSI pullback with trend filter", overlay = false,
initial_capital = 10000, default_qty_type = strategy.percent_of_equity, default_qty_value = 95,
commission_type = strategy.commission.percent, commission_value = 0.1)
rsiLen = input.int(14, "RSI length")
trendLen = input.int(200, "Trend SMA length")
entryLvl = input.float(30, "Buy when RSI crosses above")
exitLvl = input.float(55, "Sell when RSI crosses above")
atrMult = input.float(2.0, "Stop (x ATR)")
r = ta.rsi(close, rsiLen)
trend = ta.sma(close, trendLen)
atr = ta.atr(14)
longSignal = ta.crossover(r, entryLvl) and close > trend
exitSignal = ta.crossover(r, exitLvl)
var float stopPrice = na
if longSignal and strategy.position_size == 0
stopPrice := close - atrMult * atr
strategy.entry("Long", strategy.long)
if exitSignal
strategy.close("Long")
strategy.exit("Stop", "Long", stop = stopPrice)
plot(r, "RSI", color.purple)
hline(entryLvl, "Entry level")
hline(exitLvl, "Exit level")
Step 1: get the data
Any OHLCV table works. With the ccxt library you can pull public candles from most crypto exchanges without an API key:
import ccxt
import pandas as pd
exchange = ccxt.binance() # any exchange ccxt supports
ohlcv = exchange.fetch_ohlcv("BTC/USDT", timeframe="4h", limit=1000)
df = pd.DataFrame(ohlcv, columns=["time", "open", "high", "low", "close", "volume"])
df["time"] = pd.to_datetime(df["time"], unit="ms", utc=True)
df = df.set_index("time")
Timestamps from ccxt are in UTC and mark the open of each candle, like TradingView's. TradingView displays them in the exchange's or your chosen time zone, so convert before comparing bars by eye.
Step 2: indicators that match Pine
This is where most conversions go wrong. Pine's ta.rsi() and ta.atr() use Wilder's smoothing, ta.rma(), which starts with a simple average of the first values and then gives each new value a weight of 1/length. An RSI built on rolling().mean() is a different indicator with the same name.
import numpy as np
import pandas as pd
def rma(src: pd.Series, length: int) -> pd.Series:
"""Pine's ta.rma(): SMA of the first `length` values, then alpha = 1 / length."""
values = src.to_numpy(dtype=float)
out = np.full(len(values), np.nan)
valid = np.flatnonzero(~np.isnan(values))
if len(valid) < length:
return pd.Series(out, index=src.index)
first = valid[0]
seed = first + length - 1
out[seed] = values[first:seed + 1].mean()
alpha = 1.0 / length
for i in range(seed + 1, len(values)):
out[i] = alpha * values[i] + (1 - alpha) * out[i - 1]
return pd.Series(out, index=src.index)
def rsi(close: pd.Series, length: int) -> pd.Series:
"""Pine's ta.rsi(): Wilder-smoothed gains and losses."""
change = close.diff()
gain = rma(change.clip(lower=0), length)
loss = rma(-change.clip(upper=0), length)
out = 100 - 100 / (1 + gain / loss)
return out.where(loss != 0, 100.0).where(gain.notna())
def atr(df: pd.DataFrame, length: int) -> pd.Series:
"""Pine's ta.atr(): Wilder-smoothed true range."""
prev_close = df["close"].shift(1)
true_range = pd.concat(
[df["high"] - df["low"], (df["high"] - prev_close).abs(), (df["low"] - prev_close).abs()],
axis=1,
).max(axis=1)
return rma(true_range, length)
def crossover(a: pd.Series, b) -> pd.Series:
"""Pine's ta.crossover(): a is above b now and was at or below it on the previous bar."""
if np.isscalar(b):
b = pd.Series(float(b), index=a.index)
return (a > b) & (a.shift(1) <= b.shift(1))
Two details matter most: the rma() seed, and the RSI edge case where there are no losses at all, which Pine reports as 100. ewm(alpha=1/length, adjust=False) uses the same recursion as rma() but seeds differently, so early values disagree; after a few hundred bars the difference fades. The same applies when the two sides simply start from different amounts of history.
Step 3: signals
def add_signals(df: pd.DataFrame, rsi_len=14, trend_len=200, entry_lvl=30.0, exit_lvl=55.0) -> pd.DataFrame:
df = df.copy()
df["rsi"] = rsi(df["close"], rsi_len)
df["trend"] = df["close"].rolling(trend_len).mean()
df["atr"] = atr(df, 14)
df["long_signal"] = crossover(df["rsi"], entry_lvl) & (df["close"] > df["trend"])
df["exit_signal"] = crossover(df["rsi"], exit_lvl)
return df
Every column on a row uses that bar's close and earlier data only, exactly what a Pine script sees when the bar closes. Comparisons with NaN are False, so the warm-up bars produce no signals, as in Pine.
Step 4: a backtest loop that fills like Pine
Pine evaluates a strategy when a bar closes and fills the resulting market order at the next bar's open. The loop reproduces that with a pending order: decided on one bar, filled on the next.
def backtest(df: pd.DataFrame, atr_mult=2.0, capital=10_000.0, alloc=0.95, commission=0.001):
cash, units, stop = capital, 0.0, np.nan
pending = None # "buy" or "sell", decided at a bar's close, filled at the next bar's open
trades, equity = [], []
for ts, row in df.iterrows():
# 1) Fill the order decided on the previous bar, at this bar's open
if pending == "buy" and units == 0:
price = row["open"]
units = cash * alloc / (price * (1 + commission))
cash -= units * price * (1 + commission)
trades.append({"entry_time": ts, "entry": price})
elif pending == "sell" and units > 0:
cash += units * row["open"] * (1 - commission)
trades[-1].update(exit_time=ts, exit=row["open"], reason="signal")
units = 0.0
pending = None
# 2) Stop inside the bar; a gap below the stop fills at the open
if units > 0 and row["low"] <= stop:
price = min(stop, row["open"])
cash += units * price * (1 - commission)
trades[-1].update(exit_time=ts, exit=price, reason="stop")
units = 0.0
# 3) Decide on this bar's close, for the next bar's open
if units == 0 and row["long_signal"]:
pending = "buy"
stop = row["close"] - atr_mult * row["atr"]
elif units > 0 and row["exit_signal"]:
pending = "sell"
equity.append(cash + units * row["close"])
result = df.assign(equity=equity)
return result, pd.DataFrame(trades)
df = add_signals(df)
result, trades = backtest(df)
print(trades.tail())
print("Final equity:", round(result["equity"].iloc[-1], 2))
What maps to what:
pendingplays the role of Pine's order queue: nothing fills at the signal bar's close.- The stop is stored at entry, like the
var float stopPricein the Pine script, and it can trigger on the same bar the entry fills, as Pine's can. alloc=0.95andcommission=0.001mirrordefault_qty_value = 95andcommission_value = 0.1.iterrows()is slow but readable. For millions of bars, move the loop to NumPy arrays or a library; the logic stays the same.
Step 5: compare with TradingView
- Use the same exchange, symbol and timeframe on both sides. Prices differ between venues.
- Compare RSI and ATR on a few recent bars, well past the warm-up period.
- Compare the list of trades: entry and exit times and prices.
- Expect small differences, and know where they come from:
| Difference | Why |
|---|---|
| Early indicator values | Different seeding and different amounts of history loaded |
| Individual prices | TradingView's feed and the exchange API can differ slightly |
| Stop fills | Both use the bar's OHLC, but intrabar order is assumed, not known |
| Sizing and fees | Rounding, and Pine sizing from equity at the signal bar's close |
If a trade appears on one side only, it's almost always a data difference near a threshold or an off-by-one bar.
Conversion mistakes to avoid
ewm()with its default settings. pandas defaults toadjust=True, which weights the start of the series differently from Pine's recursive formula. Passadjust=Falsefor EMAs.- An RSI or ATR on
rolling().mean(). It's a different indicator; Pine uses Wilder's smoothing. - Filling at the signal bar's close. Trading at a price you only know once the bar has finished is look-ahead; fill at the next open, as Pine does by default.
- Including the forming candle. The last row returned by an exchange is usually still open. In a live bot, drop it before computing signals, or your signals will change during the candle, which is the Python version of repainting.
- Time zones. Daily candles from an exchange start at 00:00 UTC; on TradingView they may follow the exchange's session. Compare like with like.
Higher timeframes without look-ahead
request.security() has a well-known trap, and pandas has the same one. Suppose you want the daily close on 4-hour bars:
daily_close = df["close"].resample("1D").last()
# Wrong: each 4h bar would see the close of its own day, hours before it happens
# leaky = daily_close.reindex(df.index, method="ffill")
# Right: only the previous completed day is known during the day
prev_daily_close = daily_close.shift(1).reindex(df.index, method="ffill")
df["above_prev_daily_close"] = df["close"] > prev_daily_close
resample("1D") labels each day by its start, so without the shift(1) the 04:00 bar gets the 24:00 close. That's the pandas version of lookahead_on without an offset, explained for Pine in multi-timeframe Pine Script with request.security().
From backtest to bot
A live bot runs the same functions on a schedule: fetch the latest candles, drop the candle that is still forming, compute the signals on the last closed one, and send an order through the exchange API if something changed. On top of the backtest logic it needs things a backtest can ignore: order errors, partial fills, restarts, rate limits and a record of what it already did. Run it against an exchange's testnet or on paper first. Our complete ccxt bot guide builds all of that in one tested file.
If you're converting the other way, towards MetaTrader 5, the equivalent guide is how to convert Pine Script to MQL5.
Sources and further reading
- Pine Script v6 reference: ta.rma() and ta.rsi() — the formulas the Python functions above reproduce.
- Pine Script User Manual: Execution model — how a script runs bar by bar.
- pandas documentation: DataFrame.ewm and DataFrame.resample.
- ccxt Manual — fetching OHLCV data and placing orders on crypto exchanges.
Try it with Edgoro
Edgoro's crypto trading bot generator writes Python bots with ccxt from a plain-English description, with a syntax check and paper or testnet mode first; it's part of the trading bots option. To test a crypto strategy without writing code, the Backtest Lab runs it on real exchange data. The RSI used here is explained on the RSI indicator page.
Converted code can behave differently from the original; compare both before relying on either. Backtested results are hypothetical, and trading involves substantial risk of loss.