Skip to content

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

WHEN ema(btc.close, 9) CROSSES ABOVE ema(btc.close, 21)
THEN BUY btc SIZING 0.1

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:

BUY btc SIZING 0.1 ORDER_TYPE = LIMIT AT 67000      -- limit order at $67,000

To submit a stop entry (buy on breakout above a level):

BUY btc SIZING 0.1 ORDER_TYPE = STOP AT 67500       -- triggers when price hits $67,500

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 }
BUY btc SIZING 0.1 OCO { STOP AT btc.close - 200 }   -- OCO requires a LIMIT AT child

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.

WHEN ema(btc.close, 9) CROSSES BELOW ema(btc.close, 21)
 AND POSITION.btc > 0
THEN CLOSE btc

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 0 flattens it. With no open position it is a no-op — open with BUY/SELL first.
  • 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.
RESIZE aud TO 0.02 / atr(aud.candle, 20) MIN_STEP 0.002

For risk on a resized position, use a CLOSE rule — it always flattens the current position size, so it tracks the resize for free:

WHEN gold.close < gold.close[1] - 3 * atr(gold.candle, 14) THEN CLOSE gold

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.

WHEN ACCOUNT.equity < 5000
THEN CLOSE_ALL ;
     LOG WARN "equity below safety threshold — flattening"

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 BUY or SELL. Including LOG, CLOSE, or CANCEL inside an OCO_ENTRY block 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.

BUY gold SIZING 0.2 ORDER_TYPE = STOP AT gold.close + 50 TIF GTD NOW + 10m

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: gateway accounts 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 violated alert 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 a CRITICAL cancellation remains unconfirmed alert 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> = 0 returns true while OCO legs are pending; gate entries with POSITION.<stream> = 0 AND not has_pending_oco(...) if you need that distinction.

What this composes with

  • NOW — session-hour gating + NOW + duration for 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_STOP runs when the stop-loss closes the hooked position.
  • ON_TP runs when the take-profit closes it.
  • ON_CLOSE runs for another flatten, including an explicit CLOSE, 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:

ORDER_TYPE = LIMIT WITH 30
ORDER_TYPE = STOP AGAINST 20

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, or LOG actions, separated by ;.
  • A child may carry a BRACKET and TIF GTD.
  • A child cannot declare ON_FILL, another exit hook, OCO, STACK, or STACK_AT.
  • A hook-bearing parent may be plain, BRACKET, or STACK; it cannot also use ON_FILL, OCO, or STACK_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.

LOG [LEVEL] "<msg>" [<key>=<expr> ...]

Simple message

THEN LOG "entered long position"

Output (with the default logback config):

2026-05-11T10:23:45.123 [main] INFO  com.qkt.app.LiveSession - [my-strategy] entered long position

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:

WHEN btc.close > btc.open
THEN LOG "info" ; LOG WARN "warn" ; LOG ERROR "error" ; LOG DEBUG "debug"
WHEN btc.close > btc.open THEN LOG INFO "not a level keyword"

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:

  1. <stream> (required)
  2. SIZING <spec> (or inherited from DEFAULTS)
  3. Order-type modifier (ORDER_TYPE = LIMIT AT <price>, ORDER_TYPE = STOP AT <price> [LIMIT AT <price>], ORDER_TYPE = TRAILING BY|PCT ...) — defaults to market
  4. BRACKET { STOP_LOSS ..., TAKE_PROFIT ... } — both legs are required; or OCO { STOP AT ..., LIMIT AT ... }
  5. STACK <n> SPACING <points> [ABOVE|BELOW] [WITHIN <duration>] — pyramiding; without a direction the layers follow the trade direction
  6. STACK_AT MFE >= <threshold> WITHIN <duration> SIZING <qty> BRACKET { ... } or STACK_AT MAE >= <threshold> RECOVER <distance> WITHIN <duration> ... — conditional bracketed stacks (multiple per action allowed; see STACK_AT)
  7. TIMES <expression> — repeat the whole entry N times (see TIMES)
  8. EXIT AFTER <duration> — close this entry's leg that long after it fills, checked every tick (see EXIT AFTER)
  9. TIF <mode> — time-in-force
  10. ON_STOP, ON_TP, and ON_CLOSE — one-shot exit hooks
  11. LOG ... — 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 btc without SIZING and without DEFAULTS { SIZING = ... } is a compile error (BUY/SELL requires SIZING). Sizing is required at one of: action, DEFAULTS; the action wins when both are present.
  • CLOSE doesn't take a size. It closes the whole position. To exit partially, use a BRACKET with scale-out targets or a SELL that fires when long.
  • Edge-trigger gotcha for entries. Without AND POSITION.<stream> = 0, a BUY rule 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.
  • LOG is not an exit. Logging doesn't change strategy state. Use CLOSE or CANCEL for actions; LOG for the audit trail.
  • TRAILING order type requires the explicit ORDER_TYPE = keyword. Use BUY btc ORDER_TYPE = TRAILING BY 50 — bare BUY btc TRAILING BY 50 parses the BUY without an order type and chokes on TRAILING as the next statement.

What this composes with

  • SIZING — every way to specify position size
  • BRACKET — stop-loss and take-profit groups
  • STACK — pyramiding multiple entries from one signal
  • STACK_AT — conditional bracketed stacks fired by MFE thresholds
  • Streams — what <stream> refers to
  • LOG/Logging — log routing, MDC keys, file outputs