A custom feature, proven
Implement a ColumnFeature over one numeric column and prove its streaming form equals its batch form bit for bit with the shipped parity harness, then write the same idea as a user indicator script and run it.
A study trains on a MATRIX — a column per feature, a row per bar, computed in one batch pass. A live
strategy computing that same feature per bar has to arrive at the same number for the same
timestamp, or the model receives inputs it never saw. Nothing errors; the edge simply degrades.
vike_indicators::feature is the seam that makes that failure unrepresentable, and this tutorial
walks one feature through it.
Write one kernel, get the other free
use vike_indicators::feature::ColumnFeature;
/// Percent change over `lag` rows, floored at the row it answers for.
pub struct PctChange {
pub lag: usize,
}
impl ColumnFeature for PctChange {
fn vectorize(&self, x: &[f64]) -> Vec<f64> {
(0..x.len())
.map(|i| if i < self.lag { f64::NAN } else { x[i] / x[i - self.lag] - 1.0 })
.collect()
}
fn reach(&self) -> usize {
self.lag + 1
}
}That is the whole implementation. vectorize is the batch form and it is the REFERENCE — it is
what the study trains on. FeatureStream is the streaming form and it is PROVIDED: it retains
reach values and re-runs the feature's OWN vectorize over that tail, taking the final element.
There is deliberately no on_value method to override, so an implementor has no second kernel to
write and none to get wrong. Bit-identity is a property of the construction rather than of a test
that has to keep noticing.
The contract is two lines: vectorize returns exactly one value per input row, and the value at
row i reads only rows i + 1 - reach() ..= i — warm-up rows are NaN, nothing looks forward.
Before the buffer is full it holds the entire prefix x[0 ..= i], which is exactly what the batch
form can read at that row, so the warm-up rows agree for the same reason the steady state does.
Why this is not Indicator. vike_indicators::Indicator streams a vike_model::Bar — OHLCV
plus a timestamp. A model feature is a rolling statistic over an ARBITRARY numeric column: a
funding rate, an open-interest series, a venue spread, a per-cohort aggregate. Pushing one of those
through a Bar means inventing five fields.
Exactly one number is left to get wrong
reach — how many trailing rows, including the current one, a value can read. Over-declaring is
safe and merely wasteful. Under-declaring returns a different number from the streaming path,
silently, past a threshold no short test reaches. That is not a hypothetical shape:
crates/vike-indicators/tests/parity.rs's every_trimmed_indicator_is_truncation_invariant exists
because six indicators shipped with exactly that defect, and were green because the gate ran 400
bars while the trim first fired at 512.
Two shipped features show what an exact reach looks like. WindowFeature::reach is
period + lag — every window kernel reads the period values ending lag rows back, so the
deepest row reached is period + lag - 1 back and one fewer value is not enough.
rolling::LaggedDiff::reach is lag + 1, and that feature is deliberately NOT expressed as a
WindowFeature: a window kernel blanks its whole window when min_periods is unmet, so one NaN
inside lag + 1 rows makes the answer NaN, while a lagged difference reads exactly two rows and
still answers a number when the rows between them are missing.
Prove it with the shipped harness
The harness is vike_indicators::test_support, public API behind the crate's test-support
feature — a shipped build compiles none of it. It is a LIBRARY module rather than a tests/ file
for a specific reason: a study's own feature definitions belong to whoever wrote the study and live
in the author's own tree, so a test binary inside this repository could never run on them.
[dev-dependencies]
vike-indicators = { version = "*", features = ["test-support"] }#[test]
fn pct_change_streams_exactly_as_it_batches() {
vike_indicators::test_support::assert_feature_parity("pct_change(5)", &PctChange { lag: 5 });
}assert_feature_parity is the one call an author makes. It runs assert_stream_matches_batch over
every entry of SHAPES — random-walk, trend, mostly-flat, high-vol, with-gaps,
near-bound, each generated deterministically and each distinct, three of them gated to actually
contain what they are named for — then assert_stream_matches_batch_per_group over a stacked panel
of three unequal groups, so a boundary lands mid-window rather than on a convenient multiple of the
period, then assert_reach_is_tight. The series length is DERIVED as six times the reach, floored
at 400 rows, because a constant long enough for a 3-row window is nowhere near enough to truncate a
168-row one.
The tolerance is zero. Comparison is f64::to_bits equality with both-NaN counting as equal.
That needs no argument about float error budgets: the streaming form re-runs the same vectorize,
so there is no second arithmetic that could round differently.
Three refusals make the gate non-vacuous:
reach() == 0is refused — a feature reads at least the row it answers for.- A series no longer than the reach is refused, because the buffer would never evict a value and the assertion would compare a batch pass with itself while staying green with the reach declared arbitrarily wrong.
assert_reach_is_tightasserts the OPPOSITE direction — that retaining one value FEWER changes the answer — so a reach padded to uselessness (usize::MAX / 2passes every parity assertion and streams nothing) is reported rather than rewarded. A reach pastMAX_TESTABLE_REACH(2^20) is refused outright, because a stream retaining effectively everything is a batch pass wearing a streaming interface. A reach of 1 has no shorter tail to compare against and is reported as a no-op rather than as a pass.
One stream per instrument
FeatureStream's buffer is a rolling window over whatever it is fed, with no notion of an
instrument. A caller with several instruments needs one stream each, or a reset() at the
boundary — the same discipline vike_analytics::signal::hysteresis states for its own carried
state, and the one window::per_group enforces on the batch side.
assert_stream_matches_batch_per_group is what gates it.
Which preset a feature should take
WindowSpec ships two. WindowSpec::indicator(period) is the chart preset: the window ends at the
current row, population variance. WindowSpec::point_in_time(period) is the model-feature preset:
the window EXCLUDES the current row (lag = 1) and uses sample variance. Pick the second for
anything a model trains on.
The other custom indicator: a script the engine streams
ColumnFeature is the route for a study's feature matrix. There is a second route, for a rule you
want a live strategy to CALL, and it needs no Rust at all: a file dropped into
user_data/indicators/.
The contract is state-first and is stated in crates/vike-script/src/indicator.rs's module doc.
on_bar(bar) is required and is called exactly once per bar, in order. init(), warmup(),
outputs() and overlay() are optional. Inside on_bar, this IS the state and mutations to it
persist, so an author writes a recurrence the way they would think of one. Returning () means
"warming up" and reads as NaN.
vike-cli init writes two of them, deliberately unlike each other. The first keeps a window, which
is exactly the thing a strategy script cannot express on its own:
// The highest high of the last `lookback` bars.
//
// This is the shape a strategy script cannot write on its own: it has to remember earlier bars.
//
// `param()` makes `lookback` settable from the call site — `donchian_high(50)` — while keeping 20 as
// the default, so `donchian_high()` still works. Anything above a `fn` runs once per setting.
let lookback = param("lookback", 20);
// This one IS a price, so on a chart it belongs over the candles rather than in a pane of its own
// (the default). Delete this to see the difference — nothing else about the indicator changes, and
// a strategy calling it cannot tell either way.
fn overlay() {
true
}
fn init() {
#{ highs: [] }
}
// Until the window is full there is no "highest of 20", so say so with () -> NaN.
fn warmup() {
lookback - 1
}
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
}The second keeps a recurrence instead — two numbers of state, no buffer, and the same work per bar however long the series:
// Consecutive up-closes as a positive count, consecutive down-closes as a negative one.
//
// Contrast donchian_high.rhai: no window, no buffer — two numbers of state and the same work per
// bar however long the series.
fn init() {
#{ prev: (), run: 0 }
}
// One bar, not zero: "up or down versus the previous close" cannot mean anything on the first bar,
// because there is no previous close yet. warmup() is the index of the first bar your value is real
// on, so leaving it at the default 0 here would be a claim that is one bar wrong.
fn warmup() {
1
}
fn on_bar(bar) {
if this.prev == () {
this.prev = bar.close;
return ();
}
if bar.close > this.prev {
this.run = if this.run > 0 { this.run + 1 } else { 1 };
} else if bar.close < this.prev {
this.run = if this.run < 0 { this.run - 1 } else { -1 };
} else {
this.run = 0;
}
this.prev = bar.close;
this.run
}Those two ship from crates/vike-cli/src/cmd/init/content.rs's DONCHIAN_HIGH_RHAI and
STREAK_RHAI. They are the two shapes worth copying.
A third one, written for this tutorial
The smallest useful shape is neither a window nor a recurrence. No state at all, one number per bar:
// 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
}And a strategy that calls both — the shipped window for a breakout level, the new stateless one to insist the bar that breaks out is not itself already wide:
// 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()); }
}param() declares the knobs. The CLI reads them back out of the file without running a backtest:
vike-cli backtest --list-params --script quiet_breakout.rhailookback = 48
calm = 1
qty = 0.05The run: zero trades, and then zero trades again
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)Zero trades over 8760 hourly bars. The report says so in a field of its own rather than leaving a
flat equity curve to be interpreted, and the no-orders cause is the honest one: nothing was
denied, deferred or gated, so no risk gate explains the result. The strategy simply never submitted
an order.
The obvious suspect is the calm filter. A 1% high-low range is tight for an hourly BTCUSDT bar, so
widen it to 3.0 and run the same profile again:
{
"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."
}
]
}
}Identical, field for field. A knob that tripled and changed nothing is not the knob that was blocking, which moves the suspicion off the filter and onto the other term in the entry test.
The off-by-one, and the run that followed it
The shipped donchian_high pushes this bar's high into its window BEFORE taking the maximum. So the
value it returns is at least this bar's own high, and a close is never above its own high. The entry
test close() > hi is unsatisfiable on every bar of every series — no threshold, no data and no
parameter can make it fire. That is the whole bug, and it is invisible in both runs above.
The correction is one line of ordering: take the maximum of the window as it stands, then push. Call
that file breakout_level, point the strategy at it, and leave everything else alone — same
profile, same year of bars, same calm:
{
"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
}Now the strategy trades, and it loses money. The account ends at 8334.57 from a 10000 start, a 16.65% loss, with a Sharpe of -1.0476 and a peak-to-trough drawdown of 18.4%. It won 31.25% of its 48 trades and its profit factor is 0.687, so the wins do not cover the losses. That is the result; it is not a step on the way to a better one.
The BTCUSDT line reads -1180.60 rather than -1665. Those are different quantities:
crates/vike-backtest/src/engine.rs's per_symbol_pnl is realized PnL plus the open position
marked to the last price, while final_equity is the account. Read final_equity.
What the run does not say is that the idea is dead. One symbol, one year of hourly bars, one
parameter point, 48 trades — far too few to separate a bad rule from a bad draw, and there is no
out-of-sample split here at all. It also says nothing about whether the calm filter helps, because
the two zero-trade runs proved only that the filter was never reached. It has still never been
tested against a version of this strategy that trades.
The result worth keeping is the smaller one, and it is about indicators rather than about breakouts. An indicator whose arithmetic is one step out of order yields a strategy that silently does nothing, and the zero-trade diagnostic pointed at the entry condition — which was written correctly, and which was not where the defect lived.
Which project's indicators actually answered
Both runs above went through vike-cli backtest, which ships the SCRIPT to a service and never the
indicator files. The service resolves donchian_high() and range_pct() against ITS OWN
user_data/indicators/, installed once at startup by
crates/vike-datahub/src/datahub_cli.rs's install_user_indicators.
The server's set wins, and the client cannot see it
No hash or version of the indicator set is exchanged with the request. The same script run against
two services with different indicator directories returns different numbers, and nothing in the
response says so. install_user_indicators therefore logs the directory and the installed names at
startup even on success, so that two runs which disagree can be told apart by comparing two logs.
Shipping the set with the request is the real fix and is a protocol change.
Editing an indicator file after the service started changes nothing until it is restarted, for the same reason: the load happens once.
Where next
- Writing an indicator and
User indicators — the full script contract, including
outputs()for a multi-line indicator. - Loading indicators — which directory is read, when, and what a rejected file does.
- Sweep and walk-forward — what the losing run above would need before its number meant anything.
- A Rhai strategy from scratch.
- Training and Feature importance.
- Train a model.
- Property tests.
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.
Sweep, then walk forward
Search a parameter grid with a ranked sweep, then put the winner through an anchored out-of-sample walk-forward that deliberately never re-fits.