Core Concepts

Collections

Arrays and maps in kScript, the reducer methods that iterate them, in-place sorting with comparators, and the two limits that keep them safe.

Core Concept 5 min read

What you get

kScript gives you two collection types and a set of functional methods to work them:

  • Arrays are ordered lists. Build them with [...] literals and work them with push, get, set, length(), reducers, and mutators.
  • Maps are key/value stores. Create one with {} and use set, get, and size().
  • Arrow lambdas ((x) => ...) feed the array reducers (map, filter, reduce, find, some, every, forEach) and the optional comparator of sort.

Together these replace most hand-written loops. The reducers are the idiomatic way to transform a list, and they come with iteration guards built in, so a runaway transform stops loudly instead of hanging the chart. For a deeper tour of lambdas and the reducer family, see Lambdas & Reducers.

Quick reference

var xs = [1, 2, 3]          // array literal
xs.push(4)                  // append
xs.get(0)                   // read by index
xs.set(0, 99)               // write by index
xs.length()                 // count

var m = {}                  // map literal
m.set("k", 10)              // write
m.get("k")                  // read
m.size()                    // entry count

xs.map((v) => v * 2)        // transform each element
xs.filter((v) => v > 2)     // keep matching elements
xs.reduce((sum, v) => sum + v, 0)  // fold to one value
xs.find((v) => v > 2)       // first match, || na
xs.some((v) => v > 2)       // true if any match
xs.every((v) => v > 0)      // true if all match
xs.forEach((v) => { ... })  // run a side effect per element

xs.avg()                    // numeric reducers
xs.sum()
xs.median()
xs.stdev()                  // population standard deviation
xs.variance()               // population variance
xs.min()
xs.max()
xs.range()

xs.reverse()                // reverse in place and return the array
xs.sort()                   // numeric ascending sort, in place, returns the array
xs.sort((a, b) => b - a)    // custom order: comparator returns a number
xs.first()                  // first element
xs.last()                   // last element
xs.shift()                  // remove and return first element
xs.unshift(0)               // prepend

A worked example

This script exercises the whole surface in one pass: it builds an array from the current bar's prices, mutates it, runs every reducer over it, stuffs results into a map, then sums everything into one plottable score. It is dense on purpose, as a map of what fits together.

Collections happy path
//@version=2
define(title="Collections Happy Path", position="offchart", axis=true)

timeseries trade = ohlcv(symbol=currentSymbol, exchange=currentExchange)

var prices = [trade.open, trade.high, trade.low]
prices.push(trade.close)
prices.set(0, prices.get(0) + barIndex / 10)

var transformed = prices.map((value, index) => value + index)
var filtered = transformed.filter((value) => value > trade.low)
var reduced = filtered.reduce((total, value) => total + value, 0)
var firstLarge = transformed.find((value) => value > trade.close)
var hasHigh = transformed.some((value) => value > trade.high)
var allPositive = transformed.every((value) => value > 0)
var sideEffectTotal = 0
transformed.forEach((value) => {
  sideEffectTotal += value / 1000
})

var levels = {}
levels.set("reduced", reduced)
levels.set("first", nz(firstLarge, trade.close))
levels.set("flags", (hasHigh ? 1 : 0) + (allPositive ? 1 : 0))

var score = levels.get("reduced") + levels.get("first") + levels.get("flags") + prices.length() + levels.size() + sideEffectTotal

plotLine(value=score, colors=["#2563eb"], width=2, label=["Collection score"], desc=["arrays maps lambdas and reducers"])

A few idioms worth lifting out of that wall:

  • prices.set(0, prices.get(0) + barIndex / 10) reads, modifies, and writes one slot in place.
  • nz(firstLarge, trade.close) matters because find returns na when nothing matches; nz supplies a fallback so the arithmetic stays finite. See na and Color.
  • The (value, index) lambda in map shows the optional index parameter; drop it when you don't need it.

What you'll see: one line whose value moves every bar, since prices is rebuilt from live OHLC and barIndex feeds into it.

Numeric reducers and manipulators

Numeric arrays expose avg, sum, median, stdev, variance, min, max, and range. The statistics use population math: variance() divides by n, and stdev() is the square root of that population variance. Empty-array reducers return na. Arrays also expose reverse, first, last, shift, and unshift for common positional edits.

Array reducers and manipulators
//@version=2
define(title="Array Reducers Manipulators", position="offchart", axis=true)

timeseries trade = ohlcv(symbol=currentSymbol, exchange=currentExchange)

var values = [1, 2, 3, 4]
var reducerScore = values.avg() + values.sum() + values.median() + values.stdev() + values.variance() + values.min() + values.max() + values.range()

var reversed = values.reverse()
var shifted = reversed.shift()
reversed.unshift(trade.close)
var manipulatorScore = shifted + reversed.first() + reversed.last() + reversed.length()

