Docs·Delta DSL Script·Language

Control flow

Branch with if/elseif/else, iterate with for/to/step, factor logic with fn/return. Plus the difference between script-eval-time control flow and per-bar branching helpers.

Delta DSL has three block constructs — if, for, fn — and an early-return signal return. They run at script-eval time, exactly once per chart redraw, against the bulk-vector environment. They are NOT per-bar loops. For per-bar branching (the most common case in indicator code), reach for iff(cond, a, b) and mask(series, cond) instead — covered at the bottom of this page.

The two layers of control flow

Every Delta DSL script runs at two layers:

LayerExamplesWhen the choice happens
Script-eval-timeif length > 0 ... end, for j = 0 to 10 ..., fn helper(x) = …Once per chart redraw, at the script's outer scope. Picks which calculations to run.
Per-bariff(close > ma, upColor, dnColor), mask(close, breakout), ternary cond ? a : b over seriesInside vectorised math. Picks a value per bar.

Mixing the two is fine. A typical script does most of its math vectorised, occasionally drops into a for loop to emit per-event drawings (one box per pivot, one label per crossover), and uses iff / mask to colour the per-bar plots.

if / elseif / else / end

Delta DSL
if length > 200
  src := close
  warn := "Length truncated"
elseif length < 2
  src := close
  warn := "Length too short"
else
  src := input.source(srcParam)
  warn := ""
end

Rules:

  • The if keyword starts a new block. The condition expression spans to end-of-line.
  • Each elseif adds another branch. There can be any number.
  • else is optional and must come last.
  • end closes the block. The block is REQUIRED — there is no single-line if.
  • Branches are walked in order; the first truthy condition wins.
  • The body of each branch is one or more statements. Variables declared inside a branch leak out into the surrounding scope (no implicit block scope).

Truthiness

The condition is coerced to a scalar boolean by these rules:

ValueTruthy when
booleantrue
numberfinite and non-zero
stringnon-empty
seriesthe array exists and is non-empty (which is always true for normal OHLCV)
null, undefined, NaN, 0, ""always falsy

A series in if is whole-vector truthiness, not per-bar. if close > ma … end therefore always picks the then branch (a non-empty boolean series is truthy). Almost always, what you actually wanted was:

Delta DSL
// Use iff for per-bar gating:
clr = iff(close > ma, upColor, dnColor)

When to use if blocks

Most indicator code does NOT need an if block. The two patterns that do:

  1. Parameter validation — branch on a scalar input value.
    Delta DSL
    @input mode = input.string("standard", "Mode", options="standard,smoothed,exponential")
    
    if mode == "standard"
      ma := sma(close, length)
    elseif mode == "smoothed"
      ma := wma(close, length)
    else
      ma := ema(close, length)
    end
    
    plotLine(ma, color="#F0B90B", width=2)
    
  2. Conditional drawing — branch on a scalar derived from the latest bar.
    Delta DSL
    lastClose = close[0]      // scalar at right edge
    isBull    = lastClose >= sma(close, 50)[0]
    
    if isBull
      plotBgColor(close > 0, color="rgba(14, 203, 129, 0.04)")
    else
      plotBgColor(close > 0, color="rgba(246, 70, 93, 0.04)")
    end
    

For everything per-bar, see iff / mask / ternary below.

for / to / step / end

Delta DSL
// Walk the last 50 bars and draw a label at every pivot high.
ph = pivothigh(high, 5, 5)

for i = bars - 50 to bars - 1
  v = at(ph, i)
  if v == v               // not NaN
    labelNew("ph_" + tostring(i), at(time, i), v, "PH", anchor="above", color="#F0B90B")
  end
end

