Skip to content

Custom indicators (Lua)

Beyond the built-in overlays and oscillators, you can write your own indicators in Lua (Luau). Scripts run per-bar — once for each candle — with a small, batteries-included API for plotting lines, histograms, bands, shapes, and buy/sell markers. The editor gives you tabs, syntax highlighting, a live preview chart, tunable inputs, and a debug log.

Where: Top bar ▸ EditorLanguage: Lua (Luau, sandboxed)
The indicator editor: code + line numbers on the left, a live preview chart with tunable inputs on the right
The indicator editor: code + line numbers on the left, a live preview chart with tunable inputs on the right

Open it from the top bar’s Editor button. It’s a two-pane window:

  • Left — a code editor with line numbers, Lua syntax highlighting, and tabs (open several scripts at once). The toolbar has Open, Save, Delete, and Run.
  • Right — a live preview chart (pick any symbol/timeframe) where your indicator renders, a row of input steppers to tune parameters, and a debug log.

Editing is manual-compile: press Run (or Cmd/Ctrl+Enter) to compile and render. Shortcuts: Cmd/Ctrl+S save, Cmd/Ctrl+T new tab, Cmd/Ctrl+W close tab. A compile or runtime error highlights the offending line in red and prints the message to the debug log.

Saved scripts are .lua files under <config>/nocterm/scripts/ (one folder per type — indicators, signalers, bots, screeners, orders; the script() declaration routes them). Once saved, your indicator appears in the chart’s Indicators ▸ Scripts section like any built-in.

Every script starts with script(name, type) — the type decides what the script is and where it shows up:

TypeWhat it doesWhere it appears
"indicator"Plots on the chartChart ▸ Indicators ▸ Scripts
"signaler"Sets a desired net position via signal() — backtestableChart pickers + the editor’s Results tab
"bot"Trades live via bot.buy / bot.sellThe Bots dashboard
"scanner"Emits col() columns per symbolThe Screener widget (+ the editor’s Scan tab)
"order"A programmable order type — works a parent order via quote{}The ticket’s Algo mode (+ the editor’s Sim tab)

The rest of this page covers indicators; the shared language (inputs, ta.*, state, plotting) is the same everywhere.

script("My SMA", "indicator") -- name + type (overlay by default)
local length = input("length", 20) -- a tunable input (default 20)
plot(ta.sma(close, length), "#4d8bff")

That’s a complete overlay: it declares itself, takes one input, and plots a 20-period SMA.

Your script runs once per bar, oldest to newest. Two consequences:

  • state persists across bars (it resets at the start of each run). Use it for bar-recursive logic (SuperTrend, pivots, counters).
  • ta.* calls keep rolling state keyed by call order, so call them every bar, in the same order — never inside an if/loop that might skip them. Compute the value unconditionally, then branch on it.
-- Right: ta.* called every bar, branch on the result
local r = ta.rsi(close, 14)
if not na(r) and r > 70 then ... end
-- Wrong: conditional ta.* scrambles its rolling state
if close > open then local r = ta.rsi(close, 14) end
CallPurpose
script(name, type, opts?)The required first line: display name + type (see above). opts = {overlay=false} puts an indicator in its own pane below the chart (default true = on the price chart).
input(name, default)Declare a tunable number; returns its current value. Appears as a stepper in the preview and in the indicator’s settings on a chart.

Set fresh each bar:

GlobalMeaning
open high low close volumeThe current bar’s OHLCV
hl2 hlc3(high+low)/2, (high+low+close)/3
close1The previous close (nil on the first bar)
timeThe bar’s open time, seconds since epoch — math.floor(time / 86400) anchors UTC days
bar_index0-based index of the current bar
bar_countTotal number of bars

time follows the script’s clock. In bar-driven scripts — indicators, signalers, bots, scanners — it is the current bar’s open time. Order scripts run on ticks rather than bars, so there time is the current tick’s time: the wall clock live, the replayed candle clock in the editor’s Sim tab. For another timeframe’s bar times, use request(tf) and read h.time(n?) (Multiple timeframes).

CallPurpose
nz(x, d?)x if not nil, else d (default 0)
na(x)true if x is nil
stateA table that persists across bars within a run
debug(...) / print(...) / log(...)Write a line to the debug log (debug.log(...) works too)
alert(message, opts?)Raise a user alert: toast + Telegram/Discord. Live bar only, once per bar; opts = {id=, cooldown=}. Not in order scripts.

