FAQ & troubleshooting
Each entry pairs a symptom with its cause and the corrected pattern. Find your error, read the cause, copy the fix.
"DSL program must return a Signal": your program ends on a scalar
The engine rejects the program with DSL program must return a {timestamp, value} Signal. The last expression evaluated to a plain number or a comparison with no accessor anywhere in its ancestry, so it is not a {timestamp, value} Signal.
A standalone signal program must end on a Signal-valued expression, which means the final expression references bar.*, option.*, time.*, or signal(...) somewhere in its ancestry. A bare literal like 42 or 20 * 252 returns a scalar and raises.
// WRONG: the last line is a plain scalar, not a Signal
20 * 252// RIGHT: the last line references an accessor, so it is a Signal
daily_close = bar.close(sampling=sampling(period="day", time="close"))
rolling_std(log_change(daily_close, 1), 20) * sqrt(252)Inside a backtest, match the slot instead: entry, exit, and adjustment triggers return a truthy Signal, and contract selection returns option.contract(...). See DSL grammar and syntax for the full return convention.
"Variable has already been assigned and cannot be reassigned"
Variables are immutable. Assigning the same name twice raises Variable '<name>' has already been assigned and cannot be reassigned. The check is static, so it fires even when the second assignment is in dead code that never reaches the result.
Pick a new name for the second value.
// WRONG: 'vol' is assigned twice
vol = rolling_std(log_change(bar.close(), 1), 20)
vol = rolling_std(log_change(bar.close(), 1), 10)
vol// RIGHT: distinct names
vol_20 = rolling_std(log_change(bar.close(), 1), 20)
vol_10 = rolling_std(log_change(bar.close(), 1), 10)
vol_20 / vol_10My rolling window is wrong: it counts minutes, not days
periods is an observation count at the series' effective sample rate, not a calendar length. option.iv(30) returns a 1-minute series, so rolling_rank(option.iv(30), 252) ranks over the last 252 minutes, not one year. This is the single most common correctness mistake.
Sample the series to daily first, then use 252.
On a 1-minute series, rolling_rank(x, 252) covers 252 minutes. To express "over one year," sample to daily so that 252 observations is roughly one trading year.
// WRONG: option.iv(30) is a 1-minute series, so 252 is 252 minutes
rolling_rank(option.iv(30), 252)// RIGHT: sample to daily so 252 observations is about one year
iv_daily = option.iv(30, sampling=sampling(period="day", time="close"))
rolling_rank(iv_daily, 252)For why windows are observation-count and how mixed-frequency series align, see the execution model and the data accessor reference.
A bare accessor returns nothing usable
Writing bar.close or option.iv without call parentheses resolves to the accessor method, not a Signal, and raises. A bare identifier that is not an assigned variable raises Unknown variable or unsupported bare reference: <name>; a bare function name raises '<name>' is a function; call it with parentheses.
Call the accessor, or assign it to a variable first.
// WRONG: no parentheses, no accessor call
bar.close// RIGHT: call it inline, or name it first
close = bar.close()
closeMy # comment did not work
# is not a comment token. Only // starts a comment, running to the end of the line. There is no block comment form; use consecutive // lines.
// This is a comment
// and so is this
iv_daily = option.iv(30, sampling=sampling(period="day", time="close"))
rolling_rank(iv_daily, 252)A short function name is not found (mean, std, rank)
The short names mean, std, and rank are not registered, and there is no length= window keyword. Use the full names rolling_mean, rolling_std, and rolling_rank, and pass the window as periods.
// WRONG: mean() is not a registered function
mean(bar.close(), 20)// RIGHT: full name, observation-count window
rolling_mean(bar.close(), 20)The full catalog of registered names is the DSL operator and function catalog.
I got null where I expected NaN (or the reverse)
null and NaN are distinct. null is missing data: no row exists, the timestamp is before the series' first observation, or an arithmetic step touched a null. NaN is an invalid computation: sqrt(-1), log(0), 0/0, and any result that overflows to inf is converted to NaN.
In any operation, NaN takes precedence over null: if either operand at a row is NaN, the output at that row is NaN. Missing data is never silently dropped, so a row is carried through as null or NaN rather than removed. To test for either, use is_missing; to substitute a value, use fill_missing or coalesce.
My comparison is not a boolean
Comparisons and logical operators return a Float64 Signal of 1.0 and 0.0, not a boolean column. There is no boolean type to combine.
Combine two masks by multiplying them: the product is 1.0 only where both are 1.0, which is a logical AND.
// AND two conditions by multiplying their Float64 masks
iv_daily = option.iv(30, sampling=sampling(period="day", time="close"))
high_iv = rolling_rank(iv_daily, 252) > 0.50
near_close = time.session_fraction(past=False) < 0.10
high_iv * near_closeSee the DSL operator and function catalog.
"sampling and bucketing are mutually exclusive"
Passing both sampling= and bucketing= on a single accessor call is rejected. Sampling selects a point-in-time row and bucketing aggregates a window, so the two cannot apply to the same call.
Use one. Sample when you want the value at a moment (daily close); bucket when you want a window aggregate (daily mean, weekly max).
// WRONG: both set on one call
option.iv(30, sampling=sampling(period="day", time="close"), bucketing=bucketing(period="day"))// RIGHT: pick one
option.iv(30, sampling=sampling(period="day", time="close"))See the data accessor reference.
asset_class raised on sample() or bucket()
asset_class appears in the sample() and bucket() signatures but is not usable. Passing it raises. Drop it.
// WRONG: asset_class is not usable
bucket(ratio, bucketing(period="day"), asset_class="equity")// RIGHT: omit it
ratio = bar.close() / bar.open()
bucket(ratio, bucketing(period="day"))See the DSL operator and function catalog.
An entry_* column failed at compile time
Referencing an entry_* column inside contract selection fails at compile time. Contract selection runs at the start of a candidate position, where leg() is a run-start candidate with no entry snapshot yet, so its entry columns do not exist.
entry_* columns are legal only on a live held leg inside an adjustment trigger, where the leg has already been opened and its entry snapshot is populated. Move the reference into the adjustment trigger, or select the contract from data that is available at selection time.
See the data accessor reference.
My accessor or column is all null in a backtest
An all-null series or column in a backtest has four distinct causes. Tell them apart by what you referenced and where:
- A declared-but-not-held leg slug. Referencing a
leg("...")slug that is not currently held yields an all-null row for that leg. Confirm the leg is part of the open position at that minute. - No contract within the selection thresholds. When no contract satisfies all of
dte_threshold,delta_threshold,moneyness_threshold, andstrike_thresholdat a given minute, every numeric column of that selection is null at that minute. Widen a threshold or loosen the target. - A greek is null exactly when the mid IV is null. The greeks derive from the same surface as IV, so a null greek at a minute means the underlying mid IV was null there.
- The surface is too sparse to interpolate. An option value is null when the surface cannot be interpolated at the requested
dteanddelta/moneyness. Values are never extrapolated beyond the available grid.
See the data accessor reference.
signal("slug") errors: not found, cycle, depth, or dependencies
signal(slug) imports a previously saved signal and inlines its value on the importing signal's spine. It fails in four ways:
- Not found. The slug does not match a saved signal (
Signal '<slug>' not found). Save the signal under that name first. - Cycle. A signal that imports itself, directly or through another signal that imports back, is a cycle and is rejected. Break the loop.
- Depth. Import recursion is capped at depth 10. A chain of imports deeper than that is rejected.
- Dependencies. A single import graph is capped at 50 distinct dependencies.
signal(slug) takes no version parameter. An import resolves against the saved signal's current stored form, so re-saving a signal changes what every importer sees. For import mechanics, see the data accessor reference.
The data I want is not readable
Several quantities are computed upstream but have no accessor, so they cannot be read from a signal or a backtest. These include dealer exposure (GEX and VEX), the implied forward, carry and borrow, the risk-free rate as a series, the earnings metrics (implied move, event vol, next earnings date), and the IV quality and provenance flags.
What is readable: bid, mid, and ask IV via option.iv; the ex-earnings IV variant via use_ex_earn_iv=True; the five greeks delta, gamma, theta, vega, and rho; the option.contract columns; and bar.* OHLCV (split- and dividend-adjusted by default). For the full accessor surface, see the data accessor reference.
Why fills or results don't match my broker
The engine models a specific, honest subset of execution. Differences from a broker statement come from these:
- Commissions are a flat per-contract charge. Results carry the fixed per-contract commission you configure, not your broker's exact schedule of exchange, regulatory, and financing fees. Set to zero, the result is gross.
- Fills are modeled, not routed. A fill books at mid, at the quoted bid/ask, or at the Backtest.ai fill model's calibrated estimate, plus a size-dependent market-impact term. There is no fill probability and no partial fill: a resting order that might sit unfilled in reality always fills here, in full.
- The equity curve marks at mid. Even when fills pay the spread, open positions are marked at mid for the equity curve, so unrealized value does not reflect the spread you would pay to close.
- Forced liquidations always cross the spread. When the risk layer liquidates a position to cover a deficit, it closes at bid/ask, not mid, regardless of the run's fill mode.
- No trading in the first or last 5 minutes. The first fill of a session is at 09:35, and the last trade is at 15:55 on a normal day (12:55 on a 13:00 half day). Orders outside that window do not execute.
Mid marks and the commission setting both shape how a result compares to a live account. Every run also carries a benchmark comparison, S&P 500 total return by default. For the full realism accounting see backtest assumptions and realism.