Start now →

What a Profitable Pine Script Strategy Actually Looks Like at the Code Level

By Betashorts · Published April 12, 2026 · 13 min read · Source: Trading Tag
BitcoinStablecoins
What a Profitable Pine Script Strategy Actually Looks Like at the Code Level

What a Profitable Pine Script Strategy Actually Looks Like at the Code Level

BetashortsBetashorts11 min read·Just now

--

Press enter or click to view image in full size

Not a code dump. A line-by-line explanation of why every decision was made — and what breaks if you remove it.

//@version=6 · BTCUSDT Daily · Full walkthrough from declaration to backtest · 4 min read

Most Pine Script articles give you code. Paste this, run it, see what happens. The code is the entire point.

This article works differently. The code exists, but it’s not what matters. What matters is the reasoning — why each line is there, what it’s designed to do, and what a beginner would write instead that looks similar but doesn’t work the same way.

By the end you’ll have a complete, backtestable strategy. More importantly, you’ll understand every structural decision that separates code that works from code that merely runs.

The strategy in one sentence

Buy BTC when it pulls back during an uptrend and momentum begins recovering, with a volatility-adjusted stop loss and a defined take profit. Exit when either level is hit.

That’s it. The entire strategy is that sentence. Every line of code is either implementing one of those four components — uptrend filter, pullback detection, momentum recovery, exit management — or supporting those components with proper risk and execution settings.

If a line of code doesn’t map to one of those four components, it shouldn’t be there.

Section 1 — Declaration

The strategy() block: six decisions, not one

Most beginners treat the strategy() call as boilerplate — copy it from a template and move on. Every parameter in it is actually a deliberate choice that affects the validity of everything that follows.

Strategy declaration

//@version=6
strategy("RSI Pullback Strategy",
overlay = true,
initial_capital = 10000,
default_qty_type = strategy.percent_of_equity,
default_qty_value = 25,
commission_type = strategy.commission.percent,
commission_value = 0.1,
slippage = 2)

overlay = true

Draws the strategy on the price chart itself rather than in a separate panel. Essential when you’re plotting moving averages or stop levels that need to be visible against price.

overlay = false draws everything in its own panel below the chart. Your 200 EMA appears as a separate line chart with no price context — useless for visual confirmation.

default_qty_value = 25

Each trade uses 25% of available equity. This means four simultaneous positions could theoretically be open, and a single trade loss can’t exceed 25% of the account. More important: commission drag at 25% sizing is one-quarter what it is at 100% sizing.

At 100% sizing, 0.1% commission per trade on a $10,000 account costs $10 per trade. Over 300 trades, that’s $3,000 in commission — 30% of starting capital consumed before any net result is calculated. This is why most default backtest setups produce inflated results.

commission_type = strategy.commission.percent

This parameter must be set explicitly when using percentage commission. Without it, commission_value is interpreted as a fixed cash amount per trade — which produces completely wrong results at any position size other than exactly $1,000.

Without this line, commission_value=0.1 means $0.10 flat per trade regardless of position size. On a $2,500 position (25% of $10,000), the real commission is $2.50. You're modelling $0.10. The backtest is wrong by 25x.

slippage = 2

Every order executes 2 ticks worse than the signal bar’s close price. On a daily BTC chart, signals fire at close but orders fill at the next day’s open — often 1–3% away. Slippage=2 is a conservative but non-zero acknowledgement of this gap.

slippage=0 means every order fills at the exact price you signalled. This never happens in live trading. Backtests without slippage consistently outperform live results because every entry and exit is modelled at an optimal price that doesn’t exist.

Section 2 — Inputs

Parameters as variables: making the strategy adjustable

Input declarations

// ── Inputs ────────────────────────────────────────────
trendLen = input.int(200, "Trend EMA Length", minval=50)
rsiLen = input.int(14, "RSI Length", minval=2)
rsiLevel = input.int(40, "RSI Entry Level", minval=20, maxval=60)
atrLen = input.int(14, "ATR Length", minval=1)
stopMult = input.float(2.0, "Stop ATR Multiple", minval=0.5, step=0.1)
tpMult = input.float(3.0, "TP ATR Multiple", minval=0.5, step=0.1)

