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:
- 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. - Opaque
strategy.params. Every strategy invented its own key/value shape; the JSON loader couldn't enforce or discover it. - 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_000is100000. 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— abacktest(...)value (positional).axes— non-empty array ofaxis(...)values.metric— string; one oftotal_return,cagr,sharpe,sortino,max_drawdown,win_rate. Default"sharpe".max_parallel— integer ≥ 0;0meanshardware_concurrency()at run time. Default0.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"requiresn_samples(positive integer). Optionalseed(non-negative integer) — when omitted, a stable hash of the axes is used so the same.qefile produces the same sample list across runs. Whenn_samples>= full grid size, the runner falls back to grid behavior.method = "coarse_to_fine"acceptslevels(2..4, default 2) andtop_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 sosma(close, fast)style window kwargs keep working.coarse_to_fineonly runs insidewalk_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 expressiona..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 ofsignal(...)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— abacktest(...)value (positional or kwarg). Must use a singlesignal(...)strategy;portfolio(...)insidewalk_forward(...)is planned.train_window,test_window,step_window— required duration strings. Suffixes:ns/us/ms/s/m/h/d/w/mo/y; calendarmo= 30d,y= 365d (windows align by bar index, so the slop is bounded).step_windowis named to avoid thestepkeyword used by range syntax.optimize— optionalsweep(...). When set, each train slice re-runs the sweep, picks the best params byoptimize.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— oneuniverse(...)value (positional or kwarg).factors— non-empty array offactor(...)values.horizons— array of positive integers, forward-return windows in bars. Default[1, 5, 20].quantiles— integer bucket count for the long-short. Default5. Must satisfy2 * quantiles ≤ |symbols|.rebalance— long-short holding period in bars. Default5.walk_forward— optionalwalk_forward_ic(...)(see below).output—output(report = "...")writesfactor_report.jsonto disk; omit it andqe_factorprints 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— eitheryahoo_template(...)(per-symbol Yahoo fetch) orfile(path)wherepathcontains%sas 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— ISOYYYY-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/periodmust 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 inforecasting.md.
Walkthrough — writing your first .qe¶
- Bootstrap a workspace: launch
qe_dashboard. On a fresh install it drops fiveexample_*.qestarters into<workspace>/backtests/, onesweeps/example_sweep_spy.qe, and apositions/example_positions.json. - Open one in F3 WKSP and Cmd+S — that forks
qe_runon the file, streams stdout into the bottom log pane, and F4 BCKT auto-refreshes off the resultingout/<name>.json. - Edit a constant — change
let fast = 10tolet fast = 20, Cmd+S, watch the equity curve and trade count change. - Compose a new signal:
Save → it runs. The let bindings keep the entry/exit expressions readable;
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"), )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:
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_strategyininclude/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.