When to Use Rust
Why TBBO tick path lives in Rust and OHLC bar path lives in Python. File layout, build cycle, performance model.
When to use Rust vs Python
Pick Rust for tick-level TBBO logic and tight performance budgets. Pick Python for OHLC bar logic, REPL ergonomics, and richer ta.* coverage.
Rust .rs files run via the qc_strategy_api crate. They consume raw TBBO ticks (bid/ask/sizes/spread/volume/delta) and have access to a smaller but native-speed indicator library (ta::sma, ta::ema, ta::rsi, ta::macd, ta::bollinger, ta::atr_bid_ask, etc.). They compile through Cargo and run as a worker binary spawned per parameter combination.
Python .py files run via the in-process Python executor on aggregated OHLC bars. They get the full pandas + numpy stack plus the larger ta.* library (sma, ema, wma, rsi, macd, stochastic, bollinger_bands, atr, stddev, highest, lowest, change, roc).
The two paths share the same chart, the same backtest engine, the same analyzer, and the same trade output schema. Strategies of either language can return the same SL/TP shape and the same tag dict.
Notes
- Rust gives tick precision: per-tick decisions on bid/ask, microprice, spread compression, large-trade detection.
- Python gives bar-level convenience:
df['close'].rolling(20).mean(),ta.bollinger_bands(), etc. - For Python on TBBO data, the executor aggregates ticks into bars at the active timeframe before calling
calculate(self, df). Tick-level decisions inside Python are not supported. - Performance: a Rust .rs file with
prepare()doing the heavy lifting andcalculate()a tight loop will outperform a comparable Python strategy by 10x to 100x on TBBO sweeps.
File layout
A Rust strategy is a single .rs file with use qc_strategy_api::prelude::*; and an attribute-macro decorator.
Skeleton:
Example
use qc_strategy_api::prelude::*;
#[strategy(name = "My TBBO Strategy", description = "...", columns = ["volume", "delta"])]
#[tag(name = "trend_up", label = "Trend Up", color = "#26A69A")]
#[derive(Default)]
pub struct MyStrategy {
#[param(default = 20, min = 5, max = 200, label = "Period")]
pub period: usize,
#[param(default = 2.0, min = 0.5, max = 5.0, step = 0.25, label = "Mult")]
pub multiplier: f64,
}
impl Strategy for MyStrategy {
fn prepare(data: &TickData) -> DayPrep {
// hoisted: param-independent, runs once per day
let mut prep = DayPrep::empty();
prep.insert_f64("atr", ta::atr_bid_ask(&data.bid, &data.ask, 30));
prep
}
fn calculate(&self, data: &TickData, prep: &DayPrep) -> SignalOutput {
// hot path: only param-dependent work here
let n = data.len();
let (upper, middle, lower) = ta::bollinger(&data.mid, self.period, self.multiplier);
let mut entry_long = vec![false; n];
let mut entry_short = vec![false; n];
let mut exit_long = vec![false; n];
let mut exit_short = vec![false; n];
for i in self.period..n {
entry_long[i] = data.mid[i] < lower[i];
entry_short[i] = data.mid[i] > upper[i];
if middle[i].is_finite() {
exit_long[i] = data.mid[i] >= middle[i];
exit_short[i] = data.mid[i] <= middle[i];
}
}
SignalOutput::new(entry_long, exit_long, entry_short, exit_short)
}
}
Notes
- The macro generates the runner main() and the JSON schema for the params panel.
- #[derive(Default)] is required: the runner builds a default instance, then writes #[param] fields from the UI.
- Custom struct fields (without #[param]) get Default::default() and act as scratch memory per combo.
Performance model
prepare() runs once per day, calculate() runs once per parameter combo. Hoist relentlessly.
The runner calls prepare(data) once per trading day before the parameter sweep starts. Anything stored on DayPrep is shared across all combos. Then calculate(self, data, prep) runs once per combo, in parallel across worker threads.
Rules of thumb for fast Rust strategies:
- Pre-allocate output Vecs with
with_capacity(n). NeverVec::new()+ push in the tick loop. - Avoid
String::clone()incalculate(). Hoist labels to&'static str. - Use slice indexing (
data.bid[i]) over iterator chains in the hot path. - Put any
ta::*call with a literal period insideprepare(), notcalculate().