RSI mean-reversion backtest
Take the shipped rsi_meanrev example from vike-cli init to a report on the demo tape, and read what the run actually says.
The quickstart runs sma_cross. This tutorial runs the other shipped example — rsi_meanrev,
which fades extremes instead of following them — because the two make opposite bets and running
both over one slice is the quickest way to see what a tape has been doing. No credentials are
needed at any step.
1. Scaffold the examples
vike-cli initvike_cli::cmd::init writes <project>/user_data and prints the map. Its files table is the
exhaustive list of what it ships and the only thing --reset can overwrite; without --reset it
writes only what is absent, so a file you edited is never touched, never diffed and never backed
up. Two entries matter here:
user_data/strategies/rhai/rsi_meanrev/rsi_meanrev.rhai
user_data/strategies/rhai/rsi_meanrev/default.toml2. What the script says, and the two lines that are not style
content.rs's RSI_MEANREV_RHAI is eleven lines of body. It declares four knobs at the top level
— len 14, lo 30, hi 70, qty 1.0 — and then:
fn on_bar() {
let r = rsi(len.to_int());
if r.is_nan() { return; }
if r < lo && position() <= 0.0 { market(1, qty); }
if r > hi && position() >= 0.0 { market(-1, qty); }
}The indicator is read first, on every bar, before any branch. An indicator only advances when
the script calls it, so a call inside an if would skip bars and quietly stop being an average of
the last N. content.rs's module doc states the rule and
crates/vike-cli/tests/init_cli.rs's
every_shipped_rhai_strategy_reads_its_indicators_before_it_branches gates it for every shipped
script.
The position() guards are what stop it stacking size — it enters only from flat or from the
other side, so a long stretch below lo opens one position rather than one per bar.
The is_nan() return is the warm-up. crates/vike-indicators/src/indicators/base.rs's Rsi is
the Wilder RSI — a seeded average of the first n gains and losses, then the recurrence — and it
answers NaN until the bar at index n, so a 14-period RSI decides nothing for the first fourteen
deltas.
A name outside the bound set fails on every bar, not at load
crates/vike-script/src/engine.rs's RHAI_INDICATORS is the host-bound set, derived from
vike_indicators::registry() rather than hand-listed. Rhai resolves a name when the line RUNS, so
compiling proves nothing: a misspelled indicator errors every bar and, after ten consecutive
errors, RhaiStrategy self-disables — a strategy that looks mounted and never trades, which a
backtest reports as a flat curve. vike-cli indicators prints the set that is actually bound.
3. Seed the tape
backtest --seed-demovike_data::demo's DEMO_SLICES writes two spans under the venue id demo: BTCUSDT hourly from
2025-01-01T00:00:00Z to 2025-07-01T00:00:00Z, and the last seven days of the same curve at one
minute. Every bar is a closed-form function of its timestamp, so a re-seed is free as well as
safe — each slice appends under a versioned commit key and a store that already holds the tape
takes zero rows.
4. Run it
The shipped profiles/backtest.toml already names that slice. Its [strategy.params] carries
sma_cross's fast/slow, which rsi_meanrev does not read — and a params table is a READER,
not a schema, so those two keys are ignored in silence and every rsi_meanrev knob keeps the
script's own default. Replace them, or pass the shipped preset:
vike-datahub &
vike-cli backtest \
--profile user_data/profiles/backtest.toml \
--preset user_data/strategies/rhai/rsi_meanrev/default.toml \
--script user_data/strategies/rhai/rsi_meanrev/rsi_meanrev.rhaimerge_preset_params merges a preset's keys into [strategy.params] one for one, last-wins, and
leaves the rest of the profile untouched. A preset is FLAT: a [params] wrapper is refused,
because merged as-is it would hand the strategy one parameter called params that nothing reads
while every knob kept its default, and a src key is refused because that is the strategy's source
rather than a knob. The preset is resolved locally and merged before the request goes out — only
the profile text crosses the wire, so a preset named inside the profile would resolve against the
server's user_data/, not yours.
To see the knobs without a server at all:
vike-cli backtest --list-params --script user_data/strategies/rhai/rsi_meanrev/rsi_meanrev.rhaiThat runs vike_script::discover_params, which compiles the script and executes its top level —
where param(name, default) is called — and returns the pairs in declaration order.
5. What comes back
On the demo tape, with the preset's 14/30/70 and its qty = 1.0:
name: rsi_meanrev — demo BTCUSDT 1h
final_equity: -133004.57
total_return: -1430.0457%
n_trades: 51
win_rate: 0.0000%
sharpe: 1.6844
max_drawdown: 1430.7866%
per_symbol_pnl:
BTCUSDT: -136596.11That is a real run and it is worth sitting with rather than skipping past. The demo tape is not a
one-way line: crates/vike-data/src/demo.rs's price_at is a drift plus three superposed cycles —
weekly, daily and a fast wobble — so the curve does snap back, and on the weekly leg it snaps back
hard.
What defeats a short-the-overbought rule here is the drift underneath those cycles. The trend term
adds 9.5 an hour over the hourly span's six months, so the tape opens near 42,000 and its trend
alone ends near 83,000, and each weekly peak sits above the one before it. The strategy shorts each
overbought reading into that rise, and qty = 1.0 means one whole unit of a five-figure instrument
against ten thousand of cash. A win rate of exactly zero over fifty-one trades is not noise; it is a
strategy selling a drifting tape with size it cannot carry.
Note the Sharpe: 1.6844, on a run that lost fourteen times the account. A ratio computed on per-bar returns says nothing about whether the account survived, which is why the report carries drawdown and equity beside it and why no single number is the answer.
6. The same strategy on real bars
Pull real public hourly bars — no credentials, no venue account:
backtest --fetch binance:BTCUSDT:1h --from 2025-01-01T00 --to 2026-01-01T00 8760 bars returned, covering 1735689600000..=1767222000000; 8760 rows writtenPoint the profile's venue at binance, and size it for the account — qty = 0.05 rather than
1.0:
name: rsi_meanrev — BTCUSDT 1h
final_equity: 12128.52
total_return: 21.2852%
n_trades: 33
win_rate: 69.6970%
sharpe: 1.7800
max_drawdown: 8.3457%
per_symbol_pnl:
BTCUSDT: 2448.31Same script, same thresholds, same fee rate. What changed is the tape and the size. Two thirds of the trades win and the worst drawdown is 8.3%. Read that as a demonstration of how much a result depends on its slice, not as evidence the strategy works: one symbol over one year is one sample, and walk-forward is the next thing to do to it.
7. Where the run went
user_data/runs/1788477441-18704-0/
manifest.json
report.jsonThe manifest records what produced the numbers — the run id, the profile path and name, the data slice with its venue-tagged series and the store root, and the start and finish timestamps. The report is the metrics above as JSON. Runs on disk covers the layout.
8. Read the report
BacktestReport carries the profile name, final equity, total return, trade count, win rate,
Sharpe, max drawdown, profit factor, net funding and per-symbol PnL. Two fields are conditional and
both tell you something:
profit_factorserializes asnullwhen it is not finite — the house sentinel isINFINITYwhen there were no losing trades but some profit, and0.0when there was neither.zero_tradeis present ONLY when the run closed no trades AND its equity never moved.vike_analytics::zero_trade'sZeroTradeReport::analyzethen ranks the probable causes from counters the run already carried — gate drops, stale-price deferrals, session skips, the warm-up requirement — andDisplayprints that diagnosis instead of an all-zero metrics table. On a mean-reversion strategy the usual cause is thresholds the tape never reached.
9. The contrast run
Re-run the same profile against sma_cross and ema_trend. content.rs ships all three for one
reason: they are three SHAPES — always-in reversal, mean reversion, long-only filter — not three
indicators. If the long-only rule beats the always-in one, the short side was costing money.
Where next
- Sweep, then walk-forward — search the RSI thresholds, then check the winner out of sample.
- Write a Rhai strategy from scratch.
- Read a backtest report and sizing and zero-trade diagnosis.
- Presets and Run profiles.
Tutorials
Sixteen worked runs, end to end. Every number on these pages was pasted from a real execution, and the few illustrative ones say so on the page.
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.