The Signal Language (DSL)Guide

The execution model

Most surprises in the Signal language come from four places: two Signals at different cadences align on a union spine, a daily value is not readable intraday, a rolling window counts observations rather than calendar time, and null is not NaN. This page walks each one. Everything here follows from a single guarantee, and the rest of the page is the proof of it: the value of a Signal at a given timestamp depends only on the expression, the symbol, that timestamp, and the source data. It never depends on the date range you requested or on how the run was chunked internally.

Updated Jul 20268 min read

The spine

Every accessor evaluates on a trading-minute spine: 09:31 through the market close, NYSE trading days only, America/New_York. A normal session is 390 one-minute bars. The first bar is 09:31 and covers the window 09:30:00 to 09:31:00; the close bar is 16:00, covering 15:59:00 to 16:00:00. An early-close day is 210 bars, and the last bar is 13:00. The close time is looked up per date from the NYSE calendar, so early closes are handled without any flag from you.

The spine is not trimmed at either end. A minute that has no source data is null, not dropped, so the row count for a session is fixed by the calendar, not by data availability. Weekends and NYSE holidays are absent entirely.

One point of confusion worth heading off: the 5-minute open and close offsets you may have seen elsewhere on the platform (a first fill at 09:35, a last trade at 15:55) belong to the backtest engine's trading session, not to the accessor spine. The accessor spine starts at 09:31 and ends at the close. For where those offsets come from, see entry, exit, and timing.

Union spine and backward as-of fill

When a binary operator combines two Signals at different cadences, the engine builds a shared spine first, then computes. It forms the sorted union of both operands' timestamp sets as the output spine, then backward as-of fills each operand independently: every union timestamp receives that operand's most recent value with timestamp ≤ t.

Take two Signals at different rates:

Union timestamp
A
A filled
B
B filled
A + B
9:31
10
10
100
100
110
9:32
11
11
100
111
9:33
11
200
200
211
9:34
13
13
200
213

A timestamp before an operand's first observation yields null for that operand, so the result there is null too. And if the matched row's value is itself null, the fill returns null; it does not skip back to find the previous non-null value. A scalar operand is different: it is applied element-wise with no join, broadcast across every row of the Signal.

The canonical case is an IV-minus-realized-vol spread, where a daily series meets a daily series. The same mechanic governs any cadence mismatch.

// Daily IV minus annualized 20-day realized vol, normalized
iv = option.iv(30, sampling=sampling(period="day", time="close"))
daily_close = bar.close(sampling=sampling(period="day", time="close"))
rv = rolling_std(log_change(daily_close, 1), 20) * sqrt(252)
(iv - rv) / rv

The consequence you actually feel: combine a daily series with a 15-minute series and the result fires at every 15-minute tick, each tick carrying the last-known daily value.

Point-in-time visibility (the look-ahead guarantee)

This is where a mixed-frequency expression earns the no-lookahead label. A daily or bucketed value becomes visible only at its session-close label, never intraday.

