Writing strategies
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.
| Setting | Sheet field | Default | Meaning |
|---|---|---|---|
initialCapital | initial_capital | 10000 | Starting equity, more than 0. |
currency | currency | "USD" | Display label for money-denominated stats. |
commission | commission_ | 0 | Spot commission on every fill, percent of notional. Ignored on perps. |
slippageBps | slippage_bps | 0 | Adverse basis points on market, stop and trailing fills. Limit fills are exempt: a limit price is a bound. |
slippageModel | slippage_model | "fixed" | "bookEstimate" is refused by name when the strategy runs (the chart attaches no order book). |
qtyType, qtyValue | qty_type, qty_value | "percent, 100 | Default sizing. "fixed": qtyValue units per entry. "percent: qtyValue / 100 * equityAtFill / fillPrice. "cash": qtyValue / fillPrice. On perps the last two size margin, and notional is margin times leverage. |
pyramiding | pyramiding | 1 | Maximum stacked same-direction entries; excess entries are rejected and counted. |
fillModel | fill_model | "pessimistic" | Intrabar ordering assumption when a bar could fill two levels; see fill simulation. |
instrument | instrument | "spot" | "perps" enables isolated leveraged margin, liquidation, maker and taker fees and funding. |
leverage | leverage | 1 | Perps leverage, more than 0. |
maintenance | maintenance_ | 0.5 | Perps maintenance margin, at least 0 and under 100. |
makerFeePercent, takerFeePercent | maker_, taker_ | 0, 0 | Perps fee rates, percent of notional; maker on limit-bound fills, taker on market-crossing fills. Ignored on spot. |
funding | funding | "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). |
onLiquidation | on_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:
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.limitorstopmakes 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:profitandlossin price points,limitandstopas absolute prices, at least one leg. Withoutfromit 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 reachespoints, then ratchets with new extremes atoffsetbehind 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.shortandstrategy.exiteach reset one builder, the setters fill its legs, andsend()is the only call that reaches the broker, so finish one order before starting the next. An unset leg is absent. There is nocomment: 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);
}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'sqtyType. - 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,
percentOfEquityandcashsize the isolated margin commitment, not the notional. Explicit quantities andqtyType: "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.