A comprehensive guide to the Indicator type system: static typing, how
types are inferred from literals, explicit casts, the per-bar value that
replaces a series, and the messages the compiler prints when a type does
not fit. kScript (legacy) used a hybrid system, static analysis over a
dynamic runtime with one number type and an implicit series type. An
Indicator is AssemblyScript: every value has a fixed width, nothing
converts implicitly, and there is no any.
Introduction
The compiler type-checks the whole file before it runs. There are no implicit conversions between numeric widths, no coercion between strings and numbers, and no union types. That strictness is the point: a mismatch is a compile error with a line and column, never a wrong number on bar 1,400.
At its core the system has two categories of values: primitives
(f64, i32, bool, string) and references (arrays, maps, class
instances). What it does not have is a series type. kScript's
domain-specific strength was timeseries, a value with a whole history
behind it; an Indicator's central idea is the opposite: one value per bar,
read through a function, with history kept explicitly. This page covers
declarations and inference, the primitives, the per-bar model, collections,
conversion rules, and a complete example.
Variable declarations and type inference
let and const declare variables, as in TypeScript. The type comes from
the annotation if there is one, otherwise from the initializer, and the
rule to remember is the literal rule: a whole-number literal is an
i32, a literal with a decimal point is an f64.
let price = 45000.5; // f64
let count = 0; // i32, because the literal has no point
let flag = true; // bool
let name = "BTCUSDT"; // string
let value: f64 = 0; // annotated: f64 (the literal widens)
let cursor: i32 = 0; // annotated: i32Once inferred, the type is fixed. let count = 0; count = 2.5; is refused
with Conversion from type 'f64' to 'i32' requires an explicit cast,
which is also the message you get when a param (always f64) meets a
class constructor (always i32): write new Sma(i32(p_period())).
const fixes the binding, not the contents: a const window = new StaticArray<f64>(50) is filled and overwritten freely; only window = ... is refused. Module-level TA objects are let, because init()
rebuilds them at the chosen period.
The three lifetimes (a local, a module-level variable, a value with
history) are in core-variables.md; this page is about what the values
are.
Primitive types
f64 is the 64-bit float and the type of everything that crosses the
host boundary. Arithmetic on two f64 values is an f64; dividing by zero
gives Infinity or NaN, never an error, so guard denominators. NaN is
the missing value: isNaN(x) tests it, x == x is false when x is
NaN, and every comparison with NaN is false.
i32 is the 32-bit integer: periods, counters, cursors, loop bounds,
the state() return, Cross.update()'s answer. Integer division truncates
(7 / 2 is 3). i64 exists for epoch-second math (time-and-sessions.md).
bool comes from comparisons and logical operators. A bare number in an
if compiles (nonzero is true), but write the comparison out: x > 0.0
says what you mean, and !isNaN(x) is the only reliable test for a missing
value.
string is immutable text with length, charCodeAt, indexOf,
startsWith, + between strings, and == / < by code unit. Strings stay
inside the module and reach the chart only through a declared string slot;
building one allocates, so per-bar text goes through the line builder
(sb_clear, sb_text, sb_f64). There is no number-to-string coercion:
"close " + close is refused with Type 'f64' is not assignable to type 'String'; write close.toString().
The per-bar value: what replaces TimeSeries
A kScript timeseries was a complete history aligned to bars; the engine
tracked it for you and [n] looked backward. An Indicator input is a
function: in_close() returns this bar's close, and only in state(). The
host calls state() once per bar, oldest first, so a line you write once
still produces a value at every point in time, but the past is not
addressable.
What holds history:
| Need | Form |
|---|---|
close[1] | a module-level prevClose assigned at the end of state() |
sma(close, 20), rsi(close, 14) | new Sma(20), new Rsi(14): the class keeps the window, update(x) folds one value |
highest(high, 20), close[5] | a StaticArray<f64> ring buffer of 20 with a cursor |
cum(delta) | a module-level accumulator |
Immutability is built in (an input has no setter), and module scope is
where history lives: anything that must survive a call is declared there,
because a local is gone when state() returns.
OHLCV data structure
A candle is not one value with six columns. Declare one input per field you read, and read each through its own accessor:
input("open", ohlcv.open);
input("high", ohlcv.high);
input("low", ohlcv.low);
input("close", ohlcv.close);
input("volume", ohlcv.volume);
input("bar_t", time.bar_open_sec); // the timestamp, epoch secondsThe first input is the primary: it defines the grid (market and
interval) every other input aligns to. There is no priceIndex: a class
takes whichever number you hand it, so sma.update(in_close()) and
sma.update((in_high() + in_low()) / 2.0) are the same call over
different sources.
Arrays and collections
Arrays are typed and homogeneous. StaticArray<f64> is a fixed-size
block allocated once; Array<f64> grows with push; f64[] and
string[] literals work; Map<K, V> is a key-value store. The element
type is fixed at declaration and enforced: pushing a string onto an
Array<f64> is refused with Type 'String' is not assignable to type 'f64'. Arrays are references: passing one to a function passes the same
storage, so a function that writes into it writes into yours. The
allocation rule applies to all of them: build them in init() or at
module scope, never per bar (collections.md).
const periods: i32[] = [10, 20, 50];
const window = new StaticArray<f64>(200);
const weights = new Map<string, f64>();Data sources
kScript offered a direct function per feed and the universal source("type", ...). An Indicator has one form, input(name, source.field, options),
where source is a namespace from ./sdk/declare and field one of its
members; every input is an f64, and the source decides what the number
means. The catalog, the missing policy for sparse feeds, and the named
refusals for feeds a host cannot fetch are in data-sources.md.
Type conversion and casting
Nothing converts on its own. The rules:
f64toi32:i32(x)truncates toward zero. Periods, cursors, and loop bounds derived from a param need it.i32tof64:f64(n). Writing a counter to an output needs it, and so does mixing ani32into float arithmetic.boolto a number: a ternary,flag ? 1.0 : 0.0.- A number to a string:
.toString(), and only for module-internal text. - Never implicit:
f64 * i32is refused; cast one side.
const period = i32(p_period()); // f64 to i32
out_count(f64(hits)); // i32 to f64
out_is_long(fast > slow ? 1.0 : 0.0); // bool to f64
const label = "n=" + hits.toString(); // i32 to string, inside the moduleType mismatch errors name the rule. The ones you will meet:
| Message | Cause | Fix |
|---|---|---|
AS200: Conversion from type 'f64' to 'i32' requires an explicit cast. | a param or float where an integer goes | i32(...) |
TS2322: Type 'bool' is not assignable to type 'i32'. | a comparator returning a comparison | return a > b ? 1 : a < b ? -1 : 0 |
TS2322: Type 'String' is not assignable to type 'f64'. | a string pushed onto a numeric array | keep the array homogeneous |
TS2329: Index signature is missing in type 'f64'. | close[1] on a number | keep the previous value in a variable |
TS1110: Type expected. | a function without a return type | annotate: function f(x: f64): f64 |
AS100: Not implemented: Closures | an arrow function reading a local of the enclosing function | read module-level state instead (lambdas-and-reducers.md) |
AS100: Not implemented: Iterators | for (const x of xs) | an index loop |
Best practices
Annotate every module-level let and every function signature (the
annotation is documentation, and it stops the literal rule from making a
float an integer); feed inputs straight into the classes that keep the
windows; keep params, inputs, and outputs at the top as top-level
statements, state under them, the four functions last.
Complete example: multi-source momentum
The kScript type-system page closed with a momentum indicator over three
sources. The port reads the same three: the close for an RSI, the funding
rate averaged over 24 bars, and liquidations (sparse, so missing: "zero" makes a quiet bar a 0). The extreme condition is a data-only
gate and a mark drawn only where it holds.
import { funding, input, line, liquidations, lower, none, ohlcv, output, param, shape } from "./sdk/declare";
import { in_close, in_funding, in_liqs } from "./gen/inputs";
import { emitRow, out_extreme, out_extreme_rsi, out_is_extreme, out_rsi } from "./gen/outputs";
import { p_extreme_rsi, p_funding_threshold, p_rsi_period } from "./gen/params";
import { Rsi, Sma } from "./sdk/ta";
// Configuration: the RSI length, the RSI level, and the funding level.
param("rsi_period", 14, { min: 2, max: 100, description: "RSI period" });
param("extreme_rsi", 30, { min: 1, max: 99, description: "RSI at or below this is oversold" });
param("funding_threshold", 0.01, { min: 0, max: 100, description: "Funding below minus this means shorts are paying, in the feed's own units" });
// Data sources: price, funding, liquidations.
input("close", ohlcv.close);
input("funding", funding.rate_close, { description: "Funding rate at the bar's close" });
input("liqs", liquidations.liquidations, { missing: "zero", description: "Liquidation volume, 0 on quiet bars" });
// Visualization: the RSI and its level in one pane, a mark only on extreme bars.
output("rsi", line, lower, { color: "#2962FF", width: 2, description: "Relative Strength Index" });
output("extreme_rsi", line, lower, { color: "#FF6B35", width: 1, description: "Extreme RSI level" });
output("extreme", shape, lower, { color: "#00BA88", shape_where: "is_extreme", description: "Oversold, shorts paying, liquidations printing" });
output("is_extreme", none, lower);
let rsi = new Rsi(14);
let avgFunding = new Sma(24); // 24 bars of funding, one day on a 1h chart
let liqAvg = new Sma(6); // six bars of liquidation volume
let extremeLevel: f64 = 30.0;
let fundingThreshold: f64 = 0.01;
let rsiValue: f64 = NaN;
let extreme: bool = false;
export function init(): void {
rsi = new Rsi(i32(p_rsi_period()));
extremeLevel = p_extreme_rsi();
fundingThreshold = p_funding_threshold();
}
export function state(): i32 {
rsiValue = rsi.update(in_close()); // f64 in, f64 out
const fundingAvg = avgFunding.update(in_funding());
const liqRecent = liqAvg.update(in_liqs());
// Three bools, combined; NaN on any side makes its comparison false, so a warming source never fires.
const oversold: bool = rsiValue <= extremeLevel;
const shortsPaying: bool = fundingAvg < -fundingThreshold;
const liquidating: bool = liqRecent > 0.0;
extreme = oversold && shortsPaying && liquidating;
return isNaN(rsiValue) ? 0 : 1;
}
export function finalize(): void {
out_rsi(rsiValue);
out_extreme_rsi(extremeLevel);
out_extreme(rsiValue); // the mark sits on the RSI line
out_is_extreme(extreme ? 1.0 : 0.0); // bool to f64
emitRow();
}
export function reset(): void {
rsi.reset();
avgFunding.reset();
liqAvg.reset();
rsiValue = NaN;
extreme = false;
}Watch the types flow: three f64 inputs feed three classes that return
f64, those numbers feed comparisons that return bool, the bools combine
into one bool, and the ternary turns it into the f64 gate output that
the shape output reads through shape_where. Nothing converts on its
own, and every step is checked before the first bar runs.