Conditions — the WHEN clause¶
A condition is everything between WHEN and THEN in a rule. It's a boolean expression — if true, the rule's actions fire.
Shape¶
The <condition> is a boolean expression. It can be a simple comparison or a complex compound with AND / OR / NOT.
Edge-triggered vs level-triggered¶
This is the single most important thing to understand about qkt conditions.
Edge-triggered (default)¶
Most operators are edge-triggered: the rule fires on the first tick after the condition transitions from false to true. Subsequent ticks where the condition stays true do not re-fire.
CROSSES ABOVE is edge-triggered by definition. The condition is true only on the bar where the crossing happens — even if EMA9 stays above EMA21 for 100 more bars.
Level-triggered (use explicitly)¶
A bare comparison like > or < is level-triggered for evaluation — it returns true whenever the relation holds. But the rule's action gating is still edge-driven: actions don't repeat on every tick where the condition is true.
This fires the first tick where btc.close > 50000. If btc.close stays above 50k for 100 bars, you get one log line, not 100.
If you want true level-triggered firing (do something every tick the condition holds), gate the action with a position-state check that resets:
WHEN btc.close > 50000 AND POSITION.btc = 0
THEN BUY btc SIZING 0.1 -- fires once per bar where we're flat AND above 50k
-- (after a fill, POSITION.btc != 0 so it doesn't re-fire)
An exit the venue does not fill is sent again¶
A rule's market exit — CLOSE, CLOSE_ALL, or a BUY/SELL that reduces the position the
strategy holds when the rule fires — can end at the venue with nothing or only part filled: a
thin book, a price band (Deribit rests a market order at its band price, and the gateway cancels
it), a transient refusal. Its rule's condition usually still holds, so plain edge gating would never
fire it again and the position would stay open. Instead the rule re-arms (#1359):
- It fires again on its next bar while its condition still holds, so at most once a bar.
- A retry only reduces what is still held: a
BUY/SELLis cut to the open quantity (a part-filled exit resends its remainder and never reverses),CLOSEcloses what is held, and any entry in the same action is dropped. Entries are never retried. - After 3 failed fires in a row, and again at 6, 12, ..., qkt raises the operator alert (the protection-failure channel: Telegram when configured, and the log) naming the strategy, symbol and position still open. Retrying goes on regardless.
- The re-arm is persisted with the rule edges, so a restart before the retry still retries.
Backtests are unchanged: the simulators fill every market exit. EXIT AFTER closes retry the same
way (exit-after). Limit and stop exits and bracket children are not covered.
Re-entry after a position closes¶
A rule gated on the position — POSITION.btc = 0 on an entry, POSITION.btc != 0 on an exit —
re-arms the moment the position state flips, not only when a bar close happens to observe it.
So an entry whose bracket is stopped out inside the bar it was filled on enters again at the
next bar close where the signal still holds, exactly as one stopped out three bars later does.
Before this, the first case never fired again until the signal had gone false and come back: on a
4h strategy behind a regime filter that can mean weeks of silence after one quick stop.
Two rules keep this causal and identical in backtest and live:
- An entry waits for the next close. A bar is judged on the position as it stood when that bar ended. If the tick that closes a bar is also the one that takes the position out, the rule does not re-enter off that bar — the bar closed while the position was still open.
- Only a top-level
ANDterm on the rule's own stream counts (POSITION.btc = 0,!= 0,> 0,< 0). Inside anOR, underNOT, with>=/<=, or on another stream, the rule keeps plain bar-close edge behaviour.
Re-entering while the signal still holds is what the rule says, but it is also how a strategy bleeds through a losing regime one stop at a time. Say how often you are willing to re-enter:
risk:
per_strategy:
gold_trend:
max_trades_per_day: 3
cooldown_after_loss: 4h # no new entry for 4h after a losing close
or put it in the signal, so it goes false after a loss and must re-form (CROSSES ABOVE instead
of >, or AND TRADES.today = 0 for one attempt a day).
Comparison operators¶
| Operator | Meaning |
|---|---|
= or == |
Equal |
!= or <> |
Not equal |
< |
Less than |
<= |
Less than or equal |
> |
Greater than |
>= |
Greater than or equal |
Both = and == work for equality; != and <> both work for inequality. The DSL is liberal about syntax conventions you might be used to from SQL or C-family languages.
Equality and inequality accept either two numbers or two strings. String comparison is exact and
case-sensitive; <, <=, >, and >= accept numbers only. Mixed or unsupported operand types
evaluate as undefined, so the containing rule does not fire.
WHEN rsi(btc.close, 14) < 30 THEN LOG "oversold"
WHEN account.equity >= 10000 THEN BUY btc SIZING 0.01
WHEN POSITION.btc = 0 THEN LOG "flat"
WHEN status = "ready" THEN BUY btc SIZING 0.01
Boolean combinators¶
WHEN <cond1> AND <cond2> -- both must hold
WHEN <cond1> OR <cond2> -- either holds
WHEN NOT <cond> -- negation
Combine freely:
WHEN ema(btc.close, 9) > ema(btc.close, 21)
AND rsi(btc.close, 14) > 50
AND NOT (POSITION.btc > 0)
THEN BUY btc SIZING 0.1
Whitespace and line breaks are insignificant — break across lines for readability.
Precedence: NOT > AND > OR. Use parentheses if you want explicit grouping:
WHEN (a > 0 AND b > 0) OR c > 0 -- (a>0 AND b>0), or c>0
WHEN a > 0 AND (b > 0 OR c > 0) -- a>0, AND (b>0 OR c>0)
Crosses operators¶
Edge-triggered, the workhorse of moving-average strategies:
True only on the bar where expr_a crosses the boundary. Examples:
ema(btc.close, 9) CROSSES ABOVE ema(btc.close, 21)
rsi(btc.close, 14) CROSSES BELOW 70
btc.close CROSSES ABOVE highest(btc.close, 20) -- Donchian breakout
The second argument can be a constant; rsi CROSSES BELOW 70 fires on the bar where RSI drops from ≥70 to <70.
Equality belongs to the not-above side. CROSSES ABOVE fires when the prior value was
equal to or below the boundary and the current value is strictly above it.
CROSSES BELOW fires when the prior value was strictly above and the current value is
equal to or below it, so a touch from above counts as a below cross.
Range checks¶
BETWEEN¶
True when low <= expr <= high. Inclusive on both ends.
Membership (IN)¶
True when expr equals any of the listed values. Useful for symbol-conditional logic in FOR EACH blocks.
Account / position references¶
These functions/properties act like read-only stream-field accesses:
| Reference | Returns |
|---|---|
account.equity |
Current account equity (cash + open P&L) |
account.balance |
Cash balance only (excludes unrealized P&L) |
POSITION.<stream> |
Net position quantity (positive=long, negative=short, 0=flat) |
POSITION.<stream>.pnl |
Strategy P&L for the stream |
POSITION.<stream>.entry_price |
Average entry price, or null while flat |
POSITION.<stream>.holding_duration |
How long the position has been open (seconds) |
OPEN_ORDERS.<stream> |
Active risk-increasing entry-order count for this strategy and stream |
WHEN POSITION.btc = 0
AND OPEN_ORDERS.btc = 0
AND account.equity > 1000
THEN BUY btc SIZING 0.1
WHEN POSITION.btc > 0
AND POSITION.btc.holding_duration > 4 * 60 * 60 -- 4h (holding_duration is seconds)
AND POSITION.btc.pnl < 0
THEN CLOSE btc -- time-stop on a losing position
Fixed elapsed-horizon exits¶
POSITION.<stream>.holding_duration uses the injected engine clock, so the same elapsed-time exit works in replay and live execution. Like every WHEN rule, though, it is evaluated only when the rule's stream closes a bar. The close therefore lands on the first bar close at or after the horizon, up to one bar late. Entries also fire at bar close, so a position is a few hundred milliseconds short of an exact multiple of the bar when that bar closes: a holding_duration >= 4 * 60 exit on a 5-minute stream closes at about 5 minutes, and on a 1-minute stream at about 5 minutes too, not 4. When the exit time itself matters, use EXIT AFTER, which is checked on every tick and timed from the fill.
A fixed 8 × 4-hour horizon is:
This measures 32 elapsed hours from the executable fill. It does not grant a completed signal bar's close as an entry fill: a close-derived signal is only known after that bar closes, and the order fills on the next executable quote. It also counts elapsed market closures, so it is not an eight-observed-bar counter across a weekend. Research built from close.shift(-8) must disclose both differences when it is translated to exact-tick execution; a loss of expectancy under those executable semantics is not an indicator gap.
The same pattern on a 30-minute stream holds for about two bars, not one: at the first bar close after entry the position is just short of 30 minutes old, so the rule closes it at the second.
For an exact 30-minute hold, write the exit on the entry instead: BUY gold SIZING 0.1 EXIT AFTER 30m.
Position-state guards¶
The most common entry guard pattern:
WHEN <signal_condition>
AND POSITION.btc = 0
AND OPEN_ORDERS.btc = 0
THEN BUY btc ... -- enter only when flat
The most common exit pattern:
POSITION.btc = 0 prevents a second entry after a fill. OPEN_ORDERS.btc = 0 prevents another entry while a limit, stop, trailing, or other risk-increasing entry is still active. The count is scoped to the current strategy and symbol, includes partially filled entries, and returns to zero on fill, cancellation, rejection, or expiry. Protective and other risk-reducing exits are excluded, including an OTO child after its entry parent fills.
Without these guards, the entry rule can re-fire after its condition becomes false and true again, creating a second pending order or pyramiding after a fill. Omit a guard only when that behavior is intentional.
Lookback ([N])¶
stream.close[N] is the close N bars ago. 0 is the current bar, 1 is the previous bar, etc.
WHEN btc.close > btc.close[20] -- BTC up over the past 20 candles
WHEN btc.high[1] > btc.high[2] -- previous bar's high > one before
Out-of-range or negative indices return null; comparisons with null are false. Useful for "early in the run" safety:
-- This won't crash on the first tick despite no [20] history yet:
WHEN btc.close > btc.close[20] THEN LOG "up over 20 bars"
Combining conditions across streams¶
Multi-asset strategies often gate one symbol's entry on another symbol's state:
SYMBOLS
btc = BACKTEST:BTCUSDT EVERY 1m
eth = BACKTEST:ETHUSDT EVERY 1m
RULES
-- Buy ETH when BTC is in uptrend AND ETH crosses up
WHEN ema(btc.close, 50) > ema(btc.close, 200)
AND ema(eth.close, 9) CROSSES ABOVE ema(eth.close, 21)
THEN BUY eth SIZING 0.1
Both streams are evaluated on every candle close (whoever closes first triggers the candle event for that stream). Conditions that mix streams of different timeframes evaluate on the latest closed candle of each.
Common gotchas¶
- Bare comparisons don't repeat-fire. A rule with
WHEN btc.close > 50000fires once when the condition first becomes true. To fire repeatedly, gate with position-state (AND POSITION.btc = 0) and act on every tick the gate is open. - A rule waits for its streams' warmup. A stream is warm once it has closed as many bars as the longest indicator anywhere in the strategy reads from it (or its
WARMUP N BARS, if larger). Until then no rule that references the stream is evaluated, soWHEN in_session OR slow_signaldoes not fire onin_sessionwhileslow_signal's 200-bar indicator on the same stream is warming. Live trading seeds that history before the first live bar, so this only delays the start of a backtest. nullmostly propagates. After warmup a value can still be undefined (a missing quote, a series with no data, an undefined ratio); comparisons against it are undefined and the rule won't fire. The exception is short-circuit logic:TRUE OR <undefined>isTRUEandFALSE AND <undefined>isFALSE— a side that can't change the outcome doesn't suppress it.- Precedence trap.
WHEN a AND b OR cis(a AND b) OR c, which is often not what you meant. Use parentheses. =vs==. Both work — the parser accepts either. Pick a convention for your project and stick with it.btc.close[0]is the current bar. It's the same asbtc.close(no[]).btc.close[1]is the previous bar. Don't off-by-one yourself.
What this composes with¶
- Indicators — most of what goes in conditions
- NOW + calendar windows — time-of-day, weekday, and seasonal
CALENDAR_WINDOWgating - Expressions — arithmetic, account/position refs, the math helpers
- Actions — what fires after
THEN - LET — name complex condition fragments for reuse