Indicators¶
The technical-analysis functions you can call in conditions and expressions. qkt ships 59 registered indicators (every name in IndicatorRegistry, from ema to ib_defended_low) plus the multi-series resid and confirm_ratio, all written from scratch with hand-computed tests for correctness. Names are case-insensitive: ema, EMA and Ema are the same call.
Function call shape¶
Every indicator is a function call:
The first argument is what to compute on. For most indicators that's <stream>.close (close price). For ATR it's the stream itself (uses high/low/close). For VWAP it's the stream too (uses ticks + volume).
Catalog¶
Moving averages¶
ema(<value>, <period>) -- exponential moving average
sma(<value>, <period>) -- simple moving average
wma(<value>, <period>) -- weighted moving average
<value> is typically stream.close but can be any price expression. <period> is the lookback length in bars (not ticks).
ema(btc.close, 9) -- 9-bar EMA of close
sma(btc.close, 200) -- 200-bar SMA of close
sma((btc.high + btc.low) / 2, 20) -- 20-bar SMA of midpoint
When to use which:
ema— reacts faster to recent prices. Most common for short-term signal generation.sma— equal weighting; smoother but slower. Used for long-term filters (50, 100, 200 period).wma— linearly weighted (recent bars count more). Less common; sometimes useful when you want EMA-like responsiveness with a discrete window.
Oscillators¶
RSI uses Wilder's smoothing. Bounded [0, 100]. Below 30 = oversold, above 70 = overbought are the conventional thresholds.
Volatility¶
ATR is Wilder's smoothed average of true range. Uses high/low/close — so you pass the
stream, not stream.close. A period-N ATR becomes defined after N+1 candles
because the first true range needs a previous close. It is used heavily in stop-loss
sizing (STOP_LOSS BY atr(btc, 14) * 2).
MACD¶
Default Connors-style values: (12, 26, 9). Returns the MACD line:
To compare against the signal line, qkt provides macd_signal and macd_hist:
WHEN macd(btc.close, 12, 26, 9) CROSSES ABOVE macd_signal(btc.close, 12, 26, 9)
THEN BUY btc SIZING 0.1
The three values share the same internal computation — the parser deduplicates.
Bollinger Bands¶
bollinger_upper(<value>, <period>, <stddev>)
bollinger_middle(<value>, <period>, <stddev>) -- = SMA
bollinger_lower(<value>, <period>, <stddev>)
<stddev> is the band width in standard deviations; the typical value is 2.0.
More moving averages¶
dema(<value>, <period>) -- double EMA: 2·EMA − EMA(EMA); less lag than ema
tema(<value>, <period>) -- triple EMA: 3·EMA − 3·EMA(EMA) + EMA(EMA(EMA)); least lag
hma(<value>, <period>) -- Hull MA: WMA(2·WMA(N/2) − WMA(N)) over √N bars; low lag, smooth
Each is a drop-in for ema where you want a faster turn at the cost of more overshoot (dema,
tema) or a smoother low-lag line (hma). On a constant series each settles to that constant.
WHEN hma(btc.close, 20) CROSSES ABOVE tema(btc.close, 50) AND POSITION.btc = 0
THEN BUY btc SIZING 0.1
WHEN dema(btc.close, 20) < lag(dema(btc.close, 20), 1) AND POSITION.btc > 0
THEN CLOSE btc
Keltner Channels¶
keltner_upper(<stream>, <period>, <atrMult>) -- EMA(close, period) + atrMult × ATR(period)
keltner_middle(<stream>, <period>, <atrMult>) -- EMA(close, period)
keltner_lower(<stream>, <period>, <atrMult>) -- EMA(close, period) − atrMult × ATR(period)
Like Bollinger Bands but scaled by true range instead of the dispersion of closes, so the bands
widen with trading range. They read the whole candle, so pass the stream (or <stream>.candle),
not <stream>.close. The three lines share one underlying instance.
WHEN btc.close > keltner_upper(btc, 20, 2.0) AND POSITION.btc = 0 THEN BUY btc SIZING 0.1
WHEN btc.close < keltner_middle(btc.candle, 20, 2.0) AND POSITION.btc > 0 THEN CLOSE btc
Directional movement (ADX)¶
adx(<stream>, <period>) -- Wilder trend strength, 0-100 (direction-blind)
plus_di(<stream>, <period>) -- +DI: smoothed up-move share of true range, 0-100
minus_di(<stream>, <period>) -- -DI: smoothed down-move share of true range, 0-100
adx says how strongly the market trends; plus_di against minus_di says which way. The
conventional reading is a trend above 25, up when plus_di > minus_di. All three take the
stream and share one Wilder computation.
WHEN adx(gold, 14) > 25 AND plus_di(gold, 14) > minus_di(gold, 14) AND POSITION.gold = 0
THEN BUY gold SIZING 0.1
Candle oscillators¶
cci(<stream>, <period>) -- Commodity Channel Index: (TP − SMA(TP)) / (0.015 × mean deviation)
williams_r(<stream>, <period>) -- Williams %R: close within the N-bar high/low range, −100..0
stoch_k(<stream>, <kPeriod>, <dPeriod>) -- fast Stochastic %K, 0-100
stoch_d(<stream>, <kPeriod>, <dPeriod>) -- %D = SMA(%K, dPeriod), 0-100
Each reads high, low and close, so it takes the stream. cci measures distance from the average
typical price (high + low + close) / 3 in units of mean deviation (±100 is the classic band);
williams_r is the inverse of fast %K (0 at the top of the range, −100 at the bottom, and
−100 on a rangeless window); the stoch_k / stoch_d crossover is the classic trigger.
WHEN williams_r(btc, 14) < -80 AND cci(btc, 20) < -100 AND POSITION.btc = 0
THEN BUY btc SIZING 0.1
WHEN stoch_k(btc, 14, 3) CROSSES BELOW stoch_d(btc, 14, 3) AND stoch_k(btc, 14, 3) > 80 AND POSITION.btc > 0
THEN CLOSE btc
Dispersion and slope¶
stddev(<value>, <period>) -- rolling sample (n−1) standard deviation
variance(<value>, <period>) -- rolling sample variance, the square of stddev
regression_slope(<value>, <period>) -- least-squares slope through the last N values, per bar
stddev is the plain N-bar volatility estimator (zscore uses the same divisor);
regression_slope fits the window against bar index 0…N−1, so a clean ramp of one unit per bar
reads 1.0 and the sign is a smoothed trend direction. <period> must be at least 2 for the slope.
variance and regression_slope keep 16 significant digits, not the 8 decimals other indicators
round to: on FX prices a variance is around 1e-10 and a per-bar slope around 1e-6.
-- Vol-scaled sizing with a slope filter: size to a 1% move per unit of 20-bar vol, only uptrend.
LET vol20 = stddev(btc.close, 20) / btc.close
WHEN regression_slope(btc.close, 20) > 0 AND variance(btc.close, 20) > 0 AND POSITION.btc = 0
THEN BUY btc SIZING 0.01 / vol20
On-balance volume¶
obv has no period: it starts at zero on the first candle and accumulates from there, so read it
relative to its own past (lag(obv(btc), 20)) rather than as a level. It needs a feed that reports
traded volume, and a strategy binding it to any other feed is refused when it starts, live and in a
backtest alike: live, a Bybit feed qualifies and an MT5 feed (whose volume is a tick count) does not;
in a backtest, a tick store whose ticks carry volume qualifies, while fetched bars alone do not.
-- Price at a 20-bar high that OBV does not confirm: thinning participation, stand aside.
WHEN btc.close > highest(btc.close, 20) AND obv(btc) < lag(obv(btc), 20)
THEN LOG "unconfirmed breakout"
VWAP¶
Takes the stream because it needs both price and volume. The period is in ticks, not bars — VWAP is a tick-level indicator.
If a tick has no volume, that tick contributes 0 (doesn't pollute the average).
Donchian (rolling extremes)¶
WHEN btc.close > highest(btc.close, 20) -- breakout above 20-bar high
THEN BUY btc SIZING 0.1
WHEN btc.close < lowest(btc.close, 10) -- breakdown below 10-bar low
AND POSITION.btc > 0
THEN CLOSE btc
highest(close, N) excludes the current bar. It looks at the last N prior closes. This matters for breakout strategies — otherwise close > highest(close, N) could never fire (the current bar can't exceed itself).
Statistical¶
zscore answers "how far is the latest value from its recent average, in standard deviations." It is (latest − mean) / stddev over the last <period> values, using the sample (n−1) standard deviation. A z of +2 means the newest value sits two standard deviations above its window mean — the canonical mean-reversion / pairs-spread entry.
Warmup is <period> bars. While warming up zscore returns null, and it also returns null when the window is flat (zero standard deviation — the z-score is undefined). As everywhere, null makes the comparison false, so the rule simply doesn't fire.
The series can be any arithmetic expression that references at least one stream (expression-fed binding, #174) — not just a bare stream.close. This is what makes zscore a pairs-trading primitive: feed it a ratio or spread of two streams.
-- Pairs spread: trade gold against silver when their ratio reaches an extreme.
WHEN zscore(gold.close / silver.close, 100) >= 2.0 AND POSITION.gold = 0
THEN SELL gold SIZING 0.1
WHEN zscore(gold.close / silver.close, 100) <= -2.0 AND POSITION.gold = 0
THEN BUY gold SIZING 0.1
A cross-stream series like gold.close / silver.close mixes two streams. The binding gates updates on the primary alias — the first stream the expression references (here gold) — and reads the other stream's latest closed bar. For that read to be the same-window value rather than the previous window's, put the two streams in a shared SYNCHRONIZE group so their bars are delivered together:
Without the SYNCHRONIZE, the spread is still computed, but silver.close may lag gold.close by one bar — the same cross-stream alignment caveat that applies to sma(silver.close, …) inside a gold-anchored rule.
State dwell¶
runlength_where counts an uninterrupted boolean state. Each bar where <condition> is true increments the counter; the first false bar resets it to 0. This is different from a rolling fraction such as mean(CASE WHEN condition THEN 1 ELSE 0 END) SINCE T-N (or count(condition, N) / N): it preserves escape-time / dwell semantics.
LET calm = atr(gold.candle, 14) < percentile_rank(atr(gold.candle, 14), 200)
WHEN runlength_where(calm) > percentile_rank(runlength_where(calm), 100)
AND SESSION_WINDOW(11, 30, 14, 0)
THEN BUY gold SIZING 0.1
The condition may reference any stream expression. Cross-stream conditions follow the same primary-alias and SYNCHRONIZE alignment rules as expression-fed numeric indicators.
Cross-series (two-stream)¶
Two indicators take two series and measure how a pair of streams move together over a rolling window. They follow the same primary-alias / SYNCHRONIZE alignment rules as a cross-stream zscore — put the two streams in a shared SYNCHRONIZE group so each bar reads the same-window value from both.
correlation(<a>, <b>, <period>) -- rolling Pearson correlation, in [-1, +1]
beta(<a>, <b>, <period>) -- rolling OLS slope of <a> on <b> (hedge ratio)
correlation is the Pearson correlation coefficient of the two series over the last <period> bars: +1 means they move in lockstep, 0 unrelated, -1 opposite. Use it to gate on a correlation regime — for example, only act once two normally-coupled pairs decouple, betting they re-couple.
-- Fade a EUR/GBP decoupling: realized correlation has collapsed below its baseline
-- AND the ratio is extended, so bet on re-coupling.
WHEN correlation(eur.close, gbp.close, 48) < 0.40
AND abs(zscore(eur.close / gbp.close, 48)) > 2.0
THEN SELL eur SIZING 0.1
beta is the slope of an ordinary-least-squares fit of <a> on <b> over the window — how many units <a> moves per unit move in <b>. It is the classic hedge ratio: to be market-neutral against <b>, hold beta units of <b> per unit of <a>. e.g. beta(stock.close, index.close, 60) over closes that move 2-for-1 → ~2.
Both warm up over <period> bars, returning null until the window is full (and when a series has zero variance, where the statistic is undefined). Either <a> or <b> may be any arithmetic expression that references a stream, exactly like zscore.
Regression residual¶
resid fits an ordinary-least-squares regression of the dependent series on one or more regressor series over the last <period> bars and reports the latest bar's residual — the part of the dependent series the regressors do not explain. The first argument is the dependent series, every argument before the trailing integer is a regressor, and the last argument is the lookback. At least one regressor is required, and <period> must exceed the number of regressors plus one (you need more observations than coefficients to fit).
It generalizes the pairs spread: regressing one instrument on the others it co-moves with leaves a residual that is the instrument's idiosyncratic move — usually a flow shock that reverts, not new information. A residual far from zero means the dependent moved on its own.
-- Trade GBP's idiosyncratic move: the part not explained by the broad-dollar factor
-- (EUR + AUD). z-score the residual and fade the extremes.
SYMBOLS
gbp = EXNESS:GBPUSD EVERY 15m WARMUP 200 BARS
eur = EXNESS:EURUSD EVERY 15m
aud = EXNESS:AUDUSD EVERY 15m
SYNCHRONIZE gbp eur aud
RULES
WHEN zscore(resid(gbp.close, eur.close, aud.close, 96), 96) > 2.0 AND POSITION.gbp = 0
THEN SELL gbp SIZING 0.1
Each series may be any arithmetic expression that references a stream, exactly like zscore. resid returns null until the window is full and when the regressors are collinear or constant (the fit is undefined). Because zscore(resid(...)) chains two rolling windows, set an explicit WARMUP covering both (the residual period plus the z-score period) — the compiler infers only the outer window for chained indicators.
Confirmation ratio (cross-symbol)¶
confirm_ratio measures how much a basket agrees with a move in the signal series. It returns the fraction of peer series whose return over the last <lookback> bars is the same sign as the signal's return over the same window. A low ratio means the signal moved while the peers did not — an idiosyncratic move to fade; a high ratio means the whole basket moved together (a broad factor move). The first argument is the signal, every argument before the trailing integer is a peer, and the last argument is the lookback.
To flip polarity for an inverse pair, negate the peer rather than passing a polarity list: -usdchf.close rises exactly when the dollar weakens, so it confirms a EURUSD rally.
-- Fade an unconfirmed EURUSD spike: the dollar basket did not follow, so it is EUR noise.
WHEN zscore(eur.close, 48) > 2.0
AND confirm_ratio(eur.close, gbp.close, aud.close, -chf.close, 4) < 0.5
AND POSITION.eur = 0
THEN SELL eur SIZING 0.1
Like resid, confirm_ratio is bound through the multi-series path and reads the peers' latest closed bars; put the streams in a SYNCHRONIZE group for same-window alignment. It returns null until <lookback> + 1 bars are seen.
Session-anchored indicators¶
These reset on a fixed UTC clock boundary rather than sliding over a fixed bar count.
Session VWAP¶
vwap_session(<stream>, <anchorHour>) -- volume-weighted average since anchorHour UTC
vwap_session_stdev(<stream>, <anchorHour>) -- volume-weighted stddev around that VWAP
vwap_session is the volume-weighted average of typical price (high+low+close)/3, accumulated since the most recent <anchorHour>:00 UTC and reset each day at that hour — anchorHour = 0 is the classic session-open VWAP, anchorHour = 12 anchors at the London/NY overlap. Pass the stream (it needs volume). vwap_session_stdev is the volume-weighted standard deviation of price around that running VWAP, so a strategy bands the VWAP and fades touches of the bands back toward it.
The input must include the anchor candle. If observation starts after the anchor,
both values remain null until the next session anchor rather than constructing a
partial-session VWAP.
-- Fade the upper 2-sigma band of the overlap-anchored session VWAP back to VWAP.
LET vwap = vwap_session(gold.candle, 12)
LET band = vwap + 2 * vwap_session_stdev(gold.candle, 12)
WHEN gold.close >= band AND POSITION.gold = 0 THEN SELL gold SIZING 0.1
Volume-less candles contribute nothing, like vwap. Both return null until a volume-bearing candle is seen in the current session. (Note: like obv, they need a feed that reports traded volume; a strategy binding them to an MT5 feed, whose volume is a tick count, is refused when it starts.)
Session range¶
session_range_high(<stream>.candle, <sh>, <sm>, <eh>, <em>) -- high of the prior completed UTC window
session_range_low(<stream>.candle, <sh>, <sm>, <eh>, <em>) -- low of the prior completed UTC window
These latch the high and low of the most recent completed instance of the daily UTC window [sh:sm, eh:em) and hold them as constant price levels until the next instance completes. Unlike highest/lowest, which slide forward every bar, this freezes a prior session's boundaries — e.g. the overnight Asian range stays fixed through the London morning. The window wraps midnight when the start is after the end. Mid and width compose: mid = (high + low) / 2, width = high - low.
-- Fade a poke above the 00:00-07:00 UTC Asian range during the 07:00-11:30 London window.
LET asianHigh = session_range_high(gold.candle, 0, 0, 7, 0)
WHEN session_window(7, 0, 11, 30) AND gold.close > asianHigh AND POSITION.gold = 0
THEN SELL gold SIZING 0.1
The level is null until the first window completes (a warmup delay, not a bug).
Floor-trader pivots¶
pivot_p(<stream>.candle) -- central pivot (H + L + C) / 3 of the prior UTC day
pivot_r1(<stream>.candle) -- first resistance 2*P - prior_day_low
pivot_s1(<stream>.candle) -- first support 2*P - prior_day_high
The classic floor-trader pivots, computed from the prior completed UTC day's high/low/close and held constant through the current day. Because every desk computes them identically, resting take-profit and limit orders cluster at the central pivot, so it acts as an intraday mean-reversion magnet and the bands act as soft barriers. Fade an excursion back toward pivot_p with a protective stop just beyond the next band.
-- Fade a stretch above the central pivot back toward it; stop just beyond R1.
WHEN gold.close > pivot_p(gold.candle) + atr(gold.candle, 14) AND POSITION.gold = 0
THEN SELL gold SIZING 0.1 BRACKET {
STOP LOSS AT pivot_r1(gold.candle),
TAKE PROFIT AT pivot_p(gold.candle)
}
The levels are null until the first full UTC day completes.
Seasonal range (hour-of-day volatility)¶
seasonal_range(<stream>.candle, <window>) -- trailing mean range of bars sharing this bar's UTC hour
seasonal_range is the mean realized range (high - low) of the last <window> bars that share the current bar's UTC hour-of-day — a per-hour volatility baseline. Volatility is sharply seasonal (an overlap bar is wider than an Asian bar just because the session is open), so a plain rolling range can't tell "the clock turned on" from "real news hit". Dividing the bar's range by seasonal_range gives an excess-vol ratio that is large only when a bar is wide for its own hour.
-- Arm a breakout only on a bar that is wide for its hour (an information shock, not the clock).
WHEN (gold.high - gold.low) > 2 * seasonal_range(gold.candle, 20)
THEN BUY gold SIZING 0.1
It is null for a given hour until <window> earlier bars of that hour have been seen.
seasonal_range_stdev(<stream>.candle, <window>) is the companion sample standard deviation (n-1 divisor) of the same per-hour range window. Together they z-score a bar against its own hour, so the trigger fires on a bar that is wide relative to the normal spread of its hour, not just its mean:
-- Continue an outlier that clears ~2.5 sigma above its hour's own range baseline.
LET hourz = ((gold.high - gold.low) - seasonal_range(gold.candle, 20)) / seasonal_range_stdev(gold.candle, 20)
WHEN hourz > 2.5 AND POSITION.gold = 0
THEN BUY gold SIZING 0.1
seasonal_range_stdev needs <window> > 1 (the sample stddev needs at least two occurrences) and is null for a given hour until that many earlier bars of the hour have been seen.
Run length (same-direction streak)¶
runlength is the signed length of the current run of same-direction changes: +k after k consecutive rises, -k after k consecutive falls, and 0 when the last change was flat (an unchanged value breaks the run). There is no lookback window — the streak accumulates from the last direction change. Fed <stream>.close on a daily stream it counts a daily-close streak; fed any expression it counts that expression's run.
-- Continuation: enter with a 4+ up-close streak, but stand down past an 8-close blow-off.
WHEN runlength(eur.close) >= 4 AND runlength(eur.close) <= 8 AND POSITION.eur = 0
THEN BUY eur SIZING 0.1
It is null until the first change is seen (one prior value is needed).
Session momentum¶
session_momentum sums each day's within-window simple return — (last in-window close / first in-window open) - 1 over [startHour, endHour) UTC — across the last <nDays> completed days. It isolates the drift of an informative session (e.g. the 12:00-14:00 overlap) from the off-hours noise that dilutes an all-bar momentum estimate. The forming day is excluded, so the value is stable to read at the window open.
-- At the overlap open, enter in the direction of the trailing 3-day overlap-segment drift.
WHEN session_window(12, 0, 12, 1) AND session_momentum(eur.candle, 12, 14, 3) > 0
THEN BUY eur SIZING 0.1
It is null until <nDays> in-window days have completed.
Anchored return (sub-bar, grid-anchored)¶
anchored_return measures close / bucket_open - 1, where bucket_open is the open of the first bar of the current <bucketMinutes> cell on the UTC grid; it resets at each bucket boundary. Bind it on a fine stream with a coarser bucket to read the forming coarse bar's intra-bar move — invisible to plain completed-bar indicators — so a rule can compare two symbols' beta-scaled intra-bar moves on the same grid.
-- Intra-30m lead: GBP's beta-scaled move outrunning EUR's, on 1m bars.
WHEN beta(gbp.close, eur.close, 96) * anchored_return(gbp.candle, 30) - anchored_return(eur.candle, 30) > 0.0005
THEN BUY eur SIZING 0.1
It is null until the first bar of a bucket is seen.
Reopen gap (session-boundary gap)¶
reopen_gap(<stream>.candle, <minGapHours>) -- signed gap across a trading break
reopen_gap_origin(<stream>.candle, <minGapHours>) -- the pre-break close a full fill returns to
gap_fill_fraction(<stream>.candle, <minGapHours>) -- retracement toward origin, in gap units
A "reopen" is the first bar whose start follows the previous bar's end by more than <minGapHours> — the market was closed in between (the weekend). At that bar reopen_gap latches the signed gap (reopen open minus the last pre-break close), reopen_gap_origin latches that pre-break close (a stop level), and gap_fill_fraction tracks how far price has since retraced toward origin — 0 at the reopen, 1 at a full fill. All three hold until the next reopen.
-- Large, unfilled weekend gap → trade the continuation, stop at the gap origin.
WHEN abs(reopen_gap(g.candle, 12)) > 2 * atr(g.candle, 14) AND gap_fill_fraction(g.candle, 12) < 0.5
AND reopen_gap(g.candle, 12) > 0 AND POSITION.g = 0
THEN BUY g SIZING 0.1
WHEN POSITION.g > 0 AND g.close < reopen_gap_origin(g.candle, 12) THEN CLOSE g
All three are null until the first reopen; gap_fill_fraction is also null on a zero-size gap.
Failed breakout (fakeout latch)¶
failed_break_high(<stream>.candle, <rangeLen>, <reclaimBars>, <armBars>)
failed_break_low(<stream>.candle, <rangeLen>, <reclaimBars>, <armBars>)
failed_break_high reads 1 for <armBars> bars after the high of the prior <rangeLen> bars is pierced and then a bar within <reclaimBars> closes back inside — a trapped-breakout fakeout that tends to precede a larger second expansion. A pierce that keeps closing outside is a real break and never arms. failed_break_low is the downside mirror.
-- Arm a straddle only after a failed first break, not on compression alone.
WHEN failed_break_high(gbp.candle, 20, 3, 6) > 0 AND POSITION.gbp = 0 THEN BUY gbp SIZING 0.1
WHEN failed_break_low(gbp.candle, 20, 3, 6) > 0 AND POSITION.gbp = 0 THEN SELL gbp SIZING 0.1
It is null until the range window fills.
Initial-balance prior defense¶
ib_defended_high(<stream>.candle, <sessionStartHour>, <ibMinutes>)
ib_defended_low(<stream>.candle, <sessionStartHour>, <ibMinutes>)
The Initial Balance (IB) is the high/low of the session's first <ibMinutes> from <sessionStartHour> UTC. ib_defended_high reads 1 once the IB high has been tested and held earlier this session — a bar traded through it but closed back inside — else 0, and resets daily. It's the per-session memory that separates an initiative late break from a naive opening-range breakout. Pair it with session_range_high/session_range_low for the level itself.
-- Late IB break that was defended earlier, confirmed dollar-wide by GBP.
WHEN eur.close > session_range_high(eur.candle, 8, 0, 9, 0) AND ib_defended_high(eur.candle, 8, 60) > 0
AND gbp.close > session_range_high(gbp.candle, 8, 0, 9, 0) AND POSITION.eur = 0
THEN BUY eur SIZING 0.1
It is null until the IB window has elapsed with an IB captured this session.
Percentile rank¶
percentile_rank returns the fraction of the trailing <lookback> window strictly below the current value, in [0, 1). It is distribution-free, so it separates a bimodal series where zscore cannot — on a realized-vol series that splits into a calm cluster and a hot cluster, the mean sits in the empty trough between them, but the rank still puts the calm bars below 0.5.
-- Trade the mean-reversion band only in the calm half of the realized-vol regime.
WHEN percentile_rank(stddev(xag.close, 30), 200) < 0.5
AND xag.close <= keltner_lower(xag, 20, 2.0)
THEN BUY xag SIZING 0.1
Warmup is <lookback> bars.
Skew¶
skew is the third standardized moment of the last <period> bar-to-bar returns — it measures whether the recent surprises are mostly up or mostly down. Negative skew is crash-prone (many small gains, occasional sharp drop); positive skew is lottery-like (many small losses, occasional sharp jump). Standard deviation only measures spread and cannot tell the two apart. Returns are simple (p - p_prev) / p_prev, and the moments use the population divisor, so it is the textbook g1 = mean((r - mean)^3) / sigma^3.
-- Single-name skew-premium gate: enter only when skew sits in its most-negative decile.
WHEN percentile_rank(skew(aud.close, 20), 250) < 0.1
AND percentile_rank(skew(nzd.close, 20), 250) < 0.1
THEN BUY aud SIZING 0.1
Warmup is <period> + 1 bars (one extra price is needed to form the first return). A flat window with no return dispersion reports 0.
Efficiency ratio¶
er is Kaufman's Efficiency Ratio: the net directional move over <period> bars divided by the total path length (the sum of every bar-to-bar step). A clean one-way trend covers ground efficiently so er is near 1; choppy back-and-forth travel covers little net distance per step so er is near 0. It separates trend from noise where dispersion cannot — two windows can share a standard deviation yet have opposite efficiency.
-- Take the momentum signal only in a clean, low-noise trend; stand down in chop.
WHEN er(gold.close, 10) > 0.6
AND ema(gold.close, 20) > ema(gold.close, 50)
THEN BUY gold SIZING 0.1
Warmup is <period> + 1 bars. A perfectly flat window reports 0.
Variance ratio¶
variance_ratio separates mean-reversion from trending on the series' own path. If returns were an unpredictable random walk, the variance of a <k>-bar move would be exactly k times the variance of a 1-bar move; the ratio of the actual k-bar variance to k times the 1-bar variance is therefore ~1 for a random walk, < 1 when the series mean-reverts (overshoots retrace, so k-bar moves under-diffuse), and > 1 when it trends (moves compound). It is computed on simple returns over the last <lookback> bars, using overlapping k-bar return sums and population variances.
-- Fade only while the series is statistically mean-reverting; stand down when it trends.
WHEN variance_ratio(aud.close, 5, 100) < 1 AND zscore(aud.close, 20) >= 2
THEN SELL aud SIZING 0.1
Like zscore, the series can be any expression referencing a stream — variance_ratio(gold.close / silver.close, 5, 120) gates a pairs spread on its own stationarity. Warmup is <lookback> + 1 bars; it returns null until then, and also when the 1-bar variance is zero (a flat window, where the ratio is undefined).
Lag (series offset)¶
lag reports the series exactly <n> bars in the past — the missing piece for any "skip the recent window" construction. A classic example is intermediate-horizon momentum that deliberately excludes the latest month: the durable trend is the sign of lag(close, 21) - lag(close, 252), a return that ends 21 bars back and starts 252 bars back, so the noisy most-recent month is left out.
-- Skip-month trend: trade the 12-month move that excludes the last ~month.
WHEN lag(gold.close, 21) - lag(gold.close, 252) > 0 AND POSITION.gold = 0
THEN BUY gold SIZING 0.1
The reported value is the buffered input verbatim, so it is exact. Warmup is <n> + 1 bars.
Math helpers¶
Available alongside indicators:
abs(<expr>) -- absolute value
sqrt(<expr>) -- square root
log(<expr>) -- natural log
exp(<expr>) -- e^x
pow(<base>, <exp>) -- exponentiation
floor(<expr>) -- round down to the nearest integer
ceil(<expr>) -- round up to the nearest integer
round(<expr>) -- round to the nearest integer (half to even)
mod(<a>, <b>) -- floored modulo; for a positive step, distance past the grid below
round_to(<x>, <step>) -- round x to the nearest multiple of step (a price grid)
mod and round_to are the round-number / big-figure primitives: mod(price, step) is how
far price sits past the nearest multiple of step below it, and round_to(price, step) is the
nearest grid level itself — e.g. round_to(2347, 25) is 2350. Fade an approach to a round
figure by gating on mod and anchoring a LIMIT at round_to.
max and min are two things: with two or more arguments they are the scalar functions
(the larger or smaller value — the upper wick of a bar is btc.high - max(btc.open, btc.close)),
and with one series and a SINCE window they are the windowed aggregates below.
rank_of, normalize and softmax score their first argument against the rest; see
Expressions → Math helpers.
LET upperWick = btc.high - max(btc.open, btc.close)
LET lowerWick = min(btc.open, btc.close) - btc.low
WHEN upperWick > 2 * lowerWick THEN LOG "rejection bar"
Annualized 20-bar realized volatility from log returns. Composes the helpers and a 20-bar sum aggregate.
Aggregates¶
Aggregates fold a series over a window: sum, mean, max or min of one series, followed by SINCE:
sum(<expr>) SINCE OPEN | T-<N>
mean(<expr>) SINCE OPEN | T-<N>
max(<expr>) SINCE OPEN | T-<N>
min(<expr>) SINCE OPEN | T-<N>
SINCE T-N is a rolling window over the last N closed bars (null until N bars exist). SINCE OPEN covers the bars since the position on that stream opened. A rolling window also has the two-argument shorthands sum(x, N), mean(x, N), avg(x, N) and count(<condition>, N), each exactly the SINCE T-N aggregate beside it; see Expressions → Aggregates for the full rules.
| You want | Write |
|---|---|
Rolling sum of x over 20 bars |
sum(x) SINCE T-20, or sum(x, 20) |
| Rolling mean over 20 bars | mean(x) SINCE T-20, avg(x, 20), or sma(x, 20) |
| Bars out of the last 20 where a condition held | count(<condition>, 20), or sum(CASE WHEN <condition> THEN 1 ELSE 0 END) SINCE T-20 |
| Share of the last 20 bars where it held | count(<condition>, 20) / 20 |
LET upBars = count(btc.close > btc.open, 20)
LET avgRange = avg(btc.high - btc.low, 20)
LET rangeSum = sum(btc.high - btc.low, 20)
WHEN upBars >= 15 AND btc.high - btc.low > 2 * avgRange AND rangeSum > 0 THEN LOG "wide bar in an up run"
LET upDays = sum(CASE WHEN btc.close > lag(btc.close, 1) THEN 1 ELSE 0 END) SINCE T-20
WHEN upDays >= 15 THEN LOG "trend confirmed: 15 of last 20 bars were up"
Warmup¶
Every indicator has a warmup period — bars needed before it produces a meaningful value.
| Indicator | Warmup |
|---|---|
sma(value, N) |
N bars |
ema(value, N) |
N bars (seeds with SMA of first N) |
wma(value, N) |
N bars |
dema(value, N) |
2N − 1 bars |
tema(value, N) |
3N − 2 bars |
hma(value, N) |
N + round(√N) − 1 bars |
rsi(value, N) |
N+1 bars |
atr(stream, N) |
N bars |
macd(value, F, S, sig) |
S + sig bars |
bollinger_*(value, N, k) |
N bars |
keltner_*(stream, N, k) |
N + 1 bars (the ATR needs a previous close) |
adx/plus_di/minus_di(stream, N) |
2N bars (Wilder smoothing of DX on top of DI) |
cci(stream, N) |
N bars |
williams_r(stream, N) |
N bars |
stoch_k/stoch_d(stream, K, D) |
K + D − 1 bars |
stddev/variance(value, N) |
N bars |
regression_slope(value, N) |
N bars |
obv(stream) |
1 bar (cumulative from the first candle) |
vwap(stream, N) |
N ticks |
highest/lowest(value, N) |
N + 1 bars (N prior bars plus the evaluating bar) |
zscore(series, N) |
N bars |
correlation(a, b, N) |
N bars |
beta(a, b, N) |
N bars |
percentile_rank(value, N) |
N bars |
skew(value, N) |
N + 1 bars (N returns need N+1 prices) |
er(value, N) |
N + 1 bars |
variance_ratio(value, k, N) |
N + 1 bars (and null when 1-bar variance is zero) |
lag(value, n) |
n + 1 bars |
confirm_ratio(signal, …, N) |
N+1 bars |
vwap_session(stream, h) |
resets daily at hour h |
session_range_*(stream, …) |
until the first window completes |
pivot_p/pivot_r1/pivot_s1(stream.candle) |
until the first UTC day completes |
seasonal_range(stream.candle, N) |
N bars of the current bar's UTC hour |
seasonal_range_stdev(stream.candle, N) |
N bars of the current bar's UTC hour (N > 1) |
runlength(value) |
1 bar (needs one prior value) |
session_momentum(stream.candle, sh, eh, N) |
until N in-window days complete |
anchored_return(stream.candle, bucketMinutes) |
1 bar (the first bar of a bucket) |
reopen_gap/reopen_gap_origin/gap_fill_fraction(stream.candle, h) |
until the first reopen |
failed_break_high/failed_break_low(stream.candle, rangeLen, …) |
rangeLen + 1 bars |
ib_defended_high/ib_defended_low(stream.candle, sh, ibMin) |
until the IB window elapses each session |
During warmup the indicator returns null. Comparisons with null are false — your rule won't fire, but it won't crash either.
The DSL compiler automatically infers warmup requirements from your indicator calls and tells the engine to discard pre-warmup signals. To declare a custom warmup window explicitly, use the per-stream WARMUP N BARS clause in SYMBOLS (see streams):
Useful when you want to ensure long-period indicators (200-bar SMA) are warm even on short backtest windows.
Composing indicators¶
Indicators return numbers; numbers compose freely. Common patterns:
Difference between indicators¶
Ratio¶
Multiply ATR for stops¶
BRACKET { STOP_LOSS AT btc.close - atr(btc, 14) * 2, TAKE_PROFIT AT btc.close + atr(btc, 14) * 4 } -- 2-ATR stop, 4-ATR target
Combine across streams¶
When the compiler complains¶
- "Unknown indicator" — typo, or you used a name not in the catalog. Check this page.
- "Indicator requires Stream, got Number" — passing
btc.closewherebtcwas expected (ATR, VWAP). Pass the stream, not the field. - "Indicator requires Number, got Stream" — opposite — passing
btcwherebtc.closewas expected. - "Period must be positive integer" — you wrote
ema(btc.close, -9)orema(btc.close, 0).
Common gotchas¶
atrandvwaptake a stream, notstream.close. They need OHLC / volume, not just close.- Periods are in bars, not ticks — except VWAP, which is ticks.
- Indicators are recomputed per bar. Long-period indicators are computationally cheap (linear in period); don't worry.
- No mutable state across rules. Each rule's indicator references compile to independent indicator objects (deduplicated where the period + value source match). State is internal — you can't read "the previous value" via
[N]on an indicator call (ema(...)[1]is not valid). Use aLETto capture the current value, then on the next bar yourLETevaluation is for the new bar. - VWAP needs volume. A tick feed with
volume=nullproduces a VWAP ofnull. Most CSV/MT5 feeds have volume; some Bybit endpoints don't.
What this composes with¶
- Conditions — every indicator can appear in
WHENclauses - Expressions — arithmetic on indicator values
- SIZING — ATR is the canonical risk-sizing input
- BRACKET — ATR-based stops and targets
- LET — name reusable indicator combinations