Skip to content

qe — config language (.qe)

The format qe_run, qe_factor, and the dashboard expect for hand-authored backtest, sweep, walk-forward, and factor-research configs.

A .qe file evaluates to one value — a backtest(...), sweep(...), walk_forward(...), or research(...). The value's variant decides which runner consumes it; there is no imperative top-level, no side effects, no I/O at evaluation time.

Top-level values

Top-level value Runner Dashboard panel Reference
backtest(...) qe_run <file>.qe F4 BCKT This page → §backtest
sweep(...) qe_run <file>.qe (sweep mode) F4 BCKT sweep This page → §sweep
walk_forward(...) qe_run <file>.qe F4 BCKT walk-forward walk-forward.md
research(...) qe_factor <file>.qe F7 FCTR factor-research.md

The dashboard's F3 WKSP screen detects the variant on Cmd+S and dispatches to the matching CLI automatically. New to the project? Start with first-factor-tutorial.md for the WKSP → Cmd+S → F7 round trip.

Why a language

Three things the old JSON shape couldn't handle without becoming ugly:

  1. DSL inside strings. Strategy expressions like cross_above(sma(close, 10), sma(close, 50)) used to live inside JSON string literals — no syntax help, no error location, awkward to write.
  2. Opaque strategy.params. Every strategy invented its own key/value shape; the JSON loader couldn't enforce or discover it.
  3. Sweep boilerplate. Sweep specs were a JSON copy-paste of a base backtest plus dotted-path strings like "strategy.params.fast_window" edited blind.

In .qe, the signal expression IS the strategy, let-bindings remove the boilerplate, and the parser knows what's a number / string / range so type errors surface with a column number.

A first file

# example_ma_spy.qe — Cmd+S in F3 WKSP runs it.
let fast = 10
let slow = 50

backtest(
  data = yahoo("SPY", "1d", "2024-01-01", "2025-01-01"),
  strategy = signal(
    entry  = cross_above(sma(close, fast), sma(close, slow)),
    exit   = cross_below(sma(close, fast), sma(close, slow)),
    symbol = "SPY",
  ),
  execution = execution(
    capital        = 100_000,
    commission_bps = 5,
    slippage_bps   = 2,
    fill_model     = "next_open",
  ),
  output = output(results = "out/example_ma_spy.json"),
)

The file's last expression is what gets executed. Everything above that expression is let bindings that get inlined when referenced.

Grammar (EBNF)

file        = { let_binding } , expression ;
let_binding = "let" , ident , "=" , expression ;

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 ]
            | sum , ".." , sum , [ "step" , sum ] ;   (* range *)
cmp_op      = ">" | ">=" | "<" | "<=" | "==" | "!=" ;

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

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

call        = ident , "(" , [ arg , { "," , arg } , [ "," ] ] , ")" ;
arg         = expression | ident , "=" , expression ;   (* kwarg *)

array       = "[" , [ expression , { "," , expression } , [ "," ] ] , "]" ;

bool_lit    = "true" | "false" ;
string      = '"' , { any-char-except-quote } , '"' ;   (* no escapes yet *)
ident       = letter , { letter | digit | "_" } ;
number      = digit , { digit | "_" } ,
              [ "." , digit , { digit | "_" } ] ;
