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:
| Layer | Examples | When the choice happens |
|---|---|---|
| Script-eval-time | if 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-bar | iff(close > ma, upColor, dnColor), mask(close, breakout), ternary cond ? a : b over series | Inside 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
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
ifkeyword starts a new block. The condition expression spans to end-of-line. - Each
elseifadds another branch. There can be any number. elseis optional and must come last.endcloses the block. The block is REQUIRED — there is no single-lineif.- 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:
| Value | Truthy when |
|---|---|
boolean | true |
number | finite and non-zero |
string | non-empty |
| series | the 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:
// 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:
- Parameter validation — branch on a scalar input value.
@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) - Conditional drawing — branch on a scalar derived from the latest bar.
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
// 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-1whenfrom > to). Step is captured ONCE at loop entry; mutating it inside the body has no effect. endis required.from,to, andstepmust be finite scalars. Series-valued bounds raise a runtime error.- Inclusive on both ends:
for i = 0 to 9runs 10 times withi ∈ {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:
| Cap | Limit | Behaviour on overflow |
|---|---|---|
| Whole-script loop budget | 5 000 000 iterations across all for blocks | RuntimeError, script halts |
Single for ceiling | 1 000 000 iterations | RuntimeError, 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):
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"):
// 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
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
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 implicitreturn naif the body falls off the end without one). - Close with
end.
Calling user fns
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:
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.
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'scolor=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 intomask(...)-ed series and emit oneplotLineper 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:
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
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.
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 insidefor/if/fnblocks when you want to mutate a variable declared in the surrounding scope.- Variables declared in a
forbody's parent scope (via=) leak out to the script's top-level — there is nolet/constblock scope. - Variables declared inside a
fnbody live in a child of the fn's defining scope (closure). They do NOT leak into the caller.
// 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
// 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 thethenbranch always wins. Useiff(series_expr, a, b).- A
forloop that walks every bar to compute a value that the standard library already provides. Usesma,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. fnused 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
- Inputs & directives —
@input/@name/@pane. - Series operations — what
shift,change,valuewhen,barssinceactually do. - Recipes — full scripts that combine
for+ persistent drawings.