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:
- Not callable from Rhai.
var(Variance) is a reserved word, sovar(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. - Collides with a host function —
HOST_FN_NAMES. batch_only.ichimoku,zigzagandwilliams_fractalread 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.- 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 iskst, with 9. - Multi-output under its bare name, when line 0 is not the namesake line.
bollinger,donchian,keltner,envelopesandstd_error_bandsall declareupper, mid, lower, sobollinger(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 refusedmacd,adx,fisher,kst,kvoandsupertrendfor 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.
Scripting
A Rhai script as a real strategy in Vike Trader — the two-part script contract, the user-written indicator seam beside it, where the files live, and what mounts a script live.
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.