High performance, low-latency backtesting engine for testing quantitative trading strategies on historical and live data in Rust
rust_bt ๐ฆ
A high performance, low-latency backtesting engine for testing quantitative trading strategies in Rust. The engine is designed to be used in conjunction with a strategy that implements the Strategy trait. The strategy is responsible for making trading decisions based on the Broker struct. For backtesting on historical data OHLC data is required, and for live trading bid/ask data is required.
It's barebones by design, and is intended to be expanded upon to align with the relevant market microstructure and fit the type of products you intend to trade.
Overview
- High performance, low-latency
- Flexible, modular design
- Complete backtesting and live testing engine
- Market microstructure simulation, including bid-ask spread, slippage, commissions, etc.
- Detailed trade and position management, fractional orders
- Contingent orders (SL/TP)
- Margin and leverage management for complex instruments
- Pairs trading, trading multiple instruments
- Plotting and statistics
Components
- rust_core: The central trading engine
- rust_live: The live trading interface
rust_live directory and run cargo run
- rust_bt: The backtesting interface
rust_bt directory and run cargo run
- rust_ml: The machine learning interface
Getting started
The backtesting engine works out of the box: simply navigate to rustbt/rustbt and run the cargo. To change strategies, modifying rustbt/src/main.rs is necessary - by default the StatArbSpreadStrategy is loaded. If we take a look at main.rs in rustbt/rust_bt - we see the following main function:fn main() {
//start time
let start = Instant::now();
let data = handleohlc("~/rustbt/rustbt/data/SP500DJIA2mclean.csv").expect("Failed to load CSV data"); // CHANGE PATH IF NEEDED
let cash = 100_000.0; let commission = 0.0; let bidask_spread = 0.0; let margin = 0.05; let tradeonclose = false; let hedging = false; let exclusive_orders = false; let scaling_enabled = true;
let strategy: Box<dyn Strategy> = Box::new(StatArbSpreadStrategy::new()); //CHANGE THIS TO RUN OTHER STRATEGY
let mut backtest = Backtest::new( data, strategy, cash, commission, bidask_spread, margin, tradeonclose, hedging, exclusive_orders, scaling_enabled, // enable scaling );
backtest.run(); }
Here our variable 'data' defines the historical data we intend to backtest on, found in rustbt/rustbt/data. Our variable 'strategy' is where we load our saved strategies from rustbt/rustcore/strategies - the rest is self-explanatory. To utilize the ML inference module you need a C++ distribution of pytorch installed. See more here: https://docs.pytorch.org/cppdocs/installing.html.
If we run as is, we get the following results:
Backtest Statistics:
====================
Start Date 2024-04-01 00:00:00
End Date 2024-05-30 19:59:00
Exposure Time [%] 25.83
Total Return [%] 22.13
Buy & Hold Return [%] -0.77
Equity Final [$] 122132.76
Sharpe Ratio 44.19
Max Drawdown [%] -0.74
Profit Factor 1.72
Total Trades 4858
Win Rate [%] 57.04
Best Trade [$] 1665.21
Worst Trade [$] -743.40
Avg. Win [$] 19.04
Avg. Loss [$] -15.82
Beta -0.20
Alpha [%] 22.90
Alpha Risk Adjusted [%] 16.93
Return Ann [%] 244.49
Volatility Ann [%] 5.44
Max Margin Usage [%] 15.82
====================
time taken: 709.472375ms

How It Works
The strategies are implemented in rust_core, but they are adapted to suit different operational environments:
- Backtesting Strategies
Broker, OhlcData, Order, and Strategy.
these types are designed to work with preloaded historical market data, allowing the simulation of trades over past time periods. the backtesting engine in rust_bt orchestrates the process, ensuring that trades are simulated in a controlled, time-sequential manner.
- Live Trading Strategies
LiveBroker, LiveData, Order, and LiveStrategy.
These types are specifically designed to handle streaming market data and execute orders as market conditions evolve, ensuring that order placement, execution, and statistics (like pnl) update in real time.
This design ensures that while the core trading logic remains consistent in rust_core, each operational mode (backtest or live) uses the appropriate interface to manage data, process orders, and update trade statistics optimally.
flowchart TD
%% External Data Sources
HCSV["Historical Data CSV"]:::external
LAPI["Live Data API"]:::external
%% Backtesting Interface (rust_bt) subgraph "Backtesting Interface (rust_bt)" BT["Backtesting Interface"]:::interface BT_Main["Backtesting Main"]:::interface end
%% Live Trading Interface (rust_live) subgraph "Live Trading Interface (rust_live)" LIVE["Live Trading Interface"]:::interface Live_Stream["Live Data Stream"]:::interface Live_Server["Trading Server"]:::interface Live_Main["Live Main"]:::interface end
%% Machine Learning Module (rust_ml) subgraph "Machine Learning Module (rust_ml)" ML["Machine Learning Module"]:::interface ML_Inference["Inference Module"]:::interface ML_Models["Pretrained Models"]:::interface ML_Tools["Model Conversion Tools"]:::interface ML_Util["ML Utilities"]:::interface end
%% Core Trading Engine (rust_core) subgraph "Core Trading Engine (rust_core)" CORE["Core Engine"]:::core CORE_Data["Data Handling"]:::core CORE_Strategies["Strategies"]:::core CORE_Position["Position & Trade Management"]:::core CORE_Hedging["Hedging & Utilities"]:::core end
%% Data Flow Arrows HCSV -->|"input"| BT_Main LAPI -->|"stream"| Live_Stream
BT_Main -->|"simulate"| BT BT -->|"feeds"| CORE
Live_Stream -->|"channels"| LIVE Live_Server --> LIVE Live_Main --> LIVE LIVE -->|"executes"| CORE
ML_Inference --> ML ML_Models --> ML ML_Tools --> ML ML_Util --> ML ML -->|"signals"| CORE
%% Internal Core Flow COREData -->|"processes"| COREStrategies COREStrategies -->|"executes"| COREPosition COREPosition -->|"manages"| COREHedging %% Core engine central connection CORE --- CORE_Data CORE --- CORE_Strategies CORE --- CORE_Position CORE --- CORE_Hedging
%% Click Events for rust_core click CORE "https://github.com/jensnesten/rustbt/tree/main/rustcore/" click COREData "https://github.com/jensnesten/rustbt/blob/main/rustcore/src/datahandler/mod.rs" click COREStrategies "https://github.com/jensnesten/rustbt/tree/main/rust_core/src/strategies/" click COREPosition "https://github.com/jensnesten/rustbt/blob/main/rust_core/src/position/mod.rs" click COREHedging "https://github.com/jensnesten/rustbt/blob/main/rust_core/src/hedging/mod.rs"
%% Click Events for rust_bt click BT "https://github.com/jensnesten/rustbt/tree/main/rustbt/" click BTMain "https://github.com/jensnesten/rustbt/blob/main/rust_bt/src/main.rs" click HCSV "https://github.com/jensnesten/rustbt/blob/main/rustbt/data/SP500DJIA2m_clean.csv"
%% Click Events for rust_live click LIVE "https://github.com/jensnesten/rustbt/tree/main/rustlive/" click LiveStream "https://github.com/jensnesten/rustbt/blob/main/rust_live/src/stream.rs" click LiveServer "https://github.com/jensnesten/rustbt/blob/main/rust_live/src/server.rs" click LiveMain "https://github.com/jensnesten/rustbt/blob/main/rust_live/src/main.rs"
%% Click Events for rust_ml click ML "https://github.com/jensnesten/rustbt/tree/main/rustml/" click MLInference "https://github.com/jensnesten/rustbt/blob/main/rust_ml/src/inference/mod.rs" click MLModels "https://github.com/jensnesten/rustbt/tree/main/rust_ml/src/models/" click MLTools "https://github.com/jensnesten/rustbt/tree/main/rust_ml/src/tools/" click MLUtil "https://github.com/jensnesten/rustbt/blob/main/rust_ml/src/scaler.rs"
%% Styles classDef core fill:#ffe6aa,stroke:#b58900,stroke-width:2px; classDef interface fill:#cce5ff,stroke:#003366,stroke-width:2px; classDef external fill:#d5f5e3,stroke:#27ae60,stroke-width:2px;
Backtesting
Strategies are implemented by creating a new struct in rust_core/src/strategies/ that implements the Strategy trait:
use crate::engine::{Broker, OhlcData, Order, Strategy};
pub struct MyStrategy;
impl Strategy for MyStrategy { fn init(&mut self, broker: &mut Broker, data: &OhlcData) { // initialization can precompute indicators, etc.. }
fn next(&mut self, broker: &mut Broker, index: usize) { // implement the strategy logic here } }
Opening a position
TheBroker struct provides the following core functionality:
new_order(order: Order): Places a new orderclosed_trades(trade: Trade): Closes a tradeclosealltrades(): Closes all tradescash += closed_trade.pnl(): Updates the cash balance
next method is called on every tick.
To create a buy order we need to specify the size, and optionally the stop loss, take profit, limit, parent trade and instrument (to trade multiple instruments, default is 1).
let order = Order {
size: trade.size,
sl: None,
tp: None,
limit: None,
stop: None,
parent_trade: None,
instrument: 1,
};
broker.new_order(order);
self.positions.register_position(trade.size); // track order with PositionManager (optional)
PositionManager
ThePositionManager provides a simple interface for handling all types of positions:
use crate::position::PositionManager;
let mut positions = PositionManager::new(3); // allow max 3 positions per side (Long and Short)
positions.register_position(trade.size); // register a long position
positions.register_position(-trade.size); // register a short position
positions.close_position(trade.size); // register closing a long position
positions.close_position(-trade.size); // register closing a short position
The PositionManager doesnt open or close positions, it simply tracks them in parallel for more granular control. This allows for more complex order management, which then enables us to implement more sophisticated hedging techniques in real-time.
Closing a position
To close a position we use theTrade struct. After closing each trade we need to update the cash balance and add the trade to the closed trades vector - alongside with updating the position manager if used:
let trade = broker.trades.remove(0); //closes first position in trades vector
let closed_trade = Trade {
size: trade.size,
entryprice: trade.entryprice,
entryindex: trade.entryindex,
exit_price: Some(price),
exit_index: Some(index),
slorder: trade.slorder,
tporder: trade.tporder,
instrument: trade.instrument,
};
broker.cash += closed_trade.pnl();
broker.closedtrades.push(closedtrade);
self.positions.close_position(trade.size);
To close all positions we need to delete each element in the trades vector and update our stats accordingly. We do this by calling the closealltrades method from the Broker struct.
Plotting
The backtest.plot() function is used to plot the equity curve. It takes a slice of (naivedatetime, equity_value) tuples and an output file path.
if let Err(e) = backtest.plot("outputequityplot.png") {
eprintln!("error generating plot: {}", e);
}
Live Trading
Strategies are implemented in the same way as for backtesting, but the next method is called on every tick of the live data, where every 'tick' is a data event. Here we use the LiveStrategy trait:
use crate::live_engine::{LiveBroker, LiveData, Order, LiveStrategy};
pub struct MyStrategy;
impl LiveStrategy for MyLiveStrategy { fn init(&mut self, broker: &mut LiveBroker, data: &LiveData) { // Nothing to do here when trading live }
fn next(&mut self, broker: &mut LiveBroker, index: usize) { // implement the live strategy logic here } }
Streaming
The live engine is designed to handle streaming data from a live data source. The LiveData struct has been updated to offer a hybrid approach: it keeps a full history of ticks as well as a current snapshot for each instrument. Our backend is currently set up to receive data from Saxo Bank's SaxoOpenAPI, but this can be easily extended to other data sources by modifying rust_live/src/stream.rs. To run as is, you need developer access to the SaxoOpenAPI and an API token.
Every tick, which represents a snapshot for one instrument, is stored in a vector of TickSnapshot. Simultaneously, the latest tick for each instrument is maintained in a hashmap for quick access:
pub struct TickSnapshot { pub instrument: String, pub date: String, pub ask: f64, pub bid: f64, }
/// Hybrid live data: keeps a full history of ticks as well as a current snapshot per instrument. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct LiveData { pub ticks: Vec<TickSnapshot>, pub current: HashMap<String, TickSnapshot>, }
Here the first parameter of the hashmap is a string corresponding to the symbol of the instrument.
For example, in /rust_live/main.rs you can define instrument symbols like this:
// create a channel for live data
let (tx, mut rx) = mpsc::unbounded_channel::<LiveData>();
let reference_id1 = "US500"; let uic1 = 4913; let reference_id2 = "DJIA"; let uic2 = 4911;
The reference_id strings (e.g., "US500", "DJIA") represent the symbols of the instruments, which the user can set to uniquely identify each data stream.