One idea taken from a sentence to a backtest you can evaluate honestly: write the strategy as one Indicator file, install it, run it, read every part of the result, then tune it like you mean it. It is the strategy sibling of the first Indicator primer, and it needs nothing the kScript (legacy) tester has that this tree does not: the broker is the same.
1. The idea, in one sentence
"When RSI leaves oversold, buy the dip with a quarter of my equity; take the trade off when momentum recovers; protect it with a stop 4% below."
2. Write it
Start a workspace from the code-first template and replace src/indicator.ts:
om wrun create @you/rsi-reversion ./rsi-reversion --template sma-codefirst// RSI reversion with a protective stop: buy the dip on the bar RSI crosses up through oversold, protect it 4% under the close, leave on recovery.
import { input, line, lower, ohlcv, output, param } from "./sdk/declare";
import { in_close } from "./gen/inputs";
import { emitRow, out_rsi } from "./gen/outputs";
import { p_period } from "./gen/params";
import { strategy } from "./gen/strategy";
import { Cross, Rsi } from "./sdk/ta";
strategy({ initialCapital: 10000, qtyType: "percentOfEquity", qtyValue: 25, commissionPercent: 0.05, slippageBps: 2 });
param("period", 14, { min: 2, max: 200, description: "RSI length in bars" });
input("close", ohlcv.close);
output("rsi", line, lower, { description: "Wilder RSI of close, 0 to 100" });
let rsi = new Rsi(14);
const dip = new Cross();
const recovery = new Cross();
let value: f64 = NaN;
let close: f64 = NaN;
let dipped: i32 = 0;
let recovered: i32 = 0;
export function init(): void {
rsi = new Rsi(i32(p_period()));
}
export function state(): i32 {
close = in_close();
value = rsi.update(close);
if (isNaN(value)) return 0;
dipped = dip.update(value, 30.0);
recovered = recovery.update(value, 55.0);
return 1;
}
export function finalize(): void {
if (dipped == 1) strategy.long("Dip").send();
if (strategy.positionSize() > 0) strategy.exit("Protect").from("Dip").stop(close * 0.96).send();
if (recovered == 1) strategy.closeAll();
out_rsi(value);
emitRow();
}
export function reset(): void {
rsi.reset();
dip.reset();
recovery.reset();
value = NaN;
close = NaN;
dipped = 0;
recovered = 0;
}Reading it top to bottom:
strategy({ ... })is the broker. Starting equity, sizing (25%of equity per entry), commission and slippage live here, so a shared package carries its own assumptions. It takes the engine's own setting names; every field is optional.Cross.update(a, b)returns1on the baracrosses aboveb, sodipped == 1is kScript'scrossover(r, oversold). Returning0fromstate()while RSI warms up keeps those bars off the chart and out of the broker's reach: a bar that returns0places nothing.strategy.long("Dip").send()queues a market order under the idDip. It fills at the next bar's open, never earlier.strategy.exit("Protect").from("Dip").stop(close * 0.96).send()arms a protective stop against the named entry, re-placed every bar the position is held, at 4% under that bar's close.strategy.positionSize()reads the position after the bar's fills, so the guard is already flat on the bar the stop filled.strategy.closeAll()is the signal exit for the recovery case. Order calls belong infinalize(); the getters answer instate()andfinalize().
3. Install and run
om wrun install ./rsi-reversion
om backtest @you/rsi-reversion --asset BINANCE_FUTURES:BTCUSDT --window 90dom wrun install compiles the file, derives the sheet (the three declarations plus the strategy section), proves the module against the contract and installs it under ~/.openmarket/packages. om backtest names the package, the market it trades and a window: the daemon fetches the window's bars, runs the package through its own runtime, lets the broker fill every order, and prints the card. The same package on a chart opens the Strategy Tester panel with the same trades.
4. Read the result critically
Four places to look, in order:
- The header.
fill=engine_brokersays the engine's broker settled the orders; the fee and slippage columns echo the file'scommissionPercentandslippageBps. A card whose package declares no costs is a gross result, whatever its return says. - The numbers. Net profit, win rate, drawdown and trade count together, never one alone: a strategy that wins 70% of the time with a payoff ratio of 0.3 loses money. Every stat has an exact formula in the stats reference.
- The notes. Rejected orders (the pyramiding cap, a size the equity could not fund, an order placed on a live chart's forming bar) and fills settled by the fill model (
ambiguousFillCount) land here with their counts; the run says so instead of hiding them. - The report on disk.
--format jsonprints it;strategy.outputcarries the engine's own records: every trade with itsexitReason(signal,stop,limit,trail,closeAll,liquidation), the pending orders, the equity series and the full stats block.
5. Tune it honestly
Results are deterministic, so every change you see is yours. Change things in this order:
- Costs first. Set
commissionPercentandslippageBpsto what you actually pay on your venue. Most retail strategies die here, and it is cheaper to learn that from a card than from a book. - Sizing. Try
qtyType: "fixed"with a small quantity againstpercentOfEquity. Compounding changes the drawdown's character, not only the end number. - The stop. Tighten
0.96to0.99and watch the exit-reason mix shift fromsignaltostop. A stop narrow relative to the bar range asks intrabar questions the interval cannot answer; read fill simulation before tightening further. - A setting as a parameter. Link a numeric setting to a param and the same package replays at any risk without a rebuild: the tool input
paramscarries it, andom backtestechoes the package reference and the params it used underchoices.candidate, the reproduce artifact.
// The same reversion with the risk per entry as a setting: the strategy's qtyValue reads the risk_pct param at init().
import { input, line, lower, ohlcv, output, param } from "./sdk/declare";
import { in_close } from "./gen/inputs";
import { emitRow, out_rsi } from "./gen/outputs";
import { p_period } from "./gen/params";
import { strategy } from "./gen/strategy";
import { Cross, Rsi } from "./sdk/ta";
const risk = param("risk_pct", 25, { min: 1, max: 100, description: "Percent of equity per entry" });
strategy({ initialCapital: 10000, qtyType: "percentOfEquity", qtyValue: risk, commissionPercent: 0.05, slippageBps: 2 });
param("period", 14, { min: 2, max: 200, description: "RSI length in bars" });
input("close", ohlcv.close);
output("rsi", line, lower, { description: "Wilder RSI of close, 0 to 100" });
let rsi = new Rsi(14);
const dip = new Cross();
const recovery = new Cross();
let value: f64 = NaN;
let close: f64 = NaN;
let dipped: i32 = 0;
let recovered: i32 = 0;
export function init(): void {
rsi = new Rsi(i32(p_period()));
}
export function state(): i32 {
close = in_close();
value = rsi.update(close);
if (isNaN(value)) return 0;
dipped = dip.update(value, 30.0);
recovered = recovery.update(value, 55.0);
return 1;
}
export function finalize(): void {
if (dipped == 1) strategy.long("Dip").send();
if (strategy.positionSize() > 0) strategy.exit("Protect").from("Dip").stop(close * 0.96).send();
if (recovered == 1) strategy.closeAll();
out_rsi(value);
emitRow();
}
export function reset(): void {
rsi.reset();
dip.reset();
recovery.reset();
value = NaN;
close = NaN;
dipped = 0;
recovered = 0;
}param(...) returns a handle; passing it as qtyValue derives "qty_value": { "param": "risk_pct" } into the sheet, and the host reads the param at init(). A value outside the setting's rule (here, not a positive number) falls back to the default and is reported, the engine's own behavior for a bad override.
6. Keep it
The installed package is the strategy. om publish ./rsi-reversion lets other machines install it by name, and the two host-published outputs make it a signal source without a second file:
om metric series --metric wrun/@you/rsi-reversion/strategy.equity --symbol BTCUSDT --exchange BINANCE_FUTURES --interval 1h --bars 60
om watch create "RSI reversion long" --condition '{"metric":"wrun/@you/rsi-reversion/strategy.position","selector":{"exchange":"BINANCE_FUTURES","symbol":"BTCUSDT","interval":"HOUR"},"op":"gt","value":0}'strategy.position is the signed position after each bar's fills and strategy.equity the marked equity; a condition on them fires when the strategy is long, or when its equity crosses a level, on the same bars the tester shows. A hosted alert on the package can also arm the two event streams, strategy.order (an order the strategy just placed) and strategy.fill (a trade that opened or closed).
7. What you have, and what you do not
You have a deterministic, lookahead-free replay of your rules with disclosed costs and disclosed shortcuts, on every host the package runs on. You do not have a promise about the future: no backtest survives contact with a regime change, and a parameter tuned until the curve looks good is a fit to the past. Prefer fewer parameters, realistic costs, and results that stay acceptable when you nudge every setting.
Next: the full order API, how fills are simulated, and every stat defined.