Collections

Arrays and maps in an Indicator, the loops that replace the reducer methods of kScript (legacy), sorting with a comparator, and the two limits that keep a…

Arrays and maps in an Indicator, the loops that replace the reducer methods of kScript (legacy), sorting with a comparator, and the two limits that keep a module safe: memory is allocated once and never freed, and every array has one element type. kScript gave you [...] literals, {} maps, and a family of reducers with iteration guards; an Indicator gives you AssemblyScript's StaticArray, Array, and Map, and you write the loop.

What you get

  • StaticArray<T> is a fixed-size block, allocated once. It is the right shape for a window: size it from a param's declared max, index it with a cursor, and it never grows.
  • Array<T> is a growable list with push, pop, length, and the familiar methods. Grow it in init(), not per bar.
  • Map<K, V> is a key-value store with set, get, has, delete, size, keys(), and values().
  • Loops (for, while, do) are how you iterate. The reducer methods exist on Array, but each map or filter allocates a new array, so they belong in init(), not in the per-bar path (lambdas-and-reducers.md).

The rule under all three: the module has no garbage collector. Memory that is allocated stays allocated until the run ends, and the host caps a module at 4 MiB. Allocate at module scope or in init(), and the per-bar path stays flat.

Quick reference

const xs = new StaticArray<f64>(50);   // fixed size, allocated once
xs[0] = 1.0;  xs[0];  xs.length;       // write, read, count
xs.fill(NaN);                          // fill every slot
xs.sort();                             // numeric ascending, in place

const ys = new Array<f64>();           // growable
ys.push(4.0);  ys.pop();               // append, remove last
ys[0];  ys.length;                     // read by index, count
ys.slice(0, 2);  ys.reverse();         // copy a range (allocates), reverse in place
ys.includes(1.0);  ys.indexOf(2.0);    // search
ys.shift();  ys.unshift(0.0);          // remove first, prepend
ys.sort((a: f64, b: f64): i32 => (a > b ? 1 : a < b ? -1 : 0));

const weights: f64[] = [0.6, 0.4];     // typed literal
const names: string[] = ["binance", "bybit"];

const m = new Map<string, f64>();      // key-value store
m.set("k", 10.0);  m.get("k");  m.has("k");  m.delete("k");
m.size;  m.keys();  m.values();       // count, key array, value array

Array<f64> also has map, filter, reduce, forEach, some, every, and findIndex (there is no find); each callback is a non-capturing function. xs.avg(), xs.median(), and the other kScript numeric reducers do not exist: they are loops, and the worked example below is all of them.

A worked example: window statistics

The kScript "collections happy path" built an array from the bar's prices and ran every reducer over it. The Indicator version is a Window class over a ring buffer with the numeric reducers as methods: sum, avg, min, max, range, variance, stdev (population, dividing by n), and median through an allocation-free insertion sort into a scratch buffer. Everything is allocated once, at module scope.

import { input, line, lower, ohlcv, output, param } from "./sdk/declare";
import { in_close } from "./gen/inputs";
import { emitRow, out_avg, out_median, out_range, out_stdev } from "./gen/outputs";
import { p_bars } from "./gen/params";

param("bars", 20, { min: 2, max: 200, description: "Bars in the window" });
input("close", ohlcv.close);
output("avg", line, lower, { color: "#2563eb", description: "Window mean" });
output("median", line, lower, { color: "#16a34a", description: "Window median" });
output("stdev", line, lower, { color: "#f59e0b", description: "Population standard deviation" });
output("range", line, lower, { color: "#94a3b8", description: "Max minus min" });

class Window {
  values: StaticArray<f64>;
  scratch: StaticArray<f64>;
  size: i32;
  cursor: i32 = 0;
  count: i32 = 0;

  constructor(capacity: i32) {
    this.values = new StaticArray<f64>(capacity);
    this.scratch = new StaticArray<f64>(capacity);
    this.size = capacity;
  }

  resize(n: i32): void {
    this.size = n < 1 ? 1 : n > this.values.length ? this.values.length : n;
    this.reset();
  }

  push(x: f64): void {
    this.values[this.cursor] = x;
    this.cursor = (this.cursor + 1) % this.size;
    if (this.count < this.size) this.count += 1;
  }

  full(): bool {
    return this.count == this.size;
  }

  sum(): f64 {
    let s = 0.0;
    for (let i = 0; i < this.count; i++) s += this.values[i];
    return s;
  }

  avg(): f64 {
    return this.count == 0 ? NaN : this.sum() / f64(this.count);
  }

  min(): f64 {
    let m = Infinity;
    for (let i = 0; i < this.count; i++) if (this.values[i] < m) m = this.values[i];
    return this.count == 0 ? NaN : m;
  }

  max(): f64 {
    let m = -Infinity;
    for (let i = 0; i < this.count; i++) if (this.values[i] > m) m = this.values[i];
    return this.count == 0 ? NaN : m;
  }

  range(): f64 {
    return this.max() - this.min();
  }

  variance(): f64 {
    if (this.count == 0) return NaN;
    const mean = this.avg();
    let sq = 0.0;
    for (let i = 0; i < this.count; i++) {
      const d = this.values[i] - mean;
      sq += d * d;
    }
    return sq / f64(this.count); // population variance: divide by n
  }

  stdev(): f64 {
    return Math.sqrt(this.variance());
  }

