Docs·Delta DSL Script·Cookbook

Pitfalls

Common DeltaDSL mistakes and how to avoid them — repainting, NaN-aware logic, static vs dynamic colors, recursion, alert frequency, and runtime caps.

This page collects the failure modes that cost the most debugging time. Read them before writing your first non-trivial script.

1. Static color where you expected dynamic

Functions like plotLine, plotBand, plotFill, paneLine, paneBand, paneFill accept ONE color per call. If you pass an iff(condition, "#0ECB81", "#F6465D") expression, only the first bar's color is used; the rest of the line is rendered in that single color.

This is the most common "why doesn't my line change color?" question.

Delta DSL
// ❌ Compiles, but the line is monochrome (first-bar's color).
plotLine(close, color=iff(close > ma, "#0ECB81", "#F6465D"))

// ✅ Split into two masked series, plot each in a single color.
upPart = mask(close, close > ma)
dnPart = mask(close, close <= ma)
plotLine(upPart, color="#0ECB81", width=2)
plotLine(dnPart, color="#F6465D", width=2)

Per-bar color works on shape primitives (plotShape, plotMarker, plotChar), labels, persistent slots, drawing-tool shapes, and bgcolor. It does NOT work on connected-line primitives. See Color for the full list.

2. NaN poisoning

The first length bars of a moving average are NaN (no warmup data yet). Any operation involving NaN propagates NaN. A common mistake:

Delta DSL
ma = sma(close, 200)
trend = close > ma   // first 200 bars: NaN > NaN → NaN, not false

If you check trend[i] for the first 200 bars, it's NaN (not false). Calling iff(trend, ..., ...) mid-bar treats NaN as truthy in the engine — so your fallback "else" branch never fires for those bars.

Wrap with nz() if you want NaN to be treated as 0 (or any default):

Delta DSL
ma = sma(close, 200)
trend = nz(close > ma, false)     // explicit false on warmup bars

For numeric series:

Delta DSL
val = nz(complicatedExpression, 0)

Use fixnan(series) to forward-fill NaN with the previous valid value.

3. Repainting (and how to avoid it)

DeltaDSL evaluates the script on every bar update, including the still-forming current bar. If your indicator depends on the current bar's close to make a decision, it WILL change as ticks arrive — this is the engine working as designed.

The classic example is pivothigh(high, 5, 5). The function only confirms a pivot 5 bars after it occurs — but on the current (forming) bar, it's NaN and stays NaN until 5 future bars finish. If you alert on pivothigh != na, the alert fires on a 5-bars-old pivot. Most users prefer this lag — they want confirmed pivots, not provisional ones.

If you DO want intra-bar signals (alert as the condition becomes true mid-bar), set frequency="once_per_bar" on the alertcondition. The condition can flip back to false later in the bar, but you've already fired once. Use this frequency only when you've thought about the duplicate-event story.

If you want only-on-close behavior, set frequency="once_per_close" (the default) — the BE waits until the bar finishes before evaluating the condition.

4. if at script-eval time vs per-bar

if is a control flow construct, not a per-bar branch. It runs ONCE during script evaluation:

Delta DSL
@input enableMa = input.bool(true, "Show MA")
ma = sma(close, 50)

if enableMa
  plotLine(ma, color="#F0B90B")
end

This works because enableMa is a constant input — if chooses whether to register the plotLine at all. If you put a series condition inside if, the engine evaluates that series at the moment if runs, which is BEFORE the bar loop:

Delta DSL
// ❌ This doesn't paint candles when close > ma — it never paints.
//    `close > ma` resolves to a series; `if` treats series as falsy.
if close > ma
  plotLine(close, color="#0ECB81")
end

For per-bar branching, use iff(...) or mask(...):

Delta DSL
// ✅ Per-bar masked plot.
upClose = mask(close, close > ma)
plotLine(upClose, color="#0ECB81")

5. for loops are scalar-only

The for loop iterates a scalar counter, not a series. Inside the loop body, a sequential operation is performed once per loop iteration; the body may call series operations, but the LOOP COUNTER is not a series.

Delta DSL
// ❌ Doesn't loop over bars. Series indexing inside `for` may not behave the way you expect.
sum = 0
for i = 0 to bars - 1
  sum = sum + close[i]    // series indexing here works, but for tight loops
