Writing Strategies

The strategy({ ... }) declaration, the order API from ./gen/strategy, the position model and sizing. The semantics are the kScript (legacy) engine's own: the…

The strategy({ ... }) declaration, the order API from ./gen/strategy, the position model and sizing. The semantics are the kScript (legacy) engine's own: the host runs the engine's broker, so every rule below is the rule the Strategy Tester applies.

The declaration

strategy(options) is one top-level statement of src/indicator.ts, 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, so users adjust it from the package's settings with the declaration as the default.

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, with a warning if nonzero.
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 in this release (no order book is attached).
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, with a warning if nonzero.
fundingfunding"data""off" is an exact no-op; "data" settles recorded funding when a provider is attached, and counts the bars it cannot settle otherwise (this release attaches none).
onLiquidationon_liquidation"continue""halt" rejects every entry after the first liquidation.

The build derives the section into the sheet with the field names in the second column, and the package's title on the wire is its display name. A strategy package's price input reads ohlcv on the market it is run on, with no symbol or exchange pin: the broker fills against that market's own candles. The names strategy.position and strategy.equity are reserved for the host's own outputs. 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. 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 host, so finish one order before starting the next. An unset leg is absent, the engine's na. There is no comment: the engine keeps it on no record.
  • Getters, valid in state() and finalize(): 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.
import { input, line, ohlcv, output, overlay } from "./sdk/declare";
import { in_close } from "./gen/inputs";
import { emitRow, out_fast, out_slow } from "./gen/outputs";
import { strategy } from "./gen/strategy";
import { Cross, Sma } from "./sdk/ta";

strategy({ initialCapital: 10000, qtyType: "fixed", qtyValue: 1, pyramiding: 1, fillModel: "pathHeuristic" });
input("close", ohlcv.close);
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();
let fast: f64 = NaN;
let slow: f64 = NaN;
let close: f64 = NaN;
let crossed: i32 = 0;

export function init(): void {}

export function state(): i32 {
  close = in_close();
  fast = fastSma.update(close);
  slow = slowSma.update(close);
  if (isNaN(fast) || isNaN(slow)) return 0;
  crossed = cross.update(fast, slow);
  return 1;
}

export function finalize(): void {
  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);
  emitRow();
}

export function reset(): void {
  fastSma.reset();
  slowSma.reset();
  cross.reset();
  fast = NaN;
  slow = NaN;
  close = NaN;
  crossed = 0;
}

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 output 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 and leverage 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 (the newest bar the host holds). On the last bar, unfilled orders remain visible under pending orders in the output.

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.