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.
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:
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) / rvThe 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:
period set, times absenttimes set, period absentperiod and times setperiod 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:
minuteandhouralign to a clock-modulo grid. On a 15-minute grid the first sample is 09:45, since 09:31 is not on the grid.daysamples every Nth calendar day at the configuredtime.week,month, andquarteremit 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)
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 + anythingyieldsnull.NaN + anythingyieldsNaN.NaNtakes precedence overnullwhen both appear in one operation.- Any
+infor-infresult is silently converted toNaN.
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.