end                       // it's slower than calling sum(close, period).

// ✅ For "sum the last N bars" use the built-in.
total = sum(close, 50)

Use for for scalar arithmetic, configuration, building static data structures. Never use for for "loop over every bar" — that's what series operations exist for.

6. Custom function recursion limits

User-defined functions (fn … blocks) can recurse, but the runtime caps recursion at 64 levels (callDepthLimit). Recursive Fibonacci will blow this cap quickly.

Delta DSL
// ❌ Will hit recursion limit at n = 64.
fn fib(n)
  if n < 2
    return n
  end
  return fib(n - 1) + fib(n - 2)
end

// ✅ Iterative version.
fn fibIter(n)
  a = 0
  b = 1
  for _ = 0 to n - 1
    t = a + b
    a = b
    b = t
  end
  return a
end

Most indicators don't need recursion. If you find yourself recursing more than 5-10 levels, refactor to iteration.

7. The 5,000,000-iteration loop budget

The total iteration budget across ALL for loops in one evaluation is 5,000,000. A nested loop hits this fast:

Delta DSL
// ❌ 5000 × 5000 = 25,000,000 iterations — exceeds budget.
for i = 0 to 5000
  for j = 0 to 5000
    // ...
  end
end

Most legitimate indicators don't approach the budget. If yours does, you're probably duplicating work that built-in series operations would do in O(n) time.

8. The 50,000 draw-records cap

Each evaluation can emit at most 50,000 draw records. A draw record is one shape: one plotLine call (per bar with a value) is roughly N records where N is the bar count.

If you call plotShape on every bar of a 5000-bar window, that's 5000 records (assuming the predicate is true on every bar). On a 100k-bar window, that's 100k records — exceeds cap.

Workarounds:

  • Use predicates that are sparse (only fire on actual events).
  • Use persistent slot primitives (labelNew, lineNew) — these are slot-based and don't accumulate per-bar.
  • Use drawing-tool primitives (drawingHline, drawingTrendline) — these draw ONE shape regardless of bar count.

9. params are local UI prefs, not migration data

Inputs (@input parameters) are user-overridable values stored in localStorage. They are NOT a place for:

  • Migration flags ("if version_2, do X").
  • Internal compatibility hooks.
  • Persistent state across sessions for indicator logic.

Anything in @input shows up as a user-editable control. Internal constants belong as plain script-level variables.

10. Don't gate alertcondition behind if

The BE compiler scans the source for every alertcondition call statically — wrapping in an if does not remove the call from the manifest:

Delta DSL
// ❌ Still in the manifest, regardless of `userEnabled`.
if userEnabled
  alertcondition(crossUp, title="Cross")
end

Gate the condition expression inside the alertcondition call instead:

Delta DSL
// ✅
gatedCondition = userEnabled and crossUp
alertcondition(gatedCondition, title="Cross")

11. volume can be 0

On low-volume markets or during off-session bars, volume may be 0. Dividing by volume produces Infinity. Wrap suspect divisions:

Delta DSL
// ❌
ratio = trades / volume    // → Infinity when volume == 0

// ✅
safeVol = iff(volume == 0, 1, volume)
ratio = trades / safeVol

12. time is in seconds since epoch (engine X-axis units)

The time series carries UNIX seconds, not milliseconds. This matches the engine's world X axis — drawing primitives like lineNew, boxNew, labelNew, and plotVLine all consume seconds, so passing time[i] straight to them places the shape on the correct bar without any conversion.

The calendar helpers (year, month, dayofmonth, dayofweek, hour, minute, second, weekofyear) accept either unit and rescale internally — a value below 1e11 is treated as seconds and scaled up to ms before being handed to new Date(). So both of these work as expected:

Delta DSL
// ✅ — engine seconds, helpers auto-scale internally
isMonday  = dayofweek(time) == 1
isSession = hour(time) >= 9 and hour(time) <= 16

// ✅ — explicit ms literal (e.g. from a BE field), left alone
checkTime = month(1715000000000)

Do NOT manually multiply time by 1000 before passing to a calendar helper — the helper sees a ms-magnitude value and skips the rescale, but every other use of time (drawing primitives, valuewhen snapshots passed to lineNew, etc.) breaks because seconds-shaped slots expect seconds.

