Strategy Namespace

PineTS implements Pine Script’s full strategy.* surface so you can run TradingView strategies — entries, exits, position management, performance metrics — locally against your own data.

This page is the developer reference. For the surface checklist (every strategy.* entry mapped to its implementation status) see the Strategy API coverage page.

Every example below is exercised by a verification harness at PineTS/.scratchpad/strategy-doc-examples.cjs (kept in lock-step with the docs during authoring). If something doesn’t behave as documented, the harness fails.

Table of Contents


Quickstart

const { PineTS } = require('pinets');

const data = [/* …OHLCV bars… */];
const pine = new PineTS(data);

const ctx = await pine.run(($) => {
    const { strategy } = $.pine;

    // 1. Declare the strategy ONCE per run.
    strategy('My Long-Only', {
        overlay: true,
        initial_capital: 100000,
        default_qty_type: 'percent_of_equity',
        default_qty_value: 10,
    });

    // 2. Place orders based on bar conditions.
    //    Always wrap bodies in { ... } — see the brace quirk below.
    if ($.idx === 1) {
        strategy.entry('long', strategy.long, 1);
    }
    if ($.idx === 10) {
        strategy.close('long');
    }
});

// 3. Inspect the final state.
const s = ctx.strategy;
console.log('Net profit:', s.netprofit);
console.log('Closed trades:', s.closedtrades.length);
console.log('First trade profit:', s.closedtrades[0].profit);

The same script in native Pine syntax also works — pass the source to run():

const code = `
//@version=5
strategy("My Long-Only", overlay=true, initial_capital=100000,
         default_qty_type=strategy.percent_of_equity, default_qty_value=10)
if bar_index == 1
    strategy.entry("long", strategy.long, 1)
if bar_index == 10
    strategy.close("long")
`;

// (a) Bare string — simplest form.
const ctx = await pine.run(code);

// (b) Wrapped in Indicator() — required when you need to override input.* values
//     at runtime (the keys must match each input's title argument).
const { Indicator } = require('pinets');
const ctx2 = await pine.run(new Indicator(code));                    // no overrides
const ctx3 = await pine.run(new Indicator(code, { Qty: 5 }));        // override input.int(1, "Qty")

The Indicator() wrapper is the same one used for indicator scripts — strategies and indicators share the runtime, so there’s nothing strategy-specific to do. See Indicator for the full API (per-key overrides via ind.input[...] / ind.prop[...], schema introspection, etc.), or Initialization and Usage → Running with Runtime Inputs for a quick overview.


The strategy() declaration

A single strategy(title, options) call initializes the strategy state on context.strategy. It’s safe to call on every bar — only the first call initializes; subsequent calls update config only. Field names mirror Pine’s strategy() parameters exactly.

await pine.run(($) => {
    const { strategy } = $.pine;
    strategy('My Strategy', {
        overlay: true,
        initial_capital: 50000,
        default_qty_type: 'percent_of_equity',  // 'fixed' | 'percent_of_equity' | 'cash'
        default_qty_value: 25,
        commission_type:  'percent',            // 'percent' | 'cash_per_order' | 'cash_per_contract'
        commission_value: 0.075,
        slippage: 2,                            // in ticks of syminfo.mintick
        pyramiding: 3,
    });
});

After the call, context.strategy.config holds the merged options (defaults + your overrides) and the rest of context.strategy is initialized — see the object reference below.

Supported options (every field is StrategyConfig in PineTS/src/namespaces/strategy/types.ts):

Field Type Default Notes
title string '' First positional arg
overlay boolean false  
initial_capital number 1000000  
currency string 'USD'  
pyramiding number 1 Cap on same-direction open trades
default_qty_type string 'fixed' Qty unit for unspecified entry() qty
default_qty_value number 1  
commission_type string 'percent'  
commission_value number 0  
slippage number 0 Ticks against trade direction
margin_long / margin_short number 100 Margin % (used by margin_liquidation_price)
risk_free_rate number 2 Annual %, denominator input for Sharpe / Sortino
process_orders_on_close boolean false Affects strategy.close({immediately: true})
max_lines_count / max_labels_count / max_boxes_count / max_polylines_count number 50 Pass-through to drawing engine

Order primitives

