Runtime Reference
Quick reference for compiled Roboquant .rq strategies. Import the complete strategy surface with:
use rq_sdk::prelude::*;
Strategy declaration
#[strategy(name = "My Strategy")]
pub struct MyStrategy {
#[param(default = 14, min = 2, max = 100, title = "Period")]
period: i64,
indicator: Ind,
}
impl Strategy for MyStrategy {
fn on_init(&mut self, init: &mut Init) {
self.indicator = init.add(Rsi::new(self.period.max(2) as usize));
}
fn on_bar(&mut self, ctx: &mut Ctx, bar: Bar) {
let Some(rsi) = ctx.val(self.indicator) else { return };
if ctx.position() == 0 && rsi < 30.0 {
ctx.buy(1).sl(bar.close - 10.0).tp(bar.close + 20.0).send();
}
}
}
#[strategy] generates the parameter schema, default state, and runtime exports. Every non-parameter field is reset to its default value for a new run.
Parameters
#[param(default = 20, min = 5, max = 200, step = 5, title = "Lookback")]
lookback: i64,
#[param(default = 0.01, min = 0.001, max = 0.05, step = 0.001, title = "Risk")]
risk: f64,
#[param(default = true, title = "Allow shorts")]
allow_shorts: bool,
#[param(default = 20, options = [10, 20, 50], title = "Preset")]
preset: i64,
| Field type | Dashboard control |
|---|---|
i64 | Integer input |
f64 | Decimal input |
bool | On/off control |
Numeric field + options | Fixed-choice control |
options is mutually exclusive with min, max, and step.
Single-symbol hooks
fn on_init(&mut self, init: &mut Init) { ... }
fn on_bar(&mut self, ctx: &mut Ctx, bar: Bar) { ... }
fn on_tick(&mut self, ctx: &mut Ctx, tick: Tick) { ... }
fn on_timer(&mut self, ctx: &mut Ctx, now: Time) { ... }
fn on_trade(&mut self, ctx: &mut Ctx, txn: TradeTxn) { ... }
fn wants_ticks(&self) -> bool { true }
Only on_init is required by the trait. Most strategies also implement on_bar. A strategy using on_tick must implement wants_ticks and return true.
Use init.set_timer(seconds) in on_init to enable on_timer.
Market types
Bar
| Field | Type | Meaning |
|---|---|---|
time | Time | Bar open timestamp |
open, high, low, close | f64 | OHLC prices |
volume | f64 | Bar volume |
Tick
| Field | Type | Meaning |
|---|---|---|
time | Time | Trade timestamp |
price | f64 | Trade price |
size | f64 | Trade size |
side | Aggressor | Buy, Sell, or Unknown |
Time
time.hour() // u32
time.minute() // u32
time.second() // u32
time.hhmm() // u32 — 1330 for 13:30
time.date_num() // u32 — 20260806
time.weekday() // Weekday::Mon, ...
time.minutes_since(9, 30) // i64
time.et() // US Eastern wall-clock Time (EST/EDT aware)
time.et_hhmm() // u32 — 930 for 09:30 US Eastern
time.et_date_num() // u32 — Eastern session date
time.local() // wall-clock Time in the STRATEGY timezone (any IANA zone, DST aware)
time.local_hhmm() // u32 — 930 for 09:30 in the strategy timezone
time.local_date_num() // u32 — date in the strategy timezone
time.local_weekday() // Weekday in the strategy timezone
time.local_minutes_since(9, 30) // i64 — wall-clock minutes since 09:30 local
time.local_offset_minutes() // i32 — UTC offset of the strategy timezone now (-240 = EDT)
Engine timestamps are UTC. All clock-component helpers return u32 (only
minutes_since() returns i64); cast i64 params with as u32 at the
comparison and type session-date state fields as u32.
Strategy timezone
Every strategy has a timezone — one of the four platform options (ET =
America/New_York, CT = America/Chicago, UTC, or Local = the owner's
browser zone, resolved to its IANA name when saved). New strategies take the
owner's platform timezone; strategies from before the field read ET. Each
backtest run (strategy_backtests.timezone) and each deployment (session
record timezone, pod env STRATEGY_TIMEZONE) pins the zone it ran with, so
changing the strategy later never changes a finished run or a running bot.
The engine run config carries it as config["timezone"]
(NativeConfig::timezone; absent = UTC).
The local*() accessors read that clock. Write session rules with them:
local_hhmm() == 930 fires at 09:30 on the strategy's clock all year, on CME,
MT5 and crypto bars (all UTC by the time they reach the engine), in backtest
and live. With timezone America/New_York it fires at exactly the instants
et_hhmm() == 930 does. On the spring-forward day the skipped local hour
never occurs; on the fall-back day the repeated hour occurs twice (the offset
differs) — gate on ranges rather than one minute inside 01:00–03:00.
How it works: the wasm guest carries no timezone database. The host
(rq-wasm-host, IANA rules from jiff's bundled tzdb, so every build replays
identically) answers the additive rq_h_tz_table import with the offsets in
force over a window (~1 week back, ~13 months forward) and the transitions in
it; the guest binary-searches the cached table and asks again for an instant
outside it, so a live bot crossing a DST change months after deploy reads the
current rules. Only guests that call a local* accessor import it, so the
ABI version is unchanged and every existing artifact loads as before.
Custom indicators read the same local*() clock, chosen automatically (no
setting):
- Indicator code inside a strategy (an indicator file imported into it) is compiled into the strategy, so it reads that run's pinned zone.
- A chart indicator (indicator-compute, RoboCharts) reads the viewer's platform display timezone, which the chart sends with every compute request. An indicator file hosted in a strategy reads that strategy's saved zone instead. With neither, it reads America/New_York. Results are cached per zone.
hhmm()/date_num()/weekday() stay UTC and et_*() stay New York for
every strategy, whatever its timezone. Use et_hhmm()/et_date_num() for
rules that are New York by definition (the US cash open).
CME daily, weekly and monthly bars open at the session open, so bar.time of
Monday's 1D bar is Sunday evening and bar.time.weekday() returns Sun. For
the trading date use ctx.trading_day(bar.time) (00:00 UTC of that date):
ctx.trading_day(bar.time).weekday() / .date_num() give Monday's values;
a 1W bar gives its Monday and a 1M bar its first trading day. On a
multi-symbol strategy use ctx.trading_day("ES", t). For a
deliberate fixed offset, construct a shifted wall clock with
Time::from_us(time.us() + offset_hours * 3_600_000_000). Shifted values are
for civil-clock comparisons only; retain the original UTC Time for drawing
anchors, ordering, and durations. The chart-header timezone changes display
labels only and is not inherited by strategy logic.
Init
| Method | Description |
|---|---|
init.add(spec) | Register one indicator and return an Ind handle |
init.add_bbands(period, ndev) | Return upper/middle/lower handles |
init.add_macd(fast, slow, signal) | Return MACD/signal/hist handles |
init.add_stoch(k, d, smooth) | Return %K/%D handles |
init.add_dmi(period) | Return ADX/+DI/−DI handles |
init.add_donchian(period) | Return upper/middle/lower handles |
init.add_keltner(period, multiplier) | Return upper/middle/lower handles |
init.add_supertrend(period, multiplier) | Return line/direction handles |
init.add_aroon(period) | Return up/down/oscillator handles |
init.add_stochrsi(rsi_period, stoch_period, d) | Return %K/%D handles |
init.add_chandelier(period, multiplier) | Return long/short stop handles |
init.add_heikin_ashi() | Return open/high/low/close handles |
init.set_timer(seconds) | Enable timer callbacks |
Registering an indicator does not draw it. In compiled .rq strategies and
indicators, only ctx.plot* and ctx.draw output reaches the chart; an
init.add(...) series is computed and readable with ctx.val, but not drawn.
To show a series, register a plot in on_init and feed it per bar:
self.ema = init.add(Ema::new(20));
self.ema_line = init.plot_line("EMA 20").color("#A78BFA").pane("price").register();
// in on_bar
if let Some(v) = ctx.val(self.ema) { ctx.plot(self.ema_line, v); }
Do not use drawing tools to trace indicator lines. Legacy interpreted Python
strategies keep auto-drawn add_indicator lines.
Plot budget: a run keeps at most 24 plots and 1,000,000 plot points in total
across them; past that, whole plots are dropped (last registered first) and
counted as plots_truncated. The backtest chart recomputes plots per bar for
the visible range with a 2,000,000-point budget for runs up to 500,000 bars
(plot_max_points on run_wasm_backtest_py; omitted keeps the default). Over
that range the chart shows the saved lines and says they are simplified.
Built-in indicators
Single-output specs
Register with init.add(...):
| Group | Constructors |
|---|---|
| Moving averages | Sma::new(n), Ema::new(n), Wma::new(n), Trima::new(n), Dema::new(n), Tema::new(n), Kama::new(n), T3::new(n).vfactor(x) |
| Momentum | Rsi::new(n), Roc::new(n), Mom::new(n), Cmo::new(n), Trix::new(n), Cci::new(n), WilliamsR::new(n), Mfi::new(n), Ppo::new(fast, slow), Apo::new(fast, slow), Ultosc::new(p1, p2, p3) |
| Volatility/range | Atr::new(n), Natr::new(n), Trange::new(), StdDev::new(n).nbdev(x), Variance::new(n), Highest::new(n), Lowest::new(n), Midpoint::new(n), Midprice::new(n) |
| Volume | Obv::new(), Vwap::new(), Ad::new(), Adosc::new(fast, slow) |
| Statistics/trend | Zscore::new(n), Linreg::new(n), LinregSlope::new(n), LinregAngle::new(n), LinregIntercept::new(n), Tsf::new(n), Sar::new(accel, max), Bop::new() |
Indicator price sources
Price sources: supported single-price indicators default to close. Use
Ema::new(period).source(PriceSource::High) to change the input. PriceSource
is in the prelude: Open, High, Low, Close, Hl2 = (H+L)/2,
Hlc3 = (H+L+C)/3, Ohlc4 = (O+H+L+C)/4, and Hlcc4 = (H+L+C+C)/4.
.source(...) is available on Sma, Ema, Wma, Dema, Tema, Trima,
Kama, T3, Rsi, Roc, Mom, Cmo, Trix, Ppo, Apo, StdDev,
Variance, Zscore, Midpoint, Linreg, LinregSlope, LinregAngle,
LinregIntercept, and Tsf. Set other parameters before source, e.g.
T3::new(5).vfactor(0.4).source(PriceSource::Hl2) or
StdDev::new(20).nbdev(2.0).source(PriceSource::Open).
self.ema_high = init.add(Ema::new(9).source(PriceSource::High));
self.ema_low = init.add(Ema::new(9).source(PriceSource::Low));
self.ema_close = init.add(Ema::new(9));
self.rsi = init.add(Rsi::new(14).source(PriceSource::Hlc3));
self.sma = init.add(Sma::new(20).source(PriceSource::Open));
let bb = init.add_bbands_with_source(20, 2.0, PriceSource::Hl2);
let macd = init.add_macd_with_source(12, 26, 9, PriceSource::High);
MACD and Bollinger Bands source helpers apply the selected input to every
output. Store their handles as usual (bb.upper/middle/lower,
macd.macd/signal/hist). Existing add_bbands and add_macd use close.
The helpers register immediately: do not call .source(...) on their returned
handles. These multi-output helpers remain base-timeframe, single-symbol only.
Read each handle with ctx.val / ctx.val_back; registration automatically
plots separate series (ema_9_high, ema_9_low, ema_9, rsi_14_hlc3).
Do not substitute close or hand-code a supported indicator when another
source is requested. ATR, ADX/DMI, Stochastic, Donchian, and other indicators
with defined OHLC/volume inputs do not expose this modifier.
For single-output specs, chain source before timeframe:
Rsi::new(14).source(PriceSource::Hlc3).timeframe("1D"). Sources are derived
from aggregated OHLC on that timeframe, exposing only completed buckets.
Multi-symbol registration also accepts it:
init.add("ES", Sma::new(20).source(PriceSource::High)).
Every indicator retains its existing warmup and initialization. EMA retains
its SMA seed and warmup of period - 1 bars; matching source and length
alone does not guarantee identical platform initialization.
Multi-output registrations
let bb = init.add_bbands(20, 2.0); // bb.upper, bb.middle, bb.lower
let m = init.add_macd(12, 26, 9); // m.macd, m.signal, m.hist
let st = init.add_stoch(14, 3, 3); // st.k, st.d
let dmi = init.add_dmi(14); // dmi.adx, dmi.plus_di, dmi.minus_di
let dc = init.add_donchian(20); // dc.upper, dc.middle, dc.lower
let kc = init.add_keltner(20, 2.0); // kc.upper, kc.middle, kc.lower
let sup = init.add_supertrend(10, 3.0); // sup.line, sup.direction
let ar = init.add_aroon(14); // ar.up, ar.down, ar.osc
let sr = init.add_stochrsi(14, 14, 3); // sr.k, sr.d
let ch = init.add_chandelier(22, 3.0); // ch.long, ch.short
let ha = init.add_heikin_ashi(); // ha.open, ha.high, ha.low, ha.close
Store the individual Ind handles you need on the strategy struct.
Higher-timeframe indicators
Every single-output spec supports .timeframe(...):
self.daily_ema = init.add(Ema::new(20).timeframe("1D"));
self.four_hour_atr = init.add(Atr::new(14).timeframe("4H"));
Accepted forms include seconds, minutes, hours, days, and weeks such as 30s, 15m, 4H, 1D, and 1W (seconds only make sense on a sub-minute base timeframe). Values use the previous completed higher-timeframe bucket and are forward-filled onto base bars. Multi-output helper registrations do not currently accept .timeframe().
Ctx market and account state
| Method | Return | Description |
|---|---|---|
ctx.bar(back) | Option<Bar> | 0 is current, 1 previous |
ctx.bar_index() | usize | Current zero-based bar index |
ctx.time() | Time | Current engine time |
ctx.val(ind) | Option<f64> | Current indicator value |
ctx.val_back(ind, back) | Option<f64> | Historical indicator value |
ctx.book() | Option<BookView> | L2 snapshot in Order Book backtests and live deployments; None otherwise |
ctx.position() | i64 | Whole lots: positive long, negative short, zero flat; a fractional position rounds away from zero (0.15 long reads 1) |
ctx.position_lots() | f64 | The exact position in lots: engine order units × symbol().lots_per_unit |
ctx.equity() | f64 | Cash plus unrealized P&L, in the account currency |
ctx.cash() | f64 | Realized cash, in the account currency |
ctx.entry_price() | Option<f64> | Average open-position entry |
ctx.contract() | ContractSpec | .multiplier (account-currency value of a 1.0 price move per LOT — per contract on futures; may change bar to bar) and .tick_size |
ctx.symbol() | SymbolInfo | Instrument metadata, see below |
Live on Tradovate, ctx.cash() and ctx.equity() follow the broker's cash balance. On an account that reported
$0 or less at deploy (prop-firm funded accounts often do), the deploy form takes an account size from the user and
records the broker cash at that moment as the deployment's cash basis. Cash is then the entered size plus the change
in broker cash since deploy, so profits and losses still show, sizing works from the entered size, and the daily-loss
limit uses the same value. The basis is fixed for the deployment and survives pod restarts. A broker snapshot
missing its cash or net-liquidation value is skipped while a basis is set. Deployments without a basis follow the
broker's cash exactly.
ctx.symbol() returns SymbolInfo with digits, point, pip_size, tick_size, tick_value (account-currency
value of one tick for ONE lot at the current bar), contract_size, lot_min, lot_step, lot_max,
lots_per_unit (lots in one engine order unit: the lot step), and base_currency, profit_currency, account_currency. pip_size is
the instrument's pip size as its pinned instrument spec defines it; on futures it equals tick_size. The values
come from the instrument spec pinned for the run. Bar indicators read the same metadata through
ChartCtx::symbol() (and the shared Chart trait) for the chart's instrument, and ctx.contract() on a chart
is then the instrument's real contract (value of a 1.0 price move per lot, i.e. contract_size, and its tick). Futures charts
use the registered contract. A chart whose instrument has no registered or pinned spec sees the neutral default
of the fixed indicator contract (tick 0.01, multiplier 1, USD). Sizing snippets: size risk per lot
(tick_value per tick) and round the size down to lot_step. On charts, which have no
account, account_currency is the instrument's profit currency and tick_value is in that currency. Futures, with no pinned spec, use the contract: point = pip_size = tick_size,
tick_value = tick_size × multiplier, contract_size = multiplier, one order unit = one lot = one contract
(lot_min = lot_step = 1, lot_max unbounded) and USD for every currency. Multi-symbol strategies have neither contract() nor symbol(). Old compiled artifacts do not call it and keep
running unchanged; an artifact that calls it needs an engine with the rq_h_symbol_info import.
Orders
ctx.buy(size).send();
ctx.sell(size).send();
ctx.buy(size).limit(price).send();
ctx.sell(size).stop(price).send();
ctx.buy(size)
.sl(stop_price)
.tp(target_price)
.trailing(Trail::offset(10.0).activate_after(20.0))
.oco(group)
.send();
Order builders support:
| Builder method | Effect |
|---|---|
.limit(price) | Rest as a limit order |
.stop(price) | Trigger as a stop order |
.sl(price) | Attach an absolute-price stop loss |
.tp(price) | Attach an absolute-price take profit |
.trailing(trail) | Attach a trailing stop |
.oco(group) | Join an OCO group |
.reduce_only() | Allow only position reduction |
.send() | Submit the order |
On a live Tradovate bot, while the market data an order reacts to is more than 180s old, a send() that would
open or add to a position is skipped and returns OrderId(0); the bot log records a warning. An order on the
closing side passes only up to the open position net of market closes already in flight (a reduce-only stop/limit leg may also cover the entry part of your working entries); a reversal is reduced
to a reduce-only flatten without its SL/TP/trailing stop (it keeps its OCO group), and a stale limit/stop reversal
rests as that unprotected flatten. close_position() and close_partial() are never gated. Orders placed from
on_trade are judged by the last measured data lag. Check the returned id before latching per-day state such as
traded_today. To replace a protective stop/limit leg while data is delayed, cancel the old leg first, or cancel it
only after the new send() returned a non-zero id. If a closing market order is rejected while data is delayed, re-send the protective leg after the rejection (a leg placed while that close was in flight may have been reduced).
Sizes are LOTS, typed impl OrderSize (every integer type, f32/f64, Lots): ctx.buy(1), ctx.buy(qty_i64), ctx.buy(2.0) and ctx.buy(0.15)
all compile. The engine (host) converts lots to engine order units (lots / lots_per_unit, the lot step) and checks
lot_min/lot_max. A size is never rounded: a size off the lot step or outside the limits is rejected, send()
returns OrderId(0) and the run log records the reason, e.g.
order rejected: size 0.155 lots is not a multiple of the lot step 0.01,
order rejected: size 0.05 lots is below the minimum lot size 0.1, or, where the lot step is 1,
order rejected: size 0.15 is not a whole number; this instrument trades whole lots only. With a lot step of 1
(futures) a decimal that is a whole number trades exactly like the integer and integer sizes behave exactly as
before. close_partial follows the same rule and returns None. Integer sizes use the existing order entry and the
size_* helpers read the per-lot contract().multiplier, so integer sizes and the sizing helpers run on any engine;
a decimal size (f32/f64), ctx.symbol() or ctx.position_lots() needs a current engine (additive rq_h_submit_lots / rq_h_close_partial_lots / rq_h_position_lots imports), and an older
engine refuses such an artifact at load. Each distinct rejection reason is logged once per run.
Any integer type converts (u64/usize/isize beyond i64 are rejected, never wrapped) as does f32; write a
cast size as as i64, not as _, which does not compile, and round a computed size down to the step with
(lots / s.lot_step).floor() * s.lot_step. Trade reports keep integer engine units (quantity); the run's pinned
instrument spec lot_size is the lots per unit. Multi-symbol buy(symbol, size) stays whole engine units.
Account/order methods:
| Method | Description |
|---|---|
ctx.close_position() | Close the full position at market |
ctx.close_partial(size) | Partially close at market (same size rules as buy) |
ctx.cancel_order(id) | Cancel one pending order |
ctx.cancel_all() | Cancel all pending orders |
ctx.new_oco_group() | Create an OCO group |
ctx.set_trailing_stop(trail) | Attach or replace the position trail |
ctx.set_position_sl_tp(sl, tp) | Replace position stop/target; use None to clear a side |
Live closes never stack: in a live deployment the position only changes when the broker reports the fill, so a
close_position() / close_partial() (or any reduce-only market order) sent while an earlier one is still working is
reduced to the part of the position not already being closed, and skipped (reported as cancelled) when the working close
already covers it. Resting take-profit/stop-loss orders do not count as a working close and never block an exit.
Trailing stop forms:
Trail::offset(10.0)
Trail::offset(10.0).activate_after(20.0)
Trail::percent(0.02)
Trail::percent(0.02).activate_after(5.0)
On a live Tradovate deployment:
- Stop-loss and take-profit legs rest at Tradovate as good-till-cancelled orders, so they survive the session close. Entry orders stay day orders.
- Prices are rounded to the contract's tick: limit prices on the passive side (a buy limit rounds down, a sell limit rounds up, never past your price) and stop prices away from the market (a buy stop rounds up, a sell stop rounds down).
- A strategy that only receives bars moves its trailing stop when each bar closes, using the bar's high (long) or low (short).
- When a position is closed by anything other than its own stop or target — a strategy exit, a reversal or a stop of the deployment — its remaining legs are cancelled at Tradovate; a partial exit resizes them to the remaining size.
Sizing
| Method | Description |
|---|---|
ctx.size_pct_equity(fraction) | Whole lots (floored, at least 1) with approximately that notional fraction of equity |
ctx.size_risk_pct(fraction, stop_distance) | Whole lots (floored, at least 1) whose stop loss is approximately that fraction of equity |
ctx.size_vol_target(fraction, atr) | Whole lots (floored, at least 1) whose one-ATR move is approximately that fraction of equity |
Fractions use decimals: 0.01 means 1%.
For a fixed dollar-risk input, include the contract multiplier and round down to whole contracts:
let loss_per_contract = stop_distance.abs() * ctx.contract().multiplier;
let contracts = (risk_usd / loss_per_contract).floor() as i64;
Return zero when one contract exceeds the risk budget; do not silently force a minimum contract and exceed the requested risk.
L2 order book
let Some(book) = ctx.book() else { return };
| Method | Return |
|---|---|
book.spread() | Option<f64> |
book.mid() | Option<f64> |
book.microprice() | Option<f64> |
book.imbalance() | Option<f64> over the top five levels |
book.best_bid() / book.best_ask() | Option<f64> |
book.best_bid_size() / book.best_ask_size() | u32 |
book.bid(level) / book.ask(level) | Option<BookLevel> for levels 0..9 |
book.depth_bid(n) / book.depth_ask(n) | Aggregate visible size |
book.walk_buy(size) / book.walk_sell(size) | Option<(average_price, levels_walked)> |
BookLevel exposes px, sz, and ct.
ctx.book() works in backtests with Order Book fills and in live deployments:
- Backtests — the book for the current bar.
Nonein bar-only or tick-only backtests. - Live — the latest MBP-10 snapshot from Roboquant's own CME feed (not the broker), so it is the same on Tradovate Demo and Live. Snapshots are published at most every 250 ms (up to 4 per second). The live book is subscribed only when the compiled strategy's code reads
ctx.book(). There is no book callback; read it fromon_bar,on_tick, oron_timer. It isNoneuntil the first snapshot arrives after the deployment starts, and while the feed is stale for more than 10 seconds (the book is cleared rather than served frozen).
Always handle None.
Drawings
Creation methods return a builder and must end in .send():
let zone = ctx.plot_zone(high, low)
.starting_at(start_time)
.ending_at(end_time)
.label("range")
.border("#FFD700")
.fill("rgba(255,215,0,0.20)")
.send();
let level = ctx.plot_level(price).label("support").color("#00C853").send();
let hline = ctx.plot_hline(price).label("VWAP").send();
let label = ctx.plot_text("HH", price).starting_at(pivot_time).color("#FFFFFF").send();
| Method | Description |
|---|---|
ctx.plot_zone(high, low) | Auto-extending price box |
ctx.plot_level(price) | Bounded horizontal level |
ctx.plot_hline(price) | Full-width horizontal line |
ctx.plot_text(text, price) | Text anchored at a time and price |
.starting_at(time) | Set a historical left edge |
.ending_at(time) | Set a fixed right edge instead of auto-extension |
.label(text) | Set a short label |
.color(css) | Set one color for the drawing |
.border(css) / .fill(css) | Set independent outline and fill colors |
.no_border() | Fill-only box (equivalent to .border("transparent")) |
ctx.update(id, high, low) | Re-price an active zone/level |
ctx.freeze(id) | Stop extending and keep it |
ctx.invalidate(id) | Stop extending, fade, and keep history |
ctx.delete(id) | Erase it from chart history |
For a level update, pass the same price twice to ctx.update.
A non-finite price (NaN, INFINITY, NEG_INFINITY) draws nothing: the
create is skipped (its handle matches no drawing, no id is consumed) and an
update keeps the previous valid anchors. Each skip is reported in the backtest
result's drawings_summary.warnings with the call, reason and bar time. Gate
drawing on is_finite() (or a "range set" flag) rather than drawing a reset
value such as f64::NEG_INFINITY.
Keep every active drawing's DrawingId until it is frozen, invalidated, or
deleted. Dropping the handle does not close the drawing; active zones and levels
continue extending. Use plot_text for Pine labels—using plot_level as a
label creates an unintended horizontal line.
DrawingId does not implement Default. Strategy structs should store active
handles as Option<DrawingId>, assign Some(id), and clear them with .take()
or = None. Drawing calls take &ctx, so reading ctx inside a drawing
builder chain is fine:
self.zone = Some(ctx.plot_zone(high, low).starting_at(ctx.time()).send());
Order builders are different: ctx.buy(n) holds ctx mutably for the whole
chain, so hoist any ctx read into a local before ctx.buy/ctx.sell.
For evolving geometry, call ctx.update(id, high, low) while it forms, then
ctx.freeze(id) at the actual boundary. An update with the same prices the drawing
already has is a no-op, so refreshing a drawing every bar costs nothing until
its prices move. A backtest keeps at most 200,000 drawing changes for replay;
past that, replay stops showing new changes while the final drawings stay
complete. For entry/stop/target drawings, close
the lifecycle from on_trade when txn.kind == TxnKind::PositionClosed.
Advanced primitives use ctx.draw(DrawingSpecV2::…). Beyond the line/shape
constructors, single-point markers and style modifiers are available:
ctx.draw(DrawingSpecV2::dot(t, price).size("large").color("#22c55e"));
ctx.draw(DrawingSpecV2::triangle(t, price).size("tiny").direction("down").color("#ef4444"));
ctx.draw(DrawingSpecV2::vline(t).line_style("dotted"));
ctx.draw(DrawingSpecV2::trendline((t1, p1), (t2, p2)).line_style("dashed"));
ctx.draw(DrawingSpecV2::rectangle((t1, p1), (t2, p2))
.border_color("transparent")
.fill_color("rgba(255,215,0,0.25)"));
| Modifier | Applies to | Values |
|---|---|---|
.size(s) | dot, triangle | "tiny" / "normal" / "large" |
.direction(d) | triangle | "up" (default) / "down" |
.line_style(s) | any line kind | "solid" / "dashed" / "dotted" |
.line_width(w) | any line kind | 0.25..16 |
.border_color(c) / .fill_color(c) | rectangle | any CSS color; "transparent" border = borderless |
Tables
Pine-Script-style tables (table.*) are a separate output next to drawings. The methods live on Ctx, ChartCtx (and the Chart trait), MultiCtx and IndicatorPlot, with the same signatures in the native lane and the Wasm guest. The builders and value types (TableNewArgs, TableCellArgs, TableId, TextSize, TextFormatting) are in rq_sdk::prelude.
let t = ctx.table_new(TableNewArgs::new("top_right", 2, 3).bgcolor("#1e222d"));
ctx.table_cell(t, TableCellArgs::new(0, 0).text("Close").text_halign("left")
.text_formatting(TextFormatting::BOLD | TextFormatting::ITALIC));
ctx.table_merge_cells(t, 0, 2, 1, 2);
ctx.table_cell_set_text(t, 1, 0, &format!("{:.2}", bar.close));
| Method | Description |
|---|---|
ctx.table_new(TableNewArgs::new(position, columns, rows)) | Create a table. Chain .bgcolor(c), .frame_color(c), .frame_width(i64), .border_color(c), .border_width(i64), .force_overlay(bool). Returns a TableId; a skipped call returns the handle 0 |
ctx.table_cell(t, TableCellArgs::new(column, row)) | Define a cell, overwriting every property. Chain .text(&str), .width(f64), .height(f64), .text_color(&str), .text_halign(&str), .text_valign(&str), .text_size(impl Into<TextSize>), .bgcolor(&str), .tooltip(&str), .text_font_family(&str), .text_formatting(TextFormatting) |
ctx.table_cell_set_text(t, column, row, &str) | Also table_cell_set_tooltip, _text_halign, _text_valign, _text_font_family (&str) |
ctx.table_cell_set_text_color / table_cell_set_bgcolor(t, column, row, Option<&str>) | None = the chart's text colour / no colour |
ctx.table_cell_set_width / table_cell_set_height(t, column, row, f64) | Percent of the pane, 0 = auto |
ctx.table_cell_set_text_size(t, column, row, impl Into<TextSize>) | "auto", "tiny", "small", "normal", "large", "huge" or points (i64) |
ctx.table_cell_set_text_formatting(t, column, row, TextFormatting) | TextFormatting::NONE, BOLD, ITALIC, combined with | |
ctx.table_merge_cells(t, start_column, start_row, end_column, end_row) | Merge an inclusive rectangle; it shows its start cell |
ctx.table_clear(t, start_column, start_row, end: Option<(i64, i64)>) | Remove cells; None clears the start cell only. Merges stay |
ctx.table_delete(t) | Delete the table |
ctx.table_set_bgcolor / table_set_frame_color / table_set_border_color(t, Option<&str>) | Table colours |
ctx.table_set_frame_width / table_set_border_width(t, i64) | Widths, 0-100 |
ctx.table_set_position(t, &str) | Move the table |
ctx.table_all() | The live TableIds, oldest first |
Positions are top_left, top_center, top_right, middle_left, middle_center, middle_right, bottom_left, bottom_center, bottom_right. A script shows one table per position: table_new (or table_set_position) onto an occupied position deletes the table there.
TableId does not implement Default: keep it in an Option<TableId> field, as for DrawingId. Create the table and its fixed cells and merges once; merging the same area again is skipped.
The table is recorded at the end of every bar, so update its cells every bar: there is no barstate.islast, and a version is stored only when the table changes. Vectorized (kind-1) indicators commit once, at the last bar.
An invalid call (out-of-range cell, bad colour, size or position, a deleted handle, an overlapping merge, text over 4096 chars) never fails the run: it is skipped and reported in tables_summary.skipped on the backtest result. The full contract and reason codes are in contracts/tables/README.md.
Rolling windows
Rolling<T> is a fixed-capacity ring buffer for history a strategy keeps itself (custom averages, pivot windows, lookbacks). Build it in on_init; push is O(1) and never allocates, overwriting the oldest value once full.
#[strategy(name = "Range Breakout")]
pub struct RangeBreakout {
#[param(default = 20, min = 5, max = 200, title = "Lookback")]
lookback: i64,
highs: Rolling<f64>,
}
impl Strategy for RangeBreakout {
fn on_init(&mut self, _init: &mut Init) {
self.highs = Rolling::new(self.lookback.max(1) as usize);
}
fn on_bar(&mut self, ctx: &mut Ctx, bar: Bar) {
if let (true, Some(prior_high)) = (self.highs.is_full(), self.highs.max()) {
if ctx.position() == 0 && bar.close > prior_high {
ctx.buy(1).send();
}
}
self.highs.push(bar.high);
}
}
- Indexing is newest-first, like
ctx.bar(back):get(0)is the last value pushed. len,cap,is_empty,is_full,newest,oldest,iter_oldest_first,iter_newest_first,clear.- For
f64windows:sum()(0.0 while empty), andmean(),max(),min()returningOption. They are recomputed over the window on each call, so results match a hand-written loop exactly. - A
Rollingfield left at its default (never built withRolling::new) has capacity 0; pushing into it stops the strategy with an error.
When compiled code crashes
A panic in a compiled strategy or indicator (an index past the end of a list, an unwrap on a missing value, a Rolling push before Rolling::new) stops the run. The error names the message and the line: panicked at src/lib.rs:LINE:COL: <message>, where src/lib.rs is the main file and src/<name>.rs a helper file. Backtests, optimizations, live bots and chart indicators show the same line. Scripts compiled before this release show only the crash until they are edited and saved again (each save recompiles).
Logging
ctx.log("entered long");
ctx.log(format!("filled {} contracts at {:.2}", qty, price));
Logs are timestamped by the engine and appear in backtest Replay/Logs and live Deployment Logs.
Engine warnings raised while on_init runs (for example a .timeframe("1") label without a unit, which falls back to the chart timeframe) are kept and added to the run's logs ahead of the first bar. Use a unit in timeframe labels: "1m", "5m", "1h", "1D".
Trade transactions
on_trade receives TradeTxn:
| Field | Type |
|---|---|
kind | TxnKind |
time | Time |
price | Option<f64> |
size | Option<i64> |
pnl | Option<f64> |
reason | Option<CloseReason> |
order_id | Option<OrderId> |
TradeTxn may carry additional optional fields.
size is in engine order units, not lots: lots = size × symbol().lots_per_unit (identical on futures, where
one unit is one lot).
TxnKind values: OrderPlaced, OrderFilled, OrderCancelled, PositionOpened, PositionModified, PositionClosed.
Live OrderFilled differs from backtests:
- It can fire more than once for one order — once per partial fill, each with that fill's
size. Backtests fill an order in full, so they emit oneOrderFilledper order. - After a broker reconcile — at a restart, or when the engine corrects the position after it has disagreed with the broker — an
OrderFilledcan arrive with noPosition*event after it: the fill is already included inctx.position(). order_idisNonefor fills of bracket stop-loss/take-profit legs.- Fills of orders the deployment did not place itself — manual orders at the broker or another bot on the same account — are ignored. If such an order changes the position, the engine corrects
ctx.position()to the broker's position after it has disagreed for a while, without anOrderFilled.
CloseReason values: Sl, Tp, OppositeFill, Manual, ForceClose.
Multi-symbol reference
Implement MultiStrategy instead of Strategy:
fn on_init(&mut self, init: &mut MultiInit) {
self.es_ema = init.add("ES", Ema::new(20));
}
fn on_bars(&mut self, ctx: &mut MultiCtx) {
let Some(es) = ctx.close("ES") else { return };
// ...
}
| Method | Description |
|---|---|
ctx.bar(symbol, back) | Bar for one leg |
ctx.close(symbol) | Latest close for one leg |
ctx.position(symbol) | Net position for one leg |
ctx.entry_price(symbol) | Entry price for one leg |
ctx.equity() | Aggregate account equity |
ctx.time() | Current merged timestamp |
ctx.val(ind) / ctx.val_back(ind, back) | Per-symbol indicator value |
ctx.buy(symbol, size) / ctx.sell(symbol, size) | Market-order shortcuts |
ctx.order_buy(symbol, size) / ctx.order_sell(symbol, size) | Full per-leg order builders |
ctx.close_position(symbol) / ctx.close_partial(symbol, size) | Per-leg exits |
ctx.cancel_all(symbol) | Cancel a leg's resting orders |
ctx.set_position_sl_tp(symbol, sl, tp) | Replace a leg's stop/target |
ctx.set_trailing_stop(symbol, trail) | Replace a leg's trailing stop |
ctx.log(message) | Timestamped log |
Multi-symbol strategies run on OHLCV bars only and cannot currently deploy live.
Legs are real market symbols, at least 2. For one symbol on several timeframes, write a single-symbol Strategy and use .timeframe().
A multi-symbol strategy runs as a backtest with every symbol it trades. describe reports is_multi (the rq_is_multi export), and every surface refuses a multi-symbol artifact it cannot run with one of these messages:
- Backtest: the backtest runner routes on
is_multi. A multi-symbol artifact always takes the multi-symbol lane, and a run with fewer than 2 symbols fails with "This strategy trades several symbols, so a backtest needs at least 2 of them." The Backtest tab, which runs one symbol, disables Run with "This strategy trades several symbols, which the Backtest tab does not support yet." - Optimize: the optimizer refuses the artifact before loading any market data with "This strategy trades several symbols, which optimization does not support yet."
- Deploy: the backend refuses the deploy before any pod exists, and the trading pod refuses before building an engine, both with "This strategy trades several symbols, which live trading does not support yet. Deploy a single-symbol strategy instead."
Causality
| Hook | ctx.bar(0) |
|---|---|
on_bar | Just-closed bar |
on_tick | Forming bar as known at the current tick |
on_timer | Forming bar as known at the timer event |
on_trade | Forming bar as known at the transaction event |
Use ctx.bar(1) for the previous completed bar inside tick, timer, and trade hooks. Indicators read during these hooks remain anchored to the last completed bar.
Quote side and account-currency valuation
When the data source quotes bars on the bid side and supplies a per-bar spread, the backtest follows the broker's rules: buys and short covers fill at the ask (bid + that bar's spread), and buy stop/limit orders and short stop-loss/take-profit levels trigger against the ask; sells, long exits and long stops use the bid. For timeframes above one minute the bar's spread is the widest of its one-minute spreads.
When an instrument settles in a currency other than the account's, P&L is valued in the account currency per bar.
Fills during a bar's open phase (market orders at the open, intra-bar stops and targets) use the last closed
conversion rate; on_bar and the equity mark use the bar's own close. ctx.contract().multiplier, ctx.equity()
and ctx.cash() are therefore already in the account currency — never divide by an exchange rate yourself. For
instruments without a spread series or conversion, nothing changes.
Contract rolls
On continuous CME series the backtest switches to the next contract at 00:00 UTC on the published roll date. At that point the compiled engine shifts open positions, SL/TP, trailing levels and resting orders by the published price gap, so the roll jump neither fills orders nor books P&L; a held trade's entry is shown at the new-contract-equivalent price and the result includes an account warning. Dated contracts are unaffected. Prices your strategy stored itself are not shifted: re-placing orders from stored absolute levels puts them back at old-contract levels. See Backtesting for the known limits.
Run limits for chart indicators
Every guest call has a fuel budget, and a compiled indicator computed for a chart also runs under a
wall-clock deadline. The deadline is the chart compute's dispatch timeout, and
covers the run from instantiation to its result. A run that exceeds it stops with a runtime_timeout
error, and the chart shows the indicator's inline compute-limit message instead of freezing. The
deadline changes neither results nor fuel accounting: a run that finishes in time returns exactly what
it returns without one. Backtests, optimizations and live runs are unchanged.
How the deadline is enforced (rq-wasm-host, wasmtime epoch interruption):
- Epoch checks run only inside guest wasm. A host call in progress when the deadline passes finishes first, so the overrun is bounded by the longest single host call.
- Compiling the module happens outside the deadline. The chart's dispatch timeout covers it.
- Each run with a deadline uses one short-lived watchdog thread, which stops when the run ends.
- Any error after the deadline fired is reported as
runtime_timeout. The underlying trap is not added to the message.
Legacy interpreted API
This reference covers the compiled .rq runtime. Existing .py strategies use the legacy rq_backtest.Strategy API and should be maintained in their current format rather than mixing both runtimes in one strategy.
Related docs
- AI strategy authoring reference — compact compile and semantic checklist
- Strategies — complete authoring workflow
- Backtesting — fill and data behavior
- Live deployment — live capability and safety matrix