The BE side often sends timestamps as milliseconds in the alert pipeline. Treat BE-origin values as ms unless explicitly noted; treat time and anything derived from it (e.g. time[5], at(time, k), last_bar_time, first_bar_time, valuewhen(event, time)) as seconds.

13. Comparisons against na always return false

na == na is false. Always use the nz() or explicit != na check:

Delta DSL
// ❌ Doesn't work the way you think.
isPivot = pivothigh(high, 5, 5) == valuewhen(...)

// ✅ Filter NaN explicitly.
isPivot = pivothigh(high, 5, 5) != na

14. Persistent slots have stable names

labelNew("status", …) updates the slot named "status" every frame. If you write labelNew("status_" + tostring(bar_index), …) in a per-bar context, you create thousands of distinct slots — quickly exceeding the slot table cap.

Use a SHORT, fixed slot name. If you need multiple labels for different concepts, use different fixed names: "current_value", "signal_state", "last_pivot".

15. Drawing-tool shapes don't auto-clean

drawingHline("foo", value, …) keeps the line on the chart until the script is unloaded or the call is removed. If a script paints a drawingTrendline on the latest pivot every bar, you'll accumulate trendlines.

Either:

  • Use a fixed slot name (drawingHline("latest", value, …)) so each frame replaces the previous draw.
  • Use the persistent slot family (lineNew(...), labelNew(...)) — these are auto-cleaned when the script unloads.

16. The mask pattern is also for paneFill, paneBand

When you want a histogram with two-tone coloring or a band that switches color, the same mask pattern works in panes:

Delta DSL
@pane "below"

hist = macd_hist(close, 12, 26, 9)
posHist = mask(hist, hist >= 0)
negHist = mask(hist, hist <  0)

paneFill(posHist, base=0, color="rgba(14,203,129,0.5)")
paneFill(negHist, base=0, color="rgba(246,70,93,0.5)")

Don't try paneFill(hist, color=iff(hist>=0,"green","red")) — same pitfall as #1.

17. Different chart timeframes give different results

Backtesting on the daily chart vs the 1-hour chart produces different signal counts because indicators are bar-relative, not time-relative. A 14-period RSI on the 1h chart looks at 14 hours; on the daily chart it looks at 14 days.

If your strategy depends on a specific time window (e.g. "RSI over the last 24 hours"), parametrise the period to the timeframe — DeltaDSL doesn't auto-resolve timeframes today.

18. pivothigh / pivotlow lag by right bars

A pivothigh(high, 5, 5) confirms a pivot only 5 bars AFTER it occurred. The current and 4 most-recent bars always return NaN.

This is mathematically correct: a pivot is "the highest high in a window where 5 bars on each side are lower". You can't know the right side until 5 future bars have passed.

When wiring alerts on pivots, set frequency="once_per_close" and accept the 5-bar lag. Or use a smaller right (e.g. pivothigh(high, 5, 1) confirms after 1 bar) at the cost of more false pivots.

19. The script is unloaded during heavy chart actions

Switching symbols, switching exchanges, or hiding the chart unloads the script and re-loads it on the new chart. Persistent slots are reset; counters are reset; bar_index starts from 0 on the new symbol's first visible bar.

If your script needs cross-symbol persistence, that data must come from the BE, not from the script itself. The script is stateless across (symbol × exchange × timeframe) tuples.

20. Always test with multiple symbols

A script tuned to BTC's volatility may behave differently on a low-volume altcoin where volume == 0 for many bars or where close makes 50 % moves between bars. Quick sanity checks:

  • Try a high-volume major (BTC, ETH).
  • Try a low-volume small-cap (illiquid pair).
  • Try a stablecoin pair where price barely moves.
  • Try multiple timeframes (1m, 1h, 1d).

If your script works on all four, ship it. If it explodes on the small-cap, add nz() and iff(volume == 0, …) guards.

Next

  • DebugginglogInfo / logWarning / logError for confirming which of these pitfalls actually fired on your data.
  • Versioning — how to ship a fix once you've found a bug.
  • Recipes — pre-debugged patterns for common indicator types.