Every Pine Script developer hits the same wall eventually. Your strategy works on TradingView, but you want more: a ten-year backtest on your own data, a parameter sweep over 200 symbols, or a walk-forward test that doesn't fit in a browser tab. That means Python — and it usually means rewriting your script by hand and hoping you didn't change its meaning along the way.
This is the second post in a short series where I take classic Pine strategies and convert them to Python, then walk through the result line by line: what translates directly, and what quietly changes semantics. The Python below is not hand-written — it's the actual output of the converter I build (more on that at the end), so you can see exactly what a machine translation looks like, warts and all.
Last time it was a dual-EMA crossover: mostly mechanical, one decorator, some type coercions. This time it's an RSI mean-reversion strategy, and the translation gets less comfortable in exactly the two places the first post promised — a strategy.exit OCO bracket, and a var counter that tracks bars since entry. Both look innocent in Pine. Both hide a decision the translator has to make for you.
The Pine original
Buy the oversold bounce, exit on an ATR-based bracket or a time stop — whichever comes first:
//@version=6
strategy("RSI Mean Reversion", overlay=true, initial_capital=10000,
default_qty_type=strategy.percent_of_equity, default_qty_value=100)
rsiLen = input.int(14, "RSI length", minval=1)
overbought = input.int(70, "Overbought level", minval=51, maxval=100)
oversold = input.int(30, "Oversold level", minval=0, maxval=49)
maxHold = input.int(10, "Max hold (bars)", minval=1)
rsi = ta.rsi(close, rsiLen)
atrVal = ta.atr(14)
var int barsInTrade = 0
inTrade = strategy.position_size != 0
if inTrade
barsInTrade += 1
else
barsInTrade := 0
longSignal = ta.crossover(rsi, oversold)
if longSignal and not inTrade
strategy.entry("Long", strategy.long)
if inTrade
strategy.exit("Exit Long", "Long", profit=atrVal * 2, loss=atrVal)
if barsInTrade >= maxHold
strategy.close("Long", comment="Time exit")
plot(rsi, "RSI", color=color.new(color.purple, 0))
hline(overbought, "Overbought", color=color.new(color.red, 0))
hline(oversold, "Oversold", color=color.new(color.green, 0))
The Python output
"""
@pyne
"""
from pynecore import Persistent, Series
from pynecore.lib import close, color, hline, input, plot, script, strategy, ta
_pyne_in_rsiLen: int = input.int(14, "RSI length", minval=1)
_pyne_in_overbought: int = input.int(70, "Overbought level", minval=51, maxval=100)
_pyne_in_oversold: int = input.int(30, "Oversold level", minval=0, maxval=49)
_pyne_in_maxHold: int = input.int(10, "Max hold (bars)", minval=1)
@script.strategy(title="RSI Mean Reversion", overlay=True, initial_capital=10000, default_qty_type=strategy.percent_of_equity, default_qty_value=100)
def main():
rsi: Series[float] = ta.rsi(close, _pyne_in_rsiLen)
atrVal: Series[float] = ta.atr(14)
barsInTrade: Persistent[int] = 0
inTrade: Series[bool] = (strategy.position_size != 0)
if inTrade:
barsInTrade = (barsInTrade + 1)
else:
barsInTrade = 0
longSignal: Series[float] = ta.crossover(rsi, _pyne_in_oversold)
if (longSignal and (not inTrade)):
strategy.entry("Long", strategy.long)
if inTrade:
strategy.exit("Exit Long", "Long", profit=(atrVal * 2), loss=atrVal)
if (barsInTrade >= _pyne_in_maxHold):
strategy.close("Long", comment="Time exit")
plot(rsi, "RSI", color=color.new(color.purple, 0))
hline(_pyne_in_overbought, "Overbought", color=color.new(color.red, 0))
hline(_pyne_in_oversold, "Oversold", color=color.new(color.green, 0))
This script converted with zero diagnostics — nothing unsupported, nothing dropped. Now the interesting part.
What translates 1:1
The mapping table first, because this is the part you can check mechanically:
| Pine | Python | What it means |
|---|---|---|
ta.rsi(close, rsiLen) |
ta.rsi(close, _pyne_in_rsiLen) |
Same call, same name — the runtime mirrors Pine's ta.*
|
strategy.entry("Long", strategy.long) |
identical | Same call; fill timing still lives in the emulator, not the line |
strategy.exit("Exit Long", "Long", profit=atrVal * 2, loss=atrVal) |
identical | Same call — one bracket, two legs, first hit cancels the other |
strategy.close("Long", comment="Time exit") |
identical | Same call |
hline(overbought, ...) |
hline(_pyne_in_overbought, ...) |
Hoisted input, same line |
Two details worth noticing. First, ta.rsi(close, _pyne_in_rsiLen) carries no round() around the length — compare last post's ta.ema(close, round(_pyne_in_fastLen)). The converter inserts the coercion only where the target function actually needs an int; where it doesn't, it leaves your code alone. That's the kind of per-function judgment a regex-based converter can't make, and it's why the output reads like code someone wrote rather than code a machine mangled.
Second, the strategy.exit line is character-for-character identical in both versions — and that is exactly the trap. The OCO bracket looks like it lives in that line. It doesn't. The line only registers two exit prices; which leg fills first, and what happens when both trigger on the same bar, is decided by the broker emulator underneath. A Python backtest only matches TradingView if its emulator makes the same choice. Same lesson as the entry fill in post #1, one level up: the risk was never the syntax, it's the execution model around it.
What changes meaning
var becomes Persistent — and that's the whole story of Pine state. In Pine, var int barsInTrade = 0 is a quiet promise: initialise once, keep the value across bars. In Python there is no quiet promise — state has to be declared. The converter makes it explicit: barsInTrade: Persistent[int] = 0. If you hand-write this translation, this is the line you get wrong: a plain barsInTrade = 0 inside main() resets every bar, your time exit never fires, and the strategy silently holds forever. The output also spells out the reset branch — else: barsInTrade = 0 — so the counter's lifecycle is visible in the code instead of living in your head.
The counter doesn't count the entry bar — and that's correct. inTrade reads strategy.position_size != 0, and on the bar where strategy.entry fires, the position is still zero: the order hasn't filled yet. So barsInTrade starts counting on the bar after entry. If you assumed "max hold 10 bars" meant ten bars including the entry bar, your backtest just gave the trade one extra bar of rope. Off by one, on every single trade, in the direction that flatters the strategy. Worth checking in your own ports.
Two exits, one bar — who wins? On the bar where barsInTrade hits maxHold, both the bracket (strategy.exit) and the time exit (strategy.close) are submitted. Which one the trade actually exits on depends on the emulator's order handling, not on the order the lines appear in your file. Before you compare a single number against TradingView, verify this one case: force a bar where both trigger and see which exit price your engine records. Most "the port is wrong" mysteries are this, not the indicator math.
The honest summary
For a strategy built from mainstream ta.* and strategy.* calls, Pine-to-Python translation is still mostly mechanical — but the mechanical part is now the easy part. The hard part is state: var is a promise Pine keeps silently, Python makes you write it down, and the bracket is a registration, not a result. Get the counter and the exits right and the rest follows; get them wrong and the strategy runs without errors and backtests differently, which is worse than crashing.
This conversion was done by Pine2Py, the tool I build for exactly this job: paste a Pine script, get PyneCore-flavored Python back, diagnostics included when something can't be translated. It's free to try — every account gets 3 conversions, no card — and Pro is $12/month if you end up converting a whole library. Next in the series: what 1,909 real scripts taught me about which Pine constructs actually break translation — the data post, with the numbers.
Top comments (0)