Why inputs instead of hardcoded numbers

Every number in a strategy is a hypothesis about what works. Inputs make that hypothesis visible, adjustable, and testable. When a colleague asks “why 200 for the trend EMA?” — an input with a label and range communicates that this is a considered default, not an arbitrary constant.

Hardcoded numbers — writing ta.ema(close, 200) directly — produce code that requires editing the source to change any parameter. That's not a problem until you need to compare EMA 100 vs EMA 200 vs EMA 50. Inputs make that a Settings panel change, not a code edit.

minval and maxval on every input

Setting minval=50 on the trend EMA prevents someone (including future you) from setting it to 5, which would produce nonsensical results. maxval=60 on the RSI entry level prevents entries above the midpoint, which would turn a pullback strategy into a breakout strategy accidentally.

Without bounds, any value is accepted. RSI entry level of 80 means the strategy only enters when RSI is above 80 — which is overbought territory, exactly the opposite of a pullback entry. The code runs without error and produces wrong results silently.

Section 3 — Calculations

Building the three signal components

Signal calculations

// ── Trend Filter ──────────────────────────────────────
trendEma = ta.ema(close, trendLen)
inUptrend = close > trendEma
// ── RSI Momentum ──────────────────────────────────────
rsiVal = ta.rsi(close, rsiLen)
rsiRecov = ta.crossover(rsiVal, rsiLevel)
// ── Entry Signal - both conditions must be true ───────
entrySignal = inUptrend and rsiRecov
// ── ATR for exit sizing ───────────────────────────────
atrVal = ta.atr(atrLen)

inUptrend = close > trendEma (not ema > ema[1])

This asks “is price above the trend line?” — a binary filter. The alternative, checking if the EMA itself is rising, introduces lag and produces different behaviour on trending vs ranging assets. Price above EMA is simpler, more direct, and answers the exact question: is this a bull market context?

Using trendEma > trendEma[1] as the trend filter means entering when the 200 EMA just started rising — which is 200 bars behind price. You'd be confirming a trend that price discovered months ago. The filter becomes a delay mechanism rather than a condition.

ta.crossover(rsiVal, rsiLevel) — crossover not comparison

ta.crossover(a, b) is true only on the single bar where a crosses from below b to above it. Using rsiVal > rsiLevel instead would be true on every bar where RSI is above 40 — which could mean the strategy is in a trade for weeks with the entry condition continuously met, potentially re-entering immediately after an exit.

if rsiVal > 40 instead of ta.crossover: the entry fires on every bar RSI is above 40, not just the bar it crosses. During a sustained RSI above 40 period, you'd get dozens of re-entries. Crossover fires once per event. Comparison fires continuously. These are fundamentally different signals.

entrySignal = inUptrend and rsiRecov (named variable)

Combining both conditions into a named variable serves two purposes. It makes the entry logic readable as a sentence: “entry signal is true when in uptrend and RSI recovers.” And it allows reuse — the same variable goes into the if block and the plotshape() call without duplicating the condition.

Writing the full condition twice — inside if and inside plotshape() — is not just redundant. If you change one and forget the other, your chart markers no longer match your actual entry bars. The named variable guarantees consistency.

Section 4 — Execution

The entry and exit logic: where most strategies have their worst bugs

Entry and exit execution

// ── Entry ─────────────────────────────────────────────
if entrySignal
strategy.entry("Long", strategy.long)
// ── Exit - based on actual fill price ─────────────────
// Detect the bar where position just opened
var float stopLevel = na
var float tpLevel = na
if strategy.position_size > 0 and
strategy.position_size[1] == 0
entryPrice = strategy.position_avg_price
stopLevel := entryPrice - stopMult * atrVal
tpLevel := entryPrice + tpMult * atrVal
strategy.exit("Exit", "Long",
stop = stopLevel,
limit = tpLevel)

strategy.position_size > 0 and strategy.position_size[1] == 0