A bucket is emitted exactly once, when its label timestamp reaches the evaluation clock (the run's current position), not when it reaches max(data timestamp). An in-progress, future-labeled bucket is held as open operator state and never emitted early.

So bar.close(bucketing=bucketing(period="day", agg="last")) read at 14:00 today shows yesterday's 16:00 bucket as the latest value. Today's bucket appears at 16:00, when its label passes. A weekly bucket becomes visible only Friday after the close: Monday through Thursday you see the prior week's completed bucket.

// The daily bucket's value is invisible until today's 16:00 label
bar.close(bucketing=bucketing(period="day", agg="last"))

This is exactly what makes the mixed-frequency spread above look-ahead-free. When a daily value combines with an intraday value, the daily value cannot leak into an intraday tick before its session ends. Until today's close, the most recent daily value on the union spine is yesterday's.

Sampling vs bucketing

Both reduce cadence, and they are mutually exclusive on a single accessor call. Passing both is rejected with an error naming the exclusivity.

They work by different mechanics. Sampling is a point-in-time row selection: it keeps the exact spine value at the sampled time, performs no computation, and the output timestamp is that exact time. Bucketing is a window aggregate over first, last, max, min, mean, or sum (default last), and the output timestamp is the session close of the last trading day in the window.

The two meet at one identity. For period in {week, month, quarter} at any multiplier, sample(x, sampling(period=P, multiplier=N)) returns the same series as bucket(x, bucketing(period=P, multiplier=N, agg="last")). That equivalence is a schema in the metavariables x, P, and N, not a runnable program.

Sampling modes and defaults

Sampling resolves to one of three modes by which fields you set:

Mode
Trigger
Behavior
Periodic
period set, times absent
Resample at a regular cadence
Calendar
times set, period absent
Filter the spine to explicit times
Additive
both period and times set
Union of periodic and calendar (intraday period only)

The only default that fills itself in is period="day" without time, which implies time="close". There is no months to weeks=[1] to days=[1] cascade; it was removed. sampling(period="day", months=[3]) means every trading day in March at close, not the first Monday of March.

Within Periodic mode the sub-mode depends on period:

  • minute and hour align to a clock-modulo grid. On a 15-minute grid the first sample is 09:45, since 09:31 is not on the grid.
  • day samples every Nth calendar day at the configured time.
  • week, month, and quarter emit one sample per epoch-anchored group, at the session close of the last trading day in the group. time= is not permitted on these periods.

quarter is a valid period, and multipliers compose: period="quarter", multiplier=4 is annual.

Bucketing semantics

Bucketing is epoch-anchored on DAY_EPOCH = Monday 1969-12-29. A calendar date always maps to the same group regardless of how the request is sliced, so extending or shrinking the date range never changes a historical bucket's value.

Defaults are agg="last", multiplier=1, and partial=true. The partial flag is a source-data completeness check, and it is orthogonal to the clock-visibility rule above. A bucket is full when every trading day of its calendar window is present in the source data. partial=false drops any bucket where a trading day in the window is genuinely absent. A holiday is not a trading day, so its absence never triggers exclusion.

Two details that catch people. quarter is a valid bucket period; if a catalog signature omits it, that signature is stale. And last means the literal last row in the window, not the last non-null row.

Observation-count windows (the calendar footgun)

Warning

Rolling and lag periods count observations at the effective sample rate, not clock or calendar time. rolling_mean(bar.close(), 30) on the default 1-minute spine is 30 minutes. The same 30 under a daily sample is 30 trading days; under a weekly bucket, 30 weeks.

This is the single most common way a signal comes out wrong while looking right. Any window you think of as "N days" has to run on a series that is already daily.

// WRONG: option.iv(30) is a 1-minute series, so 252 is 252 MINUTES, not a year
rolling_rank(option.iv(30), 252)
// RIGHT: sample to daily first, then 252 observations ≈ one year
iv_daily = option.iv(30, sampling=sampling(period="day", time="close"))
rolling_rank(iv_daily, 252)

Sample or bucket to the cadence you mean, then count observations. The operators and functions page states the window unit for every rolling and lag operator.

No-lookahead

Every rolling and lag operator uses the current observation plus strictly past observations. A window never includes a future position. lag(x, 1) at index i reads index i-1, and the first periods rows of any lag, diff, or rolling output are null because there is not yet enough history.

There is one deliberate exception. The time.* forward functions (past=false) may look forward, because they read the deterministic public NYSE calendar, not market prices. Counting the trading days in the next 30 calendar days uses future dates that are already fixed and knowable, so no market information leaks. The no-lookahead rule is a promise about market data, and forward calendar math does not touch it.

null vs NaN

These are two distinct missing states, and conflating them will send you chasing the wrong bug.

null is missing data. There is no row at that timestamp, or the timestamp precedes a Signal's first observation, or an arithmetic operation touched a null operand. NaN is an invalid real-domain result: sqrt(-1), log(0), 0/0, inf - inf, and any division of a finite numerator by zero.

Propagation is fixed:

  • null + anything yields null.
  • NaN + anything yields NaN.
  • NaN takes precedence over null when both appear in one operation.
  • Any +inf or -inf result is silently converted to NaN.

Missing data is never silently dropped; it flows through as null. To detect or replace either state, reach for the missing-data operators is_missing, coalesce, and fill_missing on the operators and functions page.

Comparisons return Float64, not booleans

Every comparison operator (>, >=, <, <=, ==, !=) and every logical operator (&& / and, || / or, ! / not) returns a Float64 Signal of 1.0 and 0.0. There is no boolean column anywhere in the value model.

This keeps the {timestamp, value} contract uniform and lets a mask compose with arithmetic. Multiply two masks and you get a logical AND:

// Both conditions true → 1.0, else 0.0 (AND via multiply)
iv_daily = option.iv(30, sampling=sampling(period="day", time="close"))
(rolling_rank(iv_daily, 252) > 0.50) * (rolling_zscore(iv_daily, 20) > 0)

null and NaN propagate through comparisons the same way they propagate through arithmetic: a null operand gives null, a NaN operand gives NaN.

Incremental and batch evaluation are identical

Extend a saved signal to a later date and the values it already carried do not move. A fresh run over the whole range and an extended run produce byte-identical values on their overlap.

This holds because stateful operators carry raw input rows, never accumulated float partials, and re-run their kernels over the combined state + new data. The summation order of a reduction does not depend on how the run was chunked into batches. Same calendar grid, same as-of and aggregation over the same real rows, same evaluation clock, same output. Extending a signal never rewrites a value you have already seen. For the mechanics of regenerate and resume, see writing and saving a signal.

Extent and date-range semantics

end_date is inclusive of the full day. The filter is timestamp < midnight(end_date + 1 day), so a bare-date end covers that day's whole session.

The requested lower bound enters at one place only: the final merge step. Per-batch results are concatenated, sorted by timestamp, and then trimmed to the data extent. The merge layer never collapses rows: each operator emits a given timestamp at most once and the per-batch input regions are disjoint, so a repeated timestamp is not a case to reconcile, it is an operator-invariant violation. The merge detects it and raises rather than silently deduping. No per-operator logic ever reads the requested start, which is why the point-in-time guarantee holds: because a value depends only on the expression, the symbol, and the timestamp, two overlapping requested ranges agree exactly on their overlap.

Was this page helpful?