Key-value data that survives runs and app restarts — for what genuinely can’t be recomputed from the candles (prefer state for everything else). Values: numbers, strings, booleans, plain tables. store.set(key, nil) deletes.

CallScope
store.get(key) / store.set(key, value) / store.del(key)This instance — auto-namespaced by script + market (+ a persisted per-instance id on charts), so duplicate copies never collide.
store.shared.get/set/delAll instances of this script.
store.global.get/set/delEvery script — advisory cross-script data (last write wins).

Reads see the store as of run start; writes record on the live bar only and never from the editor preview or backtests (backtests also read an empty store — guard reads: local n = store.get("n") or 0). Removing a chart indicator and re-adding it starts a fresh instance bucket; use store.shared for data that should outlive that.

CallDraws
plot(value, color?)A line. Returns the output’s index (for fill). nil values leave a gap.
hist(value, color?)A histogram column (best in an oscillator pane). Returns its index.
hline(level)A horizontal reference line (e.g. RSI 30/70, a MACD zero line).
fill(idxA, idxB, color?)A shaded band between two plotted outputs (by index).

Colors are "#rrggbb" strings; omit for an auto-assigned palette color.

-- Bollinger Bands with a shaded band
script("Bands", "indicator")
local n, k = input("length", 20), input("mult", 2.0)
local basis, dev = ta.sma(close, n), ta.stdev(close, n)
local ok = not na(basis) and not na(dev)
local up = plot(ok and basis + k * dev or nil, "#888fa3")
local lo = plot(ok and basis - k * dev or nil, "#888fa3")
plot(basis, "#4d8bff")
fill(up, lo, "#4d8bff")

Drawn in (bar_index, price) space. Shapes accumulate across bars — so a marker on a signal bar stays put. For a shape you recompute every bar (like a trend line), guard it to the last bar (if bar_index == bar_count - 1 then ... end) so only the latest is drawn.

CallDraws
line(x1,y1,x2,y2, color?, width?, dashed?)A segment between two anchors
ray(x1,y1,x2,y2, color?, width?)A line that extends past the second anchor
dot(x,y, radius?, color?)A filled dot
tag(x,y, text, color?, above?)A text badge (above or below the anchor)
marker(x,y, shape, color?, size?)A glyph: "arrowup", "arrowdown", "triangleup", "triangledown", "circle", "square", "diamond", "cross"
-- Green up arrow below the bar on a fast/slow EMA cross
if ta.crossover(ta.ema(close, 9), ta.ema(close, 21)) then
marker(bar_index, low, "arrowup", "#26a69a")
tag(bar_index, low, "BUY", "#26a69a", false)
end

Call these every bar in a fixed order (see the execution model). Lengths are rounded to whole bars.

Always return a number:

CallReturns
ta.ema(x, n)Exponential moving average
ta.rma(x, n)Wilder’s smoothing (RSI/ATR style)
ta.atr(n)Average true range (reads the current bar)
ta.vwap()Cumulative volume-weighted average price
ta.cum(x)Running cumulative sum
ta.tr()True range of the current bar

May be nil while warming up — guard with na()/nz() before doing math:

CallReturns
ta.sma(x, n)Simple moving average
ta.wma(x, n)Weighted moving average
ta.stdev(x, n)Standard deviation
ta.highest(x, n) / ta.lowest(x, n)Window high / low
ta.rsi(x, n)Relative strength index
ta.change(x)x - x[1]
ta.high(n) ta.low(n) ta.close(n) ta.open(n)The field n bars back (0 = current)

Booleans:

CallReturns
ta.crossover(a, b)true the bar a crosses above b
ta.crossunder(a, b)true the bar a crosses below b

request(timeframe) returns a handle to another timeframe’s candles for the same symbol — so a script on a 5m chart can read 15m or 1h bars, and one on a 15m chart can read 1m bars. It works in both directions (the host fetches the other timeframe’s data and injects it before the run).

Call it once near the top (like input), then read fields through the handle:

CallReturns
request(tf)A handle. tf is "1m","5m","15m","30m","1h","4h","1d".
h.open(n?) h.high(n?) h.low(n?) h.close(n?) h.volume(n?) h.time(n?)The field of the n-th most recent closed tf bar at/before the current chart bar (n omitted/0 = the last closed one).

The accessors are non-repainting — they only ever return bars that have closed by the current chart bar, so the value steps when a higher-timeframe bar completes. They’re nil while the timeframe is still warming up (or hasn’t finished loading on the first paint).

