Strategy scripts
Place simulated orders against historical data: the orders API, the live account, run statistics, and referencing building blocks.
A Strategy script trades automatically over historical data and produces an automated backtest: a trade log, an equity and
drawdown curve, and full statistics in a Run report. It is always its own script — the strategy metadata marker excludes signals and filters (see how kinds are derived); entry and exit logic lives
in Filters & Signals scripts the strategy references instead.
Shape and lifecycle
The base contract is identical to Filters & Signals scripts — same class shape,
lifecycle, ctx members, millisecond timestamps and forming-bar rules. Read Filters & Signals first. Strategy scripts
additionally get:
ctx.orders— place and manage simulated orders.ctx.account— the live simulated account, updated on every market update.ctx.stats— live run statistics plus a place for your own values.
Placing and managing orders — ctx.orders
const fill = ctx.orders.marketOrder({ side: "Long", size: 1000, slDistance: 0.0020, tpDistance: 0.0040 });
// fill.fillPrice = the execution price; fill.lotIds = lots this order OPENED (empty for a pure close)
const h = ctx.orders.limitOrder({ side: "Short", size: 500, price: 1.0850 });
ctx.orders.stopOrder({ side: "Long", size: 500, price: 1.0920, tpDistance: 0.0030 });
ctx.orders.modifyOrder({ orderId: h.orderId, price: 1.0860, size: 700 });
ctx.orders.cancelOrder(h.orderId);
// Lot management:
ctx.orders.closeLot(fill.lotIds[0]); // close ONE specific lot at market
ctx.orders.closeLot(lotId, 400); // …or partially (400 units; the rest stays open)
ctx.orders.closePosition(); // flatten everything (no-op when flat)
ctx.orders.setLotStops(lotId, { slPrice: 1.0800, tpPrice: 1.1000 }); // ABSOLUTE prices
ctx.orders.cancelLotStops(lotId); // remove the lot's SL/TP entirely
ctx.orders.setLotTrail(lotId, 0.0015); // trailing stop on an open lot (null removes it) sideis"Long" | "Short";sizeis raw units of the instrument (see Sizing below).- SL/TP at placement — two forms, one per side, not both:
slDistance/tpDistanceas absolute price distances from the fill, orslPrice/tpPriceas absolute prices (validated to lie on the protective side; for a pending order they anchor to the order's own price at placement). Manage them later per lot withsetLotStops— absolute prices; omitted = unchanged,null= clear that side. - Trailing stops:
trailDistanceon a market order attaches a trailing stop to the lots it opens, orsetLotTrail(lotId, distance)on any open lot (nullremoves it). The level ratchets with price on every market update; when price pulls back through it, the lot is closed at market.lots[].trailPriceshows the current level. - Expiry (pending orders):
expiresAt(ms) — good till time; the order is cancelled at the first update at or after that time. An order that is both marketable and expiring on the same update fills. - Fills: market and stop orders fill at the ask (Long) / bid (Short); a limit order fills at its own price side. Pending orders are evaluated against every recorded market update — see No look-ahead.
- In
modifyOrder,slDistance/tpDistance: omitted = unchanged,null= clear.
The account — ctx.account
| Member | Meaning |
|---|---|
balance | Starting balance + realized P&L |
equity | Balance + open P&L, marked to market at the current bid/ask on every update |
openPnl / closedPnl | Open / realized P&L |
startingBalance | The run's starting balance |
position | Signed net position in raw units (Long +, Short −) |
pendingOrders | Open pending orders: orderId, type, side, quantity, limit/stop price, created time |
positions | Open positions with averagePrice and openPnl (netting — at most one at a time) |
lots | Open lots: lotId, side, quantity, entryPrice/entryTime, openPnl, stopLossPrice/takeProfitPrice, trailPrice |
lastClosedTrade | The most recent closed trade (side, quantity, open/close price & time, pnl); null before the first close |
The list views (pendingOrders/positions/lots) are
built at read time — don't cache them across updates.
Run statistics — ctx.stats
Live statistics computed during the run, guaranteed to match the Run report:
| Built-in (read-only) | Meaning |
|---|---|
peakEquity | Highest equity so far (starts at the starting balance) |
maxDrawdown | Worst drawdown so far (peak equity − equity) |
drawdown | Current drawdown |
trades / wins / losses | Closed-trade counts |
totalPnl | Realized P&L of closed trades |
These update as the run progresses, so logic like "halve size beyond a drawdown threshold" or "stop after three losses in a row" reads them directly — no need to re-track equity yourself.
Custom values — ctx.stats.custom.set(name, value) writes;
read back as ctx.stats.custom.<name>. Custom values are returned with
the run result and stored on the saved session:
onFinish(ctx: Context) {
ctx.stats.custom.set("winRate", ctx.stats.trades > 0 ? ctx.stats.wins / ctx.stats.trades : 0);
ctx.stats.custom.set("halted", this.halted);
} Custom-stat rules (violations are run errors, caught on the first run): at most 100 keys; values are finite numbers, strings up to 256 characters,
or booleans; names must be plain identifiers (set is reserved); write
only via set(). Set anywhere — last write wins; a final summary from onFinish is the typical use.
Referencing building blocks — ctx.deps
Declare references in metadata under dependencies: { <alias>: { scriptId, version } }. Each alias is an independently
configured instance — the same script under two aliases is two instances with their own
parameters. Dependencies may be Filters & Signals scripts or indicators — never a strategy. Versions freeze at save, transitively — a dependency's
own dependencies come along, frozen; pinning and Update to vN are covered in Scripts explained.
onInit(ctx: InitContext) {
ctx.deps.trend.setParams({ emaLen: 34 }); // onInit only; unset keys keep defaults
ctx.deps.trend.signals.crossUp.onSignal((c) => { // fires BEFORE your onUpdate this update
c.orders.marketOrder({ side: "Long", size: 1000 });
});
ctx.deps.session.filters.inSession.onChange((v, c) => { /* v = new value */ });
}
onUpdate(ctx: Context) {
if (ctx.deps.session.filters.inSession.value === true && ctx.deps.rsi.plots.rsi < 30) { /* … */ }
} - Filters & Signals dependency:
.setParams({...})(onInit only);.signals.<name>.onSignal(cb)fires the instant the dependency raises, before youronUpdatethat update — or poll instead with.signals.<name>.raisedThisTick("did it fire during this update?") and.lastRaisedAt(ms of the most recent raise);.filters.<name>.value(boolean | null—nullonly before the dependency's first update) and.onChange(cb), which fires on every change including the first set. - Subscriptions are onInit-only:
onSignalandonChangethrow if called later — subscribe once inonInit, not per update. - Indicator dependency:
.setParams({...})(numeric inputs) and.plots.<name>— current plot values, recomputed on every market update exactly like on the chart (the same compiled indicator), so what the strategy sees provably matches what you see on the chart. Its lookback is warmed with history before the range starts, so plots are real from early in the range. - The run loads the deepest
historyBarsrequirement in the whole reference tree. - Dependency outputs feed your strategy only — they do not appear in the run result.
Sizing, currency, starting balance
- Size is raw units of the instrument; trade value =
size × price. There are no lots or contract sizes — derive size however you wish, e.g. fromctx.account.balanceand a risk input. - All money values are in the instrument's quote currency — P&L, balance, equity, drawdown. A USDJPY backtest reports in JPY; there is no cross-currency conversion.
- Starting balance is set per run in the New backtesting run dialog; the default is 10,000 (quote currency).
A complete example
A strategy that references the "EMA Trend" script from the Filters & Signals page and goes long on its cross-up signal, one position at a time:
{
"name": "EMA Cross Long", "shortName": "EMAX",
"inputs": {
"emaLen": { "type": "int", "default": 20, "min": 1, "max": 500 },
"size": { "type": "int", "default": 1000, "min": 1 },
"stop": { "type": "float", "default": 0.002, "min": 0 }
},
"strategy": {},
"dependencies": { "trend": { "scriptId": "…", "version": 3 } }
} export default class implements Script {
onInit(ctx: InitContext) {
ctx.deps.trend.setParams({ emaLen: ctx.inputs.emaLen });
}
onUpdate(ctx: Context) {
if (ctx.deps.trend.signals.crossUp.raisedThisTick && ctx.account.position === 0) {
ctx.orders.marketOrder({
side: "Long",
size: ctx.inputs.size,
slDistance: ctx.inputs.stop,
tpDistance: ctx.inputs.stop * 2,
});
}
}
onFinish(ctx: Context) {
ctx.stats.custom.set("endedFlat", ctx.account.position === 0);
}
} Launch it with Run backtest from the Script Editor, or via Automated… in the + New menu — the New backtesting run dialog prefills the script and renders its inputs as a parameters form.
Trade-action limits
A run records a bounded number of trade actions — order placements, modifications,
cancellations, executions, and SL/TP changes: 1,000 per run on Free, 5,000 on any
paid plan (see Compute time & limits,
which runs also count against). Hitting the cap ends the run early but keeps everything
computed up to that point — a partial result, not a failure — though onFinish does not run, so custom stats set there will be absent. A strategy
that trades on every market update hits the cap quickly — gate entries on ctx.isNewBar or on signal conditions.
Something missing or wrong? Email support@strategytune.com.