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.
RhaiStrategy<B> in crates/vike-script/src/strategy.rs is the binding that plugs the script
context and the registered host functions into the real strategy seam every engine drives
strategies through. A script is compiled once into a RhaiStrategy, holds its own
resource-limited engine, its AST and its context, and from then on is a Strategy<B> like any
compiled one.
A complete script
This is sma_cross.rhai, one of the three examples vike-cli init scaffolds
(crates/vike-cli/src/cmd/init/content.rs's SMA_CROSS_RHAI), trimmed of nothing but comments:
let fast = param("fast", 10.0);
let slow = param("slow", 30.0);
let qty = param("qty", 1.0);
fn on_bar() {
let f = sma(fast.to_int());
let s = sma(slow.to_int());
if s.is_nan() { return; }
let target = if f > s { qty } else { -qty };
let delta = target - position();
if delta > 1e-9 { market( 1, delta); }
if delta < -1e-9 { market(-1, -delta); }
}Three things in it are rules rather than style, and each is argued below: the knobs are declared at
the top level, both indicators are read on the first lines of on_bar before any branch, and the
script trades the difference between a target and position() rather than remembering anything
itself.
Hooks
compile_with_indicators detects which of on_start, on_bar and on_stop the script defines
by walking AST::iter_functions for a zero-argument function of that name. An undefined hook is a
no-op, never a call attempt. on_fill is not a script hook at all: the Strategy impl leaves it
empty, and a script observes its resulting position through position().
param() and the top level
The strategy.rs header's design note is the one thing to carry away: a script's top-level
statements — anything outside a fn — run exactly once, in compile, via
run_ast_with_scope, and never again per hook call. That matters because rhai's call_fn defaults
to re-evaluating the AST's top level into the scope on every call; with a bare buy(1.0); at the
top level, that default silently resubmits an order on every hook call. run_hook therefore calls
with eval_ast(false), and whatever the one-time run recorded is discarded — intents, because they
belong to no bar, and any indicator touched at top level, so that a script's first real bar is the
first bar any indicator ever sees rather than a phantom zero-bar sample.
That one-time run is also what makes param(name, default) work. compile_with_params injects the
overrides before the run, so a swept value is baked into the persisted scope and visible for the
strategy's whole lifetime; param() records each (name, default) first-seen, and
discover_params compiles a script with no overrides and returns those pairs in declaration order
— the sweep UI's view of the knobs. Declare knobs at the top level; a param() call made only
inside a hook body is invisible to that run. A top-level const or let is visible inside every
fn because the one-time run populates a scope that run_hook clones fresh for each call — which
is also why a hook's own locals never accumulate across bars.
Indicators, every bar
The lib.rs contract's second half: call an indicator unconditionally on every on_bar
invocation. An indicator advances only when the script calls it, through the fed-once-per-bar
cache in engine.rs's indicator_value, so a call behind an if skips the bars where the branch
was not taken and silently stops being an average of the last N. Read every indicator into a local
at the top of on_bar, guard the warm-up NaN, then branch. Which names are callable, and in what
forms, is The engine.
A misspelled indicator compiles, and looks like no signal
Rhai resolves a function name when the line runs, not at compile. RHAI_INDICATORS's doc gives
the consequence: a script calling an unbound name hits a function-not-found error every bar and
self-disables after the strategy's consecutive-error cap — mounted, and never trading. vike-cli indicators
prints the names that will not do this; a name it does not print is not callable.
The fail-safe
run_hook's contract: a script error is logged at warn and swallowed — no panic, and no orders
that bar, since the intents recorded before the error are discarded. After 10 consecutive errors
the strategy self-disables for the rest of its life, logging at error, rather than risk a wedged
script hammering the engine; a successful hook call resets the counter. A wrong-typed indicator
argument (sma("20")) is deliberately one of these errors rather than a NaN, so a typo cannot
look like a warm-up that never finishes. Decision record 0024 counts this as one of its rails:
below the panic level, run_hook already fails safe on script errors, and a panicking handler
rides the core's per-dispatch catch_unwind like any compiled strategy.
Running it
In a backtest. The harness registry's rhai arm (crates/vike-backtest/src/harness/registry.rs's
strategy_by_name) compiles an inline src param — required; a missing one is a validation error
at load, never a silent no-op — and every other numeric param is a param() override, the same
knobs a [sweep] varies. vike-cli backtest --script <s.rhai> reads the file and injects its
source into the shipped profile's [strategy.params].src, so the script travels with the request
and the profile stays about the data; vike-cli backtest --list-params --script <s.rhai> prints the
knobs offline through discover_params, with no profile and no server. See Running a backtest
and Sweeps.
Live. [strategy] rhai = "<path>" in a vike-tradehub profile. crates/vike-tradehub/src/config.rs's
resolve_script reads the file at resolve time, logs the path and the sha256 of the source actually
compiled at INFO — the journal's answer to "which code traded", robust to the file being edited
between restarts — and compiles it through the same build_engine the backtest uses, registering
no host function of its own. Under that key every [strategy.params] entry is a param()
override, and the daemon's no-silently-ignored-key posture applies in the script's vocabulary: a
non-numeric value is refused, and so is a key the script's top level never passes to param(…).
That scripts mount live at all is a recorded architecture verdict, and its rails are the ones
above; the daemon is
vike-tradehub.
Validated by an agent. vike-cli mcp's validate_strategy tool goes through
discover_params — the broker-free public compile path, the same error surface as mounting — and
answers a bad script with ok: false and the error, never a tool error.
The Studio editor is where the same script is edited and run in the app; User strategies is where the file lives; the from-scratch tutorial walks one end to end.
The order vocabulary
The Intent verbs a script records and the ScriptCtx they land in — what the reads snapshot, what the verbs push, and how a bar's intents become broker calls.
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.