04. Your first Rust TBBO strategy
A guided walkthrough that ends with imbalance.rs: a TBBO strategy that goes long when bid pressure dominates and CVD is rising, short when the inverse. No EMAs, no SL/TP, just tick-level microstructure.
Rust strategies run on the raw TBBO tick stream. They are the right path when your edge depends on per-tick bid_size/ask_size, sub-second timing, or large-day high-throughput sweeps. This walkthrough mirrors the built-in imbalance.rs template; by the end you will know how to declare a #[strategy], hoist work into prepare(), read tick columns in calculate(), and emit a SignalOutput.
Step 1. Why Rust and TBBO
Pick the right path before writing code. Rust = TBBO ticks. Python = OHLC bars.
Quant Charts splits strategy paths by data shape:
- Python on OHLC: aggregated bars, vectorized math, fast iteration. Use this for everything that fits a bar timeframe.
- Rust on TBBO: per-tick reads, native speed, no GIL. Use this when your idea reads bid_size/ask_size, fires on individual ticks, or needs to run dozens of times per parameter combo without breaking the sweep budget.
TBBO files in this app carry timestamp, bid, ask, mid, spread, bid_size, ask_size as the canonical 7-field stream. Optional volume/delta/vwap may also be present.
Notes
#[strategy]is the macro shape for tick strategies. There is also#[ohlc_strategy]for Rust strategies on OHLC bars, but for tick-microstructure work you want#[strategy].- Rust strategies live under
your workspace/strategies/next to Python ones. The file extension drives the loader.
Step 2. Crate prelude and macro shape
The prelude pulls in everything you need: the trait, helpers, type definitions.
Example
use qc_strategy_api::prelude::*;
#[strategy(
name = "Imbalance",
description = "Bid/ask imbalance + CVD-slope entry; imbalance-flip exit.",
data_mode = "tick"
)]
#[tag(name = "long_entry", label = "Long Entry", color = "#26A69A")]
#[tag(name = "short_entry", label = "Short Entry", color = "#EF5350")]
#[derive(Default)]
pub struct Imbalance {
// params go here
}
Notes
use qc_strategy_api::prelude::*;bringsStrategy,TickData,DayPrep,SignalOutput, helpers (imbalance,cvd,rolling_mean), and theTimeframeenum into scope.#[tag(...)]declarations are equivalent to Pythondefine_tag()calls. They are metadata; the actual tag boolean comes from theSignalOutput.with_tag()calls incalculate().#[derive(Default)]is required because the runner constructs the strategy withDefault::default()and then sets parameter fields per combo.
Step 3. Parameters
Use #[param] on each field. Same shape as Python input.*.
Example
pub struct Imbalance {
#[param(default = 200, min = 1, max = 200000, label = "Smoothing (ticks)")]
pub smooth: usize,
#[param(default = 0.10, min = 0.01, max = 0.49, step = 0.01, label = "Entry Threshold")]
pub entry_threshold: f64,
#[param(default = 0.05, min = 0.0, max = 0.49, step = 0.01, label = "Exit Threshold")]
pub exit_threshold: f64,
#[param(default = 1000, min = 2, max = 1000000, label = "CVD Lookback")]
pub cvd_lookback: usize,
}
Notes
- Field types:
usizefor integer params,f64for floats,boolfor checkboxes. step = 0.01controls the increment in the panel arrows and the analyzer step grid.
Step 4. The Strategy trait
prepare() runs once per day per worker (good place for param-independent compute). calculate() runs once per parameter combo.
Example
impl Strategy for Imbalance {
fn prepare(data: &TickData) -> DayPrep {
let mut prep = DayPrep::empty();
// raw imbalance is param-independent: hoist it.
prep.insert_f64("imb_raw", imbalance(&data.bid_size, &data.ask_size));
// CVD = cumulative (bid_size - ask_size). Param-independent too.
let signed: Vec<f64> = data.bid_size.iter()
.zip(data.ask_size.iter())
.map(|(b, a)| b - a)
.collect();
prep.insert_f64("cvd", cvd(&signed));
prep
}
fn calculate(&self, data: &TickData, prep: &DayPrep) -> SignalOutput {
// ... walk ticks, build entry/exit/tag arrays ...
SignalOutput::new(vec![], vec![], vec![], vec![])
}
}
Notes
- Anything you put in
DayPrepis shared across every parameter combo for that day. On a 700-combo sweep, hoisting a 6M-tick array saves you 700x the work. - Read it back in
calculate()withprep.f64("key")(returnsOption<&[f64]>).
Step 5. Building the signal arrays
Smooth the raw imbalance with the swept window, walk ticks, set entries/exits.
Example
fn calculate(&self, data: &TickData, prep: &DayPrep) -> SignalOutput {
let n = data.len();
if n == 0 {
return SignalOutput::new(vec![], vec![], vec![], vec![]);
}
let imb_raw = prep.f64("imb_raw").map(|s| s.to_vec())
.unwrap_or_else(|| imbalance(&data.bid_size, &data.ask_size));
let imb = if self.smooth > 1 { rolling_mean(&imb_raw, self.smooth) } else { imb_raw };
let cvd_arr = prep.f64("cvd").unwrap_or(&[]);
let entry_up = 0.5 + self.entry_threshold;
let entry_dn = 0.5 - self.entry_threshold;
let exit_up = 0.5 + self.exit_threshold;
let exit_dn = 0.5 - self.exit_threshold;
let lk = self.cvd_lookback.min(n.saturating_sub(1));
let mut entry_long = vec![false; n];
let mut exit_long = vec![false; n];
let mut entry_short = vec![false; n];
let mut exit_short = vec![false; n];
let mut long_tag = vec![false; n];
let mut short_tag = vec![false; n];
for i in 0..n {
let v = imb[i];
if !v.is_finite() { continue; }
let rising = i >= lk && cvd_arr.get(i).zip(cvd_arr.get(i - lk))
.map(|(now, then)| now > then).unwrap_or(false);
let falling = i >= lk && cvd_arr.get(i).zip(cvd_arr.get(i - lk))
.map(|(now, then)| now < then).unwrap_or(false);
if v > entry_up && rising { entry_long[i] = true; long_tag[i] = true; }
if v < entry_dn && falling { entry_short[i] = true; short_tag[i] = true; }
if v < exit_dn { exit_long[i] = true; }
if v > exit_up { exit_short[i] = true; }
}
SignalOutput::new(entry_long, exit_long, entry_short, exit_short)
.with_tag("long_entry", long_tag)
.with_tag("short_entry", short_tag)
}
Notes
SignalOutput::new(entry_long, exit_long, entry_short, exit_short)is the four-array constructor. The order matters.- Builder methods chain:
.with_tag(...)for each tag,.with_sl_long(arr)/.with_tp_long(arr)for stops,.with_entry_only_sltp()to bundle stops at entry only. - Returning
SignalOutput::new(vec![], vec![], vec![], vec![])for empty days is the canonical guard - the engine treats zero-length arrays as "no signals".
Step 6. Run it
Save, recompile, attach.
- The first save takes a few seconds (full crate build). Subsequent edits are incremental.
- From the strategy panel, pick "Imbalance". Make sure the chart is loaded with a TBBO parquet (e.g.
TBBO_Converted.parquet) - this strategy will refuse to run on plain OHLC. - Open the analyzer to sweep
smooth,entry_threshold, andcvd_lookback. Withprepare()doing all the param-independent work, even 700-combo sweeps run in seconds per day.
Notes
- For a similar template that ships with the app, see
templates/workspace/strategies/built-in/rust/imbalance.rs. - For SL/TP, add
.with_sl_long(sl_arr).with_tp_long(tp_arr)and consider.with_entry_only_sltp()so brackets only update on the entry tick (cuts memory on large sweeps).