DEV Community

Lei Chen
Lei Chen

Posted on

It Ran Fine. It Produced Nothing: What 196 Silent Pine Scripts Taught Me

It Ran Fine. It Produced Nothing: What 196 Silent Pine Scripts Taught Me

Your Pine script compiles. Your Python port runs without errors. The output file is empty. "It ran" and "it works" are different claims — and in a corpus of 1,909 real scripts, 196 of them ran fine and produced nothing at all.

This is the sixth post in a series where I convert Pine strategies to Python line by line. The last two posts were about backtests that disagreed with TradingView. This one is about a quieter failure: the port that agrees with nothing, because it never produces anything to disagree with.

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

What we measured Result, in our testing What it means
Scripts that compiled 1,904 / 1,909 = 99.7% Analyzer + emitter both pass
Silent scripts 196 of the 1,904 that compiled (10.3%) Ran fine, nothing triggered on the test feed
Drawing-only scripts 366 No plot(), only drawing objects — a different category, next post
Repro 1: input gate, default false 0 / 4,089 bars with a value All three plots na, every bar
Repro 1, one flip: default true 4,075 / 4,089 bars with a value Same script, same data — the gate was the whole story
Repro 2: intraday guard on a daily feed 0 / 4,089 The condition is false on every bar
Repro 3: crossover vs 100,000 0 / 4,089 Highest close on the feed is 5,807.72

The honest number is 0. Three different scripts, three different causes, the same measured result: zero values in 4,089 bars. The rest of this post is the autopsy for each one — and the four-check list I now run before I ever blame the translator.

First, what "silent" means — and what it doesn't

The corpus numbers come from the sweep I reported in post #3: 1,909 real Pine scripts run through the translator, baseline dated 2026-10-24, in our testing. "Silent" has a precise definition there, and the definition matters more than the count:

  • Silent = the script compiled, ran bar-by-bar to completion with no errors and no violations, and produced no plot values and no drawing objects on the test feed.
  • The test feed for the sweep is a synthetic 500-bar daily feed (2020-01-01 to 2021-05-14). "Silent" is always relative to a feed: silent on this feed is not silent everywhere.
  • Silent is not drawing-only. 366 other scripts also have no plot(), but they do produce lines, boxes, or labels — they are visible, just not plotted. Conflating the two is the first mistake; the drawing-only scripts get their own post next.
  • Silent is not an error. None of the 196 threw anything. A translator that reports "success" on all of them is telling the truth and still misleading you, because "ran" is the only claim it can make. Whether the script does anything is a claim about the script, the inputs, and the feed — and only you can check that.

One scope note, stated plainly: the 196 are corpus files, and the corpus itself lives on my other machine — I can't re-open the individual silent scripts from here tonight. So the three reproductions below are minimal scripts I wrote to isolate each cause, compiled with the local compiler and run on the series' usual feed (CSI 300 daily, 4,089 bars, 2010-01-04 to 2026-11-06). They are reproductions, not the corpus scripts themselves — except for the first one, which mirrors a real corpus case the sweep already named: script 006_support-resistance-vr, whose 55 plots were all na because an input.bool defaulted to false.

Repro 1: the input gate — 55 plots, all na, one default

This is the most common way a script goes silent, and the hardest to spot, because the code looks correct. The Pine:

//@version=5
indicator("Silent repro 1: input gate", overlay=false)
showPlots = input.bool(false, "Show plots")
rsi = ta.rsi(close, 14)
plot(showPlots ? rsi : na, "RSI")
plot(showPlots ? ta.sma(close, 20) : na, "SMA20")
plot(showPlots ? ta.ema(close, 50) : na, "EMA50")
Enter fullscreen mode Exit fullscreen mode

The Python is the actual output of the converter, verified tonight with the local compiler (node bin/pine2pyne.mjs, zero diagnostics). Verbatim:

_pyne_in_showPlots: bool = input.bool(False, "Show plots")

@script.indicator(title="Silent repro 1: input gate", overlay=False)
def main():
    rsi: Series[float] = ta.rsi(close, 14)
    plot((rsi if _pyne_in_showPlots else na), "RSI")
    plot((ta.sma(close, 20) if _pyne_in_showPlots else na), "SMA20")
    plot((ta.ema(close, 50) if _pyne_in_showPlots else na), "EMA50")
Enter fullscreen mode Exit fullscreen mode

Same harness note as posts #4 and #5: the run copy inlines the input default as a plain False, because stock PyneCore (GitHub master) rejects the converter's hoisted-input style. Same values, same script.

Measured on 4,089 bars of CSI 300 daily:

Plot Non-na bars, default false Non-na bars, default true
RSI 0 4,075
SMA20 0 4,070
EMA50 0 4,040

The control column is the proof. Nothing about the script is broken: flip one default and all three plots appear, with exactly the warm-up counts you'd expect (RSI 4,089 − 14 = 4,075; SMA20 4,089 − 19 = 4,070; EMA50 4,089 − 49 = 4,040). The silence was never in the logic. It was in a checkbox the author left off — and in the corpus, the real version of this was 55 plots, not three. An input default is a decision about what the script does when nobody touches it. Read the defaults before you read the logic.