var empty = []
var emptyScore = (isna(empty.avg()) ? 1 : 0) + (isna(empty.sum()) ? 1 : 0) + (isna(empty.median()) ? 1 : 0) + (isna(empty.stdev()) ? 1 : 0) + (isna(empty.variance()) ? 1 : 0) + (isna(empty.min()) ? 1 : 0) + (isna(empty.max()) ? 1 : 0) + (isna(empty.range()) ? 1 : 0)

plotLine(value=reducerScore + barIndex / 1000, colors=["#2563eb"], width=2, label=["Reducers"], desc=["array numeric reducers use population statistics"])
plotLine(value=manipulatorScore, colors=["#16a34a"], width=2, label=["Manipulators"], desc=["reverse first last shift and unshift mutate or read arrays"])
plotLine(value=emptyScore + barIndex / 1000, colors=["#dc2626"], width=2, label=["Empty"], desc=["empty array reducers return na"])

Sorting

sort() orders an array in place and returns it. With no argument the order is numeric ascending: [30, 4, 100, 25] comes out [4, 25, 30, 100]. That is a true numeric sort, not JavaScript's default lexicographic one, which would put 100 first.

The examples in this section are self-checking: each counts failed expectations and detonates a deliberate runtime error if any assertion misses, so a script that runs at all is proof of the values in its comments.

Argless sort is numeric ascending
//@version=3
define(title="Sort Argless Ascending", position="offchart", axis=true)

timeseries trade = ohlcv(symbol=currentSymbol, exchange=currentExchange)

// Argless sort() is NUMERIC ascending (a JS-default lexicographic sort would
// yield [100, 25, 30, 4]; kScript must yield [4, 25, 30, 100]) and sorts in place.
var xs = [30, 4, 100, 25]
xs.sort()

var failures = 0
failures += xs.get(0) == 4 ? 0 : 1
failures += xs.get(1) == 25 ? 0 : 1
failures += xs.get(2) == 30 ? 0 : 1
failures += xs.get(3) == 100 ? 0 : 1
failures += xs.length() == 4 ? 0 : 1

if (failures > 0) {
  var bomb = [1]
  bomb.removeAt(9)
}

plotLine(value=xs.first() + barIndex / 1000, colors=["#2563eb"], width=2, label=["Sorted min"], desc=["first element after argless numeric ascending sort"])

Sorting is not v3-gated: the identical body under //@version=2 compiles and runs with the identical result.

Comparators

sort takes at most one argument, an arrow lambda (a, b) => number in the JavaScript convention: a negative return puts a first, a positive return puts b first. So (a, b) => a - b is ascending and (a, b) => b - a is descending. The comparator is an ordinary lambda (syntax and closures in Lambdas & Reducers); what is special is the contract on its return value, enforced at compile time and again at run time (see the rejection list below).

Descending comparator, and sorting a copy
//@version=3
define(title="Sort Comparator Descending", position="offchart", axis=true)

timeseries trade = ohlcv(symbol=currentSymbol, exchange=currentExchange)

// The JS-idiom comparator: (a, b) => number. Negative = a first, positive = b first.
// (a, b) => b - a is descending.
var xs = [5, 1, 4, 2]
xs.sort((a, b) => b - a)

var failures = 0
failures += xs.get(0) == 5 ? 0 : 1
failures += xs.get(1) == 4 ? 0 : 1
failures += xs.get(2) == 2 ? 0 : 1
failures += xs.get(3) == 1 ? 0 : 1

// copy-then-sort idiom (sort is in-place; slice() makes the copy)
var ys = [3, 1, 2]
var sortedCopy = ys.slice(0, ys.length()).sort((a, b) => a - b)
failures += ys.get(0) == 3 ? 0 : 1
failures += sortedCopy.get(0) == 1 ? 0 : 1
failures += sortedCopy.get(2) == 3 ? 0 : 1

if (failures > 0) {
  var bomb = [1]
  bomb.removeAt(9)
}

plotLine(value=xs.first() + trade.close / 1000, colors=["#2563eb"], width=2, label=["Desc first"], desc=["largest element after descending comparator sort"])

Two idioms worth lifting out:

  • (a, b) => b - a turns [5, 1, 4, 2] into [5, 4, 2, 1].
  • sort mutates the receiver, so sorting a copy is spelled ys.slice(0, ys.length()).sort(...): the slice makes the snapshot, and the original ys still starts with 3 afterwards.

Structs sort by any field, stably

To order a struct array, return the numeric difference of the field you care about. The sort is stable: elements that compare equal keep their original relative order. Below, the two price=1 rows keep their original sequence (seq 2 then 4) and the two price=2 rows keep 1 then 3.

Stable sort of structs by a field
//@version=3
define(title="Sort Structs Stable", position="offchart", axis=true)

