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.
Writing a strategy
The compiled-script strategy — the three hooks, param() knobs, why the top level runs exactly once, the fail-safe, and the three ways a finished script is run.
Loading indicators
How user_data/indicators/*.rhai becomes a process-wide set every strategy can call — the load report, its five diagnostics, the once-per-process install, and which binaries do it.