DSL grammar & syntax
A program in the Signal language is a sequence of newline-separated statements. Each statement is either an assignment (name = expr) or a bare expression. Variables are immutable: a name is assigned once and never reassigned. The last non-comment, non-blank expression is the program's result, and for a standalone signal it must evaluate to a Signal, a {timestamp, value} series. This page is the reference for that structure: statements, literals, comments, name resolution, the function-call rules, and the return contract every program satisfies.
Program structure
A program is a list of statements separated by newlines. A statement is one of two forms:
- Assignment:
name = expr. Binds the result ofexprtonamefor use in later statements. - Bare expression: an expression on its own line. Only the last one is the result (see the return contract).
Blank lines are allowed anywhere and have no effect. Only statements that transitively feed the final expression run. An assignment whose variable is never referenced by the result is dead and is skipped, so an unused accessor call issues no data query. This is dead-code elimination, applied before evaluation.
The canonical two-plus-line stub:
// 20-day annualized realized volatility
daily_close = bar.close(sampling=sampling(period="day", time="close"))
returns = log_change(daily_close, 1)
rolling_std(returns, 20) * sqrt(252)daily_close samples the minute bars to one observation per session close, returns takes the one-observation log change, and the final expression scales the 20-observation standard deviation by sqrt(252).
rolling_std(returns, 20) counts 20 observations on the current spine. Because daily_close is sampled to daily, that is 20 trading days; on a raw minute spine it would be 20 minutes. See the execution model for windowing and alignment.
The return contract
There is no return keyword. The last non-comment, non-blank expression is the program's result. An assignment counts as that last statement when nothing follows it, so ratio = a / b on the final line returns ratio.
For a standalone signal the result must evaluate to a Signal. That means the final expression references a data accessor (bar.*, option.*, time.*, or signal(...)) somewhere in its ancestry, so it carries a {timestamp, value} series rather than a plain number. Ending on a scalar or a comparison of two scalars is an error.
// Valid: the result is a Signal (bar.close in its ancestry)
close = bar.close(sampling=sampling(period="day", time="close"))
close / rolling_mean(close, 50)// Error: ends on a scalar literal
42// Error: ends on a scalar-only comparison, which is not a Signal
1 > 0Inside a backtest the result matches its slot instead: entry, exit, and adjustment triggers return a truthy Signal, and contract selection returns option.contract(...). See signals inside a backtest.
Variables and immutability
A variable is introduced by assignment and may be used in any later expression. A name is assigned exactly once per program. Reassigning it is an error, and the check is static: it runs over every statement, including dead code, before anything evaluates.
// Error: 'iv' is assigned twice
iv = option.iv(30)
iv = option.iv(60)
ivTo hold two related series, give each its own name:
// Two DTEs, two names, then the term-structure ratio
iv_30 = option.iv(30, sampling=sampling(period="day", time="close"))
iv_60 = option.iv(60, sampling=sampling(period="day", time="close"))
iv_30 / iv_60Using a variable after its single assignment is unrestricted. Only a second assignment to the same name raises.
Literals
0, 42, -73.14, 0.05, -1.51e3, 2.5e-4e is case-insensitive.true, false"short_put", "mid"Every number is a float at runtime, so 20 and 20.0 behave identically. Strings appear as argument values (iv_type="mid", type="put") and as saved-signal slugs passed to signal(...).
Comments
A comment starts with // and runs to the end of the line. It may sit on its own line or after a statement.
// Compute a 20-observation rolling mean of the daily close
close = bar.close(sampling=sampling(period="day", time="close"))
close / rolling_mean(close, 20) // close relative to its trailing meanThere is no block comment syntax. Use consecutive // lines for multi-line commentary.
# is not a comment token. A line that begins with # fails to parse. Use //.
Scalars vs Signals
A scalar is a plain number: 100, 0.5, true folds to 1.0. A Signal is a {timestamp, value} series. Accessor results are Signals; every other value stays a scalar until it combines with a Signal.
An operation involving at least one Signal returns a Signal. An operation between two scalars returns a scalar. So a program that ends on a bare scalar, or on a comparison of two scalars, is an error, because the result is not a Signal.
To lift a scalar onto a spine, combine it with an accessor result. The scalar is applied element-wise across every timestamp of the Signal:
// Lift the scalar 0.20 onto the IV spine, then compare
iv = option.iv(30, sampling=sampling(period="day", time="close"))
iv - 0.20Function calls and named arguments
A call passes positional arguments first, then named arguments. Once a named argument appears, every later argument must also be named.
// Positional dte, then named-only from there
option.iv(30, type="put", delta=0.30)Named arguments may be given in any order among themselves. A * in a signature marks the parameters after it as keyword-only, so they can only be passed by name. In the operator and accessor signatures, dte is the one positional argument on option.* methods; everything after the * is keyword-only.
Member access uses dot notation and requires call parentheses. bar.close() is a call; bar.close without parentheses is not (see name resolution and bare accessors). Projecting a single column off a Table also uses dot access, on the result of the call:
// Select a 30-DTE 30-delta call, then project its per-minute mid premium
c = option.contract(dte=30, delta=0.30)
c.midFor the full parameter list of every accessor and operator, see the DSL operator & function catalog and the operators and functions guide.
Name resolution and bare accessors
A bare name resolves in this priority order:
- User variables: names bound by an earlier assignment in the same program.
- Built-in functions and operators:
sampling,bucketing, and the operator catalog (rolling_mean,log_change,where, and the rest). - Accessors:
bar,option,time.
A name that matches none of these raises. Use full operator names (rolling_mean, rolling_std, rolling_rank, rolling_zscore), not short forms.
An accessor method written without call parentheses does not produce a Signal. It refers to the function itself, and using it as a value raises:
// Error: 'bar.close' is a function; call it with parentheses
returns = log_change(bar.close, 1)
returnsWrite bar.close(), not bar.close. A bare accessor path (bar.close, option.iv) raises 'bar.close' is a function; call it with parentheses. Call it, or assign the call to a variable first. See the data accessors guide and the data accessor reference.
Comparison and boolean results
Comparisons (>, >=, <, <=, ==, !=) and logicals (&& / and, || / or) return a Float64 1.0 for true and 0.0 for false, never a boolean column. The result is a Signal, so it composes with arithmetic directly: a comparison result is a mask that multiplies cleanly to zero out the rows you do not want.
// Zero out IV wherever it sits below 20%, keep it elsewhere
iv = option.iv(30, sampling=sampling(period="day", time="close"))
mask = iv > 0.20 // Float64 1.0 / 0.0 Signal
mask * ivCombine two masks by multiplication for AND and by addition (or ||) for OR:
// 1.0 only when IV is inside the 20%-30% band
iv = option.iv(30, sampling=sampling(period="day", time="close"))
above = iv > 0.20 // Float64 1.0 / 0.0
below = iv < 0.30 // Float64 1.0 / 0.0
above * belownull and NaN propagate through comparisons: a comparison against a missing operand yields a missing result, and NaN takes precedence over null at any row where both are present. null marks missing data; NaN marks an invalid computation. See the execution model for how both propagate through alignment.