vike
Tutorials

A Rhai strategy from scratch

Write, validate and backtest an interpreted strategy against the same Strategy seam a compiled one uses, and learn the four rules the engine will not forgive.

A Rhai script is not a second-class strategy path. vike_script::RhaiStrategy<B> implements vike_model::Strategy<B> — the same seam every compiled Rust strategy is driven through — so the backtest simulator and the live core mount it with no special case. What differs is the host surface: a script cannot see a broker, only a handful of registered functions.

The shape of a script

Three hooks, each a zero-argument fn, all optional. RhaiStrategy::compile_with_indicators detects which of on_start / on_bar / on_stop the AST defines and calls only those.

// Top level: runs EXACTLY ONCE, at compile.
let len = param("len", 20.0);
let qty = param("qty", 1.0);

fn on_bar() {
    let m = sma(len.to_int());          // read first, every bar
    if m.is_nan() { return; }           // warm-up
    let target = if close() > m { qty } else { 0.0 };
    let delta  = target - position();
    if delta >  1e-9 { market( 1,  delta); }
    if delta < -1e-9 { market(-1, -delta); }
}

The top level runs once and never again. compile executes it through Engine::run_ast_with_scope and run_hook then calls with CallFnOptions::default().eval_ast(false), because rhai's default re-evaluates an AST's top level on every call — which would make a bare top-level buy(1.0); resubmit an order on every hook invocation. Anything that one-time run recorded is discarded: the intents, and the indicator caches too, so the first real on_bar is the first bar any indicator has ever seen.

A script holds no cross-bar mutable state. run_hook clones the persisted scope for each call, so a hook's own let locals die with the clone; a top-level const survives because it was in the scope before the clone was taken. State lives host-side — the broker's position() and the indicator caches in ScriptCtx.

What you can call

engine::HOST_FN_NAMES is the whole non-indicator surface: the reads close / open / high / low / volume / position / price / equity / index / now, the order verbs buy / sell / limit / market, and param. Each verb pushes an Intent into ScriptCtx::intents through a cloned Arc — buy(qty) and sell(qty) are market(1, qty) and market(-1, qty); limit(side, qty, price) rests one — and strategy.rs drains the recorded intents onto the real broker at the end of the bar.

param(name, default) is the sweepable knob. First-seen wins in the (name, default) list discovery reports — the call itself still returns its own default — the injected override replaces the default when a profile supplies one, and the default argument is a rhai::Dynamic on purpose: registered as f64 it accepted param("fast", 5.0) and rejected param("fast", 5), so a knob worked or did not depending on a decimal point.

Indicators are bound from vike_indicators::registry(), not hand-listed: RHAI_INDICATORS is the registry minus everything unbound_reason rejects, and register_indicators filters on the same predicate, so the advertised set and the bound set cannot drift. Each bound name gets one form per argument count from zero (all registry defaults) up to its own parameter count.

MAX_INDICATOR_ARITY is the ceiling on that ladder, and an indicator with more parameters than it is not bound at a lower arity — it is not bound at all. See the fourth refusal below.

Call every indicator unconditionally, on every bar

An indicator advances only when the script calls it. A call behind an if silently skips the bars the branch did not take, and the number it then returns is no longer an average of the last N — a wrong value, never an error. Read every indicator on the first lines of on_bar, before any branch or early return.

Five kinds of registry entry are deliberately not bound, each because binding them would return a silently wrong number: a name rhai reserves (var is a reserved word, so var(20) fails at parse time), a name that would shadow a host function, the batch_only three that read future bars (ichimoku / zigzag / williams_fractal), an indicator declaring more parameters than MAX_INDICATOR_ARITY, and a multi-output indicator whose line 0 is not its namesake.

The fourth is the one the argument-count ladder above would otherwise hide: the forms stop at the ceiling, so binding such an indicator would pin every parameter past it to the registry default — knobs that look present and are not. Today that is kst, with nine; it is absent rather than crippled, and Rust is where you construct it.