letter      = "A".."Z" | "a".."z" | "_" ;
digit       = "0".."9" ;
  • Whitespace and line comments (# ... to end-of-line) are skipped.
  • Underscores in numeric literals are visual grouping only: 100_000 is 100000. Trailing or leading underscores are rejected.
  • Strings have no escape sequences in v1 — \ is taken literally. Paths with \ need to use that backslash as-is or live on a POSIX-style path; double-quote inside a string is not representable yet.

Precedence (low → high)

or                       — left assoc
and                      — left assoc
not                      — prefix unary
==  !=  <  <=  >  >=     — non-assoc, single comparison only
a..b [step c]            — non-assoc, mutually exclusive with comparison
+  -                     — left assoc (binary)
*  /                     — left assoc
unary -                  — prefix
call / array / paren     — highest

Comparison and range are mutually exclusive at the same level — a..b < c is a parse error. Use parentheses if you really mean one of them: (a..b) < c is still meaningless (range isn't comparable), but it's at least an evaluator error rather than a parse one.

Two contexts: config vs signal

Every .qe value lives in one of two evaluation contexts:

Context Where it shows up What's allowed
config Top-level + every arg position except signal(entry=…) / signal(exit=…) Let bindings, kwarg calls, ranges, arrays, strings, arithmetic, the config builtins below
signal The entry / exit args of signal(...) Per-bar variables (close, open, high, low, volume, bar_index); signal-layer functions; arithmetic / comparison / logical ops; positional args only — no kwargs, no let, no arrays, no strings

The config evaluator slices each signal subtree out of the source AST, inlines let-bound scalars, then hands the result to the existing per-bar binder. A let bound to a signal sub-expression (let ma_fast = sma(close, 10)) is also inlined where referenced inside a signal context.

Config builtins

backtest(data, strategy, execution?, output?, date_range?)

Required: - data — one DataSpec (yahoo(...) / file(...)) or an array of DataSpecs (multi-asset). - strategy — a signal(...) value.

Optional: - execution — defaults to execution() (zero fees, NextOpen, 100k capital). - output — defaults to no writes. - date_range — slice the loaded data after fetch.

signal(entry, exit, symbol?, size?)

Required keyword args: - entry, exit — signal-context expressions returning boolean.

Optional: - symbol — target symbol; defaults to "X" (matches legacy single-symbol setups). - size — per-trade order quantity; default 1.0.

yahoo(symbol, resolution, start, end?, cache_dir?)

Fetches from the Yahoo Finance v7 endpoint. symbol and start are required; resolution defaults to "1d" (also accepts "1h", "1m", "1wk", "1mo"). All five can be positional or keyword.

file(path, format?)

Local CSV. format defaults to "yahoo" (the columnar layout yfinance writes).

execution(capital?, commission_bps?, slippage_bps?, fill_model?)

All keyword, all optional. Defaults: capital = 100_000, others zero, fill_model = "next_open" (also accepts "next_close").

output(results?, report?)

Both optional, both strings. Empty/missing means "don't write".

date_range(start?, end?)

ISO YYYY-MM-DD strings; either side can be empty for open-ended.

sweep(base, axes, metric?, max_parallel?, method?, ...)

  • base — a backtest(...) value (positional).
  • axes — non-empty array of axis(...) values.
  • metric — string; one of total_return, cagr, sharpe, sortino, max_drawdown, win_rate. Default "sharpe".
  • max_parallel — integer ≥ 0; 0 means hardware_concurrency() at run time. Default 0.
  • method — string; one of "grid" (default, full cartesian product), "random" (sample N configs), or "coarse_to_fine" (grid stage 1 → refine around top-K).

Axis paths for .qe sweeps target top-level let names — overrides shadow the matching let when each cell re-evaluates the file. See tests/fixtures/-adjacent example_sweep_spy.qe for the canonical shape.

Method-specific kwargs:

  • method = "random" requires n_samples (positive integer). Optional seed (non-negative integer) — when omitted, a stable hash of the axes is used so the same .qe file produces the same sample list across runs. When n_samples >= full grid size, the runner falls back to grid behavior.
  • method = "coarse_to_fine" accepts levels (2..4, default 2) and top_k (positive integer, default 3). Stage 1 runs the full coarse grid; stage 2 refines around the top-K winners by interpolating midpoints between each winner and its axis neighbors. Integer-only axes have midpoints snapped to integers so sma(close, fast) style window kwargs keep working. coarse_to_fine only runs inside walk_forward(... optimize = sweep(method = "coarse_to_fine")) — the F4 BCKT manual heatmap UI doesn't fit the two-stage auto-refine model and rejects the method.

axis(path, values | range)

  • path — dotted-path string, same shape the JSON sweep used ("strategy.fast", "execution.commission_bps").
  • Second arg is either values = [a, b, c] (array) or a range expression a..b [step c]. Ranges inclusively cover their endpoint when the step lands on it exactly.

portfolio(strategies, weights?, names?, rebalance?)

Combine N independent signals into one portfolio with fixed capital weights. Goes inside backtest(strategy = portfolio(...)).

  • strategies — non-empty array of signal(...) values.
  • weights — array of non-negative numbers summing to 1.0 ±1e-6. Default: equal weights.
  • names — array of non-empty display names; defaults to "strategy_0", "strategy_1", …
  • rebalance — string; v1 only accepts "never". Monthly / quarterly rebalancing is planned.
let trend = signal(
  entry = cross_above(sma(close, 10), sma(close, 50)),
  exit  = cross_below(sma(close, 10), sma(close, 50)),
  symbol = "SPY",
)
let rev = signal(
  entry = rsi(close, 14) < 30,
  exit  = rsi(close, 14) > 70,
  symbol = "SPY",
)

backtest(
  data     = yahoo("SPY", "1d", "2020-01-01", "2024-12-31"),
  strategy = portfolio(
    strategies = [trend, rev],
    weights    = [0.6, 0.4],
    names      = ["trend", "rev"],
  ),
  execution = execution(capital = 100_000),
  output    = output(results = "out/portfolio.json"),
)

See docs/multi-strategy.md for the semantics (why N parallel runs, why no cross-strategy netting, what attribution actually means) and the F4 BCKT ATTRIBUTION panel.

walk_forward(base, train_window, test_window, step_window, optimize?)

Rolling in-sample / out-of-sample validation. Top-level value (peer of backtest(...) / sweep(...)).

  • base — a backtest(...) value (positional or kwarg). Must use a single signal(...) strategy; portfolio(...) inside walk_forward(...) is planned.
  • train_window, test_window, step_window — required duration strings. Suffixes: ns/us/ms/s/m/h/d/w/ mo/y; calendar mo = 30d, y = 365d (windows align by bar index, so the slop is bounded). step_window is named to avoid the step keyword used by range syntax.
  • optimize — optional sweep(...). When set, each train slice re-runs the sweep, picks the best params by optimize.metric, and applies them to the test slice.
let fast = 10
let slow = 50
let base = backtest(
  data     = yahoo("SPY", "1d", "2018-01-01", "2024-12-31"),
  strategy = signal(
    entry  = cross_above(sma(close, fast), sma(close, slow)),
    exit   = cross_below(sma(close, fast), sma(close, slow)),
    symbol = "SPY",
  ),
  execution = execution(capital = 100_000),
  output    = output(results = "out/wf_spy.json"),
)

walk_forward(base,
  train_window = "365d",
  test_window  = "90d",
  step_window  = "90d",
  optimize     = sweep(base,
    axes   = [axis("fast", values = [5, 10, 15, 20]),
              axis("slow", values = [50, 100, 150, 200])],
    metric = "sharpe",
  ),
)

Partial tail windows (where the test slice would run past the last bar) are silently discarded so OOS metrics aren't distorted by an under-length last segment. See docs/walk-forward.md for IS-OOS gap reading and the F4 BCKT walk-forward view.

Factor research builtins

A second top-level value, research(...), drives the factor research path. It evaluates exactly like the other top-level values but is consumed by qe_factor (not qe_run) and emits factor_report.json instead of results.json. The F7 FCTR dashboard panel mtime-watches that file. See factor-research.md for the workflow and the report schema; this section is the grammar reference.

research(universe, factors, horizons?, quantiles?, rebalance?, walk_forward?, output?)

Cross-sectional ranking research over a universe of symbols.

  • universe — one universe(...) value (positional or kwarg).
  • factors — non-empty array of factor(...) values.
  • horizons — array of positive integers, forward-return windows in bars. Default [1, 5, 20].
  • quantiles — integer bucket count for the long-short. Default 5. Must satisfy 2 * quantiles ≤ |symbols|.
  • rebalance — long-short holding period in bars. Default 5.
  • walk_forward — optional walk_forward_ic(...) (see below).
  • outputoutput(report = "...") writes factor_report.json to disk; omit it and qe_factor prints the JSON to stdout.

Minimal example:

research(
  universe = universe(
    symbols = ["XLK", "XLF", "XLE", "XLV", "XLY",
               "XLP", "XLI", "XLB", "XLU"],
    data    = yahoo_template("1d", "2015-01-01", "2025-01-01"),
  ),
  factors = [
    factor("momentum_20", expr = (close / sma(close, 20)) - 1.0),
  ],
  output = output(report = "out/momentum_research.json"),
)

See factor-research.md § Quick start for the long-form walk-through and IC interpretation.

factor(name, expr)

A named factor expression. name is a positional string used as the report key + the F7 dropdown label; expr is a signal-context expression (same grammar / variables as signal(entry = ...)) that must reduce to a scalar per bar — booleans collapse to 0/1 and throw away rank, defeating the point.

factor("rsi_14_inv", expr = rsi(close, 14) * -1.0)
factor("close_minus_sma10", expr = close - sma(close, 10))

See factor-research.md § Authoring rules for the scalar-vs-boolean trap, NaN propagation, and let-binding tricks across factors.

universe(symbols, data)

Defines the cross-section qe_factor loads.

  • symbols — non-empty array of strings (e.g. SPDR sector tickers).
  • data — either yahoo_template(...) (per-symbol Yahoo fetch) or file(path) where path contains %s as the symbol placeholder (e.g. "data/csv/factor_%s.csv").

Symbols are loaded sequentially and aligned to the intersection of their trading days; mismatched coverage is silently truncated.

universe(
  symbols = ["XLK", "XLF", "XLE"],
  data    = yahoo_template("1d", "2015-01-01", "2025-01-01"),
)

yahoo_template(resolution, start, end?, cache_dir?)

A template DataSpec — same shape as yahoo(...) but with the symbol deferred. qe_factor substitutes each universe symbol in turn. Only valid inside universe(data = ...).

  • resolution"1d", "1h", "1m", "1wk", "1mo".
  • start — ISO YYYY-MM-DD.
  • end — optional ISO date; defaults to "today" at load time.
  • cache_dir — optional local cache directory override.

walk_forward_ic(window_bars, step_bars)

Opt into rolling per-window IC analysis. Goes inside research(walk_forward = ...). Both args are positional or kwarg.

  • window_bars — positive integer; rolling window length. On daily bars, 252 ≈ one year.
  • step_bars — positive integer; window stride. 21 ≈ monthly.

For each (factor, horizon), qe_factor runs ic_analysis once per window and emits a walk_forward block on every ic[] entry of the v3 factor_report.json. The F7 FCTR panel renders it as a rolling-IC line below the per-bar XS IC plot.

research(
  universe = universe(
    symbols = ["XLK", "XLF", "XLE", "XLV", "XLY"],
    data    = yahoo_template("1d", "2015-01-01", "2025-01-01"),
  ),
  factors      = [factor("rsi_14_inv", expr = rsi(close, 14) * -1.0)],
  horizons     = [5, 20, 60],
  walk_forward = walk_forward_ic(window_bars = 252, step_bars = 21),
  output       = output(report = "out/wf_report.json"),
)

See factor-research.md § Walk-forward IC for how to read regime breaks, signal decay, and the trend-slope badge.

Signal-layer functions

Authoritative reference: dsl-grammar.md. The list below is the subset most commonly used inside signal(...) and factor(...) — see dsl-grammar.md for per-call semantics, NaN propagation, and the min_history inference rules.

DSL functions, positional args only:

  • Stateful: sma(x, window), ema(x, window), rsi(x, period), cross_above(a, b), cross_below(a, b). window/period must be a positive integer literal (constant-folded — let-bound integers work because they're inlined at signal-extraction time).
  • Pure: abs(x), max(a, b), min(a, b).
  • Forecasting (predict_return, lag_return, rolling_vol, rolling_zscore) — documented in forecasting.md.

Walkthrough — writing your first .qe

  1. Bootstrap a workspace: launch qe_dashboard. On a fresh install it drops five example_*.qe starters into <workspace>/backtests/, one sweeps/example_sweep_spy.qe, and a positions/example_positions.json.
  2. Open one in F3 WKSP and Cmd+S — that forks qe_run on the file, streams stdout into the bottom log pane, and F4 BCKT auto-refreshes off the resulting out/<name>.json.
  3. Edit a constant — change let fast = 10 to let fast = 20, Cmd+S, watch the equity curve and trade count change.
  4. Compose a new signal:
    let rsi_overbought = rsi(close, 14) > 70
    let above_ma       = close > sma(close, 50)
    
    backtest(
      data     = yahoo("SPY", "1d", "2024-01-01", "2025-01-01"),
      strategy = signal(
        entry  = above_ma and rsi(close, 14) < 30,
        exit   = rsi_overbought,
        symbol = "SPY",
      ),
      execution = execution(capital = 50_000),
      output    = output(results = "out/spy_rsi.json"),
    )
    
    Save → it runs. The let bindings keep the entry/exit expressions readable; rsi(close, 14) evaluating twice is harmless because the per-bar evaluator updates each call site's RSI instance every bar regardless.

Errors

Every parse / type / lookup error carries a 1-based column number and (for file loads) the file path. The dashboard surfaces the same string in its banner + F3 WKSP log pane. Three representative shapes — every example below is exercised in tests/test_dsl_config_eval.cpp or tests/test_qe_loader.cpp so the format can't silently drift:

Missing required kwarg — typo or forgotten entry =:

file: backtests/example_ma_spy.qe: expression: column 14:
  signal: missing required keyword argument 'entry'

Wrong variable name in a signal expression — typo on close:

file: backtests/example_ma_spy.qe: expression: column 23:
  inside signal expression: unknown variable 'klose'

Unknown top-level function — typo on a builtin:

file: example.qe: expression: column 1:
  unknown function 'frobnicate'

Other common shapes you'll see in practice: unknown name '<x>' (unbound let lookup), yahoo: missing required argument 'start' (missing positional), unknown metric '<x>' (sweep metric = not in the allowed list), range step cannot be zero, and at least one axis (empty sweep(axes = [])). Format is always file: <path>: <node>: column <N>: <msg>.

What stays JSON

  • results.json — engine output, machine-generated.
  • ~/Library/Application Support/qe_dashboard/config.json — auto-managed dashboard prefs.
  • positions/*.json — broker / portfolio snapshots; these are input data, not user-authored config.

Reference

  • Lexer / parser / AST: include/qe/dsl/{lexer,parser,ast}.hpp + src/dsl/{lexer,parser}.cpp
  • Config evaluator: include/qe/dsl/config_eval.hpp + src/dsl/config_eval.cpp
  • Value types: include/qe/dsl/config_value.hpp
  • File loader: include/qe/io/qe_loader.hpp
  • Strategy entry: qe::strategy::make_expression_strategy in include/qe/strategy/factory.hpp
  • Tests: tests/test_dsl_lang.cpp, tests/test_dsl_config_eval.cpp, tests/test_qe_loader.cpp, tests/test_qe_run_smoke.cpp (the [qe] test case), tests/test_qe_sweep_runner.cpp.
  • Sweep runner: include/qe/sweep/qe_runner.hpp + src/sweep/qe_runner.cpp.