  median(): f64 {
    if (this.count == 0) return NaN;
    // Copy into the scratch buffer and insertion-sort the first count slots: no allocation.
    for (let i = 0; i < this.count; i++) this.scratch[i] = this.values[i];
    for (let i = 1; i < this.count; i++) {
      const x = this.scratch[i];
      let j = i - 1;
      while (j >= 0 && this.scratch[j] > x) {
        this.scratch[j + 1] = this.scratch[j];
        j -= 1;
      }
      this.scratch[j + 1] = x;
    }
    const mid = this.count / 2;
    return this.count % 2 == 1 ? this.scratch[mid] : (this.scratch[mid - 1] + this.scratch[mid]) / 2.0;
  }

  reset(): void {
    this.cursor = 0;
    this.count = 0;
  }
}

const MAX_BARS = 200;
const window = new Window(MAX_BARS); // allocated once, sized from the param's max

export function init(): void {
  window.resize(i32(p_bars()));
}

export function state(): i32 {
  window.push(in_close());
  return window.full() ? 1 : 0;
}

export function finalize(): void {
  out_avg(window.avg());
  out_median(window.median());
  out_stdev(window.stdev());
  out_range(window.range());
  emitRow();
}

export function reset(): void {
  window.reset();
}

A few idioms worth lifting out:

  • this.values[this.cursor] = x; this.cursor = (this.cursor + 1) % this.size; is the ring buffer: the oldest slot is overwritten and nothing shifts.
  • median() sorts a copy in a preallocated scratch buffer, so a per-bar median allocates nothing. Sorting values in place would destroy the ring order.
  • Empty-window reducers return NaN, the counterpart of kScript's na, and state() abstains until the window is full anyway.
  • resize() clamps to the capacity allocated from the param's max, so a setting change never asks for memory that was not reserved.

Maps

A Map is right for a small keyed table: weights per venue, a running total per hour of day, a level per session name. Allocate the map at module scope and insert its keys in init(); from then on set on an existing key allocates nothing. Volume by hour of the day, from the time source:

import { histogram, input, line, lower, ohlcv, output, time } from "./sdk/declare";
import { in_bar_t, in_volume } from "./gen/inputs";
import { emitRow, out_hour, out_hour_total } from "./gen/outputs";

input("volume", ohlcv.volume);
input("bar_t", time.bar_open_sec);
output("hour_total", histogram, lower, { color: "#2563eb", description: "Cumulative volume traded in this bar's UTC hour, over the loaded history" });
output("hour", line, lower, { color: "#94a3b8", description: "The bar's UTC hour, 0 to 23" });

const byHour = new Map<i32, f64>();
let hour: i32 = 0;
let total: f64 = 0.0;

export function init(): void {
  for (let h = 0; h < 24; h++) byHour.set(h, 0.0); // every key exists before the first bar
}

export function state(): i32 {
  hour = i32((i64(in_bar_t()) % 86400) / 3600); // epoch seconds to the UTC hour
  total = byHour.get(hour) + in_volume();
  byHour.set(hour, total); // an existing key: no allocation
  return 1;
}

export function finalize(): void {
  out_hour_total(total);
  out_hour(f64(hour));
  emitRow();
}

export function reset(): void {
  for (let h = 0; h < 24; h++) byHour.set(h, 0.0);
  hour = 0;
  total = 0.0;
}

get on a missing key traps at runtime, so guard with has when a key might not exist, or insert every key up front as this file does.

Sorting

sort() orders an array in place and returns it, on Array<f64> and StaticArray<f64> alike. With no argument the order is numeric ascending: [30, 4, 100, 25] comes out [4, 25, 30, 100].

Comparators

sort takes one optional comparator, a function (a: T, b: T) => i32 in the JavaScript convention: a negative result puts a first, a positive result puts b first.

xs.sort((a: f64, b: f64): i32 => (a > b ? 1 : a < b ? -1 : 0));   // ascending
xs.sort((a: f64, b: f64): i32 => (a < b ? 1 : a > b ? -1 : 0));   // descending

The comparator is an ordinary non-capturing function, so it may read module-level state but not a local of the enclosing function (lambdas-and-reducers.md). Sorting a copy is xs.slice(0, xs.length).sort(...), and slice allocates, so do it in init() or use a scratch buffer as the window example does.

Structs, strings, and NaN

An array of class instances sorts by a comparator over a field ((a: Level, b: Level): i32 => (a.price > b.price ? 1 : a.price < b.price ? -1 : 0)); strings compare code unit by code unit, so a < b orders them directly. NaN > x and NaN < x are both false, so a NaN element compares equal to everything and lands wherever the algorithm leaves it: drop NaN values before sorting, or map them to Infinity in the comparator when they should sort last.

What sort rejects

The comparator's return type is i32. The JavaScript habit of returning a boolean, (a, b) => a > b, is refused at compile time with Type 'bool' is not assignable to type 'i32'. Return the difference or the three-way ternary. A comparator with untyped parameters, or without a return type, is refused too (Type expected.): annotate both.

The two limits

Collections are bounded so a module cannot exhaust memory or silently mis-type a list.

Memory is allocated once

There is no garbage collector in the module. Every new takes memory that is never returned, the host validates a 4 MiB ceiling before the module is instantiated, and a module that grows on every bar reaches it. So:

  • allocate at module scope or in init(), sized from a param's max;
  • never push without bound, never slice or map per bar, never build a string with + per bar;
  • prefer a StaticArray you overwrite to an Array you refill.

This is the Indicator counterpart of kScript's 100,000-element ceiling: not a count the runtime enforces, but a budget you allocate against, once.

Arrays are typed

An array has one element type, fixed when it is declared. Mixing types is refused at compile time: pushing a string onto an Array<f64> fails with Type 'String' is not assignable to type 'f64'. If you genuinely need mixed data per row, declare a class with typed fields (user-defined-types.md) and keep an array of those.