vike
Scripting

Writing an indicator

A Rhai file under user_data/indicators is a real Indicator — the state-first contract, param() knobs, multi-output lines, chart placement, and what an indicator may never do.

crates/vike-script/src/indicator.rs opens by naming the gap it fills: the indicator binding made every registry indicator callable from a script, but a plain Rhai function has nowhere to keep state between bars and no guarantee about how often it is called — and an indicator is exactly the thing that needs both. RhaiIndicator is a user-written file compiled into a real vike_indicators::Indicator.

The contract

A file under user_data/indicators/<name>.rhai defines, in the header's own listing:

fn init() { #{ prev: () } }        // OPTIONAL — the state you keep. Default: an empty map.
fn on_bar(bar) { ... }             // REQUIRED — called EXACTLY once per bar, in order.
fn warmup() { 20 }                 // OPTIONAL — bars before the value means anything.
fn outputs() { ["up", "lo"] }      // OPTIONAL — the output LINES. Default: one, named for it.
fn overlay() { true }              // OPTIONAL — draw on the PRICE pane? Default: false.

Inside on_bar, this is the state and mutations to it persist (rhai's bind_this_ptr), so an author writes a recurrence the way they would think of one. bar is a map of ts, open, high, low, close and volume — six fields, always present; bar_map's doc says the Option fields bid/ask/funding and symbol are deliberately omitted, because a field that is () on most series is a trap dressed as a feature. Returning () means "warming up" and reads as NaN, like a built-in that is not warm yet; read_scalar also coerces integers, so a counting indicator can return this.n without a float cast.

The shipped donchian_high.rhai (crates/vike-cli/src/cmd/init/content.rs's DONCHIAN_HIGH_RHAI) uses the whole contract:

let lookback = param("lookback", 20);

fn overlay() { true }
fn init() { #{ highs: [] } }
fn warmup() { lookback - 1 }

fn on_bar(bar) {
    this.highs.push(bar.high);
    if this.highs.len() > lookback { this.highs.remove(0); }
    if this.highs.len() < lookback { return (); }
    let hi = this.highs[0];
    for h in this.highs { if h > hi { hi = h; } }
    hi
}

compile_indicator runs the top level exactly once, so a top-level let is a real knob, and fails when on_bar is missing or takes the wrong number of arguments — a compile-time answer to what would otherwise be a per-bar function-not-found. warmup() is read as a Dynamic rather than an integer, because param() returns a float and lookback - 1 is the natural spelling; it becomes the trait's lookback(), while lookback_exact() stays false because a user's estimate of their own recurrence is not something the crate can verify.

Three properties the shape buys

The header names them, and none is incidental. Streaming/batch parity holds by construction: vectorize is the fold of on_bar over a fresh instance, so there is one code path rather than two that must agree bit-for-bit as the built-ins' must. Streaming is O(1) per bar, because the author holds their own state and cost cannot depend on history length. An indicator cannot trade: indicator_engine registers no order verbs, no context reads and no other indicators — it is a plain engine plus the same limits build_engine sets, minus the file-backed module resolver, with param as its only host function. A user indicator is a pure function of the bars it was fed and its own state, which is both a safety boundary and what makes the fold sound.

param(), exactly as in a strategy

param(name, default) is the one host function an indicator engine registers, and the design comment says why: an author who has written a strategy already knows it. The first-seen list of param() calls is the indicator's parameter surface, the equivalent of a built-in's IndicatorMeta::params. A call site's arguments are positional in that order — my_mean(50) sets the first declared knob and leaves the rest at defaults — and compile_indicator_with makes two passes, because the positions are only knowable by running the top level. More arguments than declared knobs is an error naming the knobs the file has, never a value quietly dropped. Each distinct argument tuple is its own instance with its own state, built by RhaiIndicator::compile_with re-running the file's top level, so a knob genuinely changes the recurrence.

Several lines out: fn outputs()

fn outputs() returns the ordered line names, and the file then gets the same per-line accessors a built-in gets, under the same namesake rule for whether the bare name binds — my_bands_mid(), spelled by line_fn_name. The header explains why it is a function rather than syntax: init and warmup are read through the identical AST::iter_functions probe, so the author learns no new concept. Names only — no render style, which a built-in declares for the chart and nothing would read here.

read_values enforces the return shape. With a declared list, on_bar must return exactly that many values, in order; padding would invent a number and truncating would hide one, so a mismatch is an error naming both counts. () is warm-up on every line at once, and a single line can warm up alone: [upper, (), lower]. Without outputs(), the file is single-output to the value — one number out, and an array return of any length refused, including a one-element one, because a file that declares nothing must behave exactly as it always did. read_outputs refuses an empty list, a non-string entry, and any line user_line_conflict rejects, and refuses two lines that sanitise to one accessor. One more rule is raised at instantiation by engine.rs's register_user_form: fn outputs() must not depend on a param() knob, because the accessors were registered from the prototype's list.

Faults are held, not silently NaN'd

Indicator::on_bar returns Vec<f64> and has no error channel, so a script that throws on bar N can only be NaN there — and NaN is the warm-up value. The header calls a permanently warming-up indicator the exact failure the strategy engine's bad_arg was written to avoid, so the fault is recorded in RhaiIndicator::fault rather than dropped, and the strategy-side bridge raises it as a Rhai error where the consecutive-failure cap can stop the strategy.

On the chart

user_meta wraps a compiled prototype as a &'static IndicatorMeta whose factory clones it — the whole seam between "a user wrote a file" and "the chart can plot it"; it leaks, so a binary calls it once at startup. overlay() defaults to false — its own pane — because the crate cannot know the scale of an unlabelled number, and a z-score on the price axis would wreck the price pane's autofit and take the candles with it. param_spec opens min/max as wide as f64 goes rather than guessing a range, and infers step from the default: a whole number steps by 1, a fraction by 0.001.

Names a file may not take

user_indicator_conflict refuses a file stem that is a Rhai reserved word, a host read or verb, a built-in indicator's name — even one the engine leaves unbound, such as bollinger — or a built-in's per-line accessor such as stochastic_k. All four are refusals of silent shadowing: the user set is registered last and register_fn replaces, so sma.rhai would answer every sma(20) on that one machine. The same rules apply to every line an outputs() would generate.

The file's stem is its name, and the strategy calls it by that name; how the folder is scanned, reported and installed is Loading indicators. The built-in catalog it sits beside is under indicators, where the user category is the one it lands in.

On this page