This condition is true on exactly one bar: the first bar where the strategy holds a position after previously holding none. It detects the moment the entry filled. This is the only bar where strategy.position_avg_price reflects the actual entry price — because the position just opened.

Setting exit levels in the same if entrySignal block as the entry is the most common version of this mistake. The signal fires on bar close. The entry fills at the next bar's open. strategy.position_avg_price is still 0.0 on the signal bar. Your stop and take profit are calculated from zero, not from your actual entry price.

var float stopLevel = na (declared outside the if block)

Declaring with var means these variables persist across bars — once set, they hold their values until the next entry updates them. Declaring outside the if block means they're accessible in the plotting section at the bottom. Without var, they reset to na on every bar.

Without var: stopLevel and tpLevel are na on every bar except the entry bar. The plot() calls at the bottom get na every bar, and the stop/TP lines don't draw on the chart — making it impossible to visually verify exit placement.

stopLevel = entryPrice − stopMult × atrVal

ATR (Average True Range) measures the average distance BTC moves in a day. A 2x ATR stop places the loss exit at two typical daily moves below entry. This is volatility-aware — when BTC is choppy, the stop widens. When it’s calm, it tightens. A fixed percentage stop (−2%) doesn’t adapt to conditions.

A fixed stop of entryPrice × 0.98 during a period when BTC's ATR is 4% will be hit almost immediately — the daily range exceeds the stop distance. During low-volatility periods, 2% might be too wide. ATR-based stops match the stop to the market's actual behavior rather than an arbitrary percentage.

tpMult = 3.0 (take profit at 3x ATR vs stop at 2x ATR)

The reward-to-risk ratio at entry is 3:2 = 1.5. If the stop is hit, the loss is 2 ATR. If the take profit is hit, the gain is 3 ATR. At a 40% win rate, this strategy breaks even (40% × 3 = 120, 60% × 2 = 120). Any win rate above 40% is profitable before commission. The R:R ratio determines the minimum win rate required — a deliberate design choice, not a default.

Equal stop and take profit (both 2x ATR) requires a win rate above 50% to profit. That’s a harder requirement. 3:2 R:R is more forgiving — the strategy can be wrong more often than right and still make money. This is why R:R is chosen deliberately, not left as a default.

Section 5 — Visuals

Plots that serve the strategy, not decorate it

Visual outputs

// ── Visuals ───────────────────────────────────────────
plot(trendEma, "Trend EMA",
color=color.blue, linewidth=2)
plot(strategy.position_size > 0 ? stopLevel : na,
"Stop", color=color.red,
style=plot.style_linebr, linewidth=1)
plot(strategy.position_size > 0 ? tpLevel : na,
"TP", color=color.green,
style=plot.style_linebr, linewidth=1)
plotshape(entrySignal, style=shape.triangleup,
location=location.belowbar,
color=color.new(color.green, 0), size=size.small)
bgcolor(strategy.position_size > 0 ?
color.new(color.green, 92) : na)

plot(strategy.position_size > 0 ? stopLevel : na)

The stop line only draws when the strategy holds a position. When there’s no trade, na tells Pine Script to draw nothing — plot.style_linebr then breaks the line rather than connecting the last value to the next one. The result: a clean horizontal line segment for each trade, not a continuous line across the entire chart.

plot(stopLevel) without the condition draws a continuous line through all bars — when in a trade it shows the stop, when not in a trade it draws the last stop value indefinitely across the chart. The chart looks cluttered and the stop levels are visually misleading outside of active positions.

The complete strategy

All sections together — 56 lines

