UI-first, the DSL second
Building a backtest is UI-first. You define legs, entry and exit conditions, and sizing in the interface, and the Signal language is an opt-in companion you reach for only when you want programmatic control. Signals are the one exception to that rule. A signal is authored in code, because there is no visual signal builder, and if you never want to write one you take a pre-built signal from the library and adjust it. Two paths, one vocabulary: whichever surface you use, a concept keeps the same name.
The interface does the work (for backtests)
The normal path for a backtest is the visual builder. You lay out the position's legs, set the conditions that open and close it, and pick how it sizes, all without writing a line of code. See designing the position for the full walkthrough.
Conditions are built from operands and operators. You pick an operand, a comparison operator, and a value:
- Operand: a signal, a value from a dataset, or a plain number.
- Operator: a comparison (
>,<,>=,=, and the rest). - Value: the number, level, or second operand you compare against.
Compound conditions group with AND and OR, and the grouping nests: (X OR Y) AND B reads exactly as written, with the parenthesized clause evaluated first. That covers entry logic, exit logic, and adjustment triggers built the same way across entry, exit, and timing.
Every formula field carries a visual counterpart, and the visual counterpart is the default. The DSL sits behind the same field as the opt-in alternative. Nothing about the visual path requires you to open the code, and nothing in the code is unreachable from the interface. The two are the same underlying condition expressed two ways.
Signals are the exception: you write them
There is no visual signal builder. A signal is a short program in the Signal language, and you author it as code.
This is deliberate, and it is the one place the UI-first rule does not hold. A signal is a derived timeseries, a realized-vol estimate, an IV rank, an IV-minus-RV spread, and expressing that logic as a form of dropdowns would cost more than it saves. So the signal editor is a code surface. If you want to build one, you write it and save it, covered in writing and saving a signal.
If you do not want to write code, you do not have to. That is what the library is for.
The no-code path for signals: the signal-library modal
Use a signal without writing one by taking a pre-built signal and adjusting it. Change a threshold, change a size, change a symbol. You start from something that already works instead of authoring from scratch.
The selection modal opens at every point where you select a signal: the Signal page for exploratory analysis, the model feature picker, and adding a signal to a backtest. It carries three sources:
- Your own signal library: the signals you have saved.
- The official Backtest.ai signal library: a curated set maintained by us.
- Community signals: browse what others have shared and clone one to start from.
Referencing a saved signal in code is done with signal("slug") from any program, which pulls in a signal you or someone else saved. Cloning re-saves a signal under a new name: a cloned community signal becomes a new row in your own library that you then edit.
What the Signal language is
The Signal language is a Signal expression language. A program is a set of newline-separated statements, each one a variable assignment or a bare expression, and the last expression is the result.
A standalone signal must end on a Signal. Ending on a scalar or a boolean raises an error, because a signal is a {timestamp, value} timeseries and a plain number is not one. In practice the final expression references a data accessor somewhere in its ancestry.
A few rules define the surface, and they carry across every DSL block in these docs:
- Values come from accessors. Call
bar.close(), never a bareclose. See data accessors. - Comments are
//only. There is no block-comment form. - Comparisons and logicals return Float64
1.0/0.0, never a boolean column. A condition that readsiv > 0.30produces a Signal of ones and zeros. - Full operator names. Write
rolling_mean, notmean. The operators and functions catalog is the authoritative list of callable names.
How each statement is evaluated, aligned, and made look-ahead-safe is the execution model.
When the DSL shows up elsewhere
Beyond signals, the DSL is a companion to the backtest builder, never a requirement. Every UI control that takes a condition or a contract rule has a DSL equivalent. Entry and exit conditions map to entry and exit signals. Cross-leg contract placement, a long put five strikes below the short put, maps to a contract selection expression.
The point of the mapping is that a concept keeps one name on both surfaces. The Days to expiration field in the builder is the dte argument in the DSL. Sizing you set with a control matches the sizing modes described in position sizing and capital. Move between the visual builder and the code and you meet no renamed concept.
A first taste of the Signal language
Here is a complete standalone signal. It smooths 30-DTE mid IV over the last 20 observations and ends on a Signal, so it is a valid program:
// Smoothed 30-DTE mid IV: mean of the last 20 observations
iv = option.iv(30)
rolling_mean(iv, 20)option.iv(30) evaluates on a 1-minute spine, so the 20 here is 20 minutes of observations, not 20 days. Rolling windows count observations on the series' current spine, not calendar time.
To smooth over 20 trading days, sample iv to daily first so each observation is one session:
// Smoothed 30-DTE mid IV over 20 trading days
iv = option.iv(30, sampling=sampling(period="day", time="close"))
rolling_mean(iv, 20)Now the 20 is 20 daily closes. Same operator, different spine, and the difference is the whole point of writing signals in code rather than guessing at a window count.