One script. One dataset. Two backtests. One says the strategy loses 13.5%. The other says it makes money. Neither backtest is lying — they disagree about three things the script never says out loud: what a "tick" is, how an indicator warms up, and when an order fills.

This is the fourth post in a series where I convert classic Pine strategies to Python line by line. Post #2 converted an RSI mean-reversion strategy with an ATR exit bracket. This post runs that exact strategy twice — once in the PyneCore bar-by-bar emulator, once in a hand-written pandas port, the way most people would write it — on the same 4,089 bars of CSI 300 daily data (2010-01-04 to 2026-11-06), and finds the exact bar where the two results part ways.

The headline numbers first, because this is the part you can check:

PyneCore emulator Naive pandas hand-port
Trades 60 57
Net result -$1,353.29 (-13.53% on $10,000) +206.49 index points
Win rate 27 / 60 (45%) 22 / 57 (39%)
Profit factor 0.695 — (no position sizing in the hand-port)
Avg bars held 1.0 4.1
Time exits (10-bar rule) 0 8
Buy & hold, same period +14.68% +14.68%

Same Pine, same data, same parameters. Opposite signs. The rest of this post is the autopsy: three divergences, each with the exact bar and price where it happens.

The script under test

The RSI mean-reversion strategy from post #2, unchanged. Buy the oversold bounce (RSI crossing up through 30), exit on an ATR bracket or a 10-bar time stop:

//@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") 

The Python is the actual output of the converter from post #2, verified byte-identical tonight with the local compiler (node bin/pine2pyne.mjs, zero diagnostics). One harness note, stated plainly: stock PyneCore (GitHub master, installed 2026-11-10) rejects the converter's hoisted-input style, so the run copy moves the four input.int calls into main() defaults — same values, same script. The hand-port is a vectorized pandas version: Wilder RSI/ATR via ewm(alpha=1/14), signals from the RSI series, fill at the signal bar's close, bracket checked against each following bar's high/low in price points, time exit counted from the entry bar. It is deliberately the version a careful reader would write without knowing TradingView's execution model.

Divergence #1: the RSI warm-up — bar 22, 2010-02-03

The two engines disagree about RSI before they disagree about anything else. PyneCore's ta.rsi seeds Wilder's smoothing with the average of the first 14 bars; the pandas port starts the exponential average at bar 1. The values converge — after bar 1,000 the two RSI series are identical to three decimals — but the first hundred bars differ by up to 16.47 points (mean absolute difference 2.80 over bars 14–100).

That is enough to create a trade in one engine that never exists in the other. On 2010-02-03 (bar 22):

PyneCore RSI pandas RSI
2010-02-02 23.80 33.52
2010-02-03 35.36 (crosses above 30) 44.09 (already above 30)

PyneCore sees a crossover and enters the next day. The pandas port sees no crossover, because its RSI crossed 30 days earlier, during the warm-up, when the two series still disagreed. Trade #1 in the PyneCore run — entry 2010-02-04 at 3206.43, exit the next day at 3147.72, -$176.13 — exists only because of how RSI is seeded. One crossover out of sixty differs. It is the first divergence, and it is the only one that lives in the indicator math. Everything after it lives in the execution model.

Divergence #2: the fill — every trade, starting 2010-04-30

PyneCore fills entries at the next bar's open. The hand-port fills at the signal bar's close. Take the signal on 2010-04-30 (bar 78), a signal both engines agree on:

Entry price Exit price
Naive pandas 3067.36 (Apr 30 close) 3006.20 (stop, May 4)
PyneCore 3005.49 (May 4 open) 2981.78 (May 5)

The fill gap on that one trade is 61.87 points — 2.02% of the entry price — before the exit logic even runs. The signal is identical; the price paid is not. This gap is present on every shared trade, in both directions, and it is the largest single source of difference between the two equity curves. It is also the easiest to miss, because both versions "enter on the signal" and the code never says when the order fills. In Pine, a market entry signaled on bar N fills at bar N+1's open by default. The hand-port's close fill is a choice the author made without noticing they made it.

