Skip to Content
Strategies

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

ScriptModePurpose
rsi_strategy.pinestrategy()Minimal strategy-mode example: RSI cross entries
ema_cross.pineindicator()RSI-filtered EMA cross with ATR-based stop/target
multi_signal.pineindicator()Multi-signal study with several plotted conditions
_template.pineindicator()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_ob

The 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 around

There 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_type percent, commission_value 0.1, slippage 2 ticks.
  • The position size — default_qty_type strategy.percent_of_equity with default_qty_value 100.
  • 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

  1. 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.
  2. Emit signals as data, not just as shapes. A visible plotshape() is for humans; a hidden plot(..., 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.json

Acceptance: 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.

Last updated on