type Level {
  price: number
  seq: number
}

timeseries trade = ohlcv(symbol=currentSymbol, exchange=currentExchange)

// Comparator sort over a struct array: order by any field.
// Duplicate keys prove stability (equal elements keep their original relative order).
var levels = [Level.new(price=2, seq=1), Level.new(price=1, seq=2), Level.new(price=2, seq=3), Level.new(price=1, seq=4)]
levels.sort((a, b) => a.price - b.price)

var failures = 0
failures += levels.get(0).price == 1 ? 0 : 1
failures += levels.get(1).price == 1 ? 0 : 1
failures += levels.get(2).price == 2 ? 0 : 1
failures += levels.get(3).price == 2 ? 0 : 1
// stability: within equal prices, seq order 2,4 and 1,3 is preserved
failures += levels.get(0).seq == 2 ? 0 : 1
failures += levels.get(1).seq == 4 ? 0 : 1
failures += levels.get(2).seq == 1 ? 0 : 1
failures += levels.get(3).seq == 3 ? 0 : 1

if (failures > 0) {
  var bomb = [1]
  bomb.removeAt(9)
}

plotLine(value=levels.get(0).seq + trade.close / 1000, colors=["#16a34a"], width=2, label=["Stable seq"], desc=["seq of first struct after stable sort by price"])

In place, plus a chainable return

The receiver itself is reordered, and the return value is there so calls chain: ys.sort().first() is the minimum, and zs.sort((a, b) => b - a).first() is the maximum. The return is not an alias, though. kScript array assignment always copies (a plain var alias = xs followed by alias.push(99) leaves xs at its old length), so var ret = xs.sort() snapshots an independent copy: ret.push(99) grows ret to 4 while xs stays at 3.

In place, chaining, and the copied return
//@version=3
define(title="Sort In Place Returns Array", position="offchart", axis=true)

timeseries trade = ohlcv(symbol=currentSymbol, exchange=currentExchange)

var failures = 0

// sort() mutates the RECEIVER in place; the return value lets calls chain.
var xs = [3, 1, 2]
var ret = xs.sort()
failures += xs.get(0) == 1 ? 0 : 1
failures += xs.get(1) == 2 ? 0 : 1
failures += xs.get(2) == 3 ? 0 : 1

// kScript array ASSIGNMENT copies (true of any array assignment, not sort-specific):
// the assigned return is an independent snapshot, so mutating it leaves xs alone.
ret.push(99)
failures += ret.length() == 4 ? 0 : 1
failures += xs.length() == 3 ? 0 : 1

// returning the array is what makes chaining work
var ys = [9, 7, 8]
var lowest = ys.sort().first()
failures += lowest == 7 ? 0 : 1
failures += ys.get(0) == 7 ? 0 : 1

// comparator form is equally in-place and chainable
var zs = [1, 3, 2]
var top = zs.sort((a, b) => b - a).first()
failures += top == 3 ? 0 : 1
failures += zs.get(0) == 3 ? 0 : 1
failures += zs.get(2) == 1 ? 0 : 1

if (failures > 0) {
  var bomb = [1]
  bomb.removeAt(9)
}

plotLine(value=lowest + top + trade.close / 1000, colors=["#2563eb"], width=2, label=["Chain score"], desc=["chained sort().first() results plus close drift"])

Strings have no built-in ordering

< and > compile on strings but are not an ordering: "a" < "b", "b" < "a", "a" > "b", and "b" > "a" all evaluate false. A comparator like (a, b) => a < b ? -1 : 1 therefore returns 1 for every pair and scrambles the array instead of sorting it: ["ccc", "a", "bb"] comes back ["bb", "a", "ccc"]. Order strings by deriving a numeric key instead, here by length:

Sort strings by a numeric key
//@version=3
define(title="Sort Strings Comparator", position="offchart", axis=true)

timeseries trade = ohlcv(symbol=currentSymbol, exchange=currentExchange)

// Non-numeric elements sort fine when the comparator DERIVES a number.
// (There is no lexicographic ordering: '<' on strings is not an ordering in
// kScript, so order strings by a numeric key such as length().)
var ss = ["ccc", "a", "bb"]
ss.sort((a, b) => a.length() - b.length())

var failures = 0
failures += ss.get(0) == "a" ? 0 : 1
failures += ss.get(1) == "bb" ? 0 : 1
failures += ss.get(2) == "ccc" ? 0 : 1

if (failures > 0) {
  var bomb = [1]
  bomb.removeAt(9)
}

plotLine(value=ss.get(0).length() + barIndex / 100, colors=["#2563eb"], width=2, label=["Shortest len"], desc=["length of shortest string after keyed sort"])

na elements