Divergence #3: the bracket is in ticks, not points — and that kills the time exit

This is the one that changes the strategy's shape entirely, and it is a correction to my own post #2, which described this exit as "an ATR-based bracket." The bracket as written is not 2×ATR and 1×ATR in price. In Pine, strategy.exit's profit and loss are distances in ticks from the entry price — TradingView's own strategy docs say so: loss sets the stop "a certain number of ticks away from the entry order's price," and profit defines "the profit size in ticks from the entry price." Price-level brackets use limit and stop instead.

So profit=atrVal * 2, loss=atrVal means roughly 158 ticks and 79 ticks, not 158 and 79 points. How wide is that in price? It depends on syminfo.mintick, which the script never mentions. The PyneCore run fed it a CSV, and the converter's default symbol info for a custom CSV sets mintick = 0.000001. The bracket is therefore about 0.00016 points wide — effectively zero. Any move at all, in either direction, triggers a leg at the next bar's open.

I checked this was really the mechanism and not an emulator quirk: I re-ran the same script with the bracket widened 10× (profit=atrVal*20, loss=atrVal*10) and again with constant profit=200, loss=100. All three runs produce byte-identical trade lists: 60 trades, same exits, same -$1,353.29. The bracket width cannot matter, because at these tick sizes every version of it is still ~zero points wide.

And even with a realistic tick size the conclusion barely changes: even if the tick were 0.01 — the usual tick size for an index like this — the bracket would be 1.58 points of profit and 0.79 points of stop against daily ranges around 60 points. The bracket would still fire on the first bar after entry, almost every time. The observed result is exactly that: all 60 PyneCore trades exit 1 bar after entry, all 60 at the exit bar's open, all 60 via the bracket — and the 10-bar time exit fires zero times. In the hand-port, where the bracket is (wrongly, but understandably) read as price levels, trades last 1–10 bars and the time exit fires 8 times. The two engines are not running the same exit rule. One of them is running the rule as written; the other is running the rule as intended. The fix, if you want the intended bracket, is price levels: limit = entry + 2*atrVal, stop = entry - atrVal — or keep profit/loss and divide by syminfo.mintick yourself.

The knock-on: two signals that vanish because of state

Two more PyneCore signals — bars 92 and 1464 — exist in both engines' RSI series, but the hand-port never takes them. At both bars the hand-port is still holding an earlier trade (bars 89–92 and 1462–1464), while PyneCore, whose trades all last exactly one bar, is flat and free to enter. This is the part people miss when they diff two backtests trade by trade: once the engines desynchronize by one trade, every later difference is partly a consequence of the first three. You cannot fix divergence #3 by patching the trade list. You fix it by fixing the units, the warm-up, and the fill — in that order.

The honest summary

The two backtests do not disagree about the strategy. They disagree about three things the strategy never states: RSI's first 14 bars (seeding), the entry fill (next open vs signal close), and the bracket's units (ticks vs points). Measured on the same 4,089 bars: PyneCore, running the rule as written, loses 13.53% with every trade lasting one bar; the hand-port, running the rule as intended, gains 206.49 points over 57 trades lasting 4.1 bars on average. Neither number is the strategy's "true" result — the strategy has no true result until you pin down the units, the warm-up, and the fill. The correction to post #2 stands: its "ATR bracket" is a tick bracket, and on this data the time exit in that script is dead code. If you port a strategy and the Python version disagrees with TradingView, check the units first. On a corpus this size, "the exit was in ticks" is not a rare explanation — it was the whole trade list.

This conversion work 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. The runtime is the open-source PyneCore project, with a patched build we ship for the two constructs stock PyneCore doesn't handle — details are on the site. 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. No return promises, ever: a translator moves your logic, it doesn't make it profitable.

Next in the series: the diagnostics teardown — one real strategy.exit rewritten with price-level brackets, run the same way, and the exact bar where the time exit finally fires.

Which unit has bitten you — ticks, points, or percent — when you ported a strategy by hand? I read every comment — the unit stories are what the next posts are built from.