Actions — BUY, SELL, CLOSE, CANCEL, LOG¶
The verbs that go after THEN. Each action is a complete imperative — "do this exact thing." A rule can have multiple actions separated by ; (a newline alone does not separate them).
The action verbs¶
| Verb | What it does |
|---|---|
BUY <stream> ... |
Open or add to a long position |
SELL <stream> ... |
Open or add to a short position |
CLOSE <stream> |
Flatten the position on this stream |
CLOSE_ALL |
Flatten every open position |
FLATTEN |
Alias for CLOSE_ALL — reads better in session-end / risk-off rules |
RESIZE <stream> TO <sizing> |
Trim or add to set the position to a per-bar target size, without close+reopen |
CANCEL <stream> |
Cancel any pending orders on this stream |
CANCEL_ALL |
Cancel every pending order |
OCO_ENTRY { leg1, leg2 } |
Two pending entries linked one-cancels-other; whichever fills, the other auto-cancels |
LOG [WARN\|ERROR\|DEBUG] "<msg>" [field=expr ...] |
Emit a structured log line (default level is INFO) |
FLATTEN and CLOSE_ALL compile to the same engine path; pick whichever reads more naturally:
WHEN NOW.hour_utc = 21 THEN FLATTEN -- session close
WHEN ACCOUNT.realized_pnl < -1000 THEN FLATTEN -- daily loss kill switch
BUY <stream> and SELL <stream>¶
The entry verbs. Both take the same set of modifiers.
BUY <stream>
[ SIZING <size_spec> ]
[ ORDER_TYPE = <order_type> ]
[ BRACKET { ... } | OCO { STOP AT <price>, LIMIT AT <price> } ]
[ STACK ... ] [ STACK_AT ... ]
[ TIMES <expr> ] [ EXIT AFTER <duration> ]
[ TIF GTC | IOC | FOK | DAY | GTD [UNTIL] <epoch_ms_expr> ]
[ ON_FILL { ... } ]
[ ON_STOP { ... } ]
[ ON_TP { ... } ]
[ ON_CLOSE { ... } ]
Trailing-stop order types ship as ORDER_TYPE = TRAILING BY <distance> and ORDER_TYPE = TRAILING PCT <percent> (see Trailing stop in the stop-loss recipe). 1 PCT means 1%; the percentage must be greater than 0 and less than 100.
Minimal BUY¶
SIZING is the one clause an entry cannot do without: it comes from the action or from
DEFAULTS { SIZING = ... }, and a BUY with neither is rejected when the strategy compiles
(BUY/SELL requires SIZING). Everything else has a default — market order, GTC, no bracket.
STRATEGY sized_by_default VERSION 1
DEFAULTS { SIZING = 0.1 }
SYMBOLS
btc = BACKTEST:BTCUSDT EVERY 1m
RULES
WHEN ema(btc.close, 9) CROSSES ABOVE ema(btc.close, 21)
THEN BUY btc -- 0.1 from DEFAULTS
STRATEGY unsized VERSION 1
SYMBOLS
btc = BACKTEST:BTCUSDT EVERY 1m
RULES
WHEN ema(btc.close, 9) CROSSES ABOVE ema(btc.close, 21)
THEN BUY btc
Full BUY¶
BUY btc
SIZING 1.0 PCT RISK
BRACKET {
STOP_LOSS AT btc.close - atr(btc, 14) * 2,
TAKE_PROFIT AT btc.close + atr(btc, 14) * 6
}
TIF GTC
SELL is identical in shape, just opens a short instead of a long.
On an MT5 hedging account a plain opposite-side market BUY/SELL keeps its netting meaning:
qkt closes the strategy's open opposite legs by ticket, oldest first, and opens only any excess
as a new position. So BUY btc SIZING 0.01 followed by SELL btc SIZING 0.01 ends flat at the
venue with one closed deal, the same as on a netting account — it does not leave two tickets
hedged against each other. Use CLOSE when you mean "flatten"; use an explicit hedge (OCO_ENTRY
straddle or a bracket entry) when you really want both sides open.
Order type modifiers¶
By default BUY/SELL submit market orders. To submit a limit order:
To submit a stop entry (buy on breakout above a level):
A stop-limit entry adds a LIMIT AT to the stop: once the stop price trades, a limit order rests
at the limit price instead of a market order chasing:
BUY btc SIZING 0.1 ORDER_TYPE = STOP AT 67500 LIMIT AT 67520 -- trigger at 67,500, fill no worse than 67,520
The entry order types are therefore MARKET (the default), LIMIT AT <price>, STOP AT <price>,
STOP AT <price> LIMIT AT <price>, and the trailing forms TRAILING BY <distance> /
TRAILING PCT <percent>. Each takes an expression for its price, evaluated when the rule fires.
There is no if-touched order type. The order type modifier replaces the default MARKET and
goes after the stream/sizing.
OCO { STOP AT ..., LIMIT AT ... } — exit pair by price¶
Where BRACKET states a stop and a target as distances or prices with STOP_LOSS /
TAKE_PROFIT, OCO states the same two exits as raw order prices. Both children are required
and either may reference the bar that fired the rule:
WHEN btc.close CROSSES ABOVE ema(btc.close, 50) AND POSITION.btc = 0
THEN BUY btc SIZING 0.1 OCO { STOP AT btc.close - 200, LIMIT AT btc.close + 400 }
CLOSE <stream> and CLOSE_ALL¶
CLOSE <stream> flattens the position on that stream at market. No sizing needed — it closes the
full current position. When the position has venue ticket metadata, qkt targets that ticket
explicitly. This closes the position on MT5 hedging accounts instead of opening an offsetting
counter-position; netting accounts reach the same flat result.
CLOSE_ALL does the same for every open position across all symbols. Use sparingly — usually you want to be precise.
RESIZE <stream> TO <sizing>¶
Set an open position to a target size each bar, trimming or adding to reach it without close+reopen. This is the volatility-targeting / rebalancing primitive: a position whose exposure should scale inversely to realized volatility, or to a target portfolio weight.
-- Scale gold exposure inversely to its volatility, re-sized every bar.
WHEN POSITION.gold != 0
THEN RESIZE gold TO 0.01 / atr(gold.candle, 14)
<sizing>reuses the SIZING grammar, so the target can be a quantity expression,PCT OF EQUITY(rebalance to a target weight),NOTIONAL, etc. It evaluates to a target magnitude for the symbol's primary position. (RISK-based sizing needs a stop distance and is not available here.)- It resizes the existing primary to that magnitude, same side.
TO 0flattens it. With no open position it is a no-op — open withBUY/SELLfirst. - A grow is a same-side market add (averaged into the position); a shrink is a partial close at market (entry price preserved, P&L realized on the closed portion). Both fill at the bar price, so backtest equals live.
MIN_STEP <expr>sets an anti-churn deadband — the smallest|target − current|worth acting on (default 5% of the target). Skips the micro-orders a continuous target would otherwise emit.
For risk on a resized position, use a CLOSE rule — it always flattens the current position
size, so it tracks the resize for free:
Do not combine RESIZE with a BRACKET on the same entry: a bracketed position is held as a
separate, ticketed leg that the net-position resize cannot see. Resize works on the net position
that plain BUY/SELL opens; pair it with the CLOSE-rule stop above.
CANCEL <stream> and CANCEL_ALL¶
Cancels working orders without touching open positions. A bracket whose entry has filled in part keeps
that part protected: the rest of the entry is cancelled and the filled part gets its stop and target. CLOSE
also cancels the stream's working orders, but drops those exits, because it closes the filled part itself. See
BRACKET: an entry that fills only in part.
CANCEL btc -- cancel any pending orders on btc, leaves the position alone
CANCEL_ALL -- cancel every pending order
Use case: a STACK strategy with unfilled layers — you want to cancel the rest when conditions change.
WHEN regime_changed
THEN CANCEL btc ; -- abandon unfilled stack layers
CLOSE btc -- close the already-filled portion
OCO_ENTRY { leg1, leg2 }¶
Submits two pending entry orders linked one-cancels-other. When either leg fills, the broker auto-cancels the other. Use for breakout straddles where you don't know which direction will resolve first.
OCO_ENTRY {
BUY gold SIZING 0.20 ORDER_TYPE = STOP AT gold.close + 50
BRACKET { STOP LOSS BY 180, TAKE PROFIT BY 150 }
TIF GTD UNTIL NOW + 10m,
SELL gold SIZING 0.20 ORDER_TYPE = STOP AT gold.close - 50
BRACKET { STOP LOSS BY 180, TAKE PROFIT BY 150 }
TIF GTD UNTIL NOW + 10m
}
Both legs are submitted to the broker as pending orders (typically STOP AT or LIMIT AT). Whichever triggers first becomes the live position; the OrderManager cancels the sibling on receipt of the fill event. Each leg carries its own BRACKET — when a leg fills, its stop-loss and take-profit attach to that position automatically.
Children¶
- Exactly two legs.
OCO_ENTRY { ... }with 0, 1, or 3+ legs is a parse error. - Each leg must be
BUYorSELL. IncludingLOG,CLOSE, orCANCELinside anOCO_ENTRYblock is a parse error. - Same stream or different streams. The DSL doesn't restrict; the broker decides. Same-symbol opposite-side is the hedge-straddle case; different-symbol same-side is a pairs-trading entry.
Time-in-force¶
The two legs typically share a TIF GTD UNTIL NOW + <duration> clause so both expire together if neither triggers. UNTIL is optional sugar — TIF GTD NOW + 10m is the same deadline. See NOW for relative deadlines.
Common gotchas¶
- Same-bar dual breach. If a single candle's high and low cross both stop prices, the tiebreak is broker-dependent. In backtest, the leg with the closer trigger to the candle's open fills first.
- Broker capability. Netting venues (
type: gatewayaccounts such as Bybit and Deribit) do not support pending-pair OCO natively; MT5 brokers (Phase 17 + 26b) do. As of Phase 26b, MT5 translates the pending family natively (BUY_STOP, SELL_STOP, BUY_LIMIT, SELL_LIMIT, BUY_STOP_LIMIT, SELL_STOP_LIMIT, server-side trailing). Async fill-event lifecycle (cancel-on-fill across MT5 tickets) lands in Phase 26c — until then, pending placements succeed live but qkt-side fill events for pending shapes arrive lazily via the position poller. - Both legs executing. When qkt holds the two legs together (the venue has no native OCO), a sibling cancel can lose the race and both legs execute. The leg that executes second is closed by its position ticket and a
CRITICAL OCO invariant violatedalert is raised. A leg that filled only in part counts as executed: a leg filling after its sibling filled in part is closed whole, and a leg that filled in part after its sibling executed is closed for what it filled once the venue cancels its remainder. - A refused cancel of the other leg. Once one leg executes, qkt cancels the other. A venue that refuses that cancel (MT5 reports
OrderCancelFailed; the order is still live) gets it again on the live heartbeat with backoff until it confirms, and after three unconfirmed attempts aCRITICAL cancellation remains unconfirmedalert is raised. - Restarts. Live OCO legs are restored still linked, so a leg filling after a restart cancels the other. A leg that filled while the other leg's cancel was still unconfirmed is remembered (
oco-legs.json,executed): the restart cancels the other leg again, and closes it by its ticket if it fills anyway. - Pending orders aren't positions.
POSITION.<stream> = 0returns true while OCO legs are pending; gate entries withPOSITION.<stream> = 0 AND not has_pending_oco(...)if you need that distinction.
What this composes with¶
- NOW — session-hour gating +
NOW + durationfor GTD expiry - BRACKET — per-leg SL/TP attached to the surviving fill
Exit hooks: ON_STOP, ON_TP, and ON_CLOSE¶
A BUY or SELL can attach one-shot actions to the way its position exits:
BUY gold SIZING 0.1
BRACKET { STOP LOSS BY 50, TAKE PROFIT BY 100 }
ON_STOP {
SELL gold SIZING EXIT.qty
BRACKET { STOP LOSS BY 50, TAKE PROFIT BY 100 }
}
ON_TP {
BUY gold SIZING EXIT.qty
ORDER_TYPE = LIMIT WITH 30
TIF GTD UNTIL NOW + 2h
}
ON_CLOSE {
BUY gold SIZING 0.05
}
ON_STOPruns when the stop-loss closes the hooked position.ON_TPruns when the take-profit closes it.ON_CLOSEruns for another flatten, including an explicitCLOSE, a time/risk exit, or a venue-side manual close.
The child actions travel through the ordinary strategy signal, book-scaling, risk, and broker path. A hook fires once after its hooked position is fully closed; partial exit fills accumulate until that point. Hooks and their partial-fill progress survive a restart.
Exit context¶
Only hook blocks can read the EXIT namespace:
| Accessor | Value |
|---|---|
EXIT.price |
Terminal closing fill price |
EXIT.side |
Closing fill side: BUY or SELL |
EXIT.qty |
Total quantity closed by this exit; EXIT.quantity is the same accessor |
EXIT.pnl |
Net realized strategy PnL accumulated across the exit fills |
EXIT.reason |
STOP, TP, or CLOSE |
BUY gold SIZING 0.1
BRACKET { STOP LOSS BY 50, TAKE PROFIT BY 100 }
ON_CLOSE {
LOG "flat" side=EXIT.side qty=EXIT.quantity price=EXIT.price pnl=EXIT.pnl why=EXIT.reason
}
Using EXIT.* anywhere else is a compile error. String fields such as EXIT.side and
EXIT.reason are useful in structured LOG fields; numeric fields can size and price
orders.
Inside a hook, a pending entry can be relative to the exit price:
WITH follows the closing side and AGAINST moves opposite it. For example, a
take-profit that closes a long with SELL resolves LIMIT WITH 30 below the exit
price, suitable for a pullback re-entry.
v1 constraints¶
- Hook blocks contain
BUY,SELL, orLOGactions, separated by;. - A child may carry a
BRACKETandTIF GTD. - A child cannot declare
ON_FILL, another exit hook,OCO,STACK, orSTACK_AT. - A hook-bearing parent may be plain,
BRACKET, orSTACK; it cannot also useON_FILL,OCO, orSTACK_AT.
The one-level nesting limit prevents an accidental infinite stop-and-reverse loop.
LOG¶
Emits a structured log line. Four levels (INFO, WARN, ERROR, DEBUG — INFO only by omission, see below) and optional structured fields.
Simple message¶
Output (with the default logback config):
With placeholders¶
{name} placeholders in the message string get filled from the structured fields:
THEN LOG "long entry at {price} with stop at {stopPrice}"
price=btc.close
stopPrice=btc.close - atr(btc, 14) * 2
The {price} and {stopPrice} in the string are replaced with the evaluated values. The fields also appear in the JSON output (if you're using structured logging) under log.price and log.stopPrice.
Levels¶
INFO is the implicit default — LOG "..." produces an INFO line. For other levels use the keyword:
LOG "..." -- INFO (default)
LOG WARN "..." -- something unusual but not fatal
LOG ERROR "..." -- something failed
LOG DEBUG "..." -- low-level detail; usually filtered out in production
There is no explicit LOG INFO keyword form — INFO is reached by omitting the level, and
LOG INFO "..." is a parse error:
Combining actions¶
Multiple actions per rule, separated by ;:
WHEN regime_changed
THEN
CANCEL btc ; -- cancel any pending btc orders
CLOSE btc ; -- flatten btc position
LOG "regime change" old=old_regime new=new_regime
Actions fire in order. The next action sees the state after the previous one (so LOG after CLOSE sees the closed position).
Optional clauses, in order¶
When you stack modifiers on a BUY/SELL, the order matters but the parser is forgiving:
<stream>(required)SIZING <spec>(or inherited fromDEFAULTS)- Order-type modifier (
ORDER_TYPE = LIMIT AT <price>,ORDER_TYPE = STOP AT <price> [LIMIT AT <price>],ORDER_TYPE = TRAILING BY|PCT ...) — defaults to market BRACKET { STOP_LOSS ..., TAKE_PROFIT ... }— both legs are required; orOCO { STOP AT ..., LIMIT AT ... }STACK <n> SPACING <points> [ABOVE|BELOW] [WITHIN <duration>]— pyramiding; without a direction the layers follow the trade directionSTACK_AT MFE >= <threshold> WITHIN <duration> SIZING <qty> BRACKET { ... }orSTACK_AT MAE >= <threshold> RECOVER <distance> WITHIN <duration> ...— conditional bracketed stacks (multiple per action allowed; see STACK_AT)TIMES <expression>— repeat the whole entry N times (see TIMES)EXIT AFTER <duration>— close this entry's leg that long after it fills, checked every tick (see EXIT AFTER)TIF <mode>— time-in-forceON_STOP,ON_TP, andON_CLOSE— one-shot exit hooksLOG ...— usually a separate action after;but can be inline-chained
The most common patterns:
-- Simple market buy with bracket
BUY btc SIZING 0.1 BRACKET { STOP_LOSS BY 1 PCT, TAKE_PROFIT BY 2 PCT }
-- Limit entry with bracket
BUY btc SIZING 0.1 ORDER_TYPE = LIMIT AT 67000 BRACKET { STOP_LOSS BY 300, TAKE_PROFIT BY 600 }
-- Stacked with shared bracket
BUY btc SIZING 0.1 STACK 3 SPACING 200 ABOVE WITHIN 4h
BRACKET { STOP_LOSS BY 300, TAKE_PROFIT BY 1000 }
-- Thirty independent bracketed entries at once
BUY btc SIZING 0.01 BRACKET { STOP_LOSS BY 300, TAKE_PROFIT BY 600 } TIMES 30
-- Close four minutes after the fill, whatever the price
BUY btc SIZING 0.01 EXIT AFTER 4m
Common gotchas¶
BUY btcwithoutSIZINGand withoutDEFAULTS { SIZING = ... }is a compile error (BUY/SELL requires SIZING). Sizing is required at one of: action, DEFAULTS; the action wins when both are present.CLOSEdoesn't take a size. It closes the whole position. To exit partially, use aBRACKETwith scale-out targets or aSELLthat fires when long.- Edge-trigger gotcha for entries. Without
AND POSITION.<stream> = 0, aBUYrule fires once on signal — then if the signal stays true, it doesn't re-fire (edge-trigger). If you want re-entry capability, ensure the position guard is in place. LOGis not an exit. Logging doesn't change strategy state. UseCLOSEorCANCELfor actions;LOGfor the audit trail.TRAILINGorder type requires the explicitORDER_TYPE =keyword. UseBUY btc ORDER_TYPE = TRAILING BY 50— bareBUY btc TRAILING BY 50parses the BUY without an order type and chokes onTRAILINGas the next statement.