PineTS implements the same order lifecycle as TradingView:

  1. You place an order on bar N — it goes onto state.pending_orders.
  2. On bar N+1’s open, the engine processes it — fills it if market, or checks limit/stop conditions. Slippage and commissions are applied at fill time.
  3. Position-mutating events (open / close / reverse) are mirrored on the flat scalars (position_size, position_avg_price, position_entry_name).

Internal lifecycle is handled by processStrategyOrders() and processExitOrders() in strategy/utils.ts, invoked at the start of every bar.

strategy.order()

The lowest-level primitive — no pyramiding cap, no auto-reverse, no risk filters. Use when you need exact control.

await pine.run(($) => {
    const { strategy } = $.pine;
    strategy('Order Test', { overlay: true, initial_capital: 10000 });
    if ($.idx === 1) {
        // Pine: strategy.order(id, direction, qty, limit?, stop?, ...)
        strategy.order('long1', strategy.long, 1);
    }
});

After this run, context.strategy.opentrades[0] is { entry_id: 'long1', size: 1, entry_price: <bar-2 open>, ... }.

strategy.entry()

Same shape as order(), but adds two behaviors:

  • Pyramiding cap — if the strategy() declaration sets pyramiding: N, additional same-direction entries become no-ops once N trades are open in that direction.
  • Auto-reversal — entering opposite the current position closes the existing position and opens the new one in a single market order. The pending order’s qty is automatically inflated to |current position| + requested qty.
// Pyramiding cap
strategy('Pyramid', { pyramiding: 2 });
if ($.idx === 1)  { strategy.entry('L1', strategy.long, 1); }
if ($.idx === 5)  { strategy.entry('L2', strategy.long, 1); }
if ($.idx === 10) { strategy.entry('L3', strategy.long, 1); }  // no-op once L1+L2 are open

// Auto-reversal
if ($.idx === 1) { strategy.entry('go-long',  strategy.long,  1); }
if ($.idx === 5) { strategy.entry('go-short', strategy.short, 1); }
// after bar 5+1: closedtrades=[go-long], opentrades=[go-short], position_size=-1

strategy.exit()

Attaches conditional exit orders (TP / SL / trailing stop) to an existing entry. Multiple legs on a single exit() call are treated OCO — the first to trigger fires and removes the rest.

if ($.idx === 1) {
    strategy.entry('long', strategy.long, 1);
    strategy.exit('tp/sl', {
        from_entry: 'long',      // empty/undefined = attach to ALL open entries
        profit: 10000,           // TP in TICKS of syminfo.mintick
        loss: 5000,              // SL in TICKS
        // or absolute prices:
        // limit: 120,           // TP price level
        // stop:  90,            // SL price level
        // trailing:
        // trail_price: 110,     // arm trailing once price hits this absolute level
        // trail_points: 500,    // OR arm once price moves N ticks above entry
        // trail_offset: 200,    // ride N ticks behind the running peak
    });
}

Trigger evaluation runs each bar against the bar’s intra-bar high/low (not close), matching TV’s “tick-fast” semantics — a TP/SL can fire mid-bar even if the close doesn’t reach the level.

strategy.close() / close_all()

close(id) flattens any position opened by the entry-id id (FIFO across multiple trades sharing the id). close_all() flattens all open trades regardless of id.

if ($.idx === 1) {
    strategy.entry('A', strategy.long, 1);
    strategy.entry('B', strategy.long, 1);
}
if ($.idx === 5) { strategy.close('A'); }
// after: closedtrades=[A], opentrades=[B]

// or
if ($.idx === 5) { strategy.close_all(); }
// after: closedtrades=[A, B], opentrades=[]

Both accept an immediately: true option that requires process_orders_on_close: true in the declaration and fills at the current bar’s close instead of the next bar’s open.

strategy.cancel() / cancel_all()

Removes pending orders. cancel(id) drops orders whose id matches; cancel_all() empties pending_orders. Already-filled trades are unaffected.

if ($.idx === 1) {
    strategy.entry('lim', strategy.long, 1, /*limit=*/ 1);  // limit far below market → never fills
    strategy.cancel('lim');                                 // remove it
}

The context.strategy object

After strategy() is declared, context.strategy exposes the live state — the same object every getter reads from. It’s documented in StrategyState:

interface StrategyState {
    config: StrategyConfig;             // merged declaration options

    // Trade collections (arrays, indexable, .length = Pine's count)
    opentrades:     Trade[];
    closedtrades:   Trade[];
    pending_orders: Order[];