//@version=6
strategy("RSI Pullback Strategy",
overlay = true,
initial_capital = 10000,
default_qty_type = strategy.percent_of_equity,
default_qty_value = 25,
commission_type = strategy.commission.percent,
commission_value = 0.1,
slippage = 2)
// ── Inputs ────────────────────────────────────────────
trendLen = input.int(200, "Trend EMA Length", minval=50)
rsiLen = input.int(14, "RSI Length", minval=2)
rsiLevel = input.int(40, "RSI Entry Level", minval=20, maxval=60)
atrLen = input.int(14, "ATR Length", minval=1)
stopMult = input.float(2.0, "Stop ATR Multiple", minval=0.5, step=0.1)
tpMult = input.float(3.0, "TP ATR Multiple", minval=0.5, step=0.1)
// ── Calculations ──────────────────────────────────────
trendEma = ta.ema(close, trendLen)
inUptrend = close > trendEma
rsiVal = ta.rsi(close, rsiLen)
rsiRecov = ta.crossover(rsiVal, rsiLevel)
entrySignal = inUptrend and rsiRecov
atrVal = ta.atr(atrLen)
// ── Entry ─────────────────────────────────────────────
if entrySignal
strategy.entry("Long", strategy.long)
// ── Exit - set from actual fill price ─────────────────
var float stopLevel = na
var float tpLevel = na
if strategy.position_size > 0 and
strategy.position_size[1] == 0
entryPrice = strategy.position_avg_price
stopLevel := entryPrice - stopMult * atrVal
tpLevel := entryPrice + tpMult * atrVal
strategy.exit("Exit", "Long",
stop = stopLevel,
limit = tpLevel)
// ── Visuals ───────────────────────────────────────────
plot(trendEma, "Trend EMA", color=color.blue, linewidth=2)
plot(strategy.position_size > 0 ? stopLevel : na,
"Stop", color=color.red,
style=plot.style_linebr, linewidth=1)
plot(strategy.position_size > 0 ? tpLevel : na,
"TP", color=color.green,
style=plot.style_linebr, linewidth=1)
plotshape(entrySignal, style=shape.triangleup,
location=location.belowbar,
color=color.new(color.green, 0), size=size.small)
bgcolor(strategy.position_size > 0 ?
color.new(color.green, 92) : na)

The backtest result

What this strategy produces — and what it means

Run this on BTCUSDT daily with any recent window. Here’s what the Strategy Tester will show and what each number tells you about the logic:

Press enter or click to view image in full size

The trade count is low because the RSI crossover at 40 is a selective signal — it only fires when RSI recovers from a genuine dip, not on every bar RSI is above 40. The commission drag is controlled because 25% sizing means fees are one quarter of what they’d be at full position size. The win/loss ratio is above 1.0 because the 3:2 ATR reward/risk is built into the exit logic.

Whether this strategy is profitable depends entirely on market regime. In a trending bull market, RSI pullback entries catch the continuation and hit take profits frequently. In a choppy, oscillating market, entries fire on false recoveries that reverse before hitting take profit and get stopped out. The strategy is not broken in choppy markets — it’s just not designed for them.

A strategy that works in one regime and fails in another is not a bad strategy. It is a strategy with a defined operating condition. Knowing that condition is the difference between a strategy you can use and a strategy you just test.

What made this different from a typical code dump

Every line in this strategy exists for a reason that you now know. The commission_type parameter is there because without it the commission calculation is wrong. The ta.crossover is there because a comparison would fire continuously. The exit is set on the bar after entry because that's when the fill price is known. The stop uses ATR because a fixed percentage doesn't adapt to volatility.

None of these decisions are obvious from reading the code. They’re visible only when you understand what each line is doing and what happens if it’s removed or changed. That understanding is what separates a coder who writes strategies from one who understands them.

The next time you read someone else’s Pine Script — from the public library, from a tutorial, from an AI — you now have a framework for evaluating it. Not “does this code run?” but “does every line have a reason, and do I know what that reason is?”

If you want to write and run these strategies yourself, the Pine Script Beginner’s Guide covers the language fundamentals from scratch — syntax, how series data works, variable types, and the beginner mistakes that make strategies silently misbehave. It’s a starting point for writing code, not a trading system. Includes a Bonus ZIP Toolkit of ready-to-use scripts.

.

This article was originally published on Trading Tag and is republished here under RSS syndication for informational purposes. All rights and intellectual property remain with the original author. If you are the author and wish to have this article removed, please contact us at [email protected].

NexaPay — Accept Card Payments, Receive Crypto

No KYC · Instant Settlement · Visa, Mastercard, Apple Pay, Google Pay

Get Started →