Repro 2: the timeframe guard — right script, wrong feed

//@version=5
indicator("Silent repro 2: timeframe guard", overlay=false)
rsi = ta.rsi(close, 14)
plot(timeframe.isintraday ? rsi : na, "RSI intraday only")
Enter fullscreen mode Exit fullscreen mode
@script.indicator(title="Silent repro 2: timeframe guard", overlay=False)
def main():
    rsi: Series[float] = ta.rsi(close, 14)
    plot((rsi if timeframe.isintraday else na), "RSI intraday only")
Enter fullscreen mode Exit fullscreen mode

Measured: 0 non-na bars out of 4,089. The script is correct — on an intraday chart. On the daily feed it runs, timeframe.isintraday is false on every single bar, and the plot is na 4,089 times. This is the feed-relative half of "silent": the same file, unchanged, would light up on a 15-minute feed. If your port produces nothing, the second question (after the inputs) is whether the feed you're testing on is the feed the script was written for. Symbol guards are the same shape — a script that only draws when syminfo.ticker is BTCUSD is silent on every other symbol, correctly, by design.

Repro 3: the threshold that never fires — a condition, not a bug

//@version=5
indicator("Silent repro 3: threshold never met", overlay=false)
crossUp = ta.crossover(close, 100000)
plot(crossUp ? close : na, "Cross 100k")
bgcolor(crossUp ? color.new(color.green, 90) : na)
Enter fullscreen mode Exit fullscreen mode
@script.indicator(title="Silent repro 3: threshold never met", overlay=False)
def main():
    crossUp: Series[float] = ta.crossover(close, 100000)
    plot((close if crossUp else na), "Cross 100k")
    bgcolor((color.new(color.green, 90) if crossUp else na))
Enter fullscreen mode Exit fullscreen mode

Measured: 0 non-na bars out of 4,089, and the background never colors once — both outputs share the same crossUp condition, so one measurement covers both. The highest close on this feed is 5,807.72; the threshold is 100,000. No bar can cross it. This is the version of silence that looks most like a translator bug ("my port dropped the signal!") and is least likely to be one: the condition is doing exactly what it says, on data where it can never be true. Before you debug the port, compute the range of the data the condition is tested against. A threshold above your data's maximum is not a failed translation. It is an untested branch.

The false suspect: barstate.isconfirmed is innocent

One guard looks exactly like the culprit and isn't — I ran it so I wouldn't have to guess:

//@version=5
indicator("Silent repro 4: isconfirmed suspect", overlay=false)
rsi = ta.rsi(close, 14)
plot(barstate.isconfirmed ? rsi : na, "RSI confirmed only")
Enter fullscreen mode Exit fullscreen mode

Measured: 4,075 non-na bars out of 4,089 — identical to the RSI warm-up count. On historical data every bar is already closed, so isconfirmed is true on every bar where RSI is defined, including the last one in this PyneCore run. Both things are true at once: the guard is real code with real meaning, and it cannot explain silence on a historical backtest. It only bites on live bars, where the current bar is still open. Suspects need the same treatment as causes: run them, count them, and let the innocent ones go.

Why this checklist works

Each check isolates one decision the script makes before it produces anything, and each one has a measurement that can only come out one of two ways:

  1. Read the input defaults first. Count the input.* calls, list their defaults, and ask what the script does when nobody touches them. The fix is one flip; the control run proves it.
  2. Match the feed to the script's assumptions. Timeframe guards, symbol guards, session guards — check timeframe.* and syminfo.* against the feed you're actually running. Silent on this feed is not silent everywhere.
  3. Compute the condition's range. For any threshold or crossover, get the max and min of the data it tests. If the threshold sits outside the data's range, the branch is untested, not broken.
  4. Run the suspects, don't argue about them. A guard you suspect takes five minutes to reproduce and count. barstate.isconfirmed survived; the other three didn't. The checklist is cheap because the alternative — re-reading the translator's output looking for a bug that isn't there — is not.

Rules I now follow

  1. Count the outputs before reading the code. A run with zero non-na values is a different problem from a run with wrong values. Diagnose silence first, correctness second.
  2. Check the defaults before the logic. An input default is a decision. The corpus's loudest silent case was 55 plots behind one false.
  3. Name the feed in every claim. "Silent" without a feed is meaningless. The sweep's feed is 500 daily bars; mine here is 4,089. Say which one.
  4. Keep silent and drawing-only apart. 196 scripts produced nothing; 366 produced drawings but no plots. Different categories, different fixes — don't let one number stand for both.
  5. A reproduction beats an explanation. Four small scripts, five runs, one table. Every cause in this post can be re-run from the code printed above.

Next in the series: the 366 drawing-only scripts — what a translator owes a script with no plot() at all, and where the Python port's output differs from TradingView's canvas.


What's the quietest failure you've hit porting a script — the one where everything ran and nothing happened? I read every comment — the silence stories are what the next posts are built from.


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.

Top comments (0)