    // Position info — FLAT scalars matching Pine's data model
    position_size:        number;       // SIGNED — positive long, negative short
    position_avg_price:   number;       // NaN when flat
    position_entry_name:  string;       // entry_id that opened the current position

    // Account / aggregate P&L
    initial_capital:  number;
    account_currency: string;
    equity:           number;
    netprofit:        number;           // realized only
    grossprofit:      number;
    grossloss:        number;
    openprofit:       number;           // unrealized P&L of open positions

    // Peaks
    max_drawdown:     number;
    max_runup:        number;
    equity_peak:      number;           // internal high-water mark
    equity_trough:    number;

    // Risk-adjusted performance — computed ONCE at end-of-run from the
    // monthly equity curve (report-only; see the section below). These are
    // NOT Pine built-in variables, so they're read off context.strategy only.
    sharpe_ratio:     number;
    sortino_ratio:    number;

    // Trade-stat counters
    wintrades:                number;
    losstrades:               number;
    eventrades:               number;
    wintrades_total_profit:   number;
    losstrades_total_loss:    number;

    // Position-size peaks
    max_contracts_held_all:   number;
    max_contracts_held_long:  number;
    max_contracts_held_short: number;

    // Risk rules + halt flag (set by strategy.risk.*)
    risk_rules: { /* see Risk Management */ };
    risk_halted: boolean;
}

Each Trade (in opentrades / closedtrades):

interface Trade {
    id:               string;           // internal unique id
    entry_id:         string;           // id passed to strategy.entry()
    entry_price:      number;
    entry_bar_index:  number;
    entry_time:       number;           // ms timestamp
    entry_comment?:   string;
    exit_id?:         string;           // set on close
    exit_price?:      number;
    exit_bar_index?:  number;
    exit_time?:       number;
    exit_comment?:    string;
    size:             number;           // SIGNED — positive long, negative short
    profit?:          number;           // realized P&L on close (commission-netted)
    commission?:      number;           // total commission charged on this trade
    max_drawdown?:    number;           // per-trade peak adverse excursion (in dollars)
    max_runup?:       number;           // per-trade peak favorable excursion
    status:           'open' | 'closed';
}

Each Order (in pending_orders):

interface Order {
    id:         string;
    direction:  number;                  // +1 long, -1 short
    qty:        number;                  // unsigned
    type:       'market' | 'limit' | 'stop' | 'stop-limit';
    limit?:     number;
    stop?:      number;
    bar:        number;                  // bar index where the order was placed
    time:       number;                  // ms timestamp at placement
    category?:  'entry' | 'exit';        // 'exit' for TP/SL/trailing orders
    status:     'pending' | 'filled' | 'cancelled';
    fill_price?: number;
    fill_bar?:   number;
    fill_time?:  number;

    // Exit-specific fields (when category === 'exit')
    profit?:        number;              // TP in ticks
    loss?:          number;              // SL in ticks
    trail_price?:   number;
    trail_offset?:  number;
    trail_points?:  number;
    from_entry?:    string;              // '' = all open entries
    qty_percent?:   number;
    // ... comments + alert texts per leg
    oca_name?:      string;
    oca_type?:      'cancel' | 'reduce' | 'none';
}

Trade collections

strategy.closedtrades and strategy.opentrades serve a dual role in Pine — both as a count (series int) and as a namespace for per-trade getters (profit(idx), entry_price(idx), etc.). PineTS preserves both.

From inside the run callback (or in Pine syntax) — the transpiler auto-calls the namespace, so you can write Pine-style code:

await pine.run(($) => {
    const { strategy, plot } = $.pine;
    strategy('Getter Test', { overlay: true, initial_capital: 100000 });
    if ($.idx === 1) { strategy.entry('e1', strategy.long, 1); }
    if ($.idx === 5) { strategy.close('e1'); }

    // Used as a count (auto-coerces to int via valueOf):
    plot(strategy.closedtrades);

    // Used as a namespace (per-trade getter):
    // const lastProfit = strategy.closedtrades.profit(0);
});

From plain JS code outside the callback (and on the returned context), it’s an array — use index access:

const ctx = await pine.run(/* ... */);
const s = ctx.strategy;

console.log('Closed count:', s.closedtrades.length);
console.log('First profit:', s.closedtrades[0].profit);
console.log('First trade size:', s.closedtrades[0].size);     // signed
console.log('First trade was a win:', s.closedtrades[0].profit > 0);

