vike
Scripting

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.

On this page