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 ; or newlines.
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 |
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) |
Phase 24 — more actions coming
FLATTEN (as a DSL synonym for CLOSE_ALL) lands in Phase 24. See Planned features. For now use CLOSE_ALL.
BUY <stream> and SELL <stream>¶
The entry verbs. Both take the same set of modifiers.
BUY <stream>
[ SIZING <size_spec> ]
[ <order_type_modifier> ]
[ BRACKET { ... } ]
[ STACK ... ]
[ TIF <gtc|ioc|fok|day> ]
TRAILING_STOP BY <amount> is planned for Phase 25 — see Planned features.
Minimal BUY (uses DEFAULTS)¶
This is valid only if DEFAULTS { sizing = ... } is set (otherwise the parser complains). Sizing is the only field without a sensible compile-time default.
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.
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):
Stop-limit and if-touched coming in Phase 25
STOP_LIMIT AT … LIMIT_PRICE … and IF_TOUCHED AT … are planned but not yet shipped. See Planned features. Today, only MARKET, LIMIT AT, and STOP AT are supported as entry order types.
The order type modifier replaces the default MARKET and goes right after the stream/sizing.
CLOSE <stream> and CLOSE_ALL¶
CLOSE <stream> flattens the position on that stream at market. No sizing needed — it closes the full current position.
CLOSE_ALL does the same for every open position across all symbols. Use sparingly — usually you want to be precise.
CANCEL <stream> and CANCEL_ALL¶
Cancels working orders without touching open positions.
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. 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. Bybit Spot is netting-only and does not support pending-pair OCO. Bybit Linear with hedge-mode and 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.
- 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
LOG¶
Emits a structured log line. Three levels (INFO, WARN, ERROR, DEBUG) 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 {stop}"
price=btc.close
stop=btc.close - atr(btc, 14) * 2
The {price} and {stop} 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.stop.
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.
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 (
LIMIT AT,STOP AT) — defaults to market BRACKET { ... }(orSTOP_LOSS ... TAKE_PROFIT ...bare)STACK <n> SPACING <points> ABOVE|BELOW [WITHIN <duration>]— pyramidingSTACK_AT MFE >= <threshold> WITHIN <duration> SIZING <qty> BRACKET { ... }— conditional bracketed stacks (multiple per action allowed; see STACK_AT)TIF <mode>— time-in-forceLOG ...— 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 50 PCT, TAKE_PROFIT BY 100 PCT }
-- Limit entry with bracket
BUY btc SIZING 0.1 LIMIT AT 67000 BRACKET { ... }
-- Bare stop (no take-profit, exit via rule)
BUY btc SIZING 0.1 STOP_LOSS AT btc.close - atr(btc, 14) * 2
-- 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 }
Common gotchas¶
BUY btcwithoutSIZINGand withoutDEFAULTS.sizingis a parse error. Sizing is required at exactly one of: action, DEFAULTS.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.TRAILING_STOPnot yet wired — Phase 25. See Planned features.