vike
Tutorials

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 init

vike_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.toml

2. 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-demo

vike_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.rhai

merge_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.rhai

That 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.11

That 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 written

Point 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.31

Same 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.json

The 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_factor serializes as null when it is not finite — the house sentinel is INFINITY when there were no losing trades but some profit, and 0.0 when there was neither.
  • zero_trade is present ONLY when the run closed no trades AND its equity never moved. vike_analytics::zero_trade's ZeroTradeReport::analyze then ranks the probable causes from counters the run already carried — gate drops, stale-price deferrals, session skips, the warm-up requirement — and Display prints 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

On this page