Per-trade getter methods (profit(idx), size(idx), entry_price(idx), max_drawdown_percent(idx), etc.) are listed in the API coverage table.


Read-only getters

Every Pine strategy.* scalar getter is implemented as a property on the strategy namespace inside the run callback, and is also available as a field on context.strategy after the run:

Getter Source
strategy.equity state.equity (initial + realized + unrealized)
strategy.netprofit / netprofit_percent state.netprofit (realized only)
strategy.openprofit / openprofit_percent state.openprofit (unrealized)
strategy.grossprofit / grossprofit_percent state.grossprofit
strategy.grossloss / grossloss_percent state.grossloss
strategy.position_size signed; positive long, negative short
strategy.position_avg_price NaN when flat
strategy.position_entry_name entry id that opened current position
strategy.wintrades / losstrades / eventrades trade outcome counters
strategy.avg_trade / avg_winning_trade / avg_losing_trade (+ _percent) derived averages
strategy.max_drawdown / max_drawdown_percent equity-curve trough
strategy.max_runup / max_runup_percent equity-curve peak above start
strategy.max_contracts_held_all / _long / _short running peaks
strategy.initial_capital / account_currency from declaration
strategy.margin_liquidation_price computed from position + margin

After a run, the same values are on context.strategy (the names are 1:1):

const ctx = await pine.run(/* ... */);
const s = ctx.strategy;

console.log('Equity:', s.equity);
console.log('Net profit:', s.netprofit);
console.log('Win rate:', s.wintrades / s.closedtrades.length);
console.log('Max drawdown:', s.max_drawdown);
console.log('Max contracts held (long):', s.max_contracts_held_long);

Risk-adjusted performance (Sharpe / Sortino)

PineTS reports the two risk-adjusted ratios from TradingView’s Risk-adjusted performance panel:

const ctx = await pine.run(/* ... */);
const s = ctx.strategy;

console.log('Sharpe ratio:',  s.sharpe_ratio);
console.log('Sortino ratio:', s.sortino_ratio);

These are report-only fields, not getters. Unlike netprofit or max_drawdown, Sharpe and Sortino are not Pine built-in variables — TradingView surfaces them only in the Strategy Tester report (and xlsx export), never to scripts. So there is no strategy.sharpe_ratio accessor inside the run callback; the values live on context.strategy after the run and nowhere else.

They are computed once at the end of the run (in finalizeStrategyRun), from the strategy’s monthly equity curve.

How they’re calculated

Matching TradingView’s documented methodology:

  1. Sample equity monthly. The mark-to-market strategy.equity is captured at the last bar of each calendar month.
  2. Monthly returns. Simple returns rᵢ = Eᵢ / Eᵢ₋₁ − 1, anchored at initial_capital (the first return runs from initial capital to the first month-end).
  3. Risk-free rate. RFR = risk_free_rate / 100 / 12 — the annual risk_free_rate declaration option (default 2, i.e. 2%) converted to a monthly figure.
  4. Ratios (no annualization):

    Sharpe  = (mean(r) − RFR) / SD
    Sortino = (mean(r) − RFR) / DD
    
    SD = √( Σ (rᵢ − mean(r))² / N )           — population standard deviation
    DD = √( Σ min(0, rᵢ − RFR)²   / N )        — downside deviation over all N returns, target = RFR
    

Edge cases: with fewer than two monthly returns both ratios are 0; a zero-variance (flat) curve yields Sharpe 0; a curve with no downside (every return above the RFR) yields Sortino 0.

Set the risk-free rate via the declaration:

strategy('RFR example', { initial_capital: 100000, risk_free_rate: 4 });  // 4% annual

Accuracy

The formula matches TradingView’s exactly, but the ratios are derivatives of the bar-by-bar equity curve — so their fidelity rides on the engine’s mark-to-market accuracy, not on the formula. Across the TV oracle datasets they land within roughly the second decimal (most within ~0.01; strategies with heavy margin-call activity diverge a little more, since their intra-month equity path is where PineTS and TV differ most even when final net profit matches). They are intended as a faithful summary metric, not a cent-exact reproduction like netprofit.


Constants

PineTS exposes Pine’s three sets of strategy constants. Inside the run callback they appear as bare strings; from plain JS you call them as factories.

Top-level constants (resolve to themselves — the engine accepts these names verbatim):

