RoboQuant Strategies
New RoboQuant strategies are compiled. You write a .rq entry source (plus optional sibling .rs helper modules), RoboQuant verifies it and generates a .rqc artifact, and that artifact runs in backtests, optimization, and live trading.
Minimal strategy
use rq_sdk::prelude::*;
#[strategy(name = "EMA Crossover")]
pub struct EmaCross {
#[param(default = 12, min = 2, max = 200, title = "Fast EMA")]
fast: i64,
#[param(default = 26, min = 2, max = 400, title = "Slow EMA")]
slow: i64,
fast_ema: Ind,
slow_ema: Ind,
}
impl Strategy for EmaCross {
fn on_init(&mut self, init: &mut Init) {
self.fast_ema = init.add(Ema::new(self.fast.max(2) as usize));
self.slow_ema = init.add(Ema::new(self.slow.max(2) as usize));
}
fn on_bar(&mut self, ctx: &mut Ctx, _bar: Bar) {
let (Some(fast), Some(slow)) =
(ctx.val(self.fast_ema), ctx.val(self.slow_ema))
else {
return;
};
if ctx.position() == 0 && fast > slow {
ctx.buy(1).send();
} else if ctx.position() > 0 && fast < slow {
ctx.close_position();
}
}
}
File lifecycle
A compiled strategy normally has these files:
ema_crossover.rq source — edit this
ema_crossover.rqc compiled artifact — generated
ema_crossover.params.py parameter metadata — generated
Use this workflow in Strategies → Code:
- Edit the
.rqsource. - Click Save.
- Click Compile.
- Fix any compiler diagnostics and compile again.
- Run the resulting artifact from Backtest, Optimize, or Deployments.
The Compile action saves the current editor buffer before building, so the artifact corresponds to the stored source. Do not edit .rqc or .params.py files by hand; the next compile replaces them.
Prefer one .rq file for small strategies. Larger strategies may use sibling
.rs modules declared by the entry (mod signals;); helper names must be
simple module stems. External packages, filesystem access, network access,
async code, and unsafe code are not available inside the strategy sandbox.
Parameters
Put configurable values on the strategy struct with #[param(...)]:
#[strategy(name = "Opening Range Breakout")]
pub struct Orb {
#[param(default = 30, min = 5, max = 120, step = 5, title = "Range minutes")]
range_minutes: i64,
#[param(default = 0.01, min = 0.001, max = 0.05, step = 0.001, title = "Risk fraction")]
risk_fraction: f64,
#[param(default = true, title = "Allow shorts")]
allow_shorts: bool,
#[param(default = 20, options = [10, 20, 50], title = "Lookback")]
lookback: i64,
}
Supported parameter fields are i64, f64, and bool. Numeric options render a fixed-choice control and cannot be combined with min, max, or step.
Parameter metadata is embedded in the artifact. The dashboard uses it to build Backtest, Optimize, and Deployments controls, so the same names and bounds apply everywhere without recompiling for each value.
Saved settings and .set files
The Configs menu beside strategy inputs is shared by Backtest, Optimize, Deployments, live parameter editing, and RoboCharts.
- Save current as config stores a named cloud config for this strategy.
- Save
.setfile writespresets/<name>.setinto the strategy file tree and downloads the same portable file. - Upload
.setfile validates the file against the strategy parameter schema, writes it underpresets/, and applies its values. - Project
.setfiles remain loadable from the Configs menu and are mirrored into the workspace with the rest of the strategy tree.
RoboQuant accepts MetaTrader-style fixed values and optimization-range lines:
range_minutes=30||5||5||120||N
risk_fraction=0.01||0.001||0.001||0.05||N
allow_shorts=true||false||0||true||N
The first field is the value used by a normal run. The remaining fields are
the optimization start, step, stop, and Y/N selection flag. Strategy
metadata supplies the types because .set files do not carry them. Unknown
parameter names are skipped with a warning; invalid types, bounds, or options
are rejected before the settings are applied.
Settings files contain strategy inputs only. Symbols, dates, capital, broker accounts, credentials, and platform environment values are intentionally not included.
Lifecycle hooks
Single-symbol strategies
Implement Strategy for one instrument:
| Hook | When it runs | Typical use |
|---|---|---|
on_init(&mut self, init: &mut Init) | Once before execution | Register indicators and timers |
on_bar(&mut self, ctx: &mut Ctx, bar: Bar) | At each bar close | Most trading logic |
on_tick(&mut self, ctx: &mut Ctx, tick: Tick) | On each trade print when opted in | Intrabar signals and execution |
on_timer(&mut self, ctx: &mut Ctx, now: Time) | At the timer interval | Scheduled tick-mode work |
on_trade(&mut self, ctx: &mut Ctx, txn: TradeTxn) | Order, fill, position, and close events | Journaling and state transitions |
wants_ticks(&self) -> bool | Runtime capability flag | Return true when using on_tick |
Tick strategies must opt in explicitly:
impl Strategy for TimeEntry {
fn on_init(&mut self, init: &mut Init) {
init.set_timer(60.0);
}
fn wants_ticks(&self) -> bool {
true
}
fn on_tick(&mut self, ctx: &mut Ctx, tick: Tick) {
if ctx.position() == 0 && tick.time.hhmm() == 1559 && tick.time.second() >= 58 {
ctx.buy(1).send();
}
}
}
Defining on_tick without returning true from wants_ticks compiles, but the strategy does not subscribe to ticks.
Multi-symbol strategies
Use MultiStrategy for pairs, spreads, and baskets. on_bars runs once per merged timestamp, and every market or account method is addressed by symbol name.
use rq_sdk::prelude::*;
#[strategy(name = "ES NQ Pair")]
pub struct Pairs {
#[param(default = 1, min = 1, max = 10, title = "Contracts")]
qty: i64,
es_sma: Ind,
}
impl MultiStrategy for Pairs {
fn on_init(&mut self, init: &mut MultiInit) {
self.es_sma = init.add("ES", Sma::new(20));
}
fn on_bars(&mut self, ctx: &mut MultiCtx) {
let (Some(es), Some(nq), Some(es_sma)) =
(ctx.close("ES"), ctx.close("NQ"), ctx.val(self.es_sma))
else {
return;
};
if ctx.position("ES") == 0 && es > es_sma && nq > 0.0 {
ctx.order_buy("ES", self.qty).send();
ctx.order_sell("NQ", self.qty).send();
} else if ctx.position("ES") != 0 && es < es_sma {
ctx.close_position("ES");
ctx.close_position("NQ");
}
}
}
Multi-symbol strategies are bar-mode only. They support per-leg indicators, market/limit/stop entries, SL/TP, trailing stops, partial exits, and aggregate equity in backtests and optimization. Live multi-symbol deployment is not available yet.
Indicators and warmup
Register indicators once in on_init, store their Ind handles on the struct, and read values through ctx.val:
fn on_init(&mut self, init: &mut Init) {
self.atr = init.add(Atr::new(14));
self.daily_ema = init.add(Ema::new(20).timeframe("1D"));
}
fn on_bar(&mut self, ctx: &mut Ctx, _bar: Bar) {
let (Some(atr), Some(daily_ema)) =
(ctx.val(self.atr), ctx.val(self.daily_ema))
else {
return;
};
// trading logic
}
None means the indicator has not warmed up. Do not unwrap indicator values.
Every registered indicator is also plotted on the backtest chart
automatically: price-scale series (EMA, VWAP, bands) render as overlays on
the candles, and oscillators (RSI, MACD, stochastic) get their own pane below
the chart with the canonical threshold lines. There is no separate plot call —
registration is the plot. Do not draw indicator lines with the drawing tools
(plot_hline stamps one static level; per-bar segments hit the drawing cap).
Orders and sizing
Every configured order ends with .send():
ctx.buy(1).send();
ctx.sell(2).limit(6050.0).send();
ctx.buy(1).stop(6100.0).sl(6075.0).tp(6150.0).send();
ctx.buy(1).trailing(Trail::offset(10.0).activate_after(20.0)).send();
ctx.close_position();
ctx.close_partial(1);
ctx.cancel_all();
Use the engine's sizing helpers instead of reproducing contract math:
let qty = ctx.size_risk_pct(0.01, stop_distance); // risk about 1% of equity
let qty = ctx.size_pct_equity(0.10); // about 10% notional
let qty = ctx.size_vol_target(0.01, atr); // 1-ATR move about 1%
Sizes are contracts. ctx.contract() exposes the instrument's multiplier and tick_size.
Drawings and logs
Drawings are part of the backtest result and Replay stream:
let zone = ctx.plot_zone(high, low)
.label("opening range")
.border("#FFD700")
.fill("rgba(255,215,0,0.20)")
.send();
ctx.update(zone, new_high, new_low);
ctx.freeze(zone); // keep it and stop extending
ctx.invalidate(zone); // keep it, stop extending, and fade it
ctx.delete(zone); // erase it from chart history
ctx.log("opening range complete");
Create a drawing once and update it. Creating a new zone every bar produces stacked drawings and makes replay harder to read.
Causality rules
- In
on_bar,barandctx.bar(0)are closed. - In tick and timer hooks,
ctx.bar(0)is the causal forming-bar snapshot; usectx.bar(1)for the last closed bar. - Indicator values in tick hooks are anchored to the last closed bar.
- Higher-timeframe values are delayed until that higher-timeframe bar has closed.
- Orders triggered by historical events never see a future price.
Legacy .py strategies
Existing interpreted .py strategies remain supported for maintenance and backtesting. Do not put Python code in a .rq file, create a parallel compiled copy inside the same strategy, or add the former KernelStrategy/numba twin pattern. New strategies should be authored directly in the compiled .rq format.
Common mistakes
| Mistake | Fix |
|---|---|
Editing .rqc or .params.py | Edit .rq, then compile again |
| Running after a source edit without compiling | Save and click Compile first |
Forgetting .send() | Finish every order or drawing builder with .send() |
| Reading an indicator without a warmup guard | Match Some(value) and return on None |
Using i64 as a bar index | Convert validated lookbacks to usize at the call site |
Subtracting from bar_index() before checking the range | Guard the index before subtraction |
Implementing on_tick without wants_ticks | Return true from wants_ticks() |
Expecting on_bars to run live | Multi-symbol deployment is not available yet |
| Importing external packages or using I/O | Keep strategy logic inside the provided SDK surface |
Related docs
- Backtesting — data, fills, Replay, and optimization
- Runtime reference — complete API tables
- Live deployment — account routing and safety controls