Rules:

  • for <ident> = <from> to <to> is the only loop form.
  • Optional step <expr> — defaults to +1 (or -1 when from > to). Step is captured ONCE at loop entry; mutating it inside the body has no effect.
  • end is required.
  • from, to, and step must be finite scalars. Series-valued bounds raise a runtime error.
  • Inclusive on both ends: for i = 0 to 9 runs 10 times with i ∈ {0, 1, …, 9}.
  • The counter is bound in a child scope. Assignments inside the loop to OTHER variables leak out (they're declared in the parent scope), but the counter itself does not — it's freed at end-of-loop.
  • Empty ranges (e.g. for i = 5 to 1) skip the body entirely.

Iteration limits

The runtime caps loops to keep a misbehaving script from freezing the page:

CapLimitBehaviour on overflow
Whole-script loop budget5 000 000 iterations across all for blocksRuntimeError, script halts
Single for ceiling1 000 000 iterationsRuntimeError, script halts

For typical indicator workloads (hundreds to a few thousand iterations) these caps never fire. They protect you from for i = 0 to 1e9 typos.

Writing series accumulators inside for

The most common for pattern in Delta DSL is building a series across iterations — the loop counts iterations and the body uses series ops to extend an accumulator. Example: a manual rolling sum across 20 bars (you'd normally use sum(src, 20) for this, but the pattern generalises to anything):

Delta DSL
acc = nz(close, 0)            // start accumulator as a series of zeros
for j = 1 to 19
  acc := acc + shift(close, j)
end
sma20 = acc / 20

plotLine(sma20, color="#F0B90B")

Each iteration of j adds one shifted copy of close to acc. The loop runs 19 times, but the accumulator is built across all bars at once — there is no per-bar work.

at(series, i) for per-event drawing

When the loop iterates over bar indices (not over j shift offsets), use at(series, i) to extract the scalar value of a series at absolute index i. This is different from series[k], which is a HISTORY shift (relative to "now"):

Delta DSL
// Walk every bar; emit a vertical line at each crossover.
isUp = crossover(close, sma(close, 50))

for i = 0 to bars - 1
  if at(isUp, i)
    lineNew("xup_" + tostring(i),
            at(time, i), low[0],
            at(time, i), high[0],
            color="#0ECB81", width=1)
  end
end

at(isUp, i) returns the boolean at bar i. at(time, i) returns the timestamp at bar i. The slot name ("xup_" + tostring(i)) keeps each line distinct so they all render side-by-side.

See Drawing — persistent slots for slot rules.

fn / return — user-defined functions

Two body shapes are accepted: inline (one expression after =) and block (newline after =, statements, end).

Inline form

Delta DSL
fn typical(h, l, c) = (h + l + c) / 3
fn weight(t)        = pow(2, -t / 14)

Inline-form fns are pure expressions. They:

  • Take any number of named parameters.
  • Return the value of the right-hand side automatically.
  • Are evaluated in their defining scope — they can read close, bars, any variable in the surrounding script.

Block form

Delta DSL
fn ratio(num, denom)
  if denom == 0
    return na
  end
  guard = denom != 0
  return num / denom
end

Block-form fns:

  • Use a newline after = (or no = after the ) — both shapes work).
  • Run any number of statements.
  • Terminate with an explicit return <expr> (or implicit return na if the body falls off the end without one).
  • Close with end.

Calling user fns

Delta DSL
fn band(src, mult) = ema(src, 20) + mult * stdev(src, 20)

upper = band(close, 2)
lower = band(close, -2)

plotLine(upper, color="#F0B90B")
plotLine(lower, color="#F0B90B")

User fns shadow built-ins. fn sma(s, p) = ema(s, p) makes every later sma(...) call use your version inside this script. (The shadow is local to the script — other scripts on the same chart still see the original sma.)

Recursion

Recursion works but is capped at 64 frames:

Delta DSL
fn fib(n)
  if n < 2
    return n
  end
  return fib(n - 1) + fib(n - 2)
end

f10 = fib(10)            // fine
f64 = fib(64)            // RuntimeError — depth limit

Tail-call optimisation does NOT happen. For deep iteration, switch to a for loop.

Per-bar branching: iff, mask, ternary

Most "if I'm above the MA do X else do Y" logic is per-bar — you want a value per bar of the visible window, not a one-shot script-eval-time decision. Use these instead of if blocks:

iff(cond, a, b)

Vectorised if-then-else. cond is a per-bar boolean series; a and b may be scalars or series. Returns a series the same shape as cond.

Delta DSL
filledMa = iff(close >= ma, ma, na)         // ma where above, NaN below
plotLine(filledMa, color=upColor, width=2)  // line draws only where above

When all three arguments are scalars, iff returns a scalar. When any one is a series, the result is a series.

Important. plotLine's color= argument is a single static string — passing a series of colour strings (e.g. iff(cond, "#0ECB81", "#F6465D")) falls back to the default colour. To paint two-tone lines, split the source into mask(...)-ed series and emit one plotLine per colour (next section).

mask(series, cond)

Pass-through where cond is true; NaN otherwise. Pair with plotLine to draw segmented lines that disappear in the "off" regions:

Delta DSL
bullMa = mask(ma, close >= ma)
bearMa = mask(ma, close < ma)

plotLine(bullMa, color=upColor, width=2)
plotLine(bearMa, color=dnColor, width=2)

The two plotLine calls together render the MA in two colours — upColor where price is above, dnColor where price is below. The mask pattern is the canonical way to paint multi-tone lines, since plotLine only honours a single static color.

Ternary cond ? a : b

Delta DSL
side = close >= ma ? 1 : -1

Identical semantics to iff(cond, a, b). Use whichever reads better. Nested ternaries chain right-to-left (a ? b : c ? d : e is a ? b : (c ? d : e)), but readability degrades fast — past one level of nesting, switch to iff.

holdSign(up, down, init=0)

A common shape that's neither iff nor mask but worth knowing: a sticky +1 / -1 trend tracker that flips on up[i], flips back on down[i], and carries forward in between.

Delta DSL
trend = holdSign(crossover(close, ma), crossunder(close, ma), 0)

bullMa = mask(ma, trend == 1)
bearMa = mask(ma, trend == -1)

plotLine(bullMa, color=upColor, width=2)
plotLine(bearMa, color=dnColor, width=2)

Without holdSign, you'd write a for loop that walks every bar and toggles a flag.

Scope rules

  • = declares a binding in the current scope.
  • := (the walrus) walks the scope chain and updates the first matching ancestor binding, falling back to the current scope if none exists. Use it inside for / if / fn blocks when you want to mutate a variable declared in the surrounding scope.
  • Variables declared in a for body's parent scope (via =) leak out to the script's top-level — there is no let / const block scope.
  • Variables declared inside a fn body live in a child of the fn's defining scope (closure). They do NOT leak into the caller.
Delta DSL
// Outer-scope mutation across a loop:
acc = 0
for i = 0 to 9
  acc := acc + i        // walrus — mutates the outer `acc`
end
// acc == 45 here
Delta DSL
// Closure: fn captures the defining scope.
multiplier = 3
fn scale(x) = x * multiplier

result = scale(10)        // 30
multiplier = 5            // doesn't affect already-captured value
result2 = scale(10)       // 50 — closure reads `multiplier` at call time

The second example shows that closures capture the SCOPE, not the value at definition time. Reassigning multiplier after fn scale(x) = x * multiplier changes what subsequent scale(...) calls see.

Anti-patterns

These compile but they're almost always wrong:

  • if series_expr ... end — the condition is a series, the runtime falls back to whole-vector truthiness, and the then branch always wins. Use iff(series_expr, a, b).
  • A for loop that walks every bar to compute a value that the standard library already provides. Use sma, ema, highest, etc. — those run in C-level vector loops, not script-eval time.
  • Recursion deeper than ~30 frames. The 64-frame cap is hard. For deep iteration, switch to for.
  • fn used as a one-shot helper. If you call a fn exactly once, inline the expression. Helpers shine when called from multiple places (or when their parameters make the intent clearer).

Next