Skip to content

qe-dsl — expression DSL grammar (v1)

This is the signal-layer DSL — the per-bar expression language that powers signal(entry = ..., exit = ...) inside a .qe file and the expr = ... argument of factor(...) in research configs.

For the config-layer language wrapped around these expressions (let-bindings, kwarg calls, backtest / sweep / walk_forward / research, ranges, arrays), see qe-language.md. The two layers share a parser but evaluate in distinct contexts; qe-language.md § "Two contexts: config vs signal" spells out where each one applies.

The language is pure expression — no statements, no control flow, no variable binding. Each entry / exit field in a signal(...) value is one expression; the engine evaluates it once per bar.

EBNF

expression  = or_expr ;

or_expr     = and_expr , { "or" , and_expr } ;
and_expr    = not_expr , { "and" , not_expr } ;
not_expr    = [ "not" ] , compare ;

compare     = sum , [ cmp_op , sum ] ;
cmp_op      = ">" | ">=" | "<" | "<=" | "==" | "!=" ;

sum         = product , { ( "+" | "-" ) , product } ;
product     = unary   , { ( "*" | "/" ) , unary } ;

unary       = [ "-" ] , primary ;

primary     = number
            | bool_lit
            | ident
            | call
            | "(" , expression , ")"
            ;

call        = ident , "(" , [ arg_list ] , ")" ;
arg_list    = expression , { "," , expression } ;

bool_lit    = "true" | "false" ;
ident       = letter , { letter | digit | "_" } ;
number      = digit , { digit } , [ "." , digit , { digit } ] ;
letter      = "A".."Z" | "a".."z" | "_" ;
digit       = "0".."9" ;

Whitespace (space, tab, newline) and line comments (# ... to end-of-line) are skipped by the lexer.

Precedence (low → high)

or                       — left assoc
and                      — left assoc
not                      — prefix unary
==  !=  <  <=  >  >=     — non-assoc, single comparison only
+  -                     — left assoc (binary)
*  /                     — left assoc
unary -                  — prefix
function call, ( ... )   — highest

Comparison is non-associative by design: a < b < c is a syntax error, force the user to write a < b and b < c.

Types

Every value is a double. Booleans are represented as 0.0 / 1.0; comparisons and and / or / not produce 0.0 or 1.0. if / ternary do not exist in v1 — combine with and / or or use max(a, b) / min(a, b) to express conditional numerics.

Variables (per-bar bindings)

name meaning
close bar i close
open bar i open
high bar i high
low bar i low
volume bar i volume
bar_index integer position of bar in series

All numeric. bar_index is double for uniformity but is exact for indices < 2^53.

Built-in functions

Stateful (per-call-site state slot allocated at parse time, reset on strategy start):

call semantics
sma(x, n) qe::indicators::Sma(n) fed x per bar — see sma.hpp
ema(x, n) qe::indicators::Ema(n) fed x per bar — SMA-seeded warmup
rsi(x, n) qe::indicators::Rsi(n) fed x per bar — Wilder smoothing
cross_above(a, b) true iff a strictly crossed above b this bar
cross_below(a, b) true iff a strictly crossed below b this bar

Pure (stateless):

call semantics
abs(x) std::fabs(x)
max(a, b) std::fmax(a, b)
min(a, b) std::fmin(a, b)

The second arg to sma / ema / rsi must be a constant integer literal — windows are baked at parse time so the underlying indicator can be constructed once. Inside a .qe file, however, a top-level let fast = 10 works: the config evaluator inlines integer let bindings into signal subtrees before this layer sees them. So sma(close, fast) is a parse error in raw signal-DSL but parses fine inside the signal(...) arg of qe-language.md. See qe-language.md § "Two contexts".

Forecasting builtins (predict_return, lag_return, rolling_vol, rolling_zscore) and feature primitives live in forecasting.md — same per-bar evaluator, but documented alongside the rolling-ridge math they exist for.

cross_above / cross_below keep one bar of history per call site (the prior a - b sign).

NaN handling

Indicators emit NaN until their window has filled. Any NaN propagates through arithmetic and comparisons (per IEEE 754 — NaN > x is false, NaN != NaN is true). The strategy state machine treats NaN entry / exit signals as false (no trade).

min_history inference

qe::dsl::min_history(ast, env) walks the AST and returns the maximum warmup that any reachable indicator call requires. Nested calls accumulate: ema(rsi(close, 14), 21) requires 14 + 21 = 35 bars because the inner RSI emits its first non-NaN at bar 14 and the outer EMA needs 21 of those.

Errors

The lexer and parser surface errors as qe::Result<T> carrying qe::ErrorCode::InvalidConfig with a message that includes a 1-based column number, e.g. expression: column 14: expected ')'.

Examples

# Classic SMA crossover
entry: "cross_above(sma(close, 20), sma(close, 50))"
exit:  "cross_below(sma(close, 20), sma(close, 50))"

# Trend + momentum
entry: "sma(close, 50) > sma(close, 200) and rsi(close, 14) < 30"
exit:  "rsi(close, 14) > 70"

# Volatility breakout (close above 20-day high mean + 2× range)
entry: "close > sma(high, 20) + 2 * (sma(high, 20) - sma(low, 20))"
exit:  "close < sma(close, 10)"

Out of scope (v2 candidates)

  • Historical indexing: close[1], close[i]
  • Let-bindings: let s = ema(close, 12); s > sma(close, 50)
  • User-defined functions
  • Short / pyramiding / stop / take-profit signals (state machine stays flat / long only in v1)
  • Cross-symbol expressions for pairs trading
  • String literals (none of the v1 builtins need them)