strategy.long              === 'long'
strategy.short             === 'short'
strategy.cash              === 'cash'
strategy.fixed             === 'fixed'
strategy.percent_of_equity === 'percent_of_equity'

Nested constant namespaces — the transpiler auto-calls them inside the run callback. From plain JS, you call the namespace yourself:

// Inside run callback (Pine-style):
strategy.direction.long    // 'long'
strategy.oca.cancel        // 'cancel'
strategy.commission.percent  // 'percent'

// From plain JS:
const dir = strategy.direction();   // { long: 'long', short: 'short', all: 'all' }
const oca = strategy.oca();         // { none: 'none', cancel: 'cancel', reduce: 'reduce' }
const com = strategy.commission();  // { percent: 'percent', cash_per_order: 'cash_per_order',
                                    //   cash_per_contract: 'cash_per_contract' }

Risk management

strategy.risk is a nested namespace with 6 setter functions. Each one configures a pre-trade filter on state.risk_rules. Filters are checked by the engine before every fill; once a hard-stop rule triggers, state.risk_halted is set and further entries are blocked for the remainder of the run.

await pine.run(($) => {
    const { strategy } = $.pine;
    strategy('Risk-Managed', { overlay: true, initial_capital: 100000 });
    if ($.idx === 0) {
        // Inside the run callback (Pine-style auto-call works too — strategy.risk.x()):
        const r = strategy.risk();
        r.allow_entry_in('long');           // 'long' | 'short' | 'all'
        r.max_position_size(5);             // cap contracts at 5
        r.max_drawdown(20000, 'cash');      // halt entries if equity drops $20k from peak
        r.max_intraday_loss(5000, 'cash');  // halt for the day if loss exceeds $5k
        r.max_intraday_filled_orders(10);   // halt for the day after 10 fills
        r.max_cons_loss_days(3);            // halt after 3 consecutive losing days
    }
});

// Inspect after the run:
const ctx = /* ... */;
console.log(ctx.strategy.risk_rules);
// { allow_entry_in: 'long', max_position_size: 5,
//   max_drawdown: { value: 20000, type: 'cash' }, ... }
console.log(ctx.strategy.risk_halted);  // true if any hard-stop fired

Conversion helpers

Three utility functions for currency / qty math. In PineTS today the conversion functions are identity passthroughs for same-currency strategies (the engine doesn’t currently fetch FX rates); default_entry_qty is fully functional and computes the qty a strategy.entry() would size given the current default_qty_type / default_qty_value and a hypothetical fill price.

await pine.run(($) => {
    const { strategy } = $.pine;
    strategy('Conversion', { overlay: true, currency: 'USD',
                             default_qty_type: 'percent_of_equity', default_qty_value: 10 });

    const inUsd  = strategy.convert_to_account(100);  // 100 (passthrough for same-currency)
    const inSym  = strategy.convert_to_symbol(100);   // 100
    const qty    = strategy.default_entry_qty(50000); // (equity * 10%) / 50000
});

Known divergences

The strategy namespace’s surface is implemented 1:1 with TV. A subset of strategies produce values that diverge from TV in specific fields — these are tracked iteration items, not missing surface:

  • strategy.margin_liquidation_price — PineTS uses an “equity hits zero” approximation; TV’s broker liquidation formula differs.
  • strategy.convert_to_account / convert_to_symbol — identity passthrough for same-currency cases. TV may return na when symbol/account currencies differ even nominally (e.g. BTCUSDC’s USDC vs USD).
  • OCA enforcement — order objects carry oca_name / oca_type fields, but the engine doesn’t yet auto-cancel or reduce siblings on fill. Deferred Phase 7.
  • Commission rounding — per-leg charges may differ from TV by sub-cent rounding in edge cases.
  • Per-trade max_drawdown / max_runup — PineTS tracks intra-bar high/low excursions, but TV’s accounting differs for trades that open and close in adjacent bars.
  • strategy.sharpe_ratio / strategy.sortino_ratio — the formula matches TV exactly, but the ratios are computed off the monthly equity curve and therefore inherit any bar-by-bar mark-to-market path difference. They match TV to ~2 decimals (most datasets within ~0.01), not to the cent. See Risk-adjusted performance.

For the complete checklist (every entry mapped to its implementation status) and the list of TV oracle scripts in use, see the Strategy API coverage page.