Appearance
Strategy
Kind: module
Namespace: strategy
Provides functions for simulating trades, backtesting, and forward-testing trading systems against bar data.
Strategy scripts simulate order placement and execution against historical and real-time bar data. All trades are hypothetical — no real orders are sent to a broker unless integrated externally via alerts or webhooks.
After each calc() run, the host application receives a trade report and performance analytics (strategyReport(), strategyFillReport(), strategyOrderReport(), strategyAnalytics() on the Java CalculationResult). Those structures are not exposed as script variables inside onTick().
Overview
A strategy script is declared once in the init section using strategy(). After declaration, the onTick() function handles order logic. Use strategy.position() to read the net position.
javascript
// INIT
strategy({ title: "MA Crossover" })
const fastLen = input.double("Fast MA", 10)
const slowLen = input.double("Slow MA", 20)
// TICK
function onTick() {
const fastMA = ta.sma(close, fastLen)
const slowMA = ta.sma(close, slowLen)
if (bar.isConfirmed()) {
if (ta.crossover(fastMA, slowMA)) {
strategy.entry("long", strategy.LONG)
}
if (ta.crossunder(fastMA, slowMA)) {
strategy.entry("short", strategy.SHORT)
}
}
spline(fastMA, {title: "Fast MA", color: color.BLUE})
spline(slowMA, {title: "Slow MA", color: color.ORANGE})
}A script must call either strategy() or indicator() in init, not both.
Trading Simulation
Market entries (no price level) and market closes fill at the open of the next bar. An entry with a limit and/or stop becomes a resting order instead — it enters the book on the next bar and fills when price reaches the level, or when its time in force runs out (see Entry order types).
Take-profit and stop-loss brackets from strategy.exit() are evaluated intrabar on each subsequent bar while the linked entry remains open. Resting entry orders use the same intrabar path walk and share one chronology with brackets on a bar where both could fire.
What is intrabar?
A bar represents a compressed summary of all price activity during a time period. A 1-hour bar on AAPL, for example, captures thousands of individual trades into just four values: open, high, low, and close. Everything that happened between open and close — the actual sequence of price ticks — is hidden. That hidden activity is called intrabar price movement.
Intrabar logic applies to strategy.exit() brackets (take-profit / stop-loss) and to resting entry orders (limit / stop / stop-limit on strategy.entry() or strategy.order()). Market entries and strategy.close() always use next-bar-open fills and do not use intrabar path inference.
Intrabar assumptions for exit brackets and resting entries
For a long position, take-profit triggers when high >= limit; stop-loss triggers when low <= stop. For a short position, take-profit triggers when low <= limit; stop-loss triggers when high >= stop.
When both take-profit and stop-loss are reachable within the same bar, the simulation infers a 3-leg intrabar path for the bar — open -> ext1 -> ext2 -> close — where the path visits whichever extreme (high or low) sits closer to the open first, then swings to the far extreme, then settles at close. Whichever level the path reaches first fires; a trailing stop's arm/track/fire state is walked the same way, leg by leg, so it can arm and fire within the same bar (on different legs) exactly as an intrabar path implies. This replaces a simpler "stop-loss always triggers first" assumption — the order now depends on the bar's actual shape, not a fixed pessimistic default.
When a bracket is triggered because the bar opens beyond the level (gap), the fill uses the open (better for the trader than the bracket price on take-profit gaps, worse on stop and trailing-stop gaps) — this is checked before the path walk and takes priority over it. Intrabar-only touches still fill at the bracket limit / stop / trailing-stop level, not at the open.
Slippage from strategy() applies only to the protective side — stop-loss and trailing-stop fills, both effectively market orders once triggered. A take-profit fills at "limit or better", which slippage would contradict, so it never slips.
Price grid
mintick is the instrument's price grid: the instrument only trades at multiples of it. Every incoming bar's open / high / low / close is therefore rounded to the nearest tick (ties up) before any level is checked, and the whole bar is simulated on those rounded values.
This matters because feeds do deliver off-grid quotes — an aggregated 1H bar on a 0.01-tick equity can arrive as 226.545, a price at which no trade could have happened. Without the rounding a stop at 226.54 would be compared against 226.545 and silently not trigger, and a gap fill would be reported at a price that does not exist on the instrument.
Because the rounding happens once, up front, everything downstream sees the same grid:
- take-profit / stop-loss / trailing-stop trigger checks and the 3-leg path walk
- resting entry
limit/stoplevels (snapped away from the market before placement) - fill prices, including gap fills at the open and market-entry fills
- a trailing stop's high/low-water mark, and so its trailed stop level
- run-up / drawdown extremes, margin-call checks, and the mark price behind equity
Set mintick to the instrument's real tick. The default 0.01 suits most equities; leaving it at 0.01 for an instrument quoted finer (an FX pair, a crypto pair) would round genuine price detail away.
Order timing
Market orders created on tick N are filled on the next available tick (N+1). On historical bars this means the following bar's open. Creating and filling a market order on the same tick is not allowed for entries and closes.
Resting entry orders are placed on bar N and become live at the open of bar N+1. They then fill intrabar when price reaches their level, at that bar's open when the level is already passed (a gap), or never if price does not reach them before cancellation or expiry.
Exit brackets registered on bar N are evaluated starting on bar N+1 and later bars while the entry is still open. A bracket can fill on the same bar as the entry fill if price reaches the level on a later tick of that bar — after a market entry has executed at the open, or after a resting entry has filled intrabar, on the same leg of the path or a later one.
An exit placed for an entry that has not filled yet — a resting limit / stop order, typically placed together with it — waits for that entry, however many bars it rests. It also survives the entry's cancellation and applies to the next entry with that id. Once its entry has existed and is fully closed, the bracket is gone.
Pending queue and fill batch
All market orders queued on bar N are stored in a pending queue and executed together at the open of bar N+1, in three tiers: strategy.close() calls first, then strategy.order() calls, then strategy.entry() calls. Within one tier the calls fill in the same order your script made them on bar N.
Typical flip pattern:
javascript
strategy.close("long")
strategy.entry("short", strategy.SHORT)On the fill bar the runtime processes close before entry. Default sizing for the new entry (when qty is omitted) therefore uses equity after those pending closes, not while the old position is still open, so the replacement position is sized from the post-close account state.
Explicit qty in strategy.entry({ qty: … }) is fixed at queue time and is not recalculated at fill. When the script sets lotStep, it is floored to it.
An entry() that reverses the position closes it at the entry's own fill price. Slippage is applied once: the close and the new leg trade on the same side, at the same price.
Confirmed bars
Order commands run whenever your script calls them inside onTick(). The engine does not automatically skip unconfirmed bars based on onConfirmedOnly (that option is stored for API compatibility). Gate signals with bar.isConfirmed() when you want backtest behavior to match closed-bar signals only.
Order comments
Optional comment strings label fills on the chart and appear as the Signal column in the strategy trade list. The host reads them from strategyFillReport() (StrategyFill.comment) and strategyReport() (StrategyTrade.entrySignal / exitSignal — display labels, not raw order ids).
| Function | Option | Type | Where it appears |
|---|---|---|---|
strategy.entry() | comment | string | Entry fill marker; trades list entry Signal |
strategy.order() | comment | string | Same as entry — fill marker and trades list Signal |
strategy.exit() | comment | string | Default exit label when the bracket fills |
comment_profit | string | Take-profit (limit) fill only; overrides comment | |
comment_loss | string | Stop-loss (stop) fill only; overrides comment | |
strategy.close() | comment | string | Overrides system defaults (see below) |
strategy.closeAll() | comment | string | Override for the one close order the call queues |
When comment is omitted, defaults are:
- Entry — lowercase entry
idin both fillcommentand trades-list entry Signal - Market close (
strategy.close/closeAll) — withoutcomment: filled-orders WSsignal=Close entry(s) order {entryId}(e.g.Close entry(s) order Long); fillcomment(idcolumn) =Close entry(s) order strategy.close_N; trades-list exit Signal ="close" - Exit bracket — bracket
idwhen no bracket comments are set
Passing comment replaces both the fill marker text and the trades-list exit Signal for that close (for example comment: "EMA↓ Exit Long" instead of Close entry(s) order strategy.close_0).
comment_trailing labels a trailing-stop fill and takes priority over comment on that fill.
strategy()
Kind: func
Declares the script as a strategy and configures its properties. Must be called in the init section exactly once.
Syntax
strategy(options) → voidOptions
| Name | Type | Default | Description |
|---|---|---|---|
| pyramiding? | int | 1 | Maximum number of open entries allowed in the same direction |
| initialCapital? | double | 10000 | Starting account balance (used for equity estimates and analytics) |
| currency? | string | "USD" | Account currency label |
| commission? | double | 0 | Commission rate or amount. Interpretation depends on commissionType |
| commissionType? | string | "percent" | How commission is interpreted. See Commission types |
| slippage? | int | 0 | Adverse slippage in ticks applied to every fill price |
| mintick? | double | 0.01 | The instrument's price grid: one tick = mintick. Converts slippage and tick-based offsets into a price delta, and every incoming bar's OHLC is rounded onto this grid before any level is checked (see Price grid) |
| lotStep? | double | 1 | The instrument's minimum order-quantity step. Default sizing floors to a multiple of it (see Default quantity types); an explicit qty floors to it only when the script sets lotStep. The engine does not get the step from the symbol: the 1 default suits stocks and futures, fractional instruments need their own step |
| defaultQty? | double | 1 | Default order size when qty is not specified (meaning depends on defaultQtyType) |
| defaultQtyType? | string | "fixed" | How defaultQty is interpreted: "fixed", "percent_of_equity", or "cash"; any other value is an error (see Default quantity types) |
| defaultQtyBasis? | string | "signal_bar" | When deferred "percent_of_equity" / "cash" sizing takes its equity and price snapshots: "signal_bar" or "fill". "fill" is an error next to "fixed", the defaultQtyType default included (see Default quantity types) |
| marginLong? | double | 100 | Minimum percent of long position market value that must be covered by equity. 0 disables margin calls for longs |
| marginShort? | double | 100 | Minimum percent of short position market value that must be covered by equity. 0 disables margin calls for shorts |
| onConfirmedOnly? | bool | true | Stored for API compatibility; does not gate order execution (see Confirmed bars) |
strategy() also accepts the display options shared with indicator() — overlay, lookback, title, and shortTitle — with the semantics documented there, including how declared titles are reported back to the host.
Returns
void
Caveats
- Must be called in the
initsection - Can only be called once per script
- Cannot be combined with
indicator()in the same script
Pitfall
javascript
function onTick() {
strategy({ pyramiding: 2 }) // ❌ inside onTick()
}Example
javascript
strategy({
title: "MA Crossover",
shortTitle: "MAX",
pyramiding: 1,
initialCapital: 10000,
currency: "USD",
commission: 0.1,
commissionType: "percent",
slippage: 2,
mintick: 0.01,
defaultQty: 1,
defaultQtyType: "fixed"
})strategy.entry()
Kind: func
Queues an entry to open or add to a position. With no price level it is a market entry and fills at the open of the next bar (plus slippage). Give it a limit and/or a stop and it becomes a resting order instead — see Entry order types.
If a position is open in the opposite direction, the entry reverses it: all opposite-direction entries are closed at the next open, then the new entry is opened. When the new entry did not fit when it was placed, the close still happens and only the new entry is refused; when it fits then but not at the open, nothing happens — see Refused entries. The order is sized when it is placed as its qty plus that position, so an entry of size 0 still closes the position and opens nothing. A second call with the same id on that bar resizes the waiting order to its own qty and no longer reverses: against a long of 10, strategy.entry("S", strategy.SHORT, { qty: 4 }) twice on one bar sells 4 and leaves a long of 6, as strategy.order() would.
If a position is open in the same direction, the order adds a new entry — subject to the pyramiding limit, which counts open legs in that direction, not distinct ids. Once the limit is reached, further calls in the same direction are ignored until a leg partially or fully closes. Repeating the same id does not merge fills: each fill is its own trade.
For take-profit / stop-loss after an entry has filled, use strategy.exit(): the levels here price the entry itself, not its protection.
Syntax
strategy.entry(id, direction, options?) → voidArguments
| Name | Type | Description |
|---|---|---|
| id | string | Unique identifier for this entry. Used by strategy.exit() and strategy.close() |
| direction | string | strategy.LONG or strategy.SHORT |
| options? | object | Order options (see Options) |
Options
| Name | Type | Default | Description |
|---|---|---|---|
| qty? | double | resolved from defaultQty / defaultQtyType | Number of units to trade, floored to lotStep when the script sets it. A size of 0, or one that floors to 0, opens nothing, but still acts on the unfilled order with this id — resting, or a market order waiting for the next open — which is cancelled. A first call of size 0 on the bar against an opposite position still closes that position. NaN uses the default size. A negative value, or one above 1e12, is an error |
| comment? | string | lowercase id | Label on the entry fill marker and in the trades list Signal column (see Order comments) |
| limit? | double | object | none | Absolute limit price, as 250.5 or { price: 250.5 }. See Entry order types |
| stop? | double | object | none | Absolute stop price, same two forms |
| type? | string | inferred from the levels | "market", "limit", "stop" or "stopLimit". When given it must match the levels, and its required prices are enforced |
| tif? | string | "gtc" | Time in force for a resting order; only "gtc" is supported |
| expireBars? | int | none | Bars of life for a resting order; without it the order rests until filled or cancelled. An error next to tif: "gtc" and on a market order |
When qty is omitted, the engine defers sizing to the fill bar. At queue time it stores signalClose = the close of the bar where strategy.entry() was called (the signal bar) and signalEquity = the sizing equity at that moment (realized + unrealized on open legs, each open leg charged its entry commission — slightly lower than strategy.equity(), see Equity conventions). At fill it processes any earlier pending orders in the queue (for example strategy.close()), then applies the size. Which equity and price that size uses depends on defaultQtyBasis:
defaultQtyBasis | equity | price |
|---|---|---|
"signal_bar" (default) | signalEquity, not recalculated after those closes | signalClose for a market entry; for a resting one, the price it would execute at when placed (see below) |
"fill" | Sizing equity at the fill, after those closes, with any open position marked at the fill price | The fill price, slippage included |
The fill price is defined under Fill price below. Under "signal_bar" a market entry is priced at the signal bar and paid for at the fill bar, so a size taken at 100 percent of equity can stop fitting between the two — see Refused entries. Under "fill" the order's margin is checked against the same equity and price its size came from — see Margin.
Under "signal_bar" a resting entry is sized when it is placed, at the price it would execute at against the signal bar's close: its level, or that close when the order is already past it — a buy limit above the close, or a buy stop below it, is priced at the close, and a sell order mirrors this. A stop-limit's stop price is capped by its limit, and a stop's price includes slippage. The order keeps that size wherever it fills, so a stop that a gap carries past its level costs more than it was sized for, and can be refused. A size that floors to 0 places nothing, and cancels an unfilled order with this id.
defaultQtyType | Size at fill |
|---|---|
"fixed" | defaultQty units (no equity lookup) |
"percent_of_equity" | floorToLotStep(budget / unitCost) — commission is folded in, see below |
"cash" | floorToLotStep(defaultQty / price) — commission is not folded in |
Where:
equity— from the table above. IfsignalEquityis unavailable, the defined sizing default is equity at fill time.price— from the table above. IfsignalCloseis missing or non-positive, the defined sizing default is the fill bar's open (before slippage).budget/unitCost— for"percent_of_equity"the commission is part of the cost of taking the position, so the total outlay (position value + commission) equals the requested share of equity — not the position value alone. How it enters depends oncommissionType:commissionTypebudgetunitCost"percent"equity × defaultQty / 100price × (1 + commission / 100)"cash_per_contract"equity × defaultQty / 100price + commission"cash_per_order"max(0, equity × defaultQty / 100 − commission)priceno commission equity × defaultQty / 100price"cash"sizing means "spend exactly this much on the position", so it ignores commission entirely;"fixed"needs no calculation. Equity here excludes any commission for exits that have not happened yet, so the commission is charged once, not twice.floorToLotStep— rounds down to a whole multiple oflotStep, never up. With the defaultlotStepof1that means whole units, so a size below one unit becomes0and the entry is silently skipped — correct for shares, but it makes any fractional instrument (crypto, where10000 / 60000 = 0.1667) trade nothing at all. SetlotStepto the instrument's real quantity step in that case (crypto exchanges typically quote0.00001on BTC pairs,0.0001elsewhere). An explicitqtyfloors tolotSteponly when the script sets it, so a script that does not set it trades explicit fractional sizes as given.
Fill price depends on the order kind:
- Market (no level) — the next bar's open, plus slippage.
- Resting (
limit/stop/ stop-limit) — the level when price reaches it intrabar, or the bar's open when the level is already passed at the open (a gap). Stop entries slip adversely; limit entries do not, and neither does a stop-limit, which is a limit once its stop has fired. See Where a resting order fills.
Returns
void
Example
javascript
// INIT
strategy({ pyramiding: 1, initialCapital: 10000 })
const length = input.double("Length", 14)
// TICK
function onTick() {
const [plusDI, minusDI] = ta.dmi(length)
if (bar.isConfirmed()) {
if (ta.crossover(plusDI, minusDI)) {
strategy.entry("long", strategy.LONG, { comment: "long" })
}
if (plusDI[1] >= minusDI[1] && plusDI[0] < minusDI[0]) {
strategy.entry("short", strategy.SHORT, { qty: 2, comment: "short" })
}
}
}Entry order types
An entry with a price level does not fill at the next open. It rests until the market reaches it, or until its time in force runs out.
js
strategy.entry("long", strategy.LONG, { limit: { price: 245.50 } })
strategy.entry("brk", strategy.LONG, { stop: { price: 250.00 } })
strategy.entry("sl", strategy.LONG, { stop: { price: 250.00 },
limit: { price: 250.60 } })| dxScript entry | Type in the report | Type code | Behaviour |
|---|---|---|---|
| market (no level) | — | 3 | Fills at the next bar's open. Unchanged. |
limit | ORDER_TYPE_LIMIT | 1 | Rests until price reaches the limit; fills at limit or better. |
stop | ORDER_TYPE_STOP | 5 | Dormant until the stop is reached, then fills like a market order. |
stop + limit | ORDER_TYPE_STOP_LIMIT | 6 | The stop starts it; the limit caps the worst acceptable fill. |
The type is a value, not a branch: the report carries it, and a fourth type can be added without changing the call surface. In the host order report it is an enum: a JSON client receives its name (ORDER_TYPE_LIMIT), and the type code is the number behind that name — the order-type identifier, so a consumer that keys on codes needs no lookup table of its own. A market entry never appears in the report; its code is listed for completeness. There is no trailing entry type; trailing exits are covered by Trailing stop.
Where a resting order fills
These are dxScript's own rules, defined here because bar data does not settle them by itself.
- On the intrabar path. Triggers ride the same
open → ext1 → ext2 → closetraversal the exit brackets use (see Intrabar assumptions). Two resting orders on one leg of the path fill in the order price reaches their levels, nearest first — never in the order the script placed them. - Gaps fill at the open. A bar that opens through the level has already passed it: a limit fills at the open (better than asked), a stop fills at the open (worse than asked). This holds on every bar of the order's life, not only the first. Filling at the level instead is the classic way a backtest invents profit.
- Slippage is adverse, and applied once. A stop entry fills at stop-or-worse. A limit entry is never slipped — "limit or better" is the whole contract of a limit, and this mirrors the take-profit side of a bracket. A stop-limit whose stop fires is a limit from that moment: it fills at the stop, unslipped, if the stop is inside its limit — a stop and a limit at one price included. Every unit a resting fill trades goes at that one price: the position a reversing entry or a reducing
order()closes is not slipped a second time. - A stop-limit that gaps past its limit does not fill. The stop is spent and the order keeps working as a plain limit; it fills later only if price comes back inside the cap, or ends unfilled.
- Levels snap away from the market. A level off the price grid is rounded to the grid in the direction that makes it harder to reach. Rounding toward the market would fill at a price the instrument does not quote.
- Sizing happens when the order is placed. A default-sized resting order is sized then, at the price it would execute at (see
strategy.entry()), and keeps that size wherever it fills. Its margin is checked as a market order's is: when placed and again at its level, both times on the account as the placing bar closed. UnderdefaultQtyBasis: "fill", a default-sized order is sized and checked once, at its level, on the account as it is then — see Margin. - A margin refusal is final. It does not wait for a cheaper price. What it leaves depends on the order:
- an order that would only open a position executes nothing and is rejected;
- an
entry()that reverses the position and was already too big when placed still closes it, and only the new leg is refused: the close is a fill, and the order is rejected for the new leg. Refused only at its level, it executes nothing, and the position stays open; - an
order()whose remainder does not fit executes nothing, not even the reduction, and is rejected; - a reversal of a position opened on the same bar needs margin for both legs at once, with the position valued at the fill price, never at the close of the bar under way; it is refused whole and rejected, and the position stays open.
- Excursions split at the fill. A position opened partway through a bar takes its run-up and drawdown on that bar from the part of the path after its fill, not from the bar's whole range — and only up to its exit, when a bracket or a later fill closes it on that same bar. A position the fill closes — a reversal, or an
orderthat reduces it — keeps the part before. pyramidingis checked when the order reaches the book, and again when it fills. A resting entry becomes live at the open of the bar after the one that placed it, after that open's market orders have been filled. If there is no slot left at that moment the order is never accepted, and it is not revived when a slot frees up later. Placing one on the same bar as astrategy.close()therefore works: the close has been filled by the time the order is admitted. An order admitted with a slot free is refused at its fill if the slot has been taken since, and is cancelled too.- Ordering against exits. Exit brackets and resting entries share one chronology: each fills at the point the bar's path reached its level, with no precedence between the two. An entry whose level the path reaches first is tried first — and if
pyramidingrefuses it there, the order is cancelled rather than left to fill later.
Time in force
The default is "gtc": the order rests until it fills or the script cancels it. "gtc" is the only value tif accepts.
expireBars: N gives a resting order a lifetime instead: it is live for the bar it became live on and N - 1 bars after it, then expires. expireBars: 1 is "fill on the first live bar or cancel". Bar data carries no order-lifetime clock, so the lifetime is counted in bars. The report shows such an order with time in force GTD.
A calendar-day lifetime is not available: where a trading day ends depends on the instrument's session, which bar data does not carry.
Cancelling and reporting
Calling strategy.entry() again with the id of an unfilled resting entry modifies that order instead of placing a second one; the same holds for strategy.order(). The order takes the new levels, size, comment and expireBars, keeps its place in the report, and keeps the time-in-force clock it started when it became live — a new expireBars counts from that same live bar. Re-issuing the same order on every bar is therefore safe: it is one order, and it fills once. A re-placement in the opposite direction is an error that stops the script: cancel the pending order first, or give the new one another id. A market entry() (or order()) with the id of a pending resting one takes that order's place: the resting order is cancelled and does not fill later. A market order waiting for the next open is unfilled too: the next market call with its id on that bar, a size of 0 included, modifies it, so only the last one executes. A resting call with that id cancels it and places nothing: neither order executes, not even a limit the next open has already passed.
strategy.cancel() and strategy.cancelAll() remove unfilled resting orders as well as exit brackets.
Every resting order appears in the host order report with a status, so a run whose entries never triggered is distinguishable from one that produced no signals at all:
On the wire the status is an OrderStatus enum: a JSON client receives its name, and the status code is the number behind it, so the two cannot disagree.
| Status | In the report | Status code | Meaning |
|---|---|---|---|
| waiting limit | ORDER_STATUS_WAITING_LIMIT | 7 | Resting, waiting for its limit. |
| waiting stop | ORDER_STATUS_WAITING_STOP | 8 | Resting, waiting for its stop. |
| working | ORDER_STATUS_WORKING | 10 | A stop-limit whose stop has fired; only the limit is left. |
| filled | ORDER_STATUS_FILLED | 11 | Executed. |
| canceled | ORDER_STATUS_CANCELED | 12 | Removed by strategy.cancel() / cancelAll(), replaced by a market order with its id, or refused by pyramiding when it reached its level. |
| rejected | ORDER_STATUS_REJECTED | 13 | The account could not fund it: a resting order at its level, a market order at its fill — see Refused entries. A reversing entry() whose new leg was refused is rejected too, though its close is a fill. |
| expired | ORDER_STATUS_EXPIRED | 14 | Time in force ran out. |
Refused entries
A market order has no life before it fills, so it is normally described by its fill alone and adds no order row. The exception is one the account cannot fund: it produces no fill and no trade, so without a row it would be indistinguishable from a signal that never fired. Such an order appears with status rejected, order type market, the quantity the sizing produced, the bar the script placed it on, the bar whose open refused it, and a reason. It has no time in force: it never rested. This covers strategy.entry() and strategy.order() alike.
| Reason | Meaning |
|---|---|
REJECT_REASON_INSUFFICIENT_FUNDS | Equity did not cover the order's margin requirement when it was placed or when it filled — see Margin. |
This is the failure mode of defaultQty: 100 with "percent_of_equity" and the default defaultQtyBasis: "signal_bar". The quantity comes from equity at the close of the signal bar and the order fills at the next bar's open, so at 100 percent there is no headroom: an open above that close makes the very quantity the sizing produced unaffordable, and the entry is refused rather than trimmed to fit. Sizing below 100 percent leaves room for the gap and the refusals stop. With a position already open, an order also has to fit beside it: an add sized at 100 percent of equity never does. defaultQtyBasis: "fill" takes both the equity and the price from the fill instead, so a gap or slippage no longer refuses an entry from flat or a reversal. An add still has to fit beside the position it joins: sized at 100 percent of equity, it does not under either basis. And the size can still miss by commission: "cash" sizing charges it on top of the position, and a reversal's close pays it before the new leg is checked.
An order that does not fit is refused, not trimmed to fit, and what the refusal leaves behind follows fixed rules. An entry() that reverses the position and is refused when it is placed still closes the position; only the new leg is refused, and that leg is the row's size. Refused only when it fills, the same entry() executes nothing and the position stays open. An order() whose remainder does not fit executes nothing either way, not even the reduction, and the row's size is the whole order. Resting orders follow the same rules, judged when placed and when they reach their level. An order sized at its fill under defaultQtyBasis: "fill" is judged only when it fills, and a reversing entry() refused there still closes the position. What the report adds is the ability to see it happen: for a reversal whose new leg was refused, the close is in the fill log, and the row says what did not happen.
A resting order the margin refuses at its level is reported the same way, on the row it already has: status rejected, the same reason, and the size refused — for a default-sized order, the size it was given when placed, or at its level under defaultQtyBasis: "fill". A market order whose quantity sizes to nothing is not a refusal: it is dropped without a row, whatever the account holds.
Levels are absolute prices
Only absolute prices are accepted. { percent: … } and { ticks: … } are rejected, not reinterpreted: an offset needs an anchor, and naming one silently is how a backtest and a live order end up meaning different things. Compute the price in the script instead.
A level that resolves to na — an indicator that has not warmed up — leaves the order unplaced rather than aborting the script, exactly as a na bracket trigger does. A structurally missing price is a different thing and is an error: { limit: {} }, or type: "limit" with no limit price, fails with a message naming what is missing rather than quietly falling back to a market order. The check runs when the call executes, so a strategy.entry() on a branch your data never takes is never reached — the same is true of an unrecognised option key, which is rejected rather than ignored.
strategy.order() uses the same resting mechanics and reports through the same host order report.
strategy.order()
Kind: func
Queues a directional order that moves the position arithmetically. It is the sibling of strategy.entry() for netting, scaling, and grid logic: it does not consult pyramiding, and an order against the current position reduces it instead of reversing it — only the quantity that exceeds the opposing position opens a new leg on the other side. Both the closing and the residual opening halves appear in the trade log when a split occurs.
With no price level the order is market and fills at the open of the next bar (plus slippage). Give it a limit and/or a stop and it rests in the book with the same fill rules as strategy.entry() — see Entry order types.
Syntax
strategy.order(id, direction, options?) → voidArguments
| Name | Type | Description |
|---|---|---|
| id | string | Identifier for this order |
| direction | string | strategy.LONG or strategy.SHORT |
| options? | object | Same options as strategy.entry(): qty, comment, limit, stop, type, tif, expireBars |
Behaviour vs strategy.entry()
| Concern | strategy.entry() | strategy.order() |
|---|---|---|
pyramiding | Enforced — further same-direction calls are ignored once the limit is reached | Not consulted |
| Opposing position | Reverses: closes all opposite legs, then opens the new direction | Reduces oldest leg first; only the excess opens a new leg |
| Margin does not cover the new leg | The opposite legs still close; only the new leg is refused | Nothing executes, not even the reduction |
Same id | Each fill is its own leg, even with the id of an open leg; calling again with the id of an unfilled order modifies that order — but a resting call cancels a market order waiting for the next open and places nothing | Same |
| Resting levels | limit / stop / stop-limit supported | Same |
Reduction against an open position consumes legs oldest first — in the order the legs first filled (FIFO). Default sizing and fill-price rules are the same as for strategy.entry().
Returns
void
Example
javascript
// INIT
strategy({ pyramiding: 1, initialCapital: 10000 })
// TICK
function onTick() {
if (bar.isConfirmed()) {
// Add to a long without pyramiding blocking the second call.
if (bar.index() === 0) { strategy.order("base", strategy.LONG, { qty: 10 }); }
if (bar.index() === 1) { strategy.order("add", strategy.LONG, { qty: 5 }); }
// Trim 4 units off the long without flipping short.
if (bar.index() === 2) { strategy.order("trim", strategy.SHORT, { qty: 4 }); }
}
}strategy.exit()
Kind: func
Registers a take-profit / stop-loss / trailing-stop bracket linked to an open entry. Brackets are evaluated intrabar on each bar while the entry remains open. A bracket needs at least one trigger — declarative (takeProfit / stopLoss, see Declarative TP/SL), absolute (limit / stop), or trailingStop. A call whose triggers all resolve to a non-finite value registers nothing and closes nothing; it is a no-op rather than an error, so a level derived from an indicator that has not warmed up is safe to pass unconditionally.
This command never creates a market order. To exit at the market, use strategy.close() or strategy.closeAll().
When more than one trigger is reachable on the same bar, the one the bar's intrabar path reaches first fires (see Intrabar assumptions).
Syntax
strategy.exit(id, fromEntry?, options?) → void
strategy.exit(id, options?) → void
strategy.exit(id, null, options?) → voidThe second argument is read by type, not by position count: a string is fromEntry, any other object is options, and an explicit null means "no fromEntry" with options following it. The three forms above are therefore interchangeable in the ways you would expect — strategy.exit(id, options) and strategy.exit(id, null, options) register the same bracket.
An option key this call does not recognise is an error, not an ignored key — otherwise a mistyped takeProfit would register a bracket silently missing the level you meant to set.
Arguments
| Name | Type | Description |
|---|---|---|
| id | string | Identifier for this bracket, used by strategy.cancel() and shown in the trade list unless a comment* overrides it. Re-registering the same id against the same entry replaces that bracket |
| fromEntry? | string | Entry id from strategy.entry(). Omit it (two arguments, or an explicit null third form) to cover the whole position, including entries opened later — see Optional fromEntry. An empty string means the same as omitting it |
| options? | object | Bracket options (see Options) |
Options
| Name | Type | Description |
|---|---|---|
| takeProfit? | object | Take-profit as { percent } / { ticks } offset from the entry's average price, or { price } at an absolute level, plus an optional type: "limit" (see Declarative TP/SL). May be combined with limit and with several keys at once — only the level that triggers first is armed |
| stopLoss? | object | Stop-loss as { percent } / { ticks } offset from the entry's average price, or { price } at an absolute level, plus an optional type: "stop" (see Declarative TP/SL). May be combined with stop and with several keys at once — only the level that triggers first is armed |
| limit? | double | Take-profit at an absolute price (shorthand for takeProfit: { price }; giving both is an error) |
| stop? | double | Stop-loss at an absolute price (shorthand for stopLoss: { price }; giving both is an error) |
| trailingStop? | object | { activationPercent | activationPrice | activationTicks, offsetTicks } — see Trailing stop. Several activation keys may be given; the one that activates first wins. Can be combined with takeProfit/stopLoss |
| qty? | double | Units to close. When omitted (and no qtyPercent), the bracket takes the entry's unreserved remainder (see Scaled exits). Takes precedence over qtyPercent when both are given. A value of zero or less cancels the whole call — nothing is registered and nothing is closed |
| qtyPercent? | double | Percent of the entry's initial size to close, in (0, 100] — outside that range is an error (see Scaled exits). Ignored entirely when qty is given |
| comment? | string | Default exit label when the bracket fills (see Order comments) |
| comment_profit? | string | Label on take-profit fill; overrides comment |
| comment_loss? | string | Label on stop-loss fill; overrides comment |
| comment_trailing? | string | Label on trailing-stop fill; overrides comment |
Returns
void
Notes
fromEntryis optional — see Optional fromEntry- Calling
strategy.exit()again with the sameidfor the same entry replaces the previous bracket (updated levels / quantity / comments) and recomputes its reservation; it does not stack a second bracket. Bracket identity is the pair (id, matched entry) — the sameidregistered against a different entry (via Optional fromEntry) is independent and unaffected - Multiple brackets with different
ids can target the same entry — see Scaled exits for how quantity is split between them - Brackets remain active until they fill, the entry closes, or they are cancelled.
strategy.cancel()removes every bracket with the givenidregardless of which entry it targets - Cancelling retires unfilled brackets only. Quantity a bracket has already closed cannot be brought back: a partially filled bracket keeps holding its reserved slice, and re-registering the same
idagainst that leg stays a no-op. So the common "cancel, then re-register with a tighter stop" pattern moves the remaining protection without closing the same units twice - Absolute
limit/stop/{ price }levels are rounded tomintickbefore registration;{ percent }/{ ticks }levels are derived from the entry's average price and rounded tomintickat evaluation time, from that leg's average fill price. Either way the rounding goes away from the entry — a long's take-profit up and its stop-loss down, a short's the other way round — so a level never ends up closer to the entry than asked - A side may be given several candidates — an absolute level plus a
percentand/orticksoffset — and only the one that would trigger first is armed (see Declarative TP/SL). What is rejected is naming the same absolute level twice (limittogether withtakeProfit: { price }), a level that is zero or negative, andtrailingStopwithoutoffsetTicks qtyoutranksqtyPercent: when both are given,qtyPercentis ignored- A call whose triggers all resolve to a non-finite value is a no-op — no bracket is registered and nothing is closed. This is what makes levels derived from a warming-up indicator safe to write unconditionally
- On fill,
comment_profit/comment_loss/comment_trailingtake priority overcomment - Re-issuing
trailingStopwith unchanged parameters keeps its armed state; changed parameters re-arm it from scratch — see Trailing stop
Example
javascript
// INIT
strategy({ initialCapital: 10000, commission: 0.05 })
const atrLen = input.double("ATR Length", 14)
// TICK
function onTick() {
const atr = ta.atr(atrLen)
const sma = ta.sma(close, 50)
if (bar.isConfirmed() && ta.crossover(close, sma)) {
strategy.entry("long", strategy.LONG)
strategy.exit("long-exit", "long", {
limit: close + atr * 2,
stop: close - atr,
comment_profit: "TP",
comment_loss: "SL"
})
}
}Declarative TP/SL
takeProfit and stopLoss let the engine derive bracket prices from the entry, so scripts do not have to hand-compute levels. Each option takes an object with at least one of:
| Key | Meaning |
|---|---|
percent | Offset from the entry's average price, in percent (> 0) |
ticks | Offset from the entry's average price, in ticks (> 0; one tick = mintick) |
price | Absolute price level (same as the limit / stop shorthand) |
Giving more than one is allowed: every candidate is resolved against the entry, and the side arms the one that would trigger first — the lowest of several long take-profits, the highest of several long stop-losses, mirrored for a short. A key whose value is not finite counts as absent, so a level computed from an indicator that has not warmed up simply leaves that candidate out; if nothing is left, the call registers no bracket at all instead of failing.
An optional type key may name the order type that side becomes — "stop" for stopLoss, "limit" for takeProfit:
javascript
strategy.exit("x", "long", { stopLoss: { type: "stop", percent: 5 } })It changes nothing: the option name already fixes the side, so type exists only for scripts that prefer spelling it out. Naming the other side is an error rather than a no-op, so a mistake like stopLoss: { type: "limit" } is reported instead of silently ignored.
Percent levels resolve against the average price of the matched entry at evaluation time:
- Long: take-profit =
avgPrice × (1 + percent / 100), stop-loss =avgPrice × (1 − percent / 100) - Short: take-profit =
avgPrice × (1 − percent / 100), stop-loss =avgPrice × (1 + percent / 100)
Tick levels resolve the same way, as an absolute price distance instead of a proportional one:
- Long: take-profit =
avgPrice + ticks × mintick, stop-loss =avgPrice − ticks × mintick - Short: take-profit =
avgPrice − ticks × mintick, stop-loss =avgPrice + ticks × mintick
The derived level is rounded to mintick. The base is that leg's average fill price: a later strategy.entry with the same id is a separate trade and does not rebase this one.
Intrabar trigger rules, gap handling, and slippage are identical to absolute limit / stop brackets (see Intrabar assumptions).
Example
javascript
// INIT
strategy({ initialCapital: 10000, commission: 0.05 })
// TICK
function onTick() {
if (bar.isConfirmed() && bar.index() === 10) {
strategy.entry("long", strategy.LONG)
// TP +10% and SL -5% from the actual entry fill, no manual math:
strategy.exit("bracket", "long", {
takeProfit: { percent: 10 },
stopLoss: { percent: 5 }
})
}
}Trailing stop
trailingStop arms a stop that follows price once it moves far enough in the trade's favor, instead of sitting at a fixed level. It takes an object with:
| Key | Meaning |
|---|---|
activationPercent / activationPrice / activationTicks | At least one — same three-way choice as takeProfit/stopLoss, naming how far price must move before the stop arms. Give more than one and the earliest activation wins |
offsetTicks | Required key. Distance (in ticks) the stop trails behind the best price reached since arming. A non-finite value creates no trailing stop at all, rather than one of undefined width |
Before arming, no trailing stop exists. Once price reaches the activation level:
- The stop tracks the best price seen since arming (highest for long, lowest for short) minus (long) / plus (short)
offsetTicks. - It only moves in the trade's favor — it never gives back ground when price pulls back without making a new best.
- It fires when price gives back
offsetTicksfrom that best price, resolved through the same intrabar path as take-profit/stop-loss — arming and firing can happen on the same bar (on different legs of the path) but never at the same instant.
trailingStop can be combined with takeProfit/stopLoss in the same strategy.exit() call — whichever level the intrabar path reaches first fires; the others are cancelled (same OCA-pair behavior as a plain take-profit/stop-loss bracket).
Re-issuing strategy.exit() with the same id and unchanged trailingStop parameters keeps the armed state and the ratcheted stop level — it does not re-arm from scratch. Re-issuing with changed parameters (a different offsetTicks, or a different activation) cancels and replaces the bracket, which re-arms fresh from that point.
A later strategy.entry with the same id is a new leg. Its trail (and its percent/tick activation) is priced from that fill, and does not move the earlier leg's average price or disarm a trail that leg had already earned.
Example
javascript
// INIT
strategy({ initialCapital: 10000, defaultQty: 1 })
// TICK
function onTick() {
if (bar.isConfirmed() && bar.index() === 10) {
strategy.entry("long", strategy.LONG)
// Arm once price is 5% above the fill; then trail 20 ticks behind the running high.
strategy.exit("trail", "long", {
trailingStop: { activationPercent: 5, offsetTicks: 20 }
})
}
}Optional fromEntry
Omitting fromEntry — strategy.exit(id, options) (two arguments), or an explicit null as the second argument — registers the bracket against every open leg of the position. Each leg gets its own independent bracket (own reservation, own average price), as if strategy.exit() had been called once per leg with that same id and options. An entry queued by strategy.entry() on this bar but not yet filled receives it when it fills.
It is a standing rule over the position, not a snapshot: an entry opened after the no-fromEntry call also receives the bracket, with its level derived from its own average price. The rule lasts until the position closes — once flat, it is gone, and a later unrelated entry does not inherit it. That holds with orders still in the book too: neither the side a resting entry reverses into nor a resting entry that fills after the close — on a later bar, or later on the bar whose bracket closed the position — inherits a rule set on the position. A rule placed while flat, beside a resting entry that has not filled yet, has no position to end with: it waits for that entry and applies once it fills. A reversal counts as a close: an opposite-side strategy.entry() ends the rule together with the position it flattens, so the new side starts without it (and without the closed entries' brackets, even when it reuses their id). strategy.cancel(id) removes the standing rule as well as the brackets already registered from it.
javascript
// INIT
strategy({ initialCapital: 10000, pyramiding: 3 })
// TICK
function onTick() {
if (bar.isConfirmed() && bar.index() === 10) {
strategy.entry("a", strategy.LONG)
strategy.entry("b", strategy.LONG)
}
if (bar.isConfirmed() && bar.index() === 11) {
// Both "a" and "b" are already open — both get this bracket.
strategy.exit("x", { takeProfit: { percent: 5 } })
// "c" is queued AFTER the call above and still gets "x", at 5% above ITS OWN fill price.
strategy.entry("c", strategy.LONG)
}
}Re-issuing the same id without fromEntry replaces the standing rule rather than stacking a second one.
Because bracket identity is the pair (id, matched entry), registering "x" this way alongside an explicit-fromEntry "x" on a different entry does not collide — each fires independently.
Scaled exits and the reservation model
Several brackets with different ids may target the same entry to exit a position in parts (TP1/SL1 for a portion, TP2/SL2 for the rest). Quantity is split between them by a reservation model, first-come-first-served in registration order. Each leg (each fill of the entry) keeps its own reservations:
unreserved = leg initial size − sum of sibling reservations
qty bracket reserves min(qty, unreserved)
qtyPercent bracket reserves min(initialSize × qtyPercent / 100, unreserved)
bracket without qty reserves the whole unreserved remainder ("rest" bracket)
reservation ≤ 0 → the bracket is not registeredKey consequences:
qtyPercentis a percentage of the leg's initial size, not of what remains when the bracket fires. Passingqtyas well is allowed —qtywins andqtyPercentis not even validated.- A
qtyof zero or less is treated as "close nothing": the call registers no bracket at all. It never falls back to the rest bracket, which would close the whole position instead of none of it. - A late bracket can never take quantity an earlier bracket already reserved: registering a
qtyPercent: 50bracket after a rest bracket (which holds 100%) creates nothing; after aqtyPercent: 75bracket it is reduced to 25%. - Re-registering a bracket with the same
idrecomputes its reservation against the remaining siblings. A later fill with the same scriptidis a new leg with its own initial size, not a growth of this one. - On fill each bracket closes
min(reservedQty, open size of its leg). - A bracket that fires while its leg stays open (siblings or remainder still hold quantity) keeps reserving its slice for brackets registered afterward, and cannot be re-registered a second time once fired: registering a new bracket against the same leg after an earlier sibling has already filled still computes
unreservedcorrectly against that filled sibling's slice, and callingstrategy.exit()again with the fired sibling's ownidis a no-op.
Example — exit 50% at TP1/SL1, the remainder at TP2/SL2
javascript
// INIT
strategy({ initialCapital: 10000, defaultQty: 10 })
// TICK
function onTick() {
if (bar.isConfirmed() && bar.index() === 10) {
strategy.entry("long", strategy.LONG)
// Bracket 1: half the position, tight levels
strategy.exit("tp1", "long", {
takeProfit: { percent: 5 },
stopLoss: { percent: 3 },
qtyPercent: 50
})
// Bracket 2: no qty → takes the unreserved remainder (the other half)
strategy.exit("tp2", "long", {
takeProfit: { percent: 10 },
stopLoss: { percent: 5 }
})
}
}With an entry of 10 units filled at 100: bracket tp1 reserves 5 units with TP 105 / SL 97; bracket tp2 reserves the remaining 5 units with TP 110 / SL 95. If price runs up through both levels, the trade list shows two exits: 5 units at 105 (tp1), then 5 units at 110 (tp2).
OCA semantics for brackets
One-cancels-all behavior of exit brackets, defined per bracket:
- Within one bracket (
id): the take-profit and stop-loss levels form an OCA pair by construction — a bracket fills at most once, so when one side triggers, the other side of the same bracket can never fill. - Between brackets: brackets with different
ids are independent. Each closes only its own reserved share; one bracket filling does not cancel its siblings. - Leg exhausted: when the matched leg is fully closed (by brackets,
strategy.close(), a reversal, or a margin call), all of its remaining brackets are removed automatically. strategy.close()/strategy.closeAll()do not cancel active brackets by themselves — callstrategy.cancel()explicitly if the brackets should not outlive a manual close of part of the position.
strategy.close()
Kind: func
Queues a market order to close open entries with the specified entry id. When several legs share that id, they are closed oldest first. Without qty, every matching open leg is closed. Fills at the open of the next bar (plus slippage).
Syntax
strategy.close(entryId, options?) → voidArguments
| Name | Type | Description |
|---|---|---|
| entryId | string | Entry id to close |
| options? | object | Close options (see Options) |
Options
| Name | Type | Default | Description |
|---|---|---|---|
| qty? | double | every matching leg | Units to close, floored to lotStep when the script sets it. 0, a size that floors to 0, or a non-finite value closes every matching leg. A negative value closes its absolute size |
| comment? | string | system default | Custom label; when omitted, fill comment is Close entry(s) order strategy.close_N and trades-list exit Signal is "close" (see Order comments) |
Returns
void
Notes
- Has no effect if no open entry with the given
entryIdexists - Several open legs may share
entryId;qtyis taken from them in fill order - Does not cancel active exit brackets for that entry; call
strategy.cancel()if needed - Without
comment, shows the system close text on the chart / in the orders log; the trades list Signal column still reads"close"
Example
javascript
// INIT
strategy({ initialCapital: 10000 })
// TICK
function onTick() {
if (!bar.isConfirmed()) return
if (bar.index() % 50 === 0) {
strategy.entry("buy", strategy.LONG)
}
if (bar.index() % 50 === 10 && strategy.position() > 0) {
strategy.close("buy", { comment: "close" })
}
}strategy.closeAll()
Kind: func
Queues one market order that closes every open entry at the next bar's open. It is one fill of the whole position and one closed trade per entry. Does nothing if there are no open entries.
Syntax
strategy.closeAll(options?) → void
strategy.close_all(options?) → voidBoth closeAll and close_all are accepted.
Options
| Name | Type | Default | Description |
|---|---|---|---|
| comment? | string | system default | Close position order when omitted; the custom label of the fill and of every closed trade when set (see Order comments) |
Returns
void
Example
javascript
// INIT
strategy({ initialCapital: 10000 })
const rsiLen = input.double("RSI Length", 14)
// TICK
function onTick() {
if (!bar.isConfirmed()) return
const rsi = ta.rsi(close, rsiLen)
if (rsi < 30) {
strategy.entry("long", strategy.LONG)
}
if (rsi > 70 && strategy.position() > 0) {
strategy.closeAll({ comment: "close" })
}
}strategy.cancel()
Kind: func
Cancels all active exit brackets registered with strategy.exit() that use the specified id, across every entry they were matched to. If that id was registered without a fromEntry, the standing rule goes with it, so entries opened afterwards do not inherit the bracket. Also cancels any unfilled resting entry carrying that id (see Entry order types). Has no effect on open positions or on market entry/close queues, which fill at the next open and have no life to cancel.
Only unfilled brackets are retired. A bracket that already closed part of its leg keeps its reservation and cannot be revived — re-registering that id against the same entry does nothing, so cancelling never lets the same units be closed twice.
Syntax
strategy.cancel(id) → voidArguments
| Name | Type | Description |
|---|---|---|
| id | string | Exit-bracket id from strategy.exit(), or entry/order id from a resting strategy.entry() / strategy.order() |
Returns
void
Example
javascript
function onTick() {
if (bar.index() === 0) {
strategy.entry("long", strategy.LONG)
strategy.exit("tp-sl", "long", { limit: 120, stop: 80 })
}
if (bar.index() === 5) {
strategy.cancel("tp-sl")
}
}strategy.cancelAll()
Kind: func
Cancels all active exit brackets, including any standing rules left by strategy.exit() calls that omitted fromEntry, and all unfilled resting strategy.entry() / strategy.order() calls. Partially filled brackets keep their reservation, exactly as with strategy.cancel(). Has no effect on open positions or on market entry/close queues.
Syntax
strategy.cancelAll() → void
strategy.cancel_all() → voidBoth cancelAll and cancel_all are accepted.
Returns
void
Example
javascript
function onTick() {
if (strategy.position() !== 0) {
strategy.cancel_all()
strategy.close_all()
}
}strategy.position()
Kind: func
Returns the current net position size. Positive = long, negative = short, 0 = flat.
Reflects fills processed before the current tick's pending orders (entries/closes queued on this tick fill on the next bar). It does not include notional from orders still in the pending queue.
Syntax
strategy.position() → doubleReturns
double — net quantity in units.
Example
javascript
function onTick() {
if (strategy.position() > 0) {
spline(close, {title: "In long", color: color.GREEN})
}
}Strategy state
The simulation's own numbers, readable from the script — so position sizing, equity-curve filters and "stop after a bad streak" rules can be written in the strategy itself instead of being reconstructed from host reports.
All of these are functions (like strategy.position()), and they — together with the trade collections below — require a script that declared strategy() in init. strategy.position() alone stays readable without that declaration. The trade list itself is a collection rather than a function — see Trade list.
| Function | Returns | Value |
|---|---|---|
strategy.equity() | double | initialCapital() + netProfit() + openProfit() |
strategy.netProfit() | double | Realized P&L of closed trades, commissions of both legs already deducted |
strategy.openProfit() | double | Unrealized P&L of the open legs at the current bar's close, gross of commission |
strategy.positionAvgPrice() | double | Quantity-weighted average entry price of the open legs, NaN when flat |
strategy.grossProfit() | double | Sum of the winning closed trades' P&L |
strategy.grossLoss() | double | Sum of the losing closed trades' P&L, as a positive number |
strategy.winTrades() | int | Closed trades with P&L > 0 |
strategy.lossTrades() | int | Closed trades with P&L < 0 |
strategy.evenTrades() | int | Closed trades with P&L exactly 0 |
strategy.avgTrade() | double | netProfit() / closedTrades.length, NaN before the first close |
strategy.avgWinningTrade() | double | grossProfit() / winTrades(), NaN without winners |
strategy.avgLosingTrade() | double | grossLoss() / lossTrades() as a positive number, NaN without losers |
strategy.maxDrawdown() | double | Largest decline from the peak of realized equity, in account currency (see below) |
strategy.maxRunup() | double | Largest advance from the trough of realized equity, in account currency |
strategy.netProfitPercent() | double | netProfit() as a percent of initialCapital() |
strategy.openProfitPercent() | double | openProfit() as a percent of the realized equity carrying it (initialCapital() + netProfit()) |
strategy.maxDrawdownPercent() | double | maxDrawdown() as a percent of the realized-equity peak it was measured from |
strategy.initialCapital() | double | The initialCapital option |
strategy.accountCurrency() | string | The currency option — a label only, the engine does no FX conversion |
Counters and gross figures cover closed trades only; an open position contributes to openProfit() and equity() but to none of the counters. Before the first bar, equity equals initialCapital(), every P&L and counter is 0, and positionAvgPrice() is NaN.
The three averages return NaN, not 0, while their denominator is still zero — an average of no trades is undefined, and a script that compares it against a threshold has to be able to tell the difference. Test them the same way as positionAvgPrice() (see below).
All percentages are percentages, not fractions: 1.0 means one percent. Their bases differ, following the convention:
| Function | Base |
|---|---|
netProfitPercent() | initial capital |
openProfitPercent() | realized equity right now — initialCapital() + netProfit() |
maxDrawdownPercent() | the realized-equity peak the decline was measured from, see Drawdown and run-up |
So a position 100 up on an account that has already realized 2000 of profit on 10000 of capital reports 0.833333, not 1.0.
Trade list
strategy.closedTrades and strategy.openTrades are indexable collections, shaped like a ts series: index them directly, read .length, iterate them.
javascript
strategy.closedTrades.length // how many trades have closed
strategy.closedTrades[0].entryPrice // the oldest trade
strategy.closedTrades[strategy.closedTrades.length - 1].profit // the newest
for (const t of strategy.openTrades) { total += t.profit }A trade's fields are properties, not functions — it is a data record:
| Property | Type | Value |
|---|---|---|
direction | string | strategy.LONG or strategy.SHORT |
isOpen | boolean | true for rows of openTrades |
entryId / exitId | string | the labels shown in the trades list (see Order comments) |
entryPrice / exitPrice | double | fill prices; for an open trade exitPrice is the current mark |
entryBarIndex / exitBarIndex | int | bar indices |
entryTime / exitTime | int | bar times, milliseconds |
size | double | quantity, signed by direction — negative for a short |
profit | double | P&L after both commissions; unrealized while the trade is open |
cumulativeProfit | double | running realized P&L after this trade |
maxRunup / maxDrawdown | double | the trade's own excursions |
Each row's profit is net of both commissions — the same figure the host trades list shows. openProfit() is gross of commission, so summing openTrades[].profit does not equal openProfit() while commission is configured. Use openProfit() for position-level unrealized P&L; use row profit when you need per-leg numbers that match the report.
Two rules to keep in mind:
- Index 0 is the oldest trade, and the newest is
[length - 1]. This is the opposite of atsseries, where[0]is the current bar. - An out-of-range index throws, exactly as a series does for an out-of-range lookback. Guard with
.lengthbefore indexing.
openTrades reflects the live position: its rows' profit moves with every bar, and the collection is empty while flat.
When the values are observed
A read on bar N sees that bar's fills — pending orders, margin calls and exit brackets are all processed before onTick() runs — while an order placed by the same onTick() is still queued and fills at the next bar's open (see Order timing). So on the bar where an entry fills, positionAvgPrice() already returns its price; on the bar where it was requested, it still returns NaN.
Equity conventions
Position sizing, the margin gate and the host's equity curve use a slightly different number: each open leg is additionally charged its entry commission. While a position is open the two therefore differ by that commission — with a 1% commission on a 100-unit-notional leg, strategy.equity() reads 1.00 higher than the host equity curve for the same bar. Both agree whenever the strategy is flat.
Drawdown and run-up
maxDrawdown = max over bars of ( peak(realized equity) - realized equity + this bar's adverse excursion )
maxRunup = max over bars of ( realized equity - trough(realized equity) + this bar's favourable excursion )Two consequences are worth knowing before reading the numbers:
- Unrealized profit never raises the peak. A position that runs up 30% and gives it all back produces no drawdown at all — only realized results move the reference point. This is why
maxDrawdown()can look far smaller than the dip you see on the equity curve. - The excursion comes from the bar's low/high, not its close, taken against the position's average price. A bar that dips below the average and recovers by the close still deepens the drawdown.
maxDrawdownPercent() is a running maximum of the ratio at each of those latches, so it never decreases: once a decline is 5% of the peak it fell from, later growth of the peak cannot shrink the reported figure.
The host performance summary's maxEquityDrawdown is a different statistic — a plain peak-to-trough walk of the mark-to-market equity curve — and the two legitimately disagree.
Flat is NaN, not zero
positionAvgPrice() returns NaN when there is no open position — 0 would read as a real average price of zero. NaN compares false against everything, so test it explicitly:
javascript
const avg = strategy.positionAvgPrice()
if (!Number.isNaN(avg) && close < avg * 0.98) {
strategy.closeAll({comment: "2% below average entry"})
}Trade granularity
Repeated strategy.entry() calls with the same id are separate legs — each fill is its own open trade, and pyramiding counts those legs. Closing them produces one closed trade per fill.
Example — risk a fixed fraction of equity per trade and stop trading after a 10% drawdown:
javascript
// INIT
strategy({initialCapital: 100000, defaultQty: 1})
const riskPercent = input.double("Risk per trade, %", 1)
const stopPercent = input.double("Stop distance, %", 2)
// TICK
function onTick() {
const halted = strategy.maxDrawdown() > strategy.initialCapital() * 0.1
const fastMA = ta.sma(close, 10)
const slowMA = ta.sma(close, 30)
if (!halted && strategy.position() === 0 && ta.crossover(fastMA, slowMA)) {
// Units such that a stopPercent adverse move costs riskPercent of current equity.
const risk = strategy.equity() * riskPercent / 100
const qty = Math.floor(risk / (close * stopPercent / 100))
if (qty > 0) {
strategy.entry("long", strategy.LONG, {qty: qty})
strategy.exit("protect", "long", {stopLoss: {percent: stopPercent}})
}
}
output("equity", strategy.equity())
output("drawdown", strategy.maxDrawdown())
}Direction Constants
strategy.LONG
Kind: constant
Value: "long"
Specifies the long (buy) direction for strategy.entry().
strategy.SHORT
Kind: constant
Value: "short"
Specifies the short (sell) direction for strategy.entry().
Commission types
Used as the commissionType option in strategy(). Values are strings (not exported module constants).
The commission is deducted on every fill (entry and exit) and is reflected in host-side trade reports and analytics.
| Value | Meaning |
|---|---|
"percent" | commission is a percentage of notional: qty × fillPrice × commission / 100 (default) |
"cash_per_contract" | Fixed cash per unit: qty × commission |
"cash_per_order" | Fixed cash per fill: commission regardless of size |
Any order that closes several legs is one fill: strategy.close(), strategy.closeAll(), a reversal, a reducing strategy.order() and a margin call. Its commission is charged once and shared between the closed trades by size. A leg that closes in parts pays its entry commission the same way: each part pays its share.
Example
javascript
strategy({
commission: 0.1,
commissionType: "percent"
})
// A 10-unit fill at price 100 costs: 10 × 100 × 0.1 / 100 = 1.0javascript
strategy({
commission: 2.5,
commissionType: "cash_per_contract"
})javascript
strategy({
commission: 5.0,
commissionType: "cash_per_order"
})Default quantity types
Used as defaultQtyType in strategy() together with defaultQty when strategy.entry() is called without an explicit qty.
Sizing runs on the fill bar (see Pending queue and fill batch).
defaultQtyType | Meaning |
|---|---|
"fixed" | defaultQty is the unit count |
"percent_of_equity" | defaultQty is a percent of equity; units = floor(equity × defaultQty / 100 / price) |
"cash" | defaultQty is cash to allocate; units = floor(defaultQty / price) |
equity and price are taken on the signal bar under the default defaultQtyBasis: "signal_bar" and at the fill under "fill"; strategy.entry() lists exactly which values, their fallbacks, and how commission enters. A "fixed" size is just defaultQty units, which no basis can move, so defaultQtyBasis: "fill" next to it is an error.
Underscores in either string are optional ("percent_of_equity" / "percentofequity", "signal_bar" / "signalbar").
Example — defaultQty: 100, defaultQtyType: "percent_of_equity", defaultQtyBasis: "signal_bar", signal-bar equity 11500, signal close 36.11:
floor(11500 × 100 / 100 / 36.11) = floor(318.47…) = 318Margin and margin calls
When marginLong / marginShort are greater than zero, the simulator runs two margin passes each bar:
- At bar open (after pending fills, with extremes updated): if equity already fails at the open price, liquidate at open (+ slippage).
- Intrabar: if still open, check the adverse extreme (low for longs, high for shorts) and liquidate at that price (+ slippage). Long liquidations triggered only by the session low fill at bar open when the low is below average entry (flip-bar behavior).
Equity for the check uses realized P&L minus entry commission on still-open legs. If equity < marginRequired, a margin call partially liquidates the position. If that size rounds to zero, one share/unit is liquidated (not the full position). Exit fills use order tag "Margin call". Margin-call rows use fill-based drawdown (not intrabar MAE). MAE on remaining size includes margin-call fill prices from partial liquidations on the same position. Same-bar entry + at-open margin call report zero run-up; intrabar same-bar margin calls use the session range minus entry commission.
Margin call is evaluated after pending market orders fill at the bar open and before strategy.exit() brackets.
Orders are checked twice, and refused unless both checks pass:
- When placed, on the bar whose script calls
strategy.entry()orstrategy.order(): equity must cover the margin of the position the order would leave behind, valued at that bar's close. - When filled — at the next bar's open for a market order, at its level for a resting one, however many bars later: equity must cover the margin of the part of the position the order keeps, still valued at the placing bar's close, plus the margin of what the order opens, at its fill price.
Both checks value the open legs at the placing bar's close, not at anything later: a market order fills before the fill bar's close exists, and a resting order is judged as of the bar that placed it. Re-placing a resting order with new terms places it again. Both checks read the account as it was when the order was placed: a P&L realized after that — part of the position closed before a resting order reaches its level — does not count, and a leg the order closes itself brings no P&L at the fill price into the check; it only releases its margin. One case skips the second check: a market entry against a position that a strategy.close() placed beside it has already closed in the same batch fills in full, and a margin call trims what does not fit. That exception covers only an entry in the opposite direction. An entry in the same direction as the position a strategy.close() beside it closes is checked at both points with that position still open, although the close fills first. So a strategy that exits and re-enters on one signal can re-enter only with the equity the exiting position leaves free: at margin 100 and a constant "percent_of_equity" size, a size above about 50 percent does not fit. A default-sized resting order is sized when placed, so it is checked twice like any other.
An order sized at its fill — a default-sized order under defaultQtyBasis: "fill" — has no size when placed and is checked once, when it fills, on the account as it is then: equity after the closes filled before it, with the position valued at the fill price — the equity and price its size came from — must cover the margin of the whole position the order leaves behind, at that price. Refused there, it leaves what an order refused when placed leaves: a reversing entry() still closes the position. An order with an explicit qty is checked twice as above, whatever the basis.
As everywhere in this section, equity charges open legs their entry commission, and a margin equal to equity fits: equity >= margin. What a refused order leaves behind is described in Refused entries.
Aliases such as margin_long and margin_short are accepted in strategy({ ... }).
Host trade reports
Script code does not expose strategy.equity, strategy.netProfit, strategy.trade(), or similar read-only state. After calc(), the embedding application reads:
strategyReport()— paired trades (StrategyTrade: entry/exit sides, prices, closed round-trips plus optional open legs at the last bar).entrySignal/exitSignalare display labels (comments), not necessarily the raw order ids passed tostrategy.entry()/strategy.exit(). Each partial exit repeats the original entry row.strategyFillReport()— chronological fill log (StrategyFill: one row per entry or exit execution with bar index, filled-orderssignal, filled-ordersidincomment, side, price, and size). Astrategy.close()over several legs with the sameidis one row of the whole size, whilestrategyReport()has one trade per leg.strategy.closeAll()is one row over every open leg; withoutcomment, itssignalandcommentareClose position order. Forstrategy.close()withoutcomment,signalisClose entry(s) order {entryId}andcommentisClose entry(s) order strategy.close_N.strategyOrderReport()— resting entry orders and how each ended (StrategyOrderRecordper order:id, direction, type, status, time in force,limit/stopprices, size, bar indices, fill price when filled). A filled order's size is everything it put through the market — for a reversal the position it closed plus the leg it opened. A market order appears only when it was refused — see Refused entries; one that fills is described by the fill log. The report is what distinguishes "the strategy placed twelve limit orders and four filled" from "the strategy produced no signals". On the backtesting wire this isstrategy_order_report/StrategyOrder, where type and status are enums whose values are the order codes below. Omitted entirely when no order rested and none was refused during the run.strategyAnalytics()— equity curve, benchmark closes, total commission, and aStrategyPerformanceSummary.
Open positions at the end of the series appear as trades with exit signal "open" and unrealized P&L at the last bar's close.
Example strategies
Full scripts from the strategy test suite. Each uses strategy() in init, gates entries and exits with bar.isConfirmed(), and plots key series with spline().
Bollinger Bands reversion (long)
Mean-reversion long when price crosses under the lower band while above EMA 200; exit on cross back to basis or a hard stop below the band.
javascript
strategy({ title: "Bollinger Bands Reversion" })
const bbLen = input.double("BB Length", 20)
const bbMult = input.double("StdDev", 2.0)
function onTick() {
const basis = ta.sma(close, bbLen)
const dev = bbMult * ta.stdev(close, bbLen)
const upper = basis + dev
const lower = basis - dev
const ema200 = ta.ema(close, 200)
const trendOk = close > ema200
if (bar.isConfirmed()) {
if (ta.crossunder(close, lower) && trendOk && strategy.position() === 0)
strategy.entry("long", strategy.LONG)
if (ta.crossover(close, basis) && strategy.position() > 0)
strategy.close("long")
if (close < lower * 0.98 && strategy.position() > 0)
strategy.close("long")
}
spline(basis, { title: "Basis", color: color.GRAY, overlay: true })
spline(upper, { title: "Upper", color: color.RED, overlay: true })
spline(lower, { title: "Lower", color: color.GREEN, overlay: true })
spline(ema200, { title: "EMA 200", color: color.PURPLE, overlay: true })
}EMA pullback (long)
Long when price crosses above the fast EMA in an uptrend (close above slow EMA); exit on cross under the fast EMA. Use the 5% commission.
javascript
strategy({
commission: 0.05,
commissionType: "percent"
})
const fastEmaLen = input.double("Fast EMA (entry)", 21)
const slowEmaLen = input.double("Slow EMA (trend)", 200)
function onTick() {
const emaFast = ta.ema(close, fastEmaLen)
const emaSlow = ta.ema(close, slowEmaLen)
const bullTrend = close > emaSlow
const pullbackEntry = bullTrend && ta.crossover(close, emaFast)
const exitCond = ta.crossunder(close, emaFast) && strategy.position() > 0
if (bar.isConfirmed()) {
if (pullbackEntry) strategy.entry("Long", strategy.LONG, { comment: "long" })
if (exitCond) strategy.close("Long", { comment: "close" })
}
spline(emaFast, { title: "EMA 21", color: color.ORANGE, overlay: true })
spline(emaSlow, { title: "EMA 200", color: color.PURPLE, overlay: true })
}Ichimoku cloud
Tenkan/Kijun cross with price above or below the cloud and Chikou confirmation; Kijun-based exits.
javascript
strategy({ title: "Ichimoku Cloud" })
const tenkanLen = input.double("Tenkan-sen", 9)
const kijunLen = input.double("Kijun-sen", 26)
const senkouBLen = input.double("Senkou Span B", 52)
const disp = input.double("Displacement", 26)
function onTick() {
const tenkan = ts((ta.highest(high, tenkanLen) + ta.lowest(low, tenkanLen)) / 2)
const kijun = ts((ta.highest(high, kijunLen) + ta.lowest(low, kijunLen)) / 2)
const ssa = ts((tenkan + kijun) / 2)
const ssb = ts((ta.highest(high, senkouBLen) + ta.lowest(low, senkouBLen)) / 2)
const cloudTop = Math.max(ssa, ssb)
const cloudBot = Math.min(ssa, ssb)
const chikou = close[Math.floor(disp)]
const longCond = !isNaN(tenkan) && !isNaN(kijun)
&& ta.crossover(tenkan, kijun) && close > cloudTop && close > chikou
const shortCond = !isNaN(tenkan) && !isNaN(kijun)
&& ta.crossunder(tenkan, kijun) && close < cloudBot && close < chikou
if (bar.isConfirmed()) {
if (longCond) strategy.entry("long", strategy.LONG)
if (shortCond) strategy.entry("short", strategy.SHORT)
if (close < kijun && strategy.position() > 0) strategy.close("long")
if (close > kijun && strategy.position() < 0) strategy.close("short")
}
spline(tenkan, { title: "Tenkan", color: color.BLUE, overlay: true })
spline(kijun, { title: "Kijun", color: color.RED, overlay: true })
spline(ssa, { title: "SSA", color: color.GREEN, overlay: true })
spline(ssb, { title: "SSB", color: color.RED, overlay: true })
}MACD with EMA-200 filter
MACD/signal cross entries filtered by trend (close vs EMA 200); opposing cross closes the open leg.
javascript
strategy()
const fastLen = input.double("Fast EMA", 12)
const slowLen = input.double("Slow EMA", 26)
const signalLen = input.double("Signal EMA", 9)
function onTick() {
const macdLine = ts(ta.ema(close, fastLen) - ta.ema(close, slowLen))
const signalLine = ta.ema(macdLine, signalLen)
const histLine = macdLine - signalLine
const ema200 = ta.ema(close, 200)
const bullTrend = close > ema200
const bearTrend = close < ema200
if (bar.isConfirmed()) {
if (ta.crossover(macdLine, signalLine) && bullTrend)
strategy.entry("long", strategy.LONG)
if (ta.crossunder(macdLine, signalLine) && bearTrend)
strategy.entry("short", strategy.SHORT)
if (ta.crossunder(macdLine, signalLine) && strategy.position() > 0)
strategy.close("long")
if (ta.crossover(macdLine, signalLine) && strategy.position() < 0)
strategy.close("short")
}
spline(ema200, { title: "EMA 200", color: color.PURPLE, overlay: true })
spline(macdLine, { title: "MACD", color: color.BLUE, overlay: false })
spline(signalLine, { title: "Signal", color: color.ORANGE, overlay: false })
spline(histLine, {
title: "Hist",
style: spline.STYLE_HISTOGRAM,
color: histLine >= 0 ? color.GREEN : color.RED,
overlay: false
})
spline(0, { title: "Zero", color: color.GRAY, overlay: false })
}RSI mean reversion (long)
Buy the 100% equity when RSI crosses above the oversold entry level; exit when RSI exceeds the exit level or a hard stop.
javascript
strategy({
defaultQty: 100,
defaultQtyType: "percent_of_equity"
})
const rsiLen = input.double("RSI Length", 14)
const osLvl = input.double("Oversold (entry)", 40)
const exitLvl = input.double("Exit Level", 60)
function onTick() {
const rsi = ta.rsi(close, rsiLen)
if (bar.isConfirmed()) {
if (ta.crossover(rsi, osLvl) && strategy.position() === 0)
strategy.entry("long", strategy.LONG)
if (rsi > exitLvl && strategy.position() > 0)
strategy.close("long")
if (rsi < 30 && strategy.position() > 0)
strategy.close("long")
}
spline(rsi, { title: "RSI", color: color.BLUE, overlay: false })
spline(exitLvl, { title: "Exit", color: color.RED, overlay: false })
spline(50, { title: "Mid", color: color.GRAY, overlay: false })
spline(osLvl, { title: "Entry", color: color.GREEN, overlay: false })
spline(30, { title: "Hard Stop", color: color.RED, overlay: false })
}SMA crossover (long and short)
Flip between long and short on fast/slow SMA cross; closes the opposite leg before opening the new one. Use the commission and slippage.
javascript
strategy({
title: "SMA Crossover",
commission: 0.05,
commissionType: "percent",
slippage: 1
})
const fastLen = input.double("Fast SMA", 21)
const slowLen = input.double("Slow SMA", 50)
function onTick() {
const fast = ta.sma(close, fastLen)
const slow = ta.sma(close, slowLen)
if (bar.isConfirmed()) {
if (ta.crossover(fast, slow)) {
strategy.close("short")
strategy.entry("long", strategy.LONG)
}
if (ta.crossunder(fast, slow)) {
strategy.close("long")
strategy.entry("short", strategy.SHORT)
}
}
spline(fast, { title: "21 SMA", color: color.ORANGE, overlay: true })
spline(slow, { title: "50 SMA", color: color.BLUE, overlay: true })
}Z-score mean reversion (long)
Long when Z-score falls below the negative entry threshold in an uptrend (close above EMA 200); exit near zero or on a hard stop.
javascript
strategy()
const lookback = input.double("Lookback (days)", 30)
const entryThresh = input.double("Entry |Z|", 1.8)
const exitThresh = input.double("Exit |Z|", 0.3)
function onTick() {
const mean = ta.sma(close, lookback)
const sd = ta.stdev(close, lookback)
const zScore = sd !== 0 ? (close - mean) / sd : 0
const ema200 = ta.ema(close, 200)
const trendOk = close > ema200
if (bar.isConfirmed()) {
if (zScore < -entryThresh && trendOk && strategy.position() === 0)
strategy.entry("long", strategy.LONG)
if (zScore > -exitThresh && strategy.position() > 0)
strategy.close("long")
if (zScore < -3.0 && strategy.position() > 0)
strategy.close("long")
}
spline(ema200, { title: "EMA 200", color: color.PURPLE, overlay: true })
spline(zScore, { title: "Z-Score", color: color.BLUE, overlay: false })
spline(entryThresh, { title: "+Entry", color: color.RED, overlay: false })
spline(-entryThresh, { title: "-Entry", color: color.GREEN, overlay: false })
spline(exitThresh, { title: "+Exit", color: color.RED, overlay: false })
spline(-exitThresh, { title: "-Exit", color: color.GREEN, overlay: false })
spline(0, { title: "Zero", color: color.GRAY, overlay: false })
spline(-3.0, { title: "Hard Stop", color: color.GRAY, overlay: false })
}