Writing strategies

View as MarkdownOpen the editor

The strategy({ ... }) declaration, the order API, the position model and sizing. The chart's Strategy Tester runs the engine's broker, so every rule below is the rule it applies.

The declaration

strategy(options) is one top-level statement of the file, beside param, input and output. It takes the engine's setting names; every field is optional and strategy() enables the broker with every default. A numeric field may be the handle param(...) returns: the setting then shows as a number field in the strategy's settings dialog, starting at the param's default, and changing it reruns the strategy without a recompile.

SettingSheet fieldDefaultMeaning
initialCapitalinitial_capital10000Starting equity, more than 0.
currencycurrency"USD"Display label for money-denominated stats.
commissionPercentcommission_percent0Spot commission on every fill, percent of notional. Ignored on perps.
slippageBpsslippage_bps0Adverse basis points on market, stop and trailing fills. Limit fills are exempt: a limit price is a bound.
slippageModelslippage_model"fixed""bookEstimate" is refused by name when the strategy runs (the chart attaches no order book).
qtyType, qtyValueqty_type, qty_value"percentOfEquity", 100Default sizing. "fixed": qtyValue units per entry. "percentOfEquity": qtyValue / 100 * equityAtFill / fillPrice. "cash": qtyValue / fillPrice. On perps the last two size margin, and notional is margin times leverage.
pyramidingpyramiding1Maximum stacked same-direction entries; excess entries are rejected and counted.
fillModelfill_model"pessimistic"Intrabar ordering assumption when a bar could fill two levels; see fill simulation.
instrumentinstrument"spot""perps" enables isolated leveraged margin, liquidation, maker and taker fees and funding.
leverageleverage1Perps leverage, more than 0.
maintenanceMarginPercentmaintenance_margin_percent0.5Perps maintenance margin, at least 0 and under 100.
makerFeePercent, takerFeePercentmaker_fee_percent, taker_fee_percent0, 0Perps fee rates, percent of notional; maker on limit-bound fills, taker on market-crossing fills. Ignored on spot.
fundingfunding"data""off" is an exact no-op; "data" settles recorded funding when funding data is attached, and counts the bars it cannot settle otherwise (the chart attaches none).
onLiquidationon_liquidation"continue""halt" rejects every entry after the first liquidation.

Run derives the section into the sheet with the field names in the second column, and checks every value with the engine's rules as the file builds: a value out of range stops the build, with the problem in the editor's Console. A strategy's price input reads ohlcv on the chart's own market, with no symbol or exchange pin (a file with no input line gets the chart's close as that input): the broker fills against the chart's own candles, and any other primary input is refused by name. The names strategy.position and strategy.equity are reserved for the strategy's own alert choices ("Strategy position" and "Strategy equity"): an output with either name is refused by name. There is no calc_on_every_tick: live behavior is fixed, fill simulation explains it.

The order API

Every call maps to one engine broker call, with the engine's own validation:

text
strategy.long(id).qty(n).limit(px).stop(px).oca(name).send()      // strategy.entry(id, "long", ...)
strategy.short(id).qty(n).limit(px).stop(px).oca(name).send()     // strategy.entry(id, "short", ...)
strategy.exit(id).from(entryId).qty(n).qtyPercent(p)
                 .profit(pts).limit(px).loss(pts).stop(px)
                 .trail(points, offset).oca(name).send()          // strategy.exit(id, fromEntry, ...)
strategy.close(id)     strategy.closeAll()                        // market, next open
strategy.cancel(id)    strategy.cancelAll()                       // pending unfilled orders
  • A plain strategy.long(id).send() is a market order for the next bar's open. limit or stop makes it a resting order that fills when touched; both on one entry is rejected and counted. Re-issuing an id replaces the pending unfilled order with that id.
  • strategy.exit(id) attaches bracket legs to an entry: profit and loss in price points, limit and stop as absolute prices, at least one leg. Without from it protects every open entry. Stop, limit and trail legs under one exit id are one-cancels-all.
  • trail(points, offset) activates a trailing stop once the trade's favorable excursion reaches points, then ratchets with new extremes at offset behind them.
  • oca(name) joins any orders, entries and exits alike, into a one-cancels-all group: when one fills, the rest cancel.
  • The builders are preallocated singletons: strategy.long, strategy.short and strategy.exit each reset one builder, the setters fill its legs, and send() is the only call that reaches the broker, so finish one order before starting the next. An unset leg is absent. There is no comment: the engine keeps it on no record.
  • Getters, valid in onBar() beside the order calls: strategy.positionSize() (signed), strategy.positionAvgPrice(), strategy.equity(), strategy.openProfit(), strategy.netProfit(), strategy.closedTradeCount(), strategy.winTradeCount(), strategy.lossTradeCount(), strategy.maxDrawdown().

A crossover entry bracketed by both a stop and a take-profit limit; whichever the market touches first closes the trade and cancels the other side:

// Trend entries with bracket exits: a stop and a take-profit limit on one exit id, whichever the market touches first closes the trade and cancels the other.
strategy({ initialCapital: 10000, qtyType: "fixed", qtyValue: 1, pyramiding: 1, fillModel: "pathHeuristic" });
output("fast", line, overlay, { description: "5-period SMA of close" });
output("slow", line, overlay, { description: "20-period SMA of close" });

const fastSma = new Sma(5);
const slowSma = new Sma(20);
const cross = new Cross();

function onBar(): void {
  const close = bar.close();
  const fast = fastSma.update(close);
  const slow = slowSma.update(close);
  if (isNaN(fast) || isNaN(slow)) return;
  const crossed = cross.update(fast, slow);
  if (crossed == 1) strategy.long("Trend").send();
  if (strategy.positionSize() > 0) strategy.exit("Protect").from("Trend").stop(close * 0.97).limit(close * 1.05).send();
  out_fast(fast);
  out_slow(slow);
}
BTCUSDT perpetual on Binance, 1 hour bars, Aug 2 to Aug 18, 2026Real output from OpenMarket's engine

Position model

The broker nets to one position. An entry in the opposite direction is a reversal: it closes the existing position fully at the same fill, then opens the new one at the requested quantity. pyramiding caps same-direction stacking; rejected entries are counted in the broker's rejectedOrders rather than thrown.

Sizing details worth knowing:

  • An explicit qty(...) on an order always wins over the declaration's qtyType.
  • Sized-at-fill quantities (percentOfEquity, cash) resolve from the slippage-adjusted fill price, and fractional quantities are legal (the broker does not round lots).
  • On perps, percentOfEquity and cash size the isolated margin commitment, not the notional. Explicit quantities and qtyType: "fixed" stay direct quantities and are margin-checked. Perps entries also pass an isolated-margin admission check before the opening leg applies; unrealized PnL is not collateral. Perps has the model.
  • A computed size is rejected and counted when equity at fill is not positive or the quantity is not finite and positive.

Order calls are recorded as rejected, not thrown, when the current bar has non-finite prices or is not confirmed (a live chart's forming bar). On the last bar, unfilled orders remain visible under Pending orders in the Strategy Tester's Trades tab.

Limits

At most 64 distinct order ids per run (closeAll counts as one; ids are slots, re-issuing one replaces its pending order), 64 bytes of UTF-8 per id, 4096 strategy calls per bar, and 10000 closed trades per run. Each breach refuses the run by name, and every engine rejection stays a counted rejection; the limits page lists them.