Argless sort treats na as equal to everything: an array containing na does not throw, but how much of it actually gets sorted depends on where the na sits. [3, na, 1] comes back exactly [3, na, 1], silently unsorted, yet the same values as [3, 1, na] come back [1, 3, na], and [5, 2, na, 4, 1] comes back fully sorted as [1, 2, 4, 5, na]. Treat the result as unreliable, not merely unsorted.

Argless sort with na is position-dependent
//@version=3
define(title="Sort Argless With Na", position="offchart", axis=true)

timeseries trade = ohlcv(symbol=currentSymbol, exchange=currentExchange)

// Argless sort with an na element does NOT throw, but every comparison against
// na reports "equal", so the outcome depends on where the na sits:
// [3, na, 1] comes back UNCHANGED, yet [3, 1, na] comes back [1, 3, na].
var xs = [3, na, 1]
xs.sort()

var failures = 0
failures += xs.length() == 3 ? 0 : 1
failures += xs.get(0) == 3 ? 0 : 1
failures += isna(xs.get(1)) ? 0 : 1
failures += xs.get(2) == 1 ? 0 : 1
var naCount = xs.reduce((acc, v) => acc + (isna(v) ? 1 : 0), 0)
failures += naCount == 1 ? 0 : 1

var ys = [3, 1, na]
ys.sort()
failures += ys.get(0) == 1 ? 0 : 1
failures += ys.get(1) == 3 ? 0 : 1
failures += isna(ys.get(2)) ? 0 : 1

if (failures > 0) {
  var bomb = [1]
  bomb.removeAt(9)
}

plotLine(value=xs.get(0) + trade.close / 1000, colors=["#d97706"], width=2, label=["Unsorted head"], desc=["argless sort with na is position dependent not a no-op"])

What sort rejects

The compile-time contract: the argument must be a lambda, at most one of them, returning a number. The classic JavaScript habit of returning a boolean is the first thing it catches:

Boolean comparator boundary
//@version=3
define(title="Sort Reject Bool Comparator", position="offchart", axis=true)

timeseries trade = ohlcv(symbol=currentSymbol, exchange=currentExchange)

// EXPECTED COMPILE FAILURE: the JS habit (a, b) => a > b returns a boolean.
// kScript rejects it at compile time; the comparator must return a number.
var xs = [3, 1, 2]
xs.sort((a, b) => a > b)

plotLine(value=trade.close, colors=["#dc2626"], width=2, label=["Close"], desc=["anchor for bool comparator rejection"])

This fails to compile with Array method 'sort' comparator must return a number, got boolean at 9:1. Return a - b, not a > b. The full rejection list, with observed errors:

  • A comparator returning a string, xs.sort((a, b) => "x"): fails to compile with Array method 'sort' comparator must return a number, got string at 8:1.
  • A non-lambda argument, xs.sort(1): Array method 'sort' expects a lambda callback at 8:9.
  • Two arguments, xs.sort((a, b) => a - b, 1): Array method 'sort' expects 0-1 arguments, got 2 at 8:1.
  • A comparator producing a non-finite number at run time, xs.sort((a, b) => (b - a) / 0): compiles, then the run stops with Array method 'sort' comparator must return a finite number.
  • A mixed return type that evades the static check, xs.sort((a, b) => a < b ? -1 : "x") (static type number|string): the same runtime guard fires, Array method 'sort' comparator must return a finite number.

The two limits

Collections are bounded so a script can't exhaust memory or silently mis-type a list.

Size ceiling: 100,000 elements

A single collection tops out at 100,000 elements. Push past it and the run stops with Collection size limit exceeded at 9:5 (max 100000), pointing at the offending line. In practice you only hit this by accident, usually an unbounded push in a loop.

Collection size boundary
//@version=2
define(title="Collection Size Boundary", position="offchart", axis=true)

timeseries trade = ohlcv(symbol=currentSymbol, exchange=currentExchange)

if (isLastBar) {
  var values = []
  for (var n = 0; n < 100001; n += 1) {
    values.push(n)
  }
}

plotLine(value=trade.close, colors=["#dc2626"], width=2, label=["Close"], desc=["anchor for collection size boundary"])

Arrays are typed

An array has one element type, inferred from how you first fill it. Mixing types is rejected at compile time. Here a numeric array gets a string pushed onto it:

Array type mismatch
//@version=2
define(title="Array Type Mismatch Boundary", position="offchart", axis=true)

timeseries trade = ohlcv(symbol=currentSymbol, exchange=currentExchange)
var values = [1, 2, 3]
values.push("not a number")

plotLine(value=trade.close, colors=["#dc2626"], width=2, label=["Close"], desc=["anchor for array type mismatch boundary"])

This fails to compile with Array element type mismatch: expected number, got string at 6:13. The takeaway: keep an array homogeneous. If you genuinely need to carry mixed data per row, reach for a struct instead of forcing it into one array.