-- The last closed 15m close, drawn as a step line on any chart
script("15m close", "indicator")
local htf = request("15m")
plot(htf.close(), "#f0b90b")
-- Higher-timeframe support/resistance: last closed 1h high & low
script("1h levels", "indicator")
local h1 = request("1h")
plot(h1.high(0), "#888fa3")
plot(h1.low(0), "#888fa3")

A script declared script(name, "signaler") is a tradeable strategy: call signal(size) each bar to set your desired net position. The size is signed, so you can scale in/out and flip:

CallMeaning
signal(1)1 unit long
signal(2)scale to 2 units long
signal(-1)1 unit short
signal(0)flat (close)

The position carries forward until you change it, so call signal(...) every bar from your logic. Open the editor’s Results tab to see the backtest.

Fill model (deliberately un-generous): a position change you decide on a bar fills at the next bar’s open — no look-ahead, no same-bar fills, no fees or slippage yet. P&L is per-unit (holding q units through a move of r earns q · r, so size behaves like leverage). The tearsheet reports realized and unrealized P&L, total return, max drawdown, Sharpe, exposure, fills, and the open position.

-- EMA-cross strategy: long when fast > slow, flat otherwise
script("EMA cross", "signaler")
local fast = ta.ema(close, 9)
local slow = ta.ema(close, 21)
plot(fast, "#4d8bff")
plot(slow, "#f0b90b")
if not na(slow) then
if fast > slow then signal(1) else signal(0) end
end

A script with no signal(...) calls simply doesn’t trade — it stays a plain indicator.

A custom indicator can double as a setup detector. Enable it under Settings ▸ Custom Setups, and it scans your tracked symbols on every bar close (like the built-in detectors), posting trade setups into the Setups list and chart overlays.

Call setup{...} on the bar your condition triggers:

FieldMeaning
direction"long" or "short"
entrytrigger price (defaults to the bar’s close)
stopprotective stop
targetprofit target
confidenceoptional 0..1 score
labeloptional name (defaults to the script() title)

Each setup is scored by the odds engine (win probability / EV / reward:risk) and passes through your Setup Filters, exactly like a built-in setup. Only setups emitted on the just-closed bar fire, so call it every bar from your logic — it won’t double-post.

-- An RSI-reversal setup: go long when RSI crosses back above 30
script("RSI reversal", "indicator", {overlay=false})
local r = ta.rsi(close, 14)
plot(r, "#4d8bff")
hline(30)
if not na(r) and ta.crossover(r, 30) then
setup({
direction = "long",
entry = close,
stop = ta.low(1),
target = close + 2 * ta.atr(14),
label = "RSI<30 reversal",
})
end

Set overlay = false for a pane below the chart. Histograms anchor to a zero baseline.

script("MACD", "indicator", {overlay=false})
local macd = ta.ema(close, 12) - ta.ema(close, 26)
local signal = ta.ema(macd, 9)
hist(macd - signal, "#888fa3") -- histogram
plot(macd, "#4d8bff")
plot(signal, "#f0b90b")
hline(0) -- zero line
script("Auto Trendline", "indicator")
local strength = input("strength", 8)
state.piv = state.piv or {}
local k = math.floor(strength)
local center = ta.high(k) -- candidate pivot high, k bars back
if not na(center) then
local isPivot = true
for j = 0, 2 * k do -- bounded loop is fine
local h = ta.high(j)
if not na(h) and h > center then isPivot = false break end
end
if isPivot then state.piv[#state.piv + 1] = { x = bar_index - k, y = center } end
end
if bar_index == bar_count - 1 then -- draw once, on the last bar
local p = state.piv
if #p >= 2 then
local a, b = p[#p - 1], p[#p]
line(a.x, a.y, b.x, b.y, "#ef5350")
ray(a.x, a.y, b.x, b.y, "#ef5350")
end
end
  • Scripts run in Luau, a sandboxed Lua — there’s no io, os, package, or network access. math, string, and table are available.
  • A time budget (~250 ms per run) aborts runaway loops, so a bad while won’t freeze the app — it surfaces as a runtime error in the debug log instead.
  • Errors (compile or runtime) print to the debug log and highlight the offending line in the editor.
  • The seeded examples (sma, supertrend, bollinger, macd, rsi, vwap, trendline, and a guided tutorial) live in your scripts folder — open them as starting points. The order-script seeds (chaser, twap, scaled_ladder, iceberg, trailing_stop) are covered in Algo orders.
  • Use the input steppers in the preview to tune parameters live without editing code.
  • Use debug(...) liberally; the values show in the panel under the preview chart.