The fifth is about which line you get: bollinger(20) would hand back the UPPER band to someone who read it as the middle, so the bare name is refused and bollinger_mid() is the spelling — every line of every multi-output indicator has its own accessor. unbound_reason answers why any particular name is missing. vike-cli indicators prints the callable set off the same derived list the host binds from, including your own user_data/indicators/*.rhai files in their own block.

What the engine will not let you do

build_engine opts into bounded limits (of these, a plain rhai::Engine::new() bounds only call depth, and that already at 64 in a release build), applied per hook invocation rather than cumulatively: 2,000,000 operations, 64 call levels, 8 KB strings, and 4096 elements for an array or a map. set_module_resolver installs a DummyModuleResolver, so every import is refused — a default engine roots a FileModuleResolver at the process working directory, which for the daemon is <project>, the operator's settings tree and credential store included. print and debug are redirected onto the tracing facade, because the embedding binary's stdout is a protocol.

Runtime errors fail safe: a hook that errors emits zero orders that bar and any intents it recorded before the error are discarded. After ten CONSECUTIVE errors the strategy self-disables; a successful call resets the counter.

Write the indicator the strategy needs

The rest of this page is one real strategy, written from nothing and run to a number. It wants two values per bar: a breakout level, which needs a memory of earlier bars, and a measure of how wide the current bar is, which needs none.

The first ships with vike-cli init as user_data/indicators/donchian_high.rhai. The second we write — the smallest shape the indicator seam accepts:

// range_pct — this bar's high-low range as a percentage of its close.
//
// The smallest useful shape: no window, no state, one number per bar.

fn init() {
    #{}
}

fn on_bar(bar) {
    if bar.close == 0.0 { return (); }
    (bar.high - bar.low) / bar.close * 100.0
}

Only on_bar is required. crates/vike-script/src/indicator.rs declares INIT, WARMUP, OUTPUTS and OVERLAY as optional, and an absent init yields exactly the empty map this file returns — it is written out here because it is the hook you extend the moment the indicator needs to remember anything.

Returning () is the seam's NaN. A bar whose close is zero has no percentage, so the indicator says so rather than dividing.

The strategy that calls it

// quiet_breakout — buy a breakout, but only out of a quiet bar.
//
// Uses the shipped donchian_high user indicator for the breakout level and range_pct (written for
// this tutorial) to insist the bar that breaks out is not itself a wide, already-moved bar.
let lookback = param("lookback", 48.0);
let calm = param("calm", 1.0);
let qty = param("qty", 0.05);

fn on_bar() {
    let hi = donchian_high(lookback.to_int());
    let rng = range_pct();
    if hi.is_nan() || rng.is_nan() { return; }

    if close() > hi && rng < calm && position() <= 0.0 { market(1, qty); }
    if close() < hi * 0.97 && position() > 0.0 { market(-1, position()); }
}

Both indicator reads sit on the first two lines of on_bar, unconditionally, ahead of every branch — the rule from the callout above. The NaN check comes after both reads, never instead of one.

lookback is declared as 48.0 and converted at the call site, because the indicator's own knob is a count of bars.

Validate the knobs before you run

vike-cli backtest --list-params --script quiet_breakout.rhai
lookback = 48
calm = 1
qty = 0.05

--list-params is offline. crates/vike-cli/src/cmd/backtest.rs's execute reads the file, runs vike_script::discover_params and stops — no profile, no server. Compiling IS validation here, so a syntax error, or a TOP-LEVEL call to a function that is not bound, is the answer you get instead of a list — rhai resolves a name when the line RUNS, so a call inside on_bar still passes. An agent gets the same answer from the read-only validate_strategy MCP tool.

The defaults print as rhai renders them, which is why 48.0 reads back as 48 and 1.0 as 1. The names and their order are the script's own, in declaration order.

vike-cli indicators is the companion check: it prints the callable set, with your own user_data/indicators files in their own block.

The first run: zero trades

Point a run profile at the script exactly as the RSI tutorial does, then run it:

vike-cli backtest --profile tut_breakout.toml
{
  "final_equity": 10000.0,
  "funding_paid": 0.0,
  "max_drawdown": 0.0,
  "n_trades": 0,
  "name": "quiet_breakout — BTCUSDT 1h",
  "per_symbol_pnl": [
    [
      "BTCUSDT",
      0.0
    ]
  ],
  "profit_factor": 0.0,
  "sharpe": 0.0,
  "total_return": 0.0,
  "win_rate": 0.0,
  "zero_trade": {
    "causes": [
      {
        "code": "no-orders",
        "detail": "Over 8760 bar(s)/tick(s) nothing was denied, deferred, or gated and no trade closed, so no gate explains the flat result. The strategy most likely never submitted an order - check its entry condition and that it reads the SAME symbol the data is tagged with (bar-mode `snap_to_properties` re-tags bars as `SYMBOL.VENUE`, which a strategy routing on the bare symbol will miss).",
        "headline": "No order-blocking cause was detected."
      }
    ]
  }
}
...(diagnostic continues; the full JSON is in the run's report.json)

Nothing traded over 8760 hourly bars — a year of BTCUSDT.

The zero_trade object is not decoration. crates/vike-analytics/src/report.rs's BacktestReport::zero_trade is Some only when a run closed no trades AND its equity never moved, and when it is present the human Display table stops after final_equity and n_trades: 0, printing the diagnosis in place of the metric rows below it — the JSON above, which is all vike-cli backtest prints, carries both.

crates/vike-analytics/src/zero_trade.rs's rank_causes ranks the things that block orders: gate denials, the stale-price wait, a closed session, a warm-up shortfall, an empty data slice. Here none of them fired, so it emitted the catch-all "no-orders" cause. Read it for what it is — the analyzer diagnoses gates, not logic, and it is telling you that nothing stopped your orders because there were none to stop.

One thing this run does not show you. The profile carries the strategy source in [strategy.params].src (the --script flag is what injects it — crates/vike-cli/src/cmd/backtest.rs's inject_script_src), but the indicator FILES are never shipped. The service installs its own <project>/user_data/indicators/*.rhai at startup — crates/vike-datahub/src/datahub_cli.rs's install_user_indicators — and that set wins. The same script against two services with different indicator directories returns different numbers, and nothing in the response says so.

The cause, and the corrected run

The first suspect was the range filter: calm = 1.0 demands an hourly bar whose high-low range is under 1% of its close. Raising it to 3.0 and re-running produced the same zero-trade JSON. That is the useful half of a failed fix — the filter was not the gate.

The gate was the comparison itself. The shipped donchian_high (crates/vike-cli/src/cmd/init/content.rs's DONCHIAN_HIGH_RHAI) pushes the current bar's high into its window BEFORE taking the maximum:

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
}

So the value returned on a given bar already includes that bar's own high. A close can never exceed the high of its own bar, which makes close() > donchian_high(n) false on every bar of every tape. The strategy was right about everything except the one comparison it was built on, and no error was ever raised — the indicator is a perfectly good rolling maximum, just not the one this entry needs.

The correction is a reordering: take the maximum of the window as it stood BEFORE this bar, then push. Saved as its own breakout_level.rhai and called in place of donchian_high, the same profile gives:

{
  "final_equity": 8334.568427500006,
  "funding_paid": 0.0,
  "max_drawdown": 0.1839635835571801,
  "n_trades": 48,
  "name": "quiet_breakout — BTCUSDT 1h",
  "per_symbol_pnl": [
    [
      "BTCUSDT",
      -1180.6035000000015
    ]
  ],
  "profit_factor": 0.687335413608655,
  "sharpe": -1.0475904521415624,
  "total_return": -0.16654315724999946,
  "win_rate": 0.3125
}

Reading that result

The strategy trades now, and it loses money.

48 trades over the year, of which 31.25% won. Equity ended at 8334.57 of the 10000 it started with: a total return of -0.1665, which is a 16.65% loss.

Max drawdown was 18.40% and Sharpe was -1.05.

Profit factor is gross profit over gross loss (crates/vike-analytics/src/metrics.rs's profit_factor). At 0.687 it says the winners gave back about 69 cents for every dollar the losers cost.

What the numbers say: the script compiles, both user indicators resolve, the entry condition now fires, and the resulting edge is negative on this tape.

What they do not say: anything about the idea in general. This is one symbol, one year, one bar interval, one parameter point, measured in-sample with no out-of-sample window. A single backtest number is the weakest evidence the platform can produce, and the honest next step is not to tune calm until the number turns green — a sweep will always find the best-looking point on the tape it searched.

Sweep and walk-forward is where that goes next, and it is the tutorial that shows what an out-of-sample number does to an in-sample one.

Mounting it live

A script can also mount LIVE, by PATH rather than by name: [strategy] rhai = "<path>" in a vike-tradehub profile compiles it through the same engine builder and logs the source's sha256, which is the journal's answer to which code traded.

Where next

On this page