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 first 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.
The Pine original
A dual-EMA crossover — probably the first strategy most of us ever wrote:
//@version=6 strategy("EMA Cross Strategy", overlay=true, initial_capital=10000, default_qty_type=strategy.percent_of_equity, default_qty_value=100) fastLen = input.int(9, "Fast EMA length", minval=1) slowLen = input.int(21, "Slow EMA length", minval=2) fast = ta.ema(close, fastLen) slow = ta.ema(close, slowLen) goldenCross = ta.crossover(fast, slow) deathCross = ta.crossunder(fast, slow) if goldenCross strategy.entry("Long", strategy.long) if deathCross strategy.close("Long", comment="Death cross exit") plot(fast, "Fast EMA", color=color.new(color.teal, 0)) plot(slow, "Slow EMA", color=color.new(color.orange, 0)) The Python output
""" @pyne """ from pynecore import Series from pynecore.lib import close, color, input, plot, script, strategy, ta _pyne_in_fastLen: int = input.int(9, "Fast EMA length", minval=1) _pyne_in_slowLen: int = input.int(21, "Slow EMA length", minval=2) @script.strategy(title="EMA Cross Strategy", overlay=True, initial_capital=10000, default_qty_type=strategy.percent_of_equity, default_qty_value=100) def main(): fast: Series[float] = ta.ema(close, round(_pyne_in_fastLen)) slow: Series[float] = ta.ema(close, round(_pyne_in_slowLen)) goldenCross: Series[float] = ta.crossover(fast, slow) deathCross: Series[float] = ta.crossunder(fast, slow) if goldenCross: strategy.entry("Long", strategy.long) if deathCross: strategy.close("Long", comment="Death cross exit") plot(fast, "Fast EMA", color=color.new(color.teal, 0)) plot(slow, "Slow EMA", color=color.new(color.orange, 0)) This script converted with zero diagnostics — nothing unsupported, nothing dropped. Now the interesting part.
What translates 1:1
Most of the body is a straight transliteration, and that's by design. The output targets the open-source PyneCore runtime, which mirrors Pine's standard library under pynecore.lib. So ta.ema, ta.crossover, ta.crossunder, strategy.entry, strategy.close, plot, and even color.new(color.teal, 0) keep their names and argument shapes. If you can read the Pine, you can read the Python.
Three structural changes are worth pointing out:
The strategy() call becomes a decorator. Pine's top-level declaration turns into @script.strategy(...) on a main() function, with every keyword argument — initial_capital, default_qty_type, all of it — carried over verbatim. Your whole script body moves inside main(), which the runtime executes bar by bar, just as TradingView executes your script once per bar.
Inputs are hoisted out of main(). The two input.int calls become module-level constants with a _pyne_in_ prefix. Inputs are configuration, not per-bar logic, so they live outside the bar loop. In your own Python code you can then override them for a parameter sweep — which is half the reason you wanted Python in the first place.
Integer arguments get an explicit round(). Notice ta.ema(close, round(_pyne_in_fastLen)). Pine is loose about numeric types — a length is a series int and nobody worries. Python is not loose, and PyneCore's ta.ema wants a real int. The converter inserts the coercion Pine was doing silently. When you hand-write this translation, this is exactly the kind of thing you forget until something breaks at bar 4,000.
Variables also pick up type annotations (Series[float]). They're documentation more than enforcement, but they make one thing explicit that Pine hides: fast, goldenCross, and friends are series, not scalars.
What changes meaning
This is the part that matters, because a converted strategy that runs without errors can still backtest differently.
if goldenCross: is not Python's usual if. In plain Python, if on a container-like object would be a bug. Here it works because goldenCross is a PyneCore Series, and the runtime evaluates main() per bar, so the condition reads the current bar's value. The semantics are Pine's — one decision per bar, using values known at that bar — implemented through Python syntax. Comforting, but worth knowing: code you copy out of this context into pandas won't behave the same way.
The fill assumption lives in the emulator, not in the line of code. strategy.entry("Long", strategy.long) is character-for-character identical in both versions. When the order fills is not in that line at all. On TradingView's historical bars, a market entry signaled on bar N fills at bar N+1's open by default — not at bar N's close, which is what people casually assume when they eyeball a crossover on the close that triggered it. A Python backtest only matches if its broker emulator makes the same choice. Whatever engine you run the converted code on, verify this one setting before you compare a single number against TradingView. Most "the port is wrong" mysteries are this.
barstate.* names survive; the realtime world doesn't. I ran a variant of this script with if goldenCross and barstate.isconfirmed — it also converts cleanly, name intact. But think about what these flags mean offline. There is no realtime bar in a historical Python backtest: no intrabar ticks, no "unconfirmed" final bar forming live. barstate.isrealtime branches will simply never fire, and logic that depends on the distinction between historical and realtime execution has no counterpart. If your Pine script behaves differently live than in backtest on purpose, that difference is yours to re-engineer — no translator can carry it over.
The honest summary
For a strategy built from mainstream ta.* and strategy.* calls, Pine-to-Python translation is mostly mechanical: same names, same structure, a decorator and some type coercions. The risk was never the syntax. It's the execution model around it — fill timing, bar-state semantics, series-vs-scalar thinking — and those deserve a checklist, not a rewrite.
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: an RSI mean-reversion strategy, where the translation gets less comfortable — strategy.exit with OCO brackets, and a var counter that tracks bars since entry.


