Strategies
Pine sources live in app/strategies/. They are plain text files, and they are the shared
input to every track — the browser backtest, the optimizer, the Python sweeps and the
prop-firm evaluation all read the same files. A strategy does not need re-authoring to move
between them.
How one file reaches all four tracks
The path is the contract. A script is a plain text file in app/strategies/,
and each track finds it there rather than holding a copy of its own: the editor’s strategy
picker reads that directory, and a Python sweep points its config at the file.
That is what makes moving a strategy between tracks a path change rather than a rewrite. The corollary matters just as much — logic reproduced inside a sweep config instead of pointed at the file gives you two copies, and only one of them is the strategy.
What ships today
| Script | Mode | Purpose |
|---|---|---|
rsi_strategy.pine | strategy() | Minimal strategy-mode example: RSI cross entries |
ema_cross.pine | indicator() | RSI-filtered EMA cross with ATR-based stop/target |
multi_signal.pine | indicator() | Multi-signal study with several plotted conditions |
_template.pine | indicator() | Starting point for a new script |
Only rsi_strategy.pine produces real fills. The other three are
indicator-mode, so their trade lists and PnL are reconstructed. See
Engines — real fills vs synthetic before you read any metric.
rsi_strategy.pine — real fills
The only shipped script in strategy() mode, and therefore the only one whose PnL is a real
simulation.
//@version=6
strategy("RSI Strategy", overlay=true)
length = input(14, "Length")
overSold = input(30, "Oversold")
overBought= input(70, "Overbought")
vrsi = ta.rsi(close, length)
co = ta.crossover(vrsi, overSold)
cu = ta.crossunder(vrsi, overBought)
if (not na(vrsi))
if (co)
strategy.entry("RsiLE", strategy.long, comment="RsiLE")
if (cu)
strategy.entry("RsiSE", strategy.short, comment="RsiSE")It enters long when RSI crosses back above the oversold level and short when it crosses back below overbought, and relies on the opposite signal to reverse.
This script currently does not declare commission_*, slippage or default_qty_*, so it runs with the engine’s default cost model — which is optimistic. That is worth knowing before treating its numbers as a baseline. Declaring costs explicitly is a project invariant; see Architecture decisions.
ema_cross.pine — synthetic
A fast/slow EMA cross, filtered so it only fires while RSI is inside its neutral band, with ATR-derived stop and target distances.
fast_len = input.int(9, "Fast EMA Length")
slow_len = input.int(21, "Slow EMA Length")
sl_mult = input.float(1.5, "SL ATR Mult")
tp_mult = input.float(2.5, "TP ATR Mult")
long_signal = ta.crossover(fast, slow) and rsi < rsi_ob and rsi > rsi_os
short_signal = ta.crossunder(fast, slow) and rsi > rsi_os and rsi < rsi_obThe file header is explicit about the consequence:
// Indicator form (not strategy()): the orchestrator reads signals + the
// OHLCV emitted with `--format full` to recompute the trade list.Every input.* call here is a sweep knob — each one becomes a dimension the optimizer
can search over. That is why the parameters are declared with explicit positional defaults.
Why the positional default marks a parameter as tunable
sl_mult = input.float(1.5, "SL ATR Mult")
// ^^^^^ this first argument is the value searched aroundThere is no separate list of sweepable parameters. The input.* declaration is the declaration of the dimension, and its first argument is the value the sweep tooling searches around — which is why omitting it breaks automated parameter discovery for that parameter.
multi_signal.pine — synthetic
A larger indicator study plotting several conditions at once. Same caveat: markers only, and the trade list is reconstructed from the emitted signal series.
_template.pine — synthetic
The starting point for a new script, with the same indicator-mode structure and the same
plotshape + hidden-plot convention the reconstruction layer expects.
Writing a new strategy
To get real fills, use strategy() and declare your costs:
//@version=6
strategy("My strategy",
overlay = true,
commission_type = strategy.commission.percent,
commission_value = 0.1,
slippage = 2,
default_qty_type = strategy.percent_of_equity,
default_qty_value = 100)What a real declaration must include
Three groups of arguments, and every one of them is yours to choose:
- The cost model —
commission_typepercent,commission_value0.1,slippage2 ticks. - The position size —
default_qty_typestrategy.percent_of_equitywithdefault_qty_value100. - The overlay flag —
overlay = true.
Leave them out and the engine’s own defaults apply silently, and they are optimistic.
rsi_strategy.pine is the shipped example of exactly that: it declares only
overlay = true and no cost model at all. Declaring costs explicitly is a
project invariant rather than optional hygiene — see
Architecture decisions.
To get a study the optimizer can sweep, use indicator(), keep the
plotshape + hidden signal plots convention, and give every parameter a positional default.
Two conventions worth keeping
- Positional defaults.
input.int(9, "Fast EMA Length")— the default value comes first. The sweep tooling reads the first argument as the value to search around, so omitting it breaks automated parameter discovery. - Emit signals as data, not just as shapes. A visible
plotshape()is for humans; a hiddenplot(..., display=display.none)carrying the same value is what the reconstruction layer reads. Ship both.
What the reconstruction layer reads, and where
One value, two destinations. The plotshape() marker is the one a person sees
on the chart; the hidden plot() is the one the reconstruction reads. The
header of ema_cross.pine states the contract:
// Indicator form (not strategy()): the orchestrator reads signals + the
// OHLCV emitted with `--format full` to recompute the trade list.That line has two halves. The signals arrive through the hidden plots; the
OHLCV arrives from the run, which is why the indicator form depends on a full-format
output. multi_signal.pine and _template.pine follow the same
convention — markers plus hidden plots — for the same reason.
Verifying a script before you trust it
Run it against a known dataset and check the output:
npx pinets-cli run app/strategies/ema_cross.pine \
--symbol BTCUSDT --timeframe 60 \
--candles 1000 --warmup 200 \
--format full --quiet --clean > out.jsonAcceptance: exit code 0, out.json contains plots and result.trades, there is no
error field, and every value is finite.
Registering it
A new script is picked up from app/strategies/ by the editor’s strategy picker. For a
Python sweep, point the config at the file rather than duplicating its logic — the whole
point of the shared-source design is that the same Pine runs on both tracks.