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 and calculate() 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). Never Vec::new() + push in the tick loop.
  • Avoid String::clone() in calculate(). 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 inside prepare(), not calculate().