vike
Scripting

The engine

The resource-limited rhai::Engine a strategy script runs in — what build_engine registers, what it refuses, why an unbound indicator is unbound, and where a script's print goes.

Every mounted strategy script gets its own rhai::Engine, built once by build_engine in crates/vike-script/src/engine.rs. Its doc states the shape: bounded resource limits — opt-in on a plain rhai::Engine::new(), whose Limits::new() leaves operations, strings, arrays and maps unlimited and caps only call and expression depth — so a runaway or malicious script cannot hang the strategy loop, plus the full read/verb/indicator host-function surface registered against the strategy's shared context. It is called once per mounted strategy (RhaiStrategy::compile_with_params and discover_params), never per bar; run_hook reuses the stored engine, and rhai's function map is an identity-hashed table with a per-call-site cache, so the size of the registered set changes nothing on the bar path.

The limits

build_engine sets set_max_operations(2_000_000), set_max_call_levels(64), set_max_string_size(8 * 1024), set_max_array_size(4096) and set_max_map_size(4096). They are per hook invocation — each call_fn gets a fresh operation counter — not cumulative across the strategy's lifetime. A user indicator's engine (indicator.rs's indicator_engine) mirrors the same five numbers, so one runaway script is bounded the same way whichever seam loaded it. What a script sees when it hits one is Limits.

What a script may not do

import is refused, and that line is load-bearing

build_engine's doc calls set_module_resolver the load-bearing line in the function. A default rhai::Engine installs a FileModuleResolver rooted at the process's working directory, so import "…" as m; reads a file from disk — and the daemon's working directory is <project>, the operator's settings tree with the credential store in it. It is a capability nobody granted: it appears in no registration list because it is the interpreter's default, exactly the surface an allow-list cannot see. DummyModuleResolver makes every import a refusal, and crates/vike-script/tests/module_import_refused.rs gates both engines. The same doc records why that matters beyond tidiness: the verdict that lets a script mount live would be reopened by network, filesystem, process or credential access from a script, and it rests on the live engine surface being exactly the backtest one — so closing import keeps a premise that verdict already relies on rather than narrowing a decided grant.

print and debug are redirected, not removed. redirect_script_output points them at the tracing facade under the target vike_script — print at INFO, debug at DEBUG — because rhai::Engine::new() wires both to println!, and this crate is embedded in binaries whose stdout is a protocol: vike-cli mcp speaks newline-JSON-RPC over stdout and compiles caller-supplied scripts in-process, and vike-tradehub keeps its stdout protocol-only. The doc's second warning is about volume: a print inside on_bar fires once per bar either way, but at INFO it reaches the file layer, which defaults to trace and rolls daily, so a chatty script over a long backtest writes into <project>/settings/state/logs. VIKE_LOG_FILE_LEVEL is the knob.

What is registered

Four families, in registration order.

Reads (register_reads): close, open, high, low, volume, position, price, equity return f64; index and now return i64. Each is a pure getter over one field of the script context.

Verbs (register_verbs): market(side, qty), buy(qty), sell(qty), limit(side, qty, price). Each pushes an Intent; the script never sees the context directly.

Indicators (register_indicators), then the installed user indicators. register_fn replaces, so being registered after the built-ins is what would let a user file take over a built-in name — the hazard user_indicator_conflict and user_line_conflict refuse at load. An explicitly passed indicator wins over the installed set for the same reason one step later: compile_with_indicators registers that set after build_engine has returned.

param(name, default): records (name, default) for discovery, first seen wins, and returns the injected override if present, else the default. The default is a Dynamic, not an f64: registered as f64 it accepted param("fast", 5.0) and rejected param("fast", 5) — a knob that worked or did not depending on whether the author typed a decimal point.

HOST_FN_NAMES lists every non-indicator name above, and its doc says it exists for the indicator bridge rather than for documentation: register_fn replaces a same-name registration rather than erroring, so a registry indicator that happened to be called close would silently shadow close() for every script. exclusion refuses to bind such a name; nothing collides today.

Which indicators bind

RHAI_INDICATORS is the exact set register_indicators binds as bare names, derived at run time from vike_indicators::registry() minus every entry unbound_reason rejects, so the advertisement and the binding cannot drift. The count is deliberately not stated; RHAI_INDICATORS.len() is the answer. exclusion carries the rules, each a silent-wrong-answer hazard rather than taste:

  1. Not callable from Rhai. var (Variance) is a reserved word, so var(20) fails at parse time. The check compiles <name>() on a throwaway raw engine, so a future rhai release that reserves another word cannot leave an advertised-but-uncallable name behind.
  2. Collides with a host function — HOST_FN_NAMES.
  3. batch_only. ichimoku, zigzag and williams_fractal read future bars, so their streamed value is revised later by the batch path — a backtest-vs-chart divergence that shows up as a wrong number, never an error.
  4. More parameters than the bridge expresses. Forms go up to MAX_INDICATOR_ARITY, which is 3; binding a deeper one would pin its tail parameters to their defaults, knobs that look present and are not. Today that is kst, with 9.
  5. Multi-output under its bare name, when line 0 is not the namesake line. bollinger, donchian, keltner, envelopes and std_error_bands all declare upper, mid, lower, so bollinger(20) would return the upper band to an author who read it as the middle. The doc records that this rule narrowed: it used to refuse every multi-output indicator, which refused macd, adx, fisher, kst, kvo and supertrend for a hazard that did not apply (their line 0 is their own name) while leaving Bollinger's middle band unreachable by any spelling.

Every line of every multi-output indicator is reachable through a per-line accessor, line_fn_name(indicator, line) = <indicator>_<line>: bollinger_mid, macd_signal, adx_plus_di, stochastic_k. sanitize_line lowercases and replaces every character a Rhai identifier cannot carry with _, which exists for one family — stochastic, stochf and stochrsi name their lines %K and %D. Accessors obey the same batch_only and arity rules as the bare name. is_callable — bare name or at least one accessor — is the question an advertising surface should ask, since bollinger is absent from RHAI_INDICATORS while bollinger_mid(20) is callable; unbound_reason's message for the bare name names the accessor that works.

The call shape

Each bound name gets one form per argument count, from zero up to its parameter count, capped at 3. name() takes every registry default — the only spelling for the parameterless indicators such as the candlestick patterns, vwap and obv, and the only way to reach a fractional default: psar's step defaults to 0.02 with a [0.001, 0.5] range, which no integer argument expresses. Arguments beyond the parameter count are not registered, so doji(5) is a function-not-found rather than an argument silently discarded, while parameters left off the end take their defaults.

Arguments are rhai::Dynamic, so ints and floats both resolve — alma(9, 0.85, 6) is the natural spelling of an integer period, a float offset and an integer sigma. A non-numeric argument is a raised error from bad_arg, not a NaN, because NaN is exactly what an indicator returns while warming up: every shipped template opens if h.is_nan() { return; }, so sma("20") returning NaN would make a strategy return on every bar forever, looking like a warm-up that never finishes. An error is loud in the way the strategy already handles — it counts consecutive failures and self-disables.

indicator_value is the bar path: it looks up or lazily builds the streaming instance cached under "<name>:<coerced params>", feeds it the current bar at most once per bar, and returns the requested line. Because the key is the coerced parameters, sma() and sma(20) are one instance sharing one feed, and every line of one indicator shares one instance too: bollinger_upper(20) and bollinger_mid(20) in one bar feed Bollinger once and read two entries of the same output vector.

On this page