├── src ├── utils │ ├── mod.rs │ └── fetch.rs ├── data │ └── placeholder.txt ├── strategy.rs ├── schema_handler.rs ├── slippage_models.rs ├── plot.rs ├── main.rs └── backtester.rs ├── .gitignore ├── Cargo.toml ├── LICENSE ├── README.md └── examples ├── footprint └── footprint_example.rs ├── equities └── equities_example.rs ├── futures └── futures_example.rs └── options └── options_example.rs /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | // src/utils/mod.rs 2 | pub mod fetch; 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # target 2 | /target 3 | 4 | # env 5 | .env 6 | 7 | # data 8 | /src/data -------------------------------------------------------------------------------- /src/data/placeholder.txt: -------------------------------------------------------------------------------- 1 | Where your csvs will get saved. 2 | You can delete this text file. 3 | Thanks for using InkBack! -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "InkBack" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | databento = "0.8.0" 8 | time = "0.3" 9 | tokio = { version = "1", features = ["full"] } 10 | anyhow = "1" 11 | dotenvy = "0.15" 12 | csv = "1.3" 13 | rayon = "1.8" 14 | serde = "1.0.219" 15 | iced = { version = "0.12", features = ["canvas", "tokio"] } 16 | serde_json = "1.0" -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Joseph Matteo Scorsone 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/strategy.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | #[derive(Debug, Clone)] 4 | #[allow(dead_code)] 5 | pub struct Candle { 6 | /// The primary column 7 | pub date: String, 8 | /// All numeric columns, keyed by the CSV header name. 9 | pub fields: HashMap, 10 | /// All string columns, keyed by the CSV header name. 11 | pub string_fields: HashMap, 12 | } 13 | 14 | #[allow(dead_code)] 15 | impl Candle { 16 | /// Look up a numeric column by name (e.g. `"close"`). 17 | pub fn get(&self, key: &str) -> Option { 18 | self.fields.get(key).copied() 19 | } 20 | 21 | /// Look up a string column by name (e.g. `"footprint_data"`). 22 | pub fn get_string(&self, key: &str) -> Option<&String> { 23 | self.string_fields.get(key) 24 | } 25 | } 26 | 27 | pub trait Strategy { 28 | fn on_candle(&mut self, candle: &Candle, prev: Option<&Candle>) -> Option; 29 | } 30 | 31 | #[derive(Debug, Clone, Copy, PartialEq)] 32 | #[allow(dead_code)] 33 | pub enum OrderType { 34 | MarketBuy, 35 | MarketSell, 36 | LimitBuy, 37 | LimitSell, 38 | } 39 | 40 | #[derive(Debug, Clone, Copy)] 41 | pub struct Order { 42 | pub order_type: OrderType, 43 | pub price: f64, 44 | } 45 | 46 | /// Holds parameters used to configure a trading strategy 47 | #[derive(Clone, Debug)] 48 | pub struct StrategyParams { 49 | params: HashMap, 50 | } 51 | 52 | impl StrategyParams { 53 | /// Create a new, empty parameter map 54 | pub fn new() -> Self { 55 | Self { 56 | params: HashMap::new(), 57 | } 58 | } 59 | 60 | /// Insert a key-value pair into the strategy parameters 61 | pub fn insert(&mut self, key: &str, value: f64) -> &mut Self { 62 | self.params.insert(key.to_string(), value); 63 | self 64 | } 65 | 66 | /// Retrieve a value from the parameters by key 67 | pub fn get(&self, key: &str) -> Option { 68 | self.params.get(key).copied() 69 | } 70 | } -------------------------------------------------------------------------------- /src/schema_handler.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use csv::Reader; 3 | use crate::strategy::Candle; 4 | use databento::dbn::Schema; 5 | use std::collections::HashMap; 6 | 7 | #[allow(dead_code)] 8 | /// Converts CSV files into dynamic Candle structs for any Schema 9 | pub trait SchemaHandler { 10 | /// Convert a CSV at `csv_path` into a vector of dynamic Candles 11 | fn csv_to_candles(&self, csv_path: &str) -> Result>; 12 | /// Return the Schema this handler is configured for 13 | fn schema(&self) -> Schema; 14 | } 15 | 16 | /// A generic handler that treats any CSV with a header row 17 | /// as timestamps + numeric fields + string fields. 18 | pub struct GenericCsvHandler(pub Schema); 19 | 20 | impl SchemaHandler for GenericCsvHandler { 21 | fn csv_to_candles(&self, csv_path: &str) -> Result> { 22 | let mut rdr = Reader::from_path(csv_path)?; 23 | let headers = rdr.headers()?.clone(); 24 | let mut candles = Vec::new(); 25 | 26 | for record in rdr.records() { 27 | let rec = record?; 28 | // first column is timestamp/date 29 | let date = rec.get(0).unwrap_or(&"").to_string(); 30 | let mut fields: HashMap = HashMap::with_capacity(headers.len() - 1); 31 | let mut string_fields: HashMap = HashMap::new(); 32 | 33 | // parse all other columns try as f64 first, then store as string 34 | for (i, header) in headers.iter().enumerate().skip(1) { 35 | if let Some(val_str) = rec.get(i) { 36 | if let Ok(val) = val_str.parse::() { 37 | // Filter out extremely large values that are essentially sentinel values 38 | // Special handling for timestamp fields (expiration, activation, etc.) 39 | if val.is_finite() && (val.abs() < 1e15 || header.contains("expiration") || header.contains("activation") || header.contains("ts_")) { 40 | fields.insert(header.to_string(), val); 41 | } 42 | // Skip inserting sentinel values like i64::MAX 43 | } else { 44 | // Store as string if it can't be parsed as f64 45 | string_fields.insert(header.to_string(), val_str.to_string()); 46 | } 47 | } 48 | } 49 | 50 | candles.push(Candle { date, fields, string_fields }); 51 | } 52 | 53 | Ok(candles) 54 | } 55 | 56 | fn schema(&self) -> Schema { 57 | // now a true instance method, so `self.0` is in scope 58 | self.0 59 | } 60 | } 61 | 62 | /// Factory returns a GenericCsvHandler for any schema. 63 | pub fn get_schema_handler(schema: Schema) -> Box { 64 | Box::new(GenericCsvHandler(schema)) 65 | } 66 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # InkBack by Scorsone Enterprises 2 | 3 | A high-performance historical backtesting framework written in Rust, designed for quantitative trading strategy development and analysis. Built with DataBento for market data and Iced for visualization. 4 | 5 | ## Features 6 | 7 | - **Multi-asset Support**: Equities, futures, and options backtesting 8 | - **Custom Strategy Development**: Implement your own trading strategies using the `Strategy` trait 9 | - **Parallel Processing**: Run multiple backtests concurrently with different parameter combinations 10 | - **Real-time Visualization**: Interactive equity curve plotting with Iced GUI 11 | - **Order Flow Analysis**: Built-in footprint imbalance detection and volume analysis 12 | - **Realistic Trading**: Includes slippage models, transaction costs, and order pending logic 13 | - **Data Management**: Automatic DataBento data fetching and CSV caching 14 | - **Risk Management**: Flexible position sizing and risk controls 15 | 16 | ## Architecture 17 | 18 | InkBack follows a modular design: 19 | 20 | - **Strategy**: Define custom trading logic by implementing the `Strategy` trait 21 | - **Backtester**: Core engine that processes historical data and executes strategies 22 | - **Data Handler**: Manages DataBento integration and local data storage 23 | - **Visualization**: Real-time equity curve plotting and performance metrics 24 | 25 | ## Prerequisites 26 | 27 | - **Rust**: Version 1.83.0 or higher 28 | - **DataBento API Key**: Professional market data access 29 | - **Dependencies**: Managed automatically via Cargo 30 | 31 | ## Quick Start 32 | 33 | ### 1. Clone and Setup 34 | 35 | ```bash 36 | git clone https://github.com/Joseph-Matteo-Scorsone/InkBack.git 37 | cd InkBack 38 | ``` 39 | 40 | ### 2. Configure Environment 41 | 42 | Create a `.env` file in the project root: 43 | 44 | ```env 45 | DATABENTO_API_KEY=your_databento_api_key_here 46 | ``` 47 | 48 | ### 3. Run Examples 49 | 50 | ```bash 51 | # Footprint volume imbalance strategy 52 | examples/footprint/footprint_example.rs 53 | 54 | # Options momentum strategy 55 | examples/options/options_example.rs 56 | 57 | # Futures strategy 58 | examples/futures/futures_example.rs 59 | 60 | # Equities strategy 61 | examples/equities/equities_example.rs 62 | ``` 63 | 64 | ## Creating Custom Strategies 65 | 66 | ### Basic Strategy Implementation 67 | 68 | ```rust 69 | use crate::strategy::{Strategy, Candle, Order, OrderType}; 70 | 71 | pub struct MyStrategy { 72 | // Your strategy parameters and state 73 | } 74 | 75 | impl Strategy for MyStrategy { 76 | fn on_candle(&mut self, candle: &Candle, prev: Option<&Candle>) -> Option { 77 | // Implement your trading logic here 78 | // Return Some(Order) to place an order, None to do nothing 79 | 80 | let close_price = candle.get("close")?; 81 | 82 | // Example: Simple momentum strategy 83 | if let Some(prev_candle) = prev { 84 | let prev_close = prev_candle.get("close")?; 85 | if close_price > prev_close * 1.01 { 86 | return Some(Order { 87 | order_type: OrderType::Buy, 88 | price: close_price, 89 | }); 90 | } 91 | } 92 | 93 | None 94 | } 95 | } 96 | ``` 97 | 98 | ### Accessing Market Data 99 | 100 | The `Candle` struct provides access to all market data fields: 101 | 102 | ```rust 103 | // Numeric fields (OHLCV, etc.) 104 | let close = candle.get("close")?; 105 | let volume = candle.get("volume")?; 106 | let high = candle.get("high")?; 107 | 108 | // String fields (symbols, footprint data, etc.) 109 | let symbol = candle.get_string("symbol")?; 110 | let footprint = candle.get_string("footprint_data")?; 111 | ``` 112 | 113 | ## Data Sources 114 | 115 | InkBack supports multiple DataBento schemas: 116 | 117 | - **FootPrint**: Order flow and volume profile data 118 | - **CombinedOptionsUnderlying**: Options chains with underlying data 119 | - **OHLCV**: Traditional candlestick data 120 | - **Trades**: Tick-by-tick trade data 121 | 122 | ## Configuration 123 | 124 | ### Slippage Models 125 | 126 | Configure realistic transaction costs: 127 | 128 | ```rust 129 | use crate::slippage_models::{TransactionCosts, LinearSlippage}; 130 | 131 | let costs = TransactionCosts { 132 | fixed_fee: 0.50, 133 | percentage_fee: 0.001, 134 | slippage: Box::new(LinearSlippage::new(0.0001)), 135 | }; 136 | ``` 137 | 138 | ### Parameter Optimization 139 | 140 | Run parameter sweeps for strategy optimization: 141 | 142 | ```rust 143 | let param_ranges = vec![ 144 | (10..=50).step_by(10).collect(), // lookback periods 145 | (0.01..=0.05).step_by(0.01).collect(), // thresholds 146 | ]; 147 | 148 | // Generate all combinations and run in parallel 149 | let results = param_ranges 150 | .into_par_iter() 151 | .map(|params| run_backtest(params)) 152 | .collect(); 153 | ``` 154 | 155 | ## Performance Metrics 156 | 157 | InkBack automatically calculates: 158 | 159 | - Total return and annualized return 160 | - Sharpe ratio and maximum drawdown 161 | - Win rate and profit factor 162 | - Average trade duration 163 | - Risk-adjusted metrics 164 | 165 | ## Data Management 166 | 167 | - **Automatic Caching**: Downloaded data is saved locally as CSV 168 | - **Compression Handling**: Automatic decompression of DataBento's 9th exponent format 169 | - **Incremental Updates**: Only fetch new data when needed 170 | - **Multiple Timeframes**: Support for various data frequencies 171 | 172 | ## Examples 173 | 174 | The `examples/` directory contains complete implementations: 175 | 176 | - **Footprint Strategy**: Volume imbalance detection using order flow 177 | - **Options Momentum**: Momentum-based options trading 178 | - **Futures Strategy**: Trend-following futures system 179 | - **Equities Strategy**: Mean reversion equity strategy 180 | 181 | ## Output 182 | 183 | ![alt text](https://pbs.twimg.com/media/GwL7McvWgAAmqJa?format=png&name=large) 184 | 185 | ## Contributing 186 | 187 | 1. Fork the repository 188 | 2. Create a feature branch 189 | 3. Implement your changes with tests 190 | 4. Submit a pull request 191 | 192 | ## License 193 | 194 | This project is licensed under the MIT License - see the LICENSE file for details. 195 | 196 | # DISCLAIMER 197 | 198 | PLEASE READ THIS DISCLAIMER CAREFULLY BEFORE USING THE SOFTWARE. BY ACCESSING OR USING THE SOFTWARE, YOU ACKNOWLEDGE AND AGREE TO BE BOUND BY THE TERMS HEREIN. 199 | 200 | This software and related documentation ("Software") are provided solely for educational and research purposes. The Software is not intended, designed, tested, verified or certified for commercial deployment, live trading, or production use of any kind. The output of this software should not be used as financial, investment, legal, or tax advice. 201 | 202 | ACKNOWLEDGMENT BY USING THE SOFTWARE, USERS ACKNOWLEDGE THAT THEY HAVE READ THIS DISCLAIMER, UNDERSTOOD IT, AND AGREE TO BE BOUND BY ITS TERMS AND CONDITIONS. 203 | -------------------------------------------------------------------------------- /src/slippage_models.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | #[derive(Debug, Clone, Serialize, Deserialize)] 4 | pub struct TransactionCosts { 5 | pub commission: CommissionModel, 6 | pub slippage: SlippageModel, 7 | pub spread: SpreadModel, 8 | } 9 | 10 | #[derive(Debug, Clone, Serialize, Deserialize)] 11 | pub enum CommissionModel { 12 | Fixed(f64), // Fixed fee per trade 13 | PerShare(f64), // Fee per share 14 | Percentage(f64), // Percentage of trade value 15 | Tiered(Vec<(f64, f64)>), // Volume-based tiers (volume, rate) 16 | } 17 | 18 | #[derive(Debug, Clone, Serialize, Deserialize)] 19 | pub enum SlippageModel { 20 | Fixed(f64), // Fixed percentage slippage 21 | Linear(f64), // Linear with trade size 22 | SquareRoot(f64), // Square root of trade size 23 | MarketImpact { // More sophisticated model 24 | permanent: f64, 25 | temporary: f64, 26 | liquidity_factor: f64, 27 | }, 28 | OptionsSlippage { // Options-specific slippage model 29 | base_slippage_bps: f64, // Base slippage in basis points 30 | liquidity_factor: f64, // Multiplier for low liquidity 31 | bid_ask_multiplier: f64, // Fraction of bid-ask spread as slippage 32 | }, 33 | } 34 | 35 | #[derive(Debug, Clone, Serialize, Deserialize)] 36 | pub enum SpreadModel { 37 | Fixed(f64), // Fixed spread in price units 38 | Percentage(f64), // Percentage of mid price 39 | TimeDependent(Vec<(String, f64)>), // Different spreads by time 40 | OptionsBidAsk { // Options-specific bid-ask spread model 41 | min_spread: f64, // Minimum spread in dollars 42 | spread_pct: f64, // Percentage of option price 43 | max_spread_pct: f64, // Maximum spread as % of price (for cheap options) 44 | }, 45 | } 46 | 47 | impl TransactionCosts { 48 | pub fn calculate_entry_cost(&self, price: f64, size: f64, volume: f64) -> f64 { 49 | let commission = self.calculate_commission(price, size, volume); 50 | let slippage = self.calculate_slippage(price, size, volume, true); 51 | let spread = self.calculate_spread(price) / 2.0; // Half spread for market orders 52 | 53 | commission + slippage + spread 54 | } 55 | 56 | pub fn calculate_exit_cost(&self, price: f64, size: f64, volume: f64) -> f64 { 57 | let commission = self.calculate_commission(price, size, volume); 58 | let slippage = self.calculate_slippage(price, size, volume, false); 59 | let spread = self.calculate_spread(price) / 2.0; 60 | 61 | commission + slippage + spread 62 | } 63 | 64 | pub fn adjust_fill_price(&self, order_price: f64, size: f64, volume: f64, is_buy: bool) -> f64 { 65 | let slippage_bps = match &self.slippage { 66 | SlippageModel::Fixed(bps) => *bps, 67 | SlippageModel::Linear(factor) => factor * (size / volume).min(1.0), 68 | SlippageModel::SquareRoot(factor) => factor * (size / volume).sqrt(), 69 | SlippageModel::MarketImpact { temporary, .. } => { 70 | temporary * (size / volume).powf(0.5) 71 | } 72 | SlippageModel::OptionsSlippage { base_slippage_bps, liquidity_factor, bid_ask_multiplier } => { 73 | let participation_rate = (size / volume).min(1.0); 74 | let liquidity_penalty = if participation_rate > 0.1 { 75 | liquidity_factor * participation_rate 76 | } else { 77 | 1.0 78 | }; 79 | 80 | let bid_ask_spread = self.calculate_spread(order_price); 81 | let spread_slippage = bid_ask_multiplier * bid_ask_spread; 82 | 83 | // Convert spread slippage to basis points 84 | let spread_slippage_bps = if order_price > 0.0 { 85 | (spread_slippage / order_price) * 10000.0 86 | } else { 87 | 0.0 88 | }; 89 | 90 | base_slippage_bps * liquidity_penalty + spread_slippage_bps 91 | } 92 | }; 93 | 94 | let spread_cost = self.calculate_spread(order_price) / 2.0; 95 | let total_impact = (slippage_bps / 10000.0) * order_price + spread_cost; 96 | 97 | if is_buy { 98 | order_price + total_impact 99 | } else { 100 | order_price - total_impact 101 | } 102 | } 103 | 104 | fn calculate_commission(&self, price: f64, size: f64, _volume: f64) -> f64 { 105 | match &self.commission { 106 | CommissionModel::Fixed(fee) => *fee, 107 | CommissionModel::PerShare(rate) => rate * size, 108 | CommissionModel::Percentage(pct) => (pct / 100.0) * price * size, 109 | CommissionModel::Tiered(tiers) => { 110 | let trade_value = price * size; 111 | for (threshold, rate) in tiers { 112 | if trade_value <= *threshold { 113 | return rate * trade_value; 114 | } 115 | } 116 | // If above all tiers, use the last tier rate 117 | tiers.last().map_or(0.0, |(_, rate)| rate * trade_value) 118 | } 119 | } 120 | } 121 | 122 | fn calculate_slippage(&self, price: f64, size: f64, volume: f64, _is_entry: bool) -> f64 { 123 | match &self.slippage { 124 | SlippageModel::Fixed(bps) => (bps / 10000.0) * price * size, 125 | SlippageModel::Linear(factor) => { 126 | let impact = factor * (size / volume).min(1.0); 127 | (impact / 10000.0) * price * size 128 | } 129 | SlippageModel::SquareRoot(factor) => { 130 | let impact = factor * (size / volume).sqrt(); 131 | (impact / 10000.0) * price * size 132 | } 133 | SlippageModel::MarketImpact { permanent, temporary, liquidity_factor } => { 134 | let participation_rate = size / volume; 135 | let perm_impact = permanent * participation_rate.powf(0.5); 136 | let temp_impact = temporary * participation_rate.powf(0.5); 137 | let liquidity_adj = 1.0 + liquidity_factor * (1.0 - (volume / 1000000.0).min(1.0)); 138 | 139 | ((perm_impact + temp_impact) * liquidity_adj / 10000.0) * price * size 140 | } 141 | SlippageModel::OptionsSlippage { base_slippage_bps, liquidity_factor, bid_ask_multiplier } => { 142 | let participation_rate = (size / volume).min(1.0); 143 | let liquidity_penalty = if participation_rate > 0.1 { 144 | liquidity_factor * participation_rate 145 | } else { 146 | 1.0 147 | }; 148 | 149 | // Base slippage cost 150 | let base_cost = (base_slippage_bps * liquidity_penalty / 10000.0) * price * size; 151 | 152 | // Additional bid-ask spread cost 153 | let spread = self.calculate_spread(price); 154 | let spread_cost = bid_ask_multiplier * spread * size; 155 | 156 | base_cost + spread_cost 157 | } 158 | } 159 | } 160 | 161 | fn calculate_spread(&self, price: f64) -> f64 { 162 | match &self.spread { 163 | SpreadModel::Fixed(spread) => *spread, 164 | SpreadModel::Percentage(pct) => (pct / 100.0) * price, 165 | SpreadModel::TimeDependent(_) => { 166 | // Simplified - not every schema has bid and ask. Assuming constant spread for now. 167 | 0.01 * price // 1% default 168 | } 169 | SpreadModel::OptionsBidAsk { min_spread, spread_pct, max_spread_pct } => { 170 | let percentage_spread = (spread_pct / 100.0) * price; 171 | let max_spread = (max_spread_pct / 100.0) * price; 172 | 173 | // Use the larger of minimum spread or percentage spread, but cap at max 174 | percentage_spread.max(*min_spread).min(max_spread) 175 | } 176 | } 177 | } 178 | } 179 | 180 | // configurations for different markets 181 | impl TransactionCosts { 182 | pub fn equity_trading() -> Self { 183 | Self { 184 | commission: CommissionModel::Fixed(0.0), // Many brokers are zero commission now 185 | slippage: SlippageModel::Fixed(2.0), // 2 basis points 186 | spread: SpreadModel::Percentage(0.01), // 1 basis point 187 | } 188 | } 189 | 190 | pub fn futures_trading(tick_size: f64) -> Self { 191 | Self { 192 | commission: CommissionModel::Fixed(2.50), 193 | slippage: SlippageModel::Linear(5.0), 194 | spread: SpreadModel::Fixed(tick_size), // tick size for the future you are testing 195 | } 196 | } 197 | 198 | pub fn options_trading() -> Self { 199 | Self { 200 | commission: CommissionModel::PerShare(0.65), // $0.65 per contract (typical options commission) 201 | slippage: SlippageModel::OptionsSlippage { 202 | base_slippage_bps: 10.0, // 10 basis points base slippage 203 | liquidity_factor: 2.0, // Options are less liquid than stocks 204 | bid_ask_multiplier: 0.5, // Half the bid-ask spread as slippage 205 | }, 206 | spread: SpreadModel::OptionsBidAsk { 207 | min_spread: 0.05, // Minimum $0.05 spread 208 | spread_pct: 2.0, // 2% of option price 209 | max_spread_pct: 50.0, // Cap at 50% for very cheap options 210 | }, 211 | } 212 | } 213 | } -------------------------------------------------------------------------------- /examples/footprint/footprint_example.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::VecDeque, usize}; 2 | use time::{macros::date, macros::time}; 3 | use databento::{ 4 | dbn::{Schema}, 5 | }; 6 | use serde_json::Value; 7 | use std::collections::HashMap; 8 | use anyhow::Result; 9 | 10 | mod schema_handler; 11 | mod utils; 12 | mod strategy; 13 | mod backtester; 14 | mod plot; 15 | pub mod slippage_models; 16 | 17 | use strategy::Strategy; 18 | use utils::fetch::fetch_and_save_csv; 19 | use crate::{backtester::{display_results, run_parallel_backtest}, slippage_models::TransactionCosts, strategy::{Candle, Order, OrderType, StrategyParams}}; 20 | 21 | // InkBack schemas 22 | #[derive(Clone)] 23 | pub enum InkBackSchema { 24 | FootPrint, 25 | CombinedOptionsUnderlying, 26 | } 27 | 28 | /// A footprint-based volume imbalance strategy 29 | pub struct FootprintVolumeImbalance { 30 | imbalance_threshold: f64, 31 | volume_threshold: u64, 32 | tp: f64, 33 | sl: f64, 34 | lookback_periods: usize, 35 | 36 | candle_history: VecDeque, 37 | last_signal: Option, 38 | current_position: Option, 39 | entry_price: Option, 40 | } 41 | 42 | impl FootprintVolumeImbalance { 43 | /// Construct a new footprint strategy from parameters 44 | pub fn new(params: &StrategyParams) -> Result { 45 | let imbalance_threshold = params 46 | .get("imbalance_threshold") 47 | .ok_or_else(|| anyhow::anyhow!("Missing imbalance_threshold parameter"))? as f64; 48 | let volume_threshold = params 49 | .get("volume_threshold") 50 | .ok_or_else(|| anyhow::anyhow!("Missing volume_threshold parameter"))? as u64; 51 | let lookback_periods = params 52 | .get("lookback_periods") 53 | .ok_or_else(|| anyhow::anyhow!("Missing lookback_periods parameter"))? as usize; 54 | 55 | let tp = params 56 | .get("tp") 57 | .ok_or_else(|| anyhow::anyhow!("Missing tp parameter"))? as f64; 58 | let sl = params 59 | .get("sl") 60 | .ok_or_else(|| anyhow::anyhow!("Missing sl parameter"))? as f64; 61 | 62 | Ok(Self { 63 | imbalance_threshold, 64 | volume_threshold, 65 | tp, 66 | sl, 67 | lookback_periods, 68 | candle_history: VecDeque::with_capacity(lookback_periods), 69 | last_signal: None, 70 | current_position: None, 71 | entry_price: None, 72 | }) 73 | } 74 | 75 | /// Parse footprint data from JSON string 76 | fn parse_footprint_data(&self, footprint_json: &str) -> Result, anyhow::Error> { 77 | let parsed: Value = serde_json::from_str(footprint_json)?; 78 | let mut footprint_map = HashMap::new(); 79 | 80 | if let Value::Object(obj) = parsed { 81 | for (price_str, volumes) in obj { 82 | if let Value::Array(vol_array) = volumes { 83 | if vol_array.len() >= 2 { 84 | let buy_vol = vol_array[0].as_u64().unwrap_or(0); 85 | let sell_vol = vol_array[1].as_u64().unwrap_or(0); 86 | footprint_map.insert(price_str, (buy_vol, sell_vol)); 87 | } 88 | } 89 | } 90 | } 91 | 92 | Ok(footprint_map) 93 | } 94 | 95 | /// Calculate volume imbalance for a candle 96 | fn calculate_imbalance(&self, candle: &Candle) -> Result { 97 | let footprint_data = candle.get_string("footprint_data") 98 | .ok_or_else(|| anyhow::anyhow!("Missing footprint_data in candle"))?; 99 | 100 | let footprint_map = self.parse_footprint_data(footprint_data)?; 101 | 102 | let mut total_buy_volume = 0u64; 103 | let mut total_sell_volume = 0u64; 104 | 105 | for (_, (buy_vol, sell_vol)) in footprint_map { 106 | total_buy_volume += buy_vol; 107 | total_sell_volume += sell_vol; 108 | } 109 | 110 | let total_volume = total_buy_volume + total_sell_volume; 111 | if total_volume == 0 { 112 | return Ok(0.0); 113 | } 114 | 115 | // Calculate imbalance as percentage: positive = more buying, negative = more selling 116 | let imbalance = (total_buy_volume as f64 - total_sell_volume as f64) / total_volume as f64; 117 | Ok(imbalance) 118 | } 119 | 120 | /// Calculate volume-weighted average imbalance over lookback periods 121 | fn calculate_average_imbalance(&self) -> Result { 122 | if self.candle_history.is_empty() { 123 | return Ok(0.0); 124 | } 125 | 126 | let mut weighted_imbalance = 0.0; 127 | let mut total_weight = 0.0; 128 | 129 | for candle in &self.candle_history { 130 | let volume = candle.get("volume") 131 | .ok_or_else(|| anyhow::anyhow!("Missing volume in candle"))?; 132 | let imbalance = self.calculate_imbalance(candle)?; 133 | 134 | weighted_imbalance += imbalance * volume; 135 | total_weight += volume; 136 | } 137 | 138 | if total_weight == 0.0 { 139 | Ok(0.0) 140 | } else { 141 | Ok(weighted_imbalance / total_weight) 142 | } 143 | } 144 | } 145 | 146 | impl Strategy for FootprintVolumeImbalance { 147 | fn on_candle(&mut self, candle: &Candle, _prev: Option<&Candle>) -> Option { 148 | let close = candle.get("close") 149 | .ok_or_else(|| anyhow::anyhow!("Missing 'close' in candle")).expect("Candle Error"); 150 | 151 | let volume = candle.get("volume") 152 | .ok_or_else(|| anyhow::anyhow!("Missing 'volume' in candle")).expect("Candle Error") as u64; 153 | 154 | // Add candle to history 155 | self.candle_history.push_back(candle.clone()); 156 | if self.candle_history.len() > self.lookback_periods { 157 | self.candle_history.pop_front(); 158 | } 159 | 160 | // Need enough history to make decisions 161 | if self.candle_history.len() < self.lookback_periods { 162 | // println!("Not enough history: {} < {}", self.candle_history.len(), self.lookback_periods); 163 | return None; 164 | } 165 | 166 | // If in a position, check TP/SL 167 | if let (Some(position), Some(entry)) = (self.current_position, self.entry_price) { 168 | match position { 169 | OrderType::MarketBuy => { 170 | if close >= entry * (1.0 + self.tp) || close <= entry * (1.0 - self.sl) { 171 | //println!("Exiting BUY position: close={:.2}, entry={:.2}, tp_level={:.2}, sl_level={:.2}", 172 | // close, entry, entry * (1.0 + self.tp), entry * (1.0 - self.sl)); 173 | self.current_position = None; 174 | self.entry_price = None; 175 | return Some(Order { 176 | order_type: OrderType::MarketSell, 177 | price: close, 178 | }); 179 | } 180 | } 181 | OrderType::MarketSell => { 182 | if close <= entry * (1.0 - self.tp) || close >= entry * (1.0 + self.sl) { 183 | //println!("Exiting SELL position: close={:.2}, entry={:.2}, tp_level={:.2}, sl_level={:.2}", 184 | // close, entry, entry * (1.0 - self.tp), entry * (1.0 + self.sl)); 185 | self.current_position = None; 186 | self.entry_price = None; 187 | return Some(Order { 188 | order_type: OrderType::MarketBuy, 189 | price: close, 190 | }); 191 | } 192 | } 193 | OrderType::LimitBuy => todo!(), 194 | OrderType::LimitSell => todo!(), 195 | } 196 | } 197 | 198 | // Skip if volume is too low 199 | if volume < self.volume_threshold { 200 | //println!("Volume too low: {} < {}", volume, self.volume_threshold); 201 | return None; 202 | } 203 | 204 | // Calculate current imbalance 205 | let current_imbalance = match self.calculate_imbalance(candle) { 206 | Ok(imbalance) => { 207 | // println!("Current imbalance: {:.4}", imbalance); 208 | imbalance 209 | }, 210 | Err(e) => { 211 | println!("Error calculating imbalance: {}", e); 212 | return None; 213 | }, 214 | }; 215 | 216 | // Calculate average imbalance over lookback period 217 | let avg_imbalance = match self.calculate_average_imbalance() { 218 | Ok(imbalance) => { 219 | // println!("Average imbalance: {:.4}", imbalance); 220 | imbalance 221 | }, 222 | Err(e) => { 223 | println!("Error calculating average imbalance: {}", e); 224 | return None; 225 | }, 226 | }; 227 | 228 | // Print footprint data for debugging 229 | //if let Some(footprint_data) = candle.get_string("footprint_data") { 230 | // println!("Footprint data sample: {}", footprint_data.chars().take(100).collect::()); 231 | //} 232 | 233 | // Generate signals based on imbalance 234 | let new_signal = if current_imbalance > self.imbalance_threshold && avg_imbalance > 0.0 { 235 | //println!("BUY signal: current_imbalance={:.4} > threshold={:.4} && avg_imbalance={:.4} > 0", 236 | // current_imbalance, self.imbalance_threshold, avg_imbalance); 237 | Some(OrderType::MarketBuy) 238 | } else if current_imbalance < -self.imbalance_threshold && avg_imbalance < 0.0 { 239 | //println!("SELL signal: current_imbalance={:.4} < -{:.4} && avg_imbalance={:.4} < 0", 240 | // current_imbalance, self.imbalance_threshold, avg_imbalance); 241 | Some(OrderType::MarketSell) 242 | } else { 243 | //println!("No signal: current_imbalance={:.4}, threshold={:.4}, avg_imbalance={:.4}", 244 | // current_imbalance, self.imbalance_threshold, avg_imbalance); 245 | None 246 | }; 247 | 248 | if let Some(signal) = new_signal { 249 | if Some(signal) != self.last_signal { 250 | //println!("Generating {:?} order at price {:.2}", signal, close); 251 | self.last_signal = Some(signal); 252 | self.current_position = Some(signal); 253 | self.entry_price = Some(close); 254 | return Some(Order { 255 | order_type: signal, 256 | price: close, 257 | }); 258 | } else { 259 | //println!("Signal {:?} matches last signal, skipping", signal); 260 | } 261 | } 262 | 263 | None 264 | } 265 | } 266 | 267 | #[tokio::main] 268 | async fn main() -> anyhow::Result<()> { 269 | // Load environment variables 270 | dotenvy::dotenv().ok(); 271 | 272 | // Define historical data range 273 | let start = date!(2025 - 01 - 01).with_time(time!(00:00)).assume_utc(); 274 | let end = date!(2025 - 06 - 01).with_time(time!(00:00)).assume_utc(); 275 | 276 | let starting_equity = 100_000.00; 277 | let exposure = 0.50; // % of capital allocated to each trade 278 | 279 | // Create a client for historical market data 280 | let client = databento::HistoricalClient::builder().key_from_env()?.build()?; 281 | 282 | // Fetch and save footprint data to CSV 283 | let schema = Schema::Trades; 284 | // Set the tick size for the future you are trading 285 | let es_tick_size: f64 = 0.25; 286 | let transaction_costs = TransactionCosts::futures_trading(es_tick_size); 287 | let symbol = "ES.v.0"; 288 | let csv_path = fetch_and_save_csv(client, "GLBX.MDP3", symbol, None, schema, Some(InkBackSchema::FootPrint), start, end).await?; 289 | 290 | // Footprint strategy parameter ranges 291 | let imbalance_thresholds = vec![0.2, 0.3]; // imbalance percent 292 | let volume_thresholds = vec![200, 500]; // Minimum volume 293 | let lookback_periods = vec![3, 5]; // Lookback periods for average imbalance 294 | let tp_windows = vec![0.0025, 0.005]; // take profit 295 | let sl_windows = vec![0.0025, 0.005]; // stop loss 296 | 297 | // Generate all combinations of parameters using nested loops 298 | let mut parameter_combinations = Vec::new(); 299 | for imbalance_threshold in &imbalance_thresholds { 300 | for volume_threshold in &volume_thresholds { 301 | for lookback in &lookback_periods { 302 | for tp in &tp_windows { 303 | for sl in &sl_windows { 304 | let mut params = StrategyParams::new(); 305 | params.insert("imbalance_threshold", *imbalance_threshold); 306 | params.insert("volume_threshold", *volume_threshold as f64); 307 | params.insert("lookback_periods", *lookback as f64); 308 | params.insert("tp", *tp); 309 | params.insert("sl", *sl); 310 | parameter_combinations.push(params); 311 | } 312 | } 313 | } 314 | } 315 | } 316 | 317 | let sorted_results = run_parallel_backtest( 318 | parameter_combinations, 319 | &csv_path, 320 | &symbol, 321 | schema, 322 | Some(InkBackSchema::FootPrint), 323 | |params| Ok(Box::new(FootprintVolumeImbalance::new(params)?)), 324 | starting_equity, 325 | exposure, 326 | transaction_costs.clone(), 327 | ); 328 | 329 | display_results(sorted_results, &csv_path, &symbol, schema, Some(InkBackSchema::FootPrint), starting_equity, exposure); 330 | 331 | Ok(()) 332 | } 333 | -------------------------------------------------------------------------------- /examples/equities/equities_example.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::VecDeque, usize}; 2 | use time::{macros::date, macros::time}; 3 | use databento::{ 4 | dbn::{Schema}, 5 | }; 6 | use anyhow::Result; 7 | 8 | mod schema_handler; 9 | mod utils; 10 | mod strategy; 11 | mod backtester; 12 | mod plot; 13 | pub mod slippage_models; 14 | 15 | use strategy::Strategy; 16 | use utils::fetch::fetch_and_save_csv; 17 | use crate::{backtester::{display_results, run_parallel_backtest}, slippage_models::TransactionCosts, strategy::{Candle, Order, OrderType, StrategyParams}}; 18 | 19 | // InkBack schemas 20 | #[derive(Clone)] 21 | pub enum InkBackSchema { 22 | FootPrint, 23 | CombinedOptionsUnderlying, 24 | } 25 | 26 | /// Moving Average Cross Strategy 27 | pub struct MovingAverageCrossStrategy { 28 | // Strategy parameters 29 | pub short_ma_period: usize, // Short moving average period 30 | pub long_ma_period: usize, // Long moving average period 31 | pub volume_threshold: f64, // Minimum volume threshold for trades 32 | pub profit_target: f64, // % profit target 33 | pub stop_loss: f64, // % stop loss 34 | 35 | // Moving average tracking 36 | pub short_ma_history: VecDeque, 37 | pub long_ma_history: VecDeque, 38 | pub volume_history: VecDeque, 39 | 40 | // Current state 41 | pub position_state: PositionState, 42 | pub entry_price: f64, 43 | pub entry_time: String, 44 | 45 | // Moving averages 46 | pub short_ma: f64, 47 | pub long_ma: f64, 48 | pub prev_short_ma: f64, 49 | pub prev_long_ma: f64, 50 | } 51 | 52 | #[derive(Debug, Clone, Copy, PartialEq)] 53 | pub enum PositionState { 54 | Flat, 55 | Long, 56 | Short, 57 | } 58 | 59 | impl MovingAverageCrossStrategy { 60 | pub fn new(params: &StrategyParams) -> Result { 61 | let short_ma_period = params 62 | .get("short_ma_period") 63 | .ok_or_else(|| anyhow::anyhow!("Missing short_ma_period parameter"))? as usize; 64 | 65 | let long_ma_period = params 66 | .get("long_ma_period") 67 | .ok_or_else(|| anyhow::anyhow!("Missing long_ma_period parameter"))? as usize; 68 | 69 | if short_ma_period >= long_ma_period { 70 | return Err(anyhow::anyhow!("Short MA period must be less than long MA period")); 71 | } 72 | 73 | let volume_threshold = params 74 | .get("volume_threshold") 75 | .unwrap_or(0.0); 76 | 77 | let profit_target = params 78 | .get("profit_target") 79 | .unwrap_or(0.0) / 100.0; 80 | 81 | let stop_loss = params 82 | .get("stop_loss") 83 | .unwrap_or(0.0) / 100.0; 84 | 85 | Ok(Self { 86 | short_ma_period, 87 | long_ma_period, 88 | volume_threshold, 89 | profit_target, 90 | stop_loss, 91 | short_ma_history: VecDeque::with_capacity(short_ma_period), 92 | long_ma_history: VecDeque::with_capacity(long_ma_period), 93 | volume_history: VecDeque::with_capacity(long_ma_period), 94 | position_state: PositionState::Flat, 95 | entry_price: 0.0, 96 | entry_time: String::new(), 97 | short_ma: 0.0, 98 | long_ma: 0.0, 99 | prev_short_ma: 0.0, 100 | prev_long_ma: 0.0, 101 | }) 102 | } 103 | 104 | // Reset method to ensure clean state 105 | pub fn reset(&mut self) { 106 | self.short_ma_history.clear(); 107 | self.long_ma_history.clear(); 108 | self.volume_history.clear(); 109 | self.position_state = PositionState::Flat; 110 | self.entry_price = 0.0; 111 | self.entry_time.clear(); 112 | self.short_ma = 0.0; 113 | self.long_ma = 0.0; 114 | self.prev_short_ma = 0.0; 115 | self.prev_long_ma = 0.0; 116 | } 117 | 118 | /// Calculate simple moving average from a VecDeque 119 | fn calculate_sma(history: &VecDeque) -> f64 { 120 | if history.is_empty() { 121 | return 0.0; 122 | } 123 | history.iter().sum::() / history.len() as f64 124 | } 125 | 126 | /// Update moving averages 127 | fn update_moving_averages(&mut self, price: f64) { 128 | // Store previous values 129 | self.prev_short_ma = self.short_ma; 130 | self.prev_long_ma = self.long_ma; 131 | 132 | // Add new price to histories 133 | self.short_ma_history.push_back(price); 134 | self.long_ma_history.push_back(price); 135 | 136 | // Maintain window sizes 137 | if self.short_ma_history.len() > self.short_ma_period { 138 | self.short_ma_history.pop_front(); 139 | } 140 | if self.long_ma_history.len() > self.long_ma_period { 141 | self.long_ma_history.pop_front(); 142 | } 143 | 144 | // Calculate new moving averages 145 | if self.short_ma_history.len() == self.short_ma_period { 146 | self.short_ma = Self::calculate_sma(&self.short_ma_history); 147 | } 148 | if self.long_ma_history.len() == self.long_ma_period { 149 | self.long_ma = Self::calculate_sma(&self.long_ma_history); 150 | } 151 | } 152 | 153 | /// Check for moving average crossover signals 154 | fn check_crossover_signal(&self) -> Option { 155 | // Need full history for both MAs and previous values 156 | if self.short_ma_history.len() < self.short_ma_period || 157 | self.long_ma_history.len() < self.long_ma_period || 158 | self.prev_short_ma == 0.0 || self.prev_long_ma == 0.0 { 159 | return None; 160 | } 161 | 162 | // Golden Cross: Short MA crosses above Long MA (bullish signal) 163 | if self.prev_short_ma <= self.prev_long_ma && self.short_ma > self.long_ma { 164 | return Some(OrderType::MarketBuy); 165 | } 166 | 167 | // Death Cross: Short MA crosses below Long MA (bearish signal) 168 | if self.prev_short_ma >= self.prev_long_ma && self.short_ma < self.long_ma { 169 | return Some(OrderType::MarketSell); 170 | } 171 | 172 | None 173 | } 174 | 175 | /// Check volume conditions 176 | fn check_volume_condition(&self, current_volume: u64) -> bool { 177 | if self.volume_threshold <= 0.0 { 178 | return true; // No volume filter 179 | } 180 | 181 | if self.volume_history.len() < 20 { 182 | return true; // Not enough history, allow trade 183 | } 184 | 185 | // Calculate average volume over last 20 periods 186 | let avg_volume = self.volume_history.iter() 187 | .rev() 188 | .take(20) 189 | .sum::() as f64 / 20.0; 190 | 191 | current_volume as f64 >= avg_volume * self.volume_threshold 192 | } 193 | 194 | /// Check if we should exit current position 195 | fn should_exit_position(&self, current_price: f64) -> bool { 196 | if self.position_state == PositionState::Flat || self.entry_price == 0.0 { 197 | return false; 198 | } 199 | 200 | let pnl_pct = match self.position_state { 201 | PositionState::Long => (current_price - self.entry_price) / self.entry_price, 202 | PositionState::Short => (self.entry_price - current_price) / self.entry_price, 203 | PositionState::Flat => return false, 204 | }; 205 | 206 | // Exit on profit target or stop loss (if configured) 207 | if self.profit_target > 0.0 && pnl_pct >= self.profit_target { 208 | return true; 209 | } 210 | if self.stop_loss > 0.0 && pnl_pct <= -self.stop_loss { 211 | return true; 212 | } 213 | 214 | false 215 | } 216 | 217 | /// Get the trading price (handles bid/ask or uses close price) 218 | fn get_trading_price(&self, candle: &Candle) -> f64 { 219 | // Try to get bid/ask for more realistic pricing 220 | if let (Some(bid), Some(ask)) = (candle.get("bid"), candle.get("ask")) { 221 | if bid > 0.0 && ask > 0.0 && ask > bid { 222 | return (bid + ask) / 2.0; // Use mid price 223 | } 224 | } 225 | 226 | // Fall back to close price 227 | candle.get("close") 228 | .or_else(|| candle.get("price")) 229 | .unwrap_or(0.0) 230 | } 231 | } 232 | 233 | impl Strategy for MovingAverageCrossStrategy { 234 | fn on_candle(&mut self, candle: &Candle, _prev: Option<&Candle>) -> Option { 235 | let current_price = self.get_trading_price(candle); 236 | 237 | // Skip invalid prices 238 | if current_price <= 0.0 { 239 | return None; 240 | } 241 | 242 | // Get volume (handle different possible field names) 243 | let volume = candle.get("volume") 244 | .or_else(|| candle.get("size")) 245 | .unwrap_or(0.0) as u64; 246 | 247 | // Update volume history 248 | self.volume_history.push_back(volume); 249 | if self.volume_history.len() > 100 { // Keep last 100 periods 250 | self.volume_history.pop_front(); 251 | } 252 | 253 | // Update moving averages 254 | self.update_moving_averages(current_price); 255 | 256 | // If we're in a position, check for exit conditions first 257 | if self.position_state != PositionState::Flat { 258 | if self.should_exit_position(current_price) { 259 | let exit_order = match self.position_state { 260 | PositionState::Long => OrderType::MarketSell, 261 | PositionState::Short => OrderType::MarketBuy, 262 | PositionState::Flat => return None, 263 | }; 264 | 265 | // Reset position state 266 | self.position_state = PositionState::Flat; 267 | self.entry_price = 0.0; 268 | self.entry_time.clear(); 269 | 270 | return Some(Order { 271 | order_type: exit_order, 272 | price: current_price, 273 | }); 274 | } 275 | 276 | // Check for opposite crossover signal to close position 277 | if let Some(signal) = self.check_crossover_signal() { 278 | let should_close = match (self.position_state, signal) { 279 | (PositionState::Long, OrderType::MarketSell) => true, 280 | (PositionState::Short, OrderType::MarketBuy) => true, 281 | _ => false, 282 | }; 283 | 284 | if should_close { 285 | let exit_order = match self.position_state { 286 | PositionState::Long => OrderType::MarketSell, 287 | PositionState::Short => OrderType::MarketBuy, 288 | PositionState::Flat => return None, 289 | }; 290 | 291 | // Reset position state 292 | self.position_state = PositionState::Flat; 293 | self.entry_price = 0.0; 294 | self.entry_time.clear(); 295 | 296 | return Some(Order { 297 | order_type: exit_order, 298 | price: current_price, 299 | }); 300 | } 301 | } 302 | 303 | return None; // Stay in position if no exit signal 304 | } 305 | 306 | // Check for entry signal 307 | if let Some(signal) = self.check_crossover_signal() { 308 | // Check volume condition 309 | if !self.check_volume_condition(volume) { 310 | return None; 311 | } 312 | 313 | // Update position state 314 | self.position_state = match signal { 315 | OrderType::MarketBuy => PositionState::Long, 316 | OrderType::MarketSell => PositionState::Short, 317 | OrderType::LimitBuy => todo!(), 318 | OrderType::LimitSell => todo!(), 319 | }; 320 | self.entry_price = current_price; 321 | self.entry_time = candle.date.clone(); 322 | 323 | return Some(Order { 324 | order_type: signal, 325 | price: current_price, 326 | }); 327 | } 328 | 329 | None 330 | } 331 | } 332 | 333 | #[tokio::main] 334 | async fn main() -> anyhow::Result<()> { 335 | // Load environment variables 336 | dotenvy::dotenv().ok(); 337 | 338 | // Define historical data range 339 | let start = date!(2024 - 06 - 01).with_time(time!(00:00)).assume_utc(); 340 | let end = date!(2024 - 06 - 30).with_time(time!(00:00)).assume_utc(); 341 | 342 | let starting_equity = 100_000.00; 343 | let exposure = 0.95; // Use 95% of account for stock trading 344 | 345 | // Create a client for historical market data 346 | let client = databento::HistoricalClient::builder().key_from_env()?.build()?; 347 | 348 | // Fetch SPY stock data 349 | let schema = Schema::Ohlcv1H; // or Schema::Ohlcv1M for minute bars 350 | let symbol = "SPY"; 351 | let csv_path = fetch_and_save_csv( 352 | client, 353 | "XNAS.ITCH", // NASDAQ dataset 354 | symbol, // SPY ETF 355 | None, // No options data needed 356 | schema, 357 | None, // No custom schema needed 358 | start, 359 | end 360 | ).await?; 361 | 362 | // Set transaction costs for stock trading 363 | let transaction_costs = TransactionCosts::equity_trading(); 364 | 365 | // Define parameter ranges for the moving average strategy 366 | let short_ma_periods = vec![10, 20]; // Short MA periods 367 | let long_ma_periods = vec![20, 50]; // Long MA periods 368 | let volume_thresholds = vec![0.0, 1.2]; // Volume multipliers (0 = no filter) 369 | let profit_targets = vec![5.0, 10.0]; // % profit targets 370 | let stop_losses = vec![3.0, 5.0]; // % stop losses 371 | 372 | // Generate all parameter combinations 373 | let mut parameter_combinations = Vec::new(); 374 | for short_ma in &short_ma_periods { 375 | for long_ma in &long_ma_periods { 376 | if short_ma < long_ma { // Ensure short MA is less than long MA 377 | for volume_thresh in &volume_thresholds { 378 | for profit_target in &profit_targets { 379 | for stop_loss in &stop_losses { 380 | let mut params = StrategyParams::new(); 381 | params.insert("short_ma_period", *short_ma as f64); 382 | params.insert("long_ma_period", *long_ma as f64); 383 | params.insert("volume_threshold", *volume_thresh); 384 | params.insert("profit_target", *profit_target); 385 | params.insert("stop_loss", *stop_loss); 386 | parameter_combinations.push(params); 387 | } 388 | } 389 | } 390 | } 391 | } 392 | } 393 | 394 | let sorted_results = run_parallel_backtest( 395 | parameter_combinations, 396 | &csv_path, 397 | &symbol, 398 | schema, 399 | None, 400 | |params| Ok(Box::new(MovingAverageCrossStrategy::new(params)?)), 401 | starting_equity, 402 | exposure, 403 | transaction_costs.clone(), 404 | ); 405 | 406 | display_results(sorted_results, &csv_path, &symbol, schema, None, starting_equity, exposure); 407 | 408 | Ok(()) 409 | } 410 | -------------------------------------------------------------------------------- /examples/futures/futures_example.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::VecDeque, usize}; 2 | use time::{macros::date, macros::time}; 3 | use databento::{ 4 | dbn::{Schema}, 5 | }; 6 | use anyhow::Result; 7 | 8 | mod schema_handler; 9 | mod utils; 10 | mod strategy; 11 | mod backtester; 12 | mod plot; 13 | pub mod slippage_models; 14 | 15 | use strategy::Strategy; 16 | use utils::fetch::fetch_and_save_csv; 17 | use crate::{backtester::{display_results, run_parallel_backtest}, slippage_models::TransactionCosts, strategy::{Candle, Order, OrderType, StrategyParams}}; 18 | 19 | // InkBack schemas 20 | #[derive(Clone)] 21 | pub enum InkBackSchema { 22 | FootPrint, 23 | CombinedOptionsUnderlying, 24 | } 25 | 26 | /// Moving Average Cross Strategy 27 | pub struct MovingAverageCrossStrategy { 28 | // Strategy parameters 29 | pub short_ma_period: usize, // Short moving average period 30 | pub long_ma_period: usize, // Long moving average period 31 | pub volume_threshold: f64, // Minimum volume threshold for trades 32 | pub profit_target: f64, // % profit target 33 | pub stop_loss: f64, // % stop loss 34 | 35 | // Moving average tracking 36 | pub short_ma_history: VecDeque, 37 | pub long_ma_history: VecDeque, 38 | pub volume_history: VecDeque, 39 | 40 | // Current state 41 | pub position_state: PositionState, 42 | pub entry_price: f64, 43 | pub entry_time: String, 44 | 45 | // Moving averages 46 | pub short_ma: f64, 47 | pub long_ma: f64, 48 | pub prev_short_ma: f64, 49 | pub prev_long_ma: f64, 50 | } 51 | 52 | #[derive(Debug, Clone, Copy, PartialEq)] 53 | pub enum PositionState { 54 | Flat, 55 | Long, 56 | Short, 57 | } 58 | 59 | impl MovingAverageCrossStrategy { 60 | pub fn new(params: &StrategyParams) -> Result { 61 | let short_ma_period = params 62 | .get("short_ma_period") 63 | .ok_or_else(|| anyhow::anyhow!("Missing short_ma_period parameter"))? as usize; 64 | 65 | let long_ma_period = params 66 | .get("long_ma_period") 67 | .ok_or_else(|| anyhow::anyhow!("Missing long_ma_period parameter"))? as usize; 68 | 69 | if short_ma_period >= long_ma_period { 70 | return Err(anyhow::anyhow!("Short MA period must be less than long MA period")); 71 | } 72 | 73 | let volume_threshold = params 74 | .get("volume_threshold") 75 | .unwrap_or(0.0); 76 | 77 | let profit_target = params 78 | .get("profit_target") 79 | .unwrap_or(0.0) / 100.0; 80 | 81 | let stop_loss = params 82 | .get("stop_loss") 83 | .unwrap_or(0.0) / 100.0; 84 | 85 | Ok(Self { 86 | short_ma_period, 87 | long_ma_period, 88 | volume_threshold, 89 | profit_target, 90 | stop_loss, 91 | short_ma_history: VecDeque::with_capacity(short_ma_period), 92 | long_ma_history: VecDeque::with_capacity(long_ma_period), 93 | volume_history: VecDeque::with_capacity(long_ma_period), 94 | position_state: PositionState::Flat, 95 | entry_price: 0.0, 96 | entry_time: String::new(), 97 | short_ma: 0.0, 98 | long_ma: 0.0, 99 | prev_short_ma: 0.0, 100 | prev_long_ma: 0.0, 101 | }) 102 | } 103 | 104 | // Reset method to ensure clean state 105 | pub fn reset(&mut self) { 106 | self.short_ma_history.clear(); 107 | self.long_ma_history.clear(); 108 | self.volume_history.clear(); 109 | self.position_state = PositionState::Flat; 110 | self.entry_price = 0.0; 111 | self.entry_time.clear(); 112 | self.short_ma = 0.0; 113 | self.long_ma = 0.0; 114 | self.prev_short_ma = 0.0; 115 | self.prev_long_ma = 0.0; 116 | } 117 | 118 | /// Calculate simple moving average from a VecDeque 119 | fn calculate_sma(history: &VecDeque) -> f64 { 120 | if history.is_empty() { 121 | return 0.0; 122 | } 123 | history.iter().sum::() / history.len() as f64 124 | } 125 | 126 | /// Update moving averages 127 | fn update_moving_averages(&mut self, price: f64) { 128 | // Store previous values 129 | self.prev_short_ma = self.short_ma; 130 | self.prev_long_ma = self.long_ma; 131 | 132 | // Add new price to histories 133 | self.short_ma_history.push_back(price); 134 | self.long_ma_history.push_back(price); 135 | 136 | // Maintain window sizes 137 | if self.short_ma_history.len() > self.short_ma_period { 138 | self.short_ma_history.pop_front(); 139 | } 140 | if self.long_ma_history.len() > self.long_ma_period { 141 | self.long_ma_history.pop_front(); 142 | } 143 | 144 | // Calculate new moving averages 145 | if self.short_ma_history.len() == self.short_ma_period { 146 | self.short_ma = Self::calculate_sma(&self.short_ma_history); 147 | } 148 | if self.long_ma_history.len() == self.long_ma_period { 149 | self.long_ma = Self::calculate_sma(&self.long_ma_history); 150 | } 151 | } 152 | 153 | /// Check for moving average crossover signals 154 | fn check_crossover_signal(&self) -> Option { 155 | // Need full history for both MAs and previous values 156 | if self.short_ma_history.len() < self.short_ma_period || 157 | self.long_ma_history.len() < self.long_ma_period || 158 | self.prev_short_ma == 0.0 || self.prev_long_ma == 0.0 { 159 | return None; 160 | } 161 | 162 | // Golden Cross: Short MA crosses above Long MA (bullish signal) 163 | if self.prev_short_ma <= self.prev_long_ma && self.short_ma > self.long_ma { 164 | return Some(OrderType::MarketBuy); 165 | } 166 | 167 | // Death Cross: Short MA crosses below Long MA (bearish signal) 168 | if self.prev_short_ma >= self.prev_long_ma && self.short_ma < self.long_ma { 169 | return Some(OrderType::MarketSell); 170 | } 171 | 172 | None 173 | } 174 | 175 | /// Check volume conditions 176 | fn check_volume_condition(&self, current_volume: u64) -> bool { 177 | if self.volume_threshold <= 0.0 { 178 | return true; // No volume filter 179 | } 180 | 181 | if self.volume_history.len() < 20 { 182 | return true; // Not enough history, allow trade 183 | } 184 | 185 | // Calculate average volume over last 20 periods 186 | let avg_volume = self.volume_history.iter() 187 | .rev() 188 | .take(20) 189 | .sum::() as f64 / 20.0; 190 | 191 | current_volume as f64 >= avg_volume * self.volume_threshold 192 | } 193 | 194 | /// Check if we should exit current position 195 | fn should_exit_position(&self, current_price: f64) -> bool { 196 | if self.position_state == PositionState::Flat || self.entry_price == 0.0 { 197 | return false; 198 | } 199 | 200 | let pnl_pct = match self.position_state { 201 | PositionState::Long => (current_price - self.entry_price) / self.entry_price, 202 | PositionState::Short => (self.entry_price - current_price) / self.entry_price, 203 | PositionState::Flat => return false, 204 | }; 205 | 206 | // Exit on profit target or stop loss (if configured) 207 | if self.profit_target > 0.0 && pnl_pct >= self.profit_target { 208 | return true; 209 | } 210 | if self.stop_loss > 0.0 && pnl_pct <= -self.stop_loss { 211 | return true; 212 | } 213 | 214 | false 215 | } 216 | 217 | /// Get the trading price (handles bid/ask or uses close price) 218 | fn get_trading_price(&self, candle: &Candle) -> f64 { 219 | // Try to get bid/ask for more realistic pricing 220 | if let (Some(bid), Some(ask)) = (candle.get("bid"), candle.get("ask")) { 221 | if bid > 0.0 && ask > 0.0 && ask > bid { 222 | return (bid + ask) / 2.0; // Use mid price 223 | } 224 | } 225 | 226 | // Fall back to close price 227 | candle.get("close") 228 | .or_else(|| candle.get("price")) 229 | .unwrap_or(0.0) 230 | } 231 | } 232 | 233 | impl Strategy for MovingAverageCrossStrategy { 234 | fn on_candle(&mut self, candle: &Candle, _prev: Option<&Candle>) -> Option { 235 | let current_price = self.get_trading_price(candle); 236 | 237 | // Skip invalid prices 238 | if current_price <= 0.0 { 239 | return None; 240 | } 241 | 242 | // Get volume (handle different possible field names) 243 | let volume = candle.get("volume") 244 | .or_else(|| candle.get("size")) 245 | .unwrap_or(0.0) as u64; 246 | 247 | // Update volume history 248 | self.volume_history.push_back(volume); 249 | if self.volume_history.len() > 100 { // Keep last 100 periods 250 | self.volume_history.pop_front(); 251 | } 252 | 253 | // Update moving averages 254 | self.update_moving_averages(current_price); 255 | 256 | // If we're in a position, check for exit conditions first 257 | if self.position_state != PositionState::Flat { 258 | if self.should_exit_position(current_price) { 259 | let exit_order = match self.position_state { 260 | PositionState::Long => OrderType::MarketSell, 261 | PositionState::Short => OrderType::MarketBuy, 262 | PositionState::Flat => return None, 263 | }; 264 | 265 | // Reset position state 266 | self.position_state = PositionState::Flat; 267 | self.entry_price = 0.0; 268 | self.entry_time.clear(); 269 | 270 | return Some(Order { 271 | order_type: exit_order, 272 | price: current_price, 273 | }); 274 | } 275 | 276 | // Check for opposite crossover signal to close position 277 | if let Some(signal) = self.check_crossover_signal() { 278 | let should_close = match (self.position_state, signal) { 279 | (PositionState::Long, OrderType::MarketSell) => true, 280 | (PositionState::Short, OrderType::MarketBuy) => true, 281 | _ => false, 282 | }; 283 | 284 | if should_close { 285 | let exit_order = match self.position_state { 286 | PositionState::Long => OrderType::MarketSell, 287 | PositionState::Short => OrderType::MarketBuy, 288 | PositionState::Flat => return None, 289 | }; 290 | 291 | // Reset position state 292 | self.position_state = PositionState::Flat; 293 | self.entry_price = 0.0; 294 | self.entry_time.clear(); 295 | 296 | return Some(Order { 297 | order_type: exit_order, 298 | price: current_price, 299 | }); 300 | } 301 | } 302 | 303 | return None; // Stay in position if no exit signal 304 | } 305 | 306 | // Check for entry signal 307 | if let Some(signal) = self.check_crossover_signal() { 308 | // Check volume condition 309 | if !self.check_volume_condition(volume) { 310 | return None; 311 | } 312 | 313 | // Update position state 314 | self.position_state = match signal { 315 | OrderType::MarketBuy => PositionState::Long, 316 | OrderType::MarketSell => PositionState::Short, 317 | OrderType::LimitBuy => todo!(), 318 | OrderType::LimitSell => todo!(), 319 | }; 320 | self.entry_price = current_price; 321 | self.entry_time = candle.date.clone(); 322 | 323 | return Some(Order { 324 | order_type: signal, 325 | price: current_price, 326 | }); 327 | } 328 | 329 | None 330 | } 331 | } 332 | 333 | #[tokio::main] 334 | async fn main() -> anyhow::Result<()> { 335 | // Load environment variables 336 | dotenvy::dotenv().ok(); 337 | 338 | // Define historical data range 339 | let start = date!(2024 - 06 - 01).with_time(time!(00:00)).assume_utc(); 340 | let end = date!(2024 - 06 - 30).with_time(time!(00:00)).assume_utc(); 341 | 342 | let starting_equity = 100_000.00; 343 | let exposure = 0.20; // Use 20% of account 344 | 345 | // Create a client for historical market data 346 | let client = databento::HistoricalClient::builder().key_from_env()?.build()?; 347 | 348 | // Fetch data 349 | let schema = Schema::Ohlcv1H; // or Schema::Ohlcv1M for minute bars 350 | let symbol = "NQ.v.0"; 351 | let csv_path = fetch_and_save_csv( 352 | client, 353 | "GLBX.MDP3", // Futures dataset 354 | symbol, // NQ future 355 | None, // No options data needed 356 | schema, 357 | None, // No custom schema needed 358 | start, 359 | end 360 | ).await?; 361 | 362 | // Set transaction costs for futures trading 363 | let nq_tick_size = 0.25; 364 | let transaction_costs = TransactionCosts::futures_trading(nq_tick_size); 365 | 366 | // Define parameter ranges for the moving average strategy 367 | let short_ma_periods = vec![10, 20]; // Short MA periods 368 | let long_ma_periods = vec![20, 50]; // Long MA periods 369 | let volume_thresholds = vec![0.0, 1.2]; // Volume multipliers (0 = no filter) 370 | let profit_targets = vec![5.0, 10.0]; // % profit targets 371 | let stop_losses = vec![3.0, 5.0]; // % stop losses 372 | 373 | // Generate all parameter combinations 374 | let mut parameter_combinations = Vec::new(); 375 | for short_ma in &short_ma_periods { 376 | for long_ma in &long_ma_periods { 377 | if short_ma < long_ma { // Ensure short MA is less than long MA 378 | for volume_thresh in &volume_thresholds { 379 | for profit_target in &profit_targets { 380 | for stop_loss in &stop_losses { 381 | let mut params = StrategyParams::new(); 382 | params.insert("short_ma_period", *short_ma as f64); 383 | params.insert("long_ma_period", *long_ma as f64); 384 | params.insert("volume_threshold", *volume_thresh); 385 | params.insert("profit_target", *profit_target); 386 | params.insert("stop_loss", *stop_loss); 387 | parameter_combinations.push(params); 388 | } 389 | } 390 | } 391 | } 392 | } 393 | } 394 | 395 | let sorted_results = run_parallel_backtest( 396 | parameter_combinations, 397 | &csv_path, 398 | &symbol, 399 | schema, 400 | None, 401 | |params| Ok(Box::new(MovingAverageCrossStrategy::new(params)?)), 402 | starting_equity, 403 | exposure, 404 | transaction_costs.clone(), 405 | ); 406 | 407 | display_results(sorted_results, &csv_path, &symbol, schema, None, starting_equity, exposure); 408 | 409 | Ok(()) 410 | } 411 | -------------------------------------------------------------------------------- /src/plot.rs: -------------------------------------------------------------------------------- 1 | use iced::{ 2 | widget::{canvas, checkbox, column, container, row, scrollable, text, Canvas}, 3 | Application, Color, Command, Element, Length, Point, Rectangle, Settings, Theme, 4 | }; 5 | 6 | #[derive(Debug, Clone)] 7 | pub struct EquityCurve { 8 | pub label: String, 9 | pub equity_data: Vec, 10 | pub visible: bool, 11 | pub color: Color, 12 | } 13 | 14 | pub struct EquityPlotter { 15 | equity_curves: Vec, 16 | benchmark: Option>, 17 | show_benchmark: bool, 18 | } 19 | 20 | #[derive(Debug, Clone)] 21 | pub enum Message { 22 | ToggleCurve(usize), 23 | ToggleBenchmark, 24 | } 25 | 26 | impl Application for EquityPlotter { 27 | type Message = Message; 28 | type Theme = Theme; 29 | type Executor = iced::executor::Default; 30 | type Flags = (Vec<(String, Vec)>, Option>); 31 | 32 | fn new(flags: Self::Flags) -> (Self, Command) { 33 | let (curves_data, benchmark) = flags; 34 | 35 | // Generate colors for each curve 36 | let colors = generate_colors(curves_data.len()); 37 | 38 | let equity_curves: Vec = curves_data 39 | .into_iter() 40 | .enumerate() 41 | .map(|(i, (label, data))| EquityCurve { 42 | label, 43 | equity_data: data, 44 | visible: true, 45 | color: colors[i], 46 | }) 47 | .collect(); 48 | 49 | ( 50 | Self { 51 | equity_curves, 52 | benchmark, 53 | show_benchmark: true, 54 | }, 55 | Command::none(), 56 | ) 57 | } 58 | 59 | fn title(&self) -> String { 60 | "InkBack from Scorsone Enterprises".to_string() 61 | } 62 | 63 | fn update(&mut self, message: Self::Message) -> Command { 64 | match message { 65 | Message::ToggleCurve(index) => { 66 | if let Some(curve) = self.equity_curves.get_mut(index) { 67 | curve.visible = !curve.visible; 68 | } 69 | } 70 | Message::ToggleBenchmark => { 71 | self.show_benchmark = !self.show_benchmark; 72 | } 73 | } 74 | Command::none() 75 | } 76 | 77 | fn view(&self) -> Element { 78 | let chart = Canvas::new(ChartRenderer { 79 | equity_curves: &self.equity_curves, 80 | benchmark: self.benchmark.as_ref(), 81 | show_benchmark: self.show_benchmark, 82 | }) 83 | .width(Length::FillPortion(3)) 84 | .height(Length::Fill); 85 | 86 | let controls = self.create_controls(); 87 | 88 | row![ 89 | chart, 90 | container(controls) 91 | .width(Length::FillPortion(1)) 92 | .padding(20) 93 | ] 94 | .into() 95 | } 96 | 97 | fn theme(&self) -> Self::Theme { 98 | Theme::Dark 99 | } 100 | } 101 | 102 | impl EquityPlotter { 103 | fn create_controls(&self) -> Element { 104 | let mut controls = column![ 105 | text("Strategy Controls").size(20), 106 | text("Toggle visibility:").size(16), 107 | ] 108 | .spacing(10); 109 | 110 | // Add benchmark toggle if benchmark exists 111 | if self.benchmark.is_some() { 112 | controls = controls.push( 113 | checkbox("Show Benchmark", self.show_benchmark) 114 | .on_toggle(|_| Message::ToggleBenchmark) 115 | ); 116 | } 117 | 118 | // Add controls for each equity curve 119 | for (i, curve) in self.equity_curves.iter().enumerate() { 120 | let checkbox_widget = checkbox(&curve.label, curve.visible) 121 | .on_toggle(move |_| Message::ToggleCurve(i)) 122 | .style(iced::theme::Checkbox::Custom(Box::new(CurveCheckboxStyle(curve.color)))); 123 | 124 | controls = controls.push(checkbox_widget); 125 | } 126 | 127 | scrollable(controls).into() 128 | } 129 | } 130 | 131 | struct CurveCheckboxStyle(Color); 132 | 133 | impl iced::widget::checkbox::StyleSheet for CurveCheckboxStyle { 134 | type Style = Theme; 135 | 136 | fn active(&self, style: &Self::Style, is_checked: bool) -> iced::widget::checkbox::Appearance { 137 | let palette = style.palette(); 138 | 139 | iced::widget::checkbox::Appearance { 140 | background: if is_checked { 141 | iced::Background::Color(self.0) 142 | } else { 143 | iced::Background::Color(palette.background) 144 | }, 145 | icon_color: palette.text, 146 | text_color: Some(palette.text), 147 | border: iced::Border { 148 | color: if is_checked { self.0 } else { palette.text }, 149 | width: 1.0, 150 | radius: 2.0.into(), 151 | }, 152 | } 153 | } 154 | 155 | fn hovered(&self, style: &Self::Style, is_checked: bool) -> iced::widget::checkbox::Appearance { 156 | let mut appearance = self.active(style, is_checked); 157 | // Add slight transparency for hover effect 158 | appearance.background = match appearance.background { 159 | iced::Background::Color(color) => { 160 | iced::Background::Color(Color { 161 | a: color.a * 0.8, 162 | ..color 163 | }) 164 | } 165 | iced::Background::Gradient(_gradient) => todo!(), 166 | }; 167 | appearance 168 | } 169 | } 170 | 171 | struct ChartRenderer<'a> { 172 | equity_curves: &'a [EquityCurve], 173 | benchmark: Option<&'a Vec>, 174 | show_benchmark: bool, 175 | } 176 | 177 | impl<'a> canvas::Program for ChartRenderer<'a> { 178 | type State = (); 179 | 180 | fn draw( 181 | &self, 182 | _state: &Self::State, 183 | renderer: &iced::Renderer, 184 | _theme: &Theme, 185 | bounds: Rectangle, 186 | _cursor: iced::mouse::Cursor, 187 | ) -> Vec { 188 | let mut frame = canvas::Frame::new(renderer, bounds.size()); 189 | 190 | // Chart margins 191 | let margin = 80.0; 192 | let chart_bounds = Rectangle { 193 | x: margin, 194 | y: margin, 195 | width: bounds.width - 2.0 * margin, 196 | height: bounds.height - 2.0 * margin, 197 | }; 198 | 199 | // Find global min/max for scaling 200 | let (min_val, max_val) = self.find_global_range(); 201 | let max_length = self.find_max_length(); 202 | 203 | if max_length == 0 { 204 | return vec![frame.into_geometry()]; 205 | } 206 | 207 | // Draw grid and axes 208 | self.draw_grid_and_axes(&mut frame, &chart_bounds, min_val, max_val, max_length); 209 | 210 | // Draw benchmark if enabled 211 | if self.show_benchmark { 212 | if let Some(benchmark) = self.benchmark { 213 | self.draw_line( 214 | &mut frame, 215 | benchmark, 216 | &chart_bounds, 217 | min_val, 218 | max_val, 219 | max_length, 220 | Color::WHITE, 221 | 2.0, 222 | ); 223 | } 224 | } 225 | 226 | // Draw visible equity curves 227 | for curve in self.equity_curves.iter().filter(|c| c.visible) { 228 | self.draw_line( 229 | &mut frame, 230 | &curve.equity_data, 231 | &chart_bounds, 232 | min_val, 233 | max_val, 234 | max_length, 235 | curve.color, 236 | 1.5, 237 | ); 238 | } 239 | 240 | vec![frame.into_geometry()] 241 | } 242 | } 243 | 244 | impl<'a> ChartRenderer<'a> { 245 | fn find_global_range(&self) -> (f64, f64) { 246 | let mut min_val = f64::INFINITY; 247 | let mut max_val = f64::NEG_INFINITY; 248 | 249 | // Check visible equity curves 250 | for curve in self.equity_curves.iter().filter(|c| c.visible) { 251 | if let (Some(&curve_min), Some(&curve_max)) = 252 | (curve.equity_data.iter().min_by(|a, b| a.partial_cmp(b).unwrap()), 253 | curve.equity_data.iter().max_by(|a, b| a.partial_cmp(b).unwrap())) { 254 | min_val = min_val.min(curve_min); 255 | max_val = max_val.max(curve_max); 256 | } 257 | } 258 | 259 | // Check benchmark if shown 260 | if self.show_benchmark { 261 | if let Some(benchmark) = self.benchmark { 262 | if let (Some(&bench_min), Some(&bench_max)) = 263 | (benchmark.iter().min_by(|a, b| a.partial_cmp(b).unwrap()), 264 | benchmark.iter().max_by(|a, b| a.partial_cmp(b).unwrap())) { 265 | min_val = min_val.min(bench_min); 266 | max_val = max_val.max(bench_max); 267 | } 268 | } 269 | } 270 | 271 | // Add some padding 272 | let padding = (max_val - min_val) * 0.05; 273 | (min_val - padding, max_val + padding) 274 | } 275 | 276 | fn find_max_length(&self) -> usize { 277 | let mut max_len = 0; 278 | 279 | for curve in self.equity_curves.iter().filter(|c| c.visible) { 280 | max_len = max_len.max(curve.equity_data.len()); 281 | } 282 | 283 | if self.show_benchmark { 284 | if let Some(benchmark) = self.benchmark { 285 | max_len = max_len.max(benchmark.len()); 286 | } 287 | } 288 | 289 | max_len 290 | } 291 | 292 | fn draw_grid_and_axes( 293 | &self, 294 | frame: &mut canvas::Frame, 295 | bounds: &Rectangle, 296 | min_val: f64, 297 | max_val: f64, 298 | max_length: usize, 299 | ) { 300 | use iced::widget::canvas::{Path, Stroke, Text}; 301 | 302 | // Draw axes 303 | let stroke = Stroke::default().with_width(1.0).with_color(Color::from_rgb(0.3, 0.3, 0.3)); 304 | 305 | // Y-axis 306 | let y_axis = Path::line( 307 | Point::new(bounds.x, bounds.y), 308 | Point::new(bounds.x, bounds.y + bounds.height), 309 | ); 310 | frame.stroke(&y_axis, stroke.clone()); 311 | 312 | // X-axis 313 | let x_axis = Path::line( 314 | Point::new(bounds.x, bounds.y + bounds.height), 315 | Point::new(bounds.x + bounds.width, bounds.y + bounds.height), 316 | ); 317 | frame.stroke(&x_axis, stroke); 318 | 319 | // Draw grid lines and labels 320 | let grid_stroke = Stroke::default().with_width(0.5).with_color(Color::from_rgb(0.2, 0.2, 0.2)); 321 | 322 | // Horizontal grid lines for equity values 323 | for i in 0..=5 { 324 | let y_ratio = i as f32 / 5.0; 325 | let y = bounds.y + bounds.height * (1.0 - y_ratio); 326 | let value = min_val + (max_val - min_val) * y_ratio as f64; 327 | 328 | let grid_line = Path::line( 329 | Point::new(bounds.x, y), 330 | Point::new(bounds.x + bounds.width, y), 331 | ); 332 | frame.stroke(&grid_line, grid_stroke.clone()); 333 | 334 | // Y-axis labels 335 | let label = Text { 336 | content: format!("{:.0}", value), 337 | position: Point::new(bounds.x - 5.0, y), 338 | color: Color::WHITE, 339 | size: iced::Pixels(12.0), 340 | horizontal_alignment: iced::alignment::Horizontal::Right, 341 | vertical_alignment: iced::alignment::Vertical::Center, 342 | ..Default::default() 343 | }; 344 | frame.fill_text(label); 345 | } 346 | 347 | // Vertical grid lines (for time) 348 | for i in 0..=5 { 349 | let x_ratio = i as f32 / 5.0; 350 | let x = bounds.x + bounds.width * x_ratio; 351 | let time_point = (max_length as f32 * x_ratio) as usize; 352 | 353 | let grid_line = Path::line( 354 | Point::new(x, bounds.y), 355 | Point::new(x, bounds.y + bounds.height), 356 | ); 357 | frame.stroke(&grid_line, grid_stroke.clone()); 358 | 359 | // X-axis labels 360 | let label = Text { 361 | content: format!("{}", time_point), 362 | position: Point::new(x, bounds.y + bounds.height + 15.0), 363 | color: Color::WHITE, 364 | size: iced::Pixels(12.0), 365 | horizontal_alignment: iced::alignment::Horizontal::Center, 366 | vertical_alignment: iced::alignment::Vertical::Top, 367 | ..Default::default() 368 | }; 369 | frame.fill_text(label); 370 | } 371 | } 372 | 373 | fn draw_line( 374 | &self, 375 | frame: &mut canvas::Frame, 376 | data: &[f64], 377 | bounds: &Rectangle, 378 | min_val: f64, 379 | max_val: f64, 380 | max_length: usize, 381 | color: Color, 382 | width: f32, 383 | ) { 384 | use iced::widget::canvas::{Path, Stroke}; 385 | 386 | if data.len() < 2 { 387 | return; 388 | } 389 | 390 | let path_builder = Path::new(|builder| { 391 | let value_range = max_val - min_val; 392 | 393 | for (i, &value) in data.iter().enumerate() { 394 | let x = bounds.x + (i as f32 / (max_length - 1) as f32) * bounds.width; 395 | let y_ratio = if value_range != 0.0 { 396 | ((value - min_val) / value_range) as f32 397 | } else { 398 | 0.5 399 | }; 400 | let y = bounds.y + bounds.height * (1.0 - y_ratio); 401 | 402 | if i == 0 { 403 | builder.move_to(Point::new(x, y)); 404 | } else { 405 | builder.line_to(Point::new(x, y)); 406 | } 407 | } 408 | }); 409 | 410 | let stroke = Stroke::default().with_width(width).with_color(color); 411 | frame.stroke(&path_builder, stroke); 412 | } 413 | } 414 | 415 | // Generate visually distinct colors for the curves 416 | fn generate_colors(count: usize) -> Vec { 417 | let mut colors = Vec::with_capacity(count); 418 | 419 | for i in 0..count { 420 | let hue = ((i as f32) * 360.0 / count as f32) % 360.0; 421 | colors.push(hsv_to_rgb(hue, 0.85, 0.95)); 422 | } 423 | 424 | colors 425 | } 426 | 427 | fn hsv_to_rgb(h: f32, s: f32, v: f32) -> Color { 428 | let c = v * s; 429 | let x = c * (1.0 - ((h / 60.0) % 2.0 - 1.0).abs()); 430 | let m = v - c; 431 | 432 | let (r, g, b) = if h < 60.0 { 433 | (c, x, 0.0) 434 | } else if h < 120.0 { 435 | (x, c, 0.0) 436 | } else if h < 180.0 { 437 | (0.0, c, x) 438 | } else if h < 240.0 { 439 | (0.0, x, c) 440 | } else if h < 300.0 { 441 | (x, 0.0, c) 442 | } else { 443 | (c, 0.0, x) 444 | }; 445 | 446 | Color::from_rgb(r + m, g + m, b + m) 447 | } 448 | 449 | // Main for application 450 | pub fn run_equity_plotter( 451 | equity_curves: Vec<(String, Vec)>, 452 | benchmark: Option>, 453 | ) -> iced::Result { 454 | EquityPlotter::run(Settings::with_flags((equity_curves, benchmark))) 455 | } 456 | 457 | // Called from main 458 | pub fn plot_equity_curves( 459 | equity_curves: Vec<(String, Vec)>, 460 | benchmark: Option>, 461 | ) { 462 | if let Err(e) = run_equity_plotter(equity_curves, benchmark) { 463 | eprintln!("Error running Iced application: {}", e); 464 | } 465 | } -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::VecDeque, usize}; 2 | use time::{macros::date, macros::time}; 3 | use databento::{ 4 | dbn::{Schema}, 5 | }; 6 | use anyhow::Result; 7 | 8 | mod schema_handler; 9 | mod utils; 10 | mod strategy; 11 | mod backtester; 12 | mod plot; 13 | pub mod slippage_models; 14 | 15 | use strategy::Strategy; 16 | use utils::fetch::fetch_and_save_csv; 17 | use crate::{backtester::{display_results, run_parallel_backtest}, slippage_models::TransactionCosts, strategy::{Candle, Order, OrderType, StrategyParams}}; 18 | 19 | // InkBack schemas 20 | #[derive(Clone)] 21 | pub enum InkBackSchema { 22 | FootPrint, 23 | CombinedOptionsUnderlying, 24 | } 25 | 26 | /// Option Momentum Strategy 27 | pub struct OptionsMomentumStrategy { 28 | // Strategy parameters 29 | pub lookback_periods: usize, // Periods to calculate momentum 30 | pub momentum_threshold: f64, // % momentum required for signal 31 | pub profit_target: f64, // % profit target 32 | pub stop_loss: f64, // % stop loss 33 | pub min_days_to_expiry: f64, // Minimum days to expiration 34 | 35 | // State tracking 36 | pub underlying_history: VecDeque, 37 | pub volume_history: VecDeque, 38 | pub position_state: PositionState, 39 | 40 | // Current contract tracking 41 | pub current_contract: Option, 42 | } 43 | 44 | #[derive(Debug, Clone)] 45 | pub struct ContractInfo { 46 | pub instrument_id: u32, 47 | pub symbol: String, 48 | pub strike_price: f64, 49 | pub expiration: u64, 50 | pub option_type: OptionType, 51 | pub entry_price: f64, 52 | pub entry_time: String, 53 | } 54 | 55 | #[derive(Debug, Clone, Copy, PartialEq)] 56 | pub enum OptionType { 57 | Call, 58 | Put, 59 | } 60 | 61 | #[derive(Debug, Clone, Copy, PartialEq)] 62 | pub enum PositionState { 63 | Flat, 64 | Long, 65 | Short, 66 | } 67 | 68 | impl OptionsMomentumStrategy { 69 | pub fn new(params: &StrategyParams) -> Result { 70 | let lookback_periods = params 71 | .get("lookback_periods") 72 | .ok_or_else(|| anyhow::anyhow!("Missing lookback_periods parameter"))? as usize; 73 | 74 | let momentum_threshold = params 75 | .get("momentum_threshold") 76 | .ok_or_else(|| anyhow::anyhow!("Missing momentum_threshold parameter"))? / 100.0; 77 | 78 | let profit_target = params 79 | .get("profit_target") 80 | .ok_or_else(|| anyhow::anyhow!("Missing profit_target parameter"))? / 100.0; 81 | 82 | let stop_loss = params 83 | .get("stop_loss") 84 | .ok_or_else(|| anyhow::anyhow!("Missing stop_loss parameter"))? / 100.0; 85 | 86 | let min_days_to_expiry = params 87 | .get("min_days_to_expiry") 88 | .ok_or_else(|| anyhow::anyhow!("Missing min_days_to_expiry parameter"))?; 89 | 90 | Ok(Self { 91 | lookback_periods, 92 | momentum_threshold, 93 | profit_target, 94 | stop_loss, 95 | min_days_to_expiry, 96 | underlying_history: VecDeque::with_capacity(lookback_periods + 1), 97 | volume_history: VecDeque::with_capacity(lookback_periods + 1), 98 | position_state: PositionState::Flat, 99 | current_contract: None, 100 | }) 101 | } 102 | 103 | // Add a reset method to ensure clean state 104 | pub fn reset(&mut self) { 105 | self.underlying_history.clear(); 106 | self.volume_history.clear(); 107 | self.position_state = PositionState::Flat; 108 | self.current_contract = None; 109 | } 110 | 111 | /// Calculate momentum as percentage price change over lookback period 112 | fn get_momentum(&self) -> Option { 113 | if self.underlying_history.len() < self.lookback_periods { 114 | return None; 115 | } 116 | 117 | let current_price = *self.underlying_history.back()?; 118 | let past_price = *self.underlying_history.get(self.underlying_history.len() - self.lookback_periods)?; 119 | Some((current_price - past_price) / past_price) 120 | } 121 | 122 | /// Parse option information from candle data 123 | fn parse_option_info(&self, candle: &Candle) -> Option<(OptionType, f64, u64, u32, String)> { 124 | // Get option type from instrument_class 125 | let instrument_class_str = candle.get_string("instrument_class")?; 126 | let option_type = match instrument_class_str.chars().next()? { 127 | 'C' => OptionType::Call, 128 | 'P' => OptionType::Put, 129 | _ => { 130 | println!("Warning: Unknown instrument class: {}", instrument_class_str); 131 | return None; 132 | } 133 | }; 134 | 135 | // Get strike price - must be positive 136 | let strike_price = candle.get("strike_price")?; 137 | if strike_price <= 0.0 { 138 | println!("Warning: Invalid strike price: {}", strike_price); 139 | return None; 140 | } 141 | 142 | // Expiration - must be positive 143 | let expiration_f64 = candle.get("expiration")?; 144 | if expiration_f64 <= 0.0 || !expiration_f64.is_finite() { 145 | println!("Warning: Invalid expiration: {}", expiration_f64); 146 | return None; 147 | } 148 | let expiration = expiration_f64 as u64; 149 | 150 | // Get instrument ID for contract tracking 151 | let instrument_id_f64 = candle.get("instrument_id")?; 152 | if instrument_id_f64 <= 0.0 || !instrument_id_f64.is_finite() { 153 | println!("Warning: Invalid instrument ID: {}", instrument_id_f64); 154 | return None; 155 | } 156 | let instrument_id = instrument_id_f64 as u32; 157 | 158 | // Get symbol for logging - use raw_symbol or symbol_def 159 | let symbol = candle.get_string("raw_symbol") 160 | .or_else(|| candle.get_string("symbol_def")) 161 | .or_else(|| candle.get_string("symbol")) 162 | .unwrap_or(&"UNKNOWN".to_string()) 163 | .clone(); 164 | 165 | Some((option_type, strike_price, expiration, instrument_id, symbol)) 166 | } 167 | 168 | /// Check if this option contract meets our trading criteria 169 | fn should_trade_option(&self, candle: &Candle, underlying_price: f64) -> Option { 170 | let option_price = candle.get("price")?; 171 | 172 | // Filter out options with extremely small premiums (< $0.05) 173 | if option_price < 0.05 { 174 | return None; 175 | } 176 | 177 | let (option_type, strike_price, expiration, _instrument_id, _symbol) = self.parse_option_info(candle)?; 178 | //println!("expiration: {}\n", expiration); 179 | 180 | // Check days to expiration (assuming expiration is in UNIX timestamp format) 181 | let current_time_ns = candle.date.parse::().unwrap_or_else(|_| { 182 | println!("Warning: Failed to parse candle date: {}", candle.date); 183 | 0 184 | }); 185 | 186 | // Validate that we have valid timestamps 187 | if current_time_ns == 0 || expiration == 0 { 188 | return None; 189 | } 190 | 191 | // Convert nanoseconds to seconds 192 | let current_time = current_time_ns / 1_000_000_000; 193 | let expiration_seconds = expiration / 1_000_000_000; 194 | 195 | // Validate that expiration is in the future 196 | if expiration_seconds <= current_time { 197 | return None; 198 | } 199 | 200 | let days_to_expiry = (expiration_seconds - current_time) / 86400; // Convert seconds to days 201 | if days_to_expiry <= self.min_days_to_expiry as u64 { 202 | return None; 203 | } 204 | 205 | // Get momentum 206 | let momentum = self.get_momentum()?; 207 | 208 | match option_type { 209 | OptionType::Call => { 210 | // Calculate moneyness for calls (underlying/strike) 211 | let moneyness = underlying_price / strike_price; 212 | 213 | // Filter out options more than 20% out of the money for better liquidity 214 | if moneyness < 0.8 { 215 | return None; 216 | } 217 | 218 | // Trade calls on positive momentum if the option is reasonable moneyness 219 | if momentum > self.momentum_threshold { 220 | // Focus on near-the-money options for better delta exposure 221 | if moneyness >= 0.90 && moneyness <= 1.10 { // 10% ITM to 10% OTM 222 | Some(OrderType::MarketBuy) 223 | } else { 224 | None 225 | } 226 | } else { 227 | None 228 | } 229 | }, 230 | OptionType::Put => { 231 | // Calculate moneyness for puts (strike/underlying) 232 | let moneyness = strike_price / underlying_price; 233 | 234 | // Filter out options more than 20% out of the money 235 | if moneyness < 0.8 { 236 | return None; 237 | } 238 | 239 | // Trade puts on negative momentum if the option is reasonable moneyness 240 | if momentum < -self.momentum_threshold { 241 | // Focus on near-the-money options for better delta exposure 242 | if moneyness >= 0.90 && moneyness <= 1.10 { // 10% ITM to 10% OTM 243 | Some(OrderType::MarketBuy) 244 | } else { 245 | None 246 | } 247 | } else { 248 | None 249 | } 250 | }, 251 | } 252 | } 253 | 254 | /// Check if we should exit current position 255 | fn should_exit_position(&self, current_price: f64, current_time_ns: u64) -> bool { 256 | if let Some(ref contract) = self.current_contract { 257 | let pnl_pct = (current_price - contract.entry_price) / contract.entry_price; 258 | 259 | // Exit on profit target or stop loss 260 | if pnl_pct >= self.profit_target || pnl_pct <= -self.stop_loss { 261 | return true; 262 | } 263 | 264 | // Force exit if too close to expiration (3 days or less) 265 | let current_time = current_time_ns / 1_000_000_000; 266 | let expiration_seconds = contract.expiration / 1_000_000_000; 267 | 268 | if expiration_seconds > current_time { 269 | let days_to_expiry = (expiration_seconds - current_time) / 86400; 270 | if days_to_expiry <= self.min_days_to_expiry as u64 { 271 | println!("Force exit: {} days to expiry", days_to_expiry); 272 | return true; 273 | } 274 | } 275 | 276 | false 277 | } else { 278 | false 279 | } 280 | } 281 | } 282 | 283 | impl Strategy for OptionsMomentumStrategy { 284 | fn on_candle(&mut self, candle: &Candle, _prev: Option<&Candle>) -> Option { 285 | // Get underlying price and option price 286 | let underlying_bid = candle.get("underlying_bid")?; 287 | let underlying_ask = candle.get("underlying_ask")?; 288 | let underlying_price = (underlying_bid + underlying_ask) / 2.0; 289 | 290 | let option_price = candle.get("price")?; 291 | let size = candle.get("size")? as u64; 292 | 293 | // Update price and volume history 294 | self.underlying_history.push_back(underlying_price); 295 | self.volume_history.push_back(size); 296 | 297 | if self.underlying_history.len() > self.lookback_periods + 1 { 298 | self.underlying_history.pop_front(); 299 | } 300 | if self.volume_history.len() > self.lookback_periods + 1 { 301 | self.volume_history.pop_front(); 302 | } 303 | 304 | // If we're in a position, check for exit conditions first 305 | if self.position_state != PositionState::Flat { 306 | if let Some(ref current_contract) = self.current_contract { 307 | // Only exit if this candle is for the same contract we're holding 308 | if let Some((_, _, _, instrument_id, _)) = self.parse_option_info(candle) { 309 | if instrument_id == current_contract.instrument_id { 310 | let current_time_ns = candle.date.parse::().unwrap_or(0); 311 | if self.should_exit_position(option_price, current_time_ns) { 312 | // Reset position state 313 | self.position_state = PositionState::Flat; 314 | self.current_contract = None; 315 | 316 | return Some(Order { 317 | order_type: OrderType::MarketSell, 318 | price: option_price, 319 | }); 320 | } 321 | } 322 | } 323 | } 324 | return None; // Stay in position if no exit signal 325 | } 326 | 327 | // Need enough history for momentum calculation 328 | if self.underlying_history.len() <= self.lookback_periods { 329 | return None; 330 | } 331 | 332 | // Check for entry signal 333 | if let Some(order_type) = self.should_trade_option(candle, underlying_price) { 334 | if let Some((option_type, strike_price, expiration, instrument_id, symbol)) = self.parse_option_info(candle) { 335 | 336 | // Create new contract info 337 | let contract_info = ContractInfo { 338 | instrument_id, 339 | symbol: symbol.clone(), 340 | strike_price, 341 | expiration, 342 | option_type, 343 | entry_price: option_price, 344 | entry_time: candle.date.clone(), 345 | }; 346 | 347 | // Update position state 348 | self.position_state = match order_type { 349 | OrderType::MarketBuy => PositionState::Long, 350 | OrderType::MarketSell => PositionState::Short, 351 | OrderType::LimitBuy => todo!(), 352 | OrderType::LimitSell => todo!(), 353 | }; 354 | self.current_contract = Some(contract_info); 355 | 356 | return Some(Order { 357 | order_type, 358 | price: option_price, 359 | }); 360 | } 361 | } 362 | 363 | None 364 | } 365 | } 366 | 367 | #[tokio::main] 368 | async fn main() -> anyhow::Result<()> { 369 | // Load environment variables 370 | dotenvy::dotenv().ok(); 371 | 372 | // Define historical data range 373 | let start = date!(2025 - 06 - 01).with_time(time!(00:00)).assume_utc(); 374 | let end = date!(2025 - 06 - 30).with_time(time!(00:00)).assume_utc(); 375 | 376 | let starting_equity = 100_000.00; 377 | let exposure = 0.10; // % of the account to put on each trade. 378 | 379 | // Create a client for historical market data 380 | let client = databento::HistoricalClient::builder().key_from_env()?.build()?; 381 | 382 | // Fetch combined options and underlying data 383 | println!("Fetching crude oil options and underlying data..."); 384 | let schema = Schema::Trades; 385 | let symbol = "CL.c.0"; 386 | let csv_path = fetch_and_save_csv( 387 | client, 388 | "GLBX.MDP3", // Crude oil futures dataset 389 | symbol, // Crude oil continuous contract 390 | Some("LO.OPT"), // Light crude oil options 391 | schema, 392 | Some(InkBackSchema::CombinedOptionsUnderlying), 393 | start, 394 | end 395 | ).await?; 396 | 397 | // Set transaction costs for options trading 398 | let transaction_costs = TransactionCosts::options_trading(); 399 | 400 | // Define parameter ranges for the momentum strategy 401 | let lookback_periods = vec![3, 5]; // Momentum calculation periods 402 | let momentum_thresholds = vec![0.4]; // % momentum threshold 403 | let profit_targets = vec![0.20, 0.40]; // % profit targets 404 | let stop_losses = vec![0.20, 30.0]; // % stop losses 405 | let min_days_to_expiry = vec![2.0]; // Minimum days to expiration 406 | 407 | // Generate all parameter combinations 408 | let mut parameter_combinations = Vec::new(); 409 | for lookback in &lookback_periods { 410 | for threshold in &momentum_thresholds { 411 | for profit in &profit_targets { 412 | for stop in &stop_losses { 413 | for min_days in &min_days_to_expiry { 414 | let mut params = StrategyParams::new(); 415 | params.insert("lookback_periods", *lookback as f64); 416 | params.insert("momentum_threshold", *threshold); 417 | params.insert("profit_target", *profit); 418 | params.insert("stop_loss", *stop); 419 | params.insert("min_days_to_expiry", *min_days); 420 | parameter_combinations.push(params); 421 | } 422 | } 423 | } 424 | } 425 | } 426 | 427 | let sorted_results = run_parallel_backtest( 428 | parameter_combinations, 429 | &csv_path, 430 | &symbol, 431 | schema, 432 | Some(InkBackSchema::CombinedOptionsUnderlying), 433 | |params| Ok(Box::new(OptionsMomentumStrategy::new(params)?)), 434 | starting_equity, 435 | exposure, 436 | transaction_costs.clone(), 437 | ); 438 | 439 | display_results(sorted_results, &csv_path, &symbol, schema, Some(InkBackSchema::CombinedOptionsUnderlying), starting_equity, exposure); 440 | 441 | Ok(()) 442 | } 443 | -------------------------------------------------------------------------------- /examples/options/options_example.rs: -------------------------------------------------------------------------------- 1 | use std::{collections::VecDeque, usize}; 2 | use time::{macros::date, macros::time}; 3 | use databento::{ 4 | dbn::{Schema}, 5 | }; 6 | use anyhow::Result; 7 | 8 | mod schema_handler; 9 | mod utils; 10 | mod strategy; 11 | mod backtester; 12 | mod plot; 13 | pub mod slippage_models; 14 | 15 | use strategy::Strategy; 16 | use utils::fetch::fetch_and_save_csv; 17 | use crate::{backtester::{display_results, run_parallel_backtest}, slippage_models::TransactionCosts, strategy::{Candle, Order, OrderType, StrategyParams}}; 18 | 19 | // InkBack schemas 20 | #[derive(Clone)] 21 | pub enum InkBackSchema { 22 | FootPrint, 23 | CombinedOptionsUnderlying, 24 | } 25 | 26 | /// Option Momentum Strategy 27 | pub struct OptionsMomentumStrategy { 28 | // Strategy parameters 29 | pub lookback_periods: usize, // Periods to calculate momentum 30 | pub momentum_threshold: f64, // % momentum required for signal 31 | pub profit_target: f64, // % profit target 32 | pub stop_loss: f64, // % stop loss 33 | pub min_days_to_expiry: f64, // Minimum days to expiration 34 | 35 | // State tracking 36 | pub underlying_history: VecDeque, 37 | pub volume_history: VecDeque, 38 | pub position_state: PositionState, 39 | 40 | // Current contract tracking 41 | pub current_contract: Option, 42 | } 43 | 44 | #[derive(Debug, Clone)] 45 | pub struct ContractInfo { 46 | pub instrument_id: u32, 47 | pub symbol: String, 48 | pub strike_price: f64, 49 | pub expiration: u64, 50 | pub option_type: OptionType, 51 | pub entry_price: f64, 52 | pub entry_time: String, 53 | } 54 | 55 | #[derive(Debug, Clone, Copy, PartialEq)] 56 | pub enum OptionType { 57 | Call, 58 | Put, 59 | } 60 | 61 | #[derive(Debug, Clone, Copy, PartialEq)] 62 | pub enum PositionState { 63 | Flat, 64 | Long, 65 | Short, 66 | } 67 | 68 | impl OptionsMomentumStrategy { 69 | pub fn new(params: &StrategyParams) -> Result { 70 | let lookback_periods = params 71 | .get("lookback_periods") 72 | .ok_or_else(|| anyhow::anyhow!("Missing lookback_periods parameter"))? as usize; 73 | 74 | let momentum_threshold = params 75 | .get("momentum_threshold") 76 | .ok_or_else(|| anyhow::anyhow!("Missing momentum_threshold parameter"))? / 100.0; 77 | 78 | let profit_target = params 79 | .get("profit_target") 80 | .ok_or_else(|| anyhow::anyhow!("Missing profit_target parameter"))? / 100.0; 81 | 82 | let stop_loss = params 83 | .get("stop_loss") 84 | .ok_or_else(|| anyhow::anyhow!("Missing stop_loss parameter"))? / 100.0; 85 | 86 | let min_days_to_expiry = params 87 | .get("min_days_to_expiry") 88 | .ok_or_else(|| anyhow::anyhow!("Missing min_days_to_expiry parameter"))?; 89 | 90 | Ok(Self { 91 | lookback_periods, 92 | momentum_threshold, 93 | profit_target, 94 | stop_loss, 95 | min_days_to_expiry, 96 | underlying_history: VecDeque::with_capacity(lookback_periods + 1), 97 | volume_history: VecDeque::with_capacity(lookback_periods + 1), 98 | position_state: PositionState::Flat, 99 | current_contract: None, 100 | }) 101 | } 102 | 103 | // Add a reset method to ensure clean state 104 | pub fn reset(&mut self) { 105 | self.underlying_history.clear(); 106 | self.volume_history.clear(); 107 | self.position_state = PositionState::Flat; 108 | self.current_contract = None; 109 | } 110 | 111 | /// Calculate momentum as percentage price change over lookback period 112 | fn get_momentum(&self) -> Option { 113 | if self.underlying_history.len() < self.lookback_periods { 114 | return None; 115 | } 116 | 117 | let current_price = *self.underlying_history.back()?; 118 | let past_price = *self.underlying_history.get(self.underlying_history.len() - self.lookback_periods)?; 119 | Some((current_price - past_price) / past_price) 120 | } 121 | 122 | /// Parse option information from candle data 123 | fn parse_option_info(&self, candle: &Candle) -> Option<(OptionType, f64, u64, u32, String)> { 124 | // Get option type from instrument_class 125 | let instrument_class_str = candle.get_string("instrument_class")?; 126 | let option_type = match instrument_class_str.chars().next()? { 127 | 'C' => OptionType::Call, 128 | 'P' => OptionType::Put, 129 | _ => { 130 | println!("Warning: Unknown instrument class: {}", instrument_class_str); 131 | return None; 132 | } 133 | }; 134 | 135 | // Get strike price - must be positive 136 | let strike_price = candle.get("strike_price")?; 137 | if strike_price <= 0.0 { 138 | println!("Warning: Invalid strike price: {}", strike_price); 139 | return None; 140 | } 141 | 142 | // Expiration - must be positive 143 | let expiration_f64 = candle.get("expiration")?; 144 | if expiration_f64 <= 0.0 || !expiration_f64.is_finite() { 145 | println!("Warning: Invalid expiration: {}", expiration_f64); 146 | return None; 147 | } 148 | let expiration = expiration_f64 as u64; 149 | 150 | // Get instrument ID for contract tracking 151 | let instrument_id_f64 = candle.get("instrument_id")?; 152 | if instrument_id_f64 <= 0.0 || !instrument_id_f64.is_finite() { 153 | println!("Warning: Invalid instrument ID: {}", instrument_id_f64); 154 | return None; 155 | } 156 | let instrument_id = instrument_id_f64 as u32; 157 | 158 | // Get symbol for logging - use raw_symbol or symbol_def 159 | let symbol = candle.get_string("raw_symbol") 160 | .or_else(|| candle.get_string("symbol_def")) 161 | .or_else(|| candle.get_string("symbol")) 162 | .unwrap_or(&"UNKNOWN".to_string()) 163 | .clone(); 164 | 165 | Some((option_type, strike_price, expiration, instrument_id, symbol)) 166 | } 167 | 168 | /// Check if this option contract meets our trading criteria 169 | fn should_trade_option(&self, candle: &Candle, underlying_price: f64) -> Option { 170 | let option_price = candle.get("price")?; 171 | 172 | // Filter out options with extremely small premiums (< $0.05) 173 | if option_price < 0.05 { 174 | return None; 175 | } 176 | 177 | let (option_type, strike_price, expiration, _instrument_id, _symbol) = self.parse_option_info(candle)?; 178 | //println!("expiration: {}\n", expiration); 179 | 180 | // Check days to expiration (assuming expiration is in UNIX timestamp format) 181 | let current_time_ns = candle.date.parse::().unwrap_or_else(|_| { 182 | println!("Warning: Failed to parse candle date: {}", candle.date); 183 | 0 184 | }); 185 | 186 | // Validate that we have valid timestamps 187 | if current_time_ns == 0 || expiration == 0 { 188 | return None; 189 | } 190 | 191 | // Convert nanoseconds to seconds 192 | let current_time = current_time_ns / 1_000_000_000; 193 | let expiration_seconds = expiration / 1_000_000_000; 194 | 195 | // Validate that expiration is in the future 196 | if expiration_seconds <= current_time { 197 | return None; 198 | } 199 | 200 | let days_to_expiry = (expiration_seconds - current_time) / 86400; // Convert seconds to days 201 | if days_to_expiry <= self.min_days_to_expiry as u64 { 202 | return None; 203 | } 204 | 205 | // Get momentum 206 | let momentum = self.get_momentum()?; 207 | 208 | match option_type { 209 | OptionType::Call => { 210 | // Calculate moneyness for calls (underlying/strike) 211 | let moneyness = underlying_price / strike_price; 212 | 213 | // Filter out options more than 20% out of the money for better liquidity 214 | if moneyness < 0.8 { 215 | return None; 216 | } 217 | 218 | // Trade calls on positive momentum if the option is reasonable moneyness 219 | if momentum > self.momentum_threshold { 220 | // Focus on near-the-money options for better delta exposure 221 | if moneyness >= 0.90 && moneyness <= 1.10 { // 10% ITM to 10% OTM 222 | Some(OrderType::MarketBuy) 223 | } else { 224 | None 225 | } 226 | } else { 227 | None 228 | } 229 | }, 230 | OptionType::Put => { 231 | // Calculate moneyness for puts (strike/underlying) 232 | let moneyness = strike_price / underlying_price; 233 | 234 | // Filter out options more than 20% out of the money 235 | if moneyness < 0.8 { 236 | return None; 237 | } 238 | 239 | // Trade puts on negative momentum if the option is reasonable moneyness 240 | if momentum < -self.momentum_threshold { 241 | // Focus on near-the-money options for better delta exposure 242 | if moneyness >= 0.90 && moneyness <= 1.10 { // 10% ITM to 10% OTM 243 | Some(OrderType::MarketBuy) 244 | } else { 245 | None 246 | } 247 | } else { 248 | None 249 | } 250 | }, 251 | } 252 | } 253 | 254 | /// Check if we should exit current position 255 | fn should_exit_position(&self, current_price: f64, current_time_ns: u64) -> bool { 256 | if let Some(ref contract) = self.current_contract { 257 | let pnl_pct = (current_price - contract.entry_price) / contract.entry_price; 258 | 259 | // Exit on profit target or stop loss 260 | if pnl_pct >= self.profit_target || pnl_pct <= -self.stop_loss { 261 | return true; 262 | } 263 | 264 | // Force exit if too close to expiration (3 days or less) 265 | let current_time = current_time_ns / 1_000_000_000; 266 | let expiration_seconds = contract.expiration / 1_000_000_000; 267 | 268 | if expiration_seconds > current_time { 269 | let days_to_expiry = (expiration_seconds - current_time) / 86400; 270 | if days_to_expiry <= self.min_days_to_expiry as u64 { 271 | println!("Force exit: {} days to expiry", days_to_expiry); 272 | return true; 273 | } 274 | } 275 | 276 | false 277 | } else { 278 | false 279 | } 280 | } 281 | } 282 | 283 | impl Strategy for OptionsMomentumStrategy { 284 | fn on_candle(&mut self, candle: &Candle, _prev: Option<&Candle>) -> Option { 285 | // Get underlying price and option price 286 | let underlying_bid = candle.get("underlying_bid")?; 287 | let underlying_ask = candle.get("underlying_ask")?; 288 | let underlying_price = (underlying_bid + underlying_ask) / 2.0; 289 | 290 | let option_price = candle.get("price")?; 291 | let size = candle.get("size")? as u64; 292 | 293 | // Update price and volume history 294 | self.underlying_history.push_back(underlying_price); 295 | self.volume_history.push_back(size); 296 | 297 | if self.underlying_history.len() > self.lookback_periods + 1 { 298 | self.underlying_history.pop_front(); 299 | } 300 | if self.volume_history.len() > self.lookback_periods + 1 { 301 | self.volume_history.pop_front(); 302 | } 303 | 304 | // If we're in a position, check for exit conditions first 305 | if self.position_state != PositionState::Flat { 306 | if let Some(ref current_contract) = self.current_contract { 307 | // Only exit if this candle is for the same contract we're holding 308 | if let Some((_, _, _, instrument_id, _)) = self.parse_option_info(candle) { 309 | if instrument_id == current_contract.instrument_id { 310 | let current_time_ns = candle.date.parse::().unwrap_or(0); 311 | if self.should_exit_position(option_price, current_time_ns) { 312 | // Reset position state 313 | self.position_state = PositionState::Flat; 314 | self.current_contract = None; 315 | 316 | return Some(Order { 317 | order_type: OrderType::MarketSell, 318 | price: option_price, 319 | }); 320 | } 321 | } 322 | } 323 | } 324 | return None; // Stay in position if no exit signal 325 | } 326 | 327 | // Need enough history for momentum calculation 328 | if self.underlying_history.len() <= self.lookback_periods { 329 | return None; 330 | } 331 | 332 | // Check for entry signal 333 | if let Some(order_type) = self.should_trade_option(candle, underlying_price) { 334 | if let Some((option_type, strike_price, expiration, instrument_id, symbol)) = self.parse_option_info(candle) { 335 | 336 | // Create new contract info 337 | let contract_info = ContractInfo { 338 | instrument_id, 339 | symbol: symbol.clone(), 340 | strike_price, 341 | expiration, 342 | option_type, 343 | entry_price: option_price, 344 | entry_time: candle.date.clone(), 345 | }; 346 | 347 | // Update position state 348 | self.position_state = match order_type { 349 | OrderType::MarketBuy => PositionState::Long, 350 | OrderType::MarketSell => PositionState::Short, 351 | OrderType::LimitBuy => todo!(), 352 | OrderType::LimitSell => todo!(), 353 | }; 354 | self.current_contract = Some(contract_info); 355 | 356 | return Some(Order { 357 | order_type, 358 | price: option_price, 359 | }); 360 | } 361 | } 362 | 363 | None 364 | } 365 | } 366 | 367 | #[tokio::main] 368 | async fn main() -> anyhow::Result<()> { 369 | // Load environment variables 370 | dotenvy::dotenv().ok(); 371 | 372 | // Define historical data range 373 | let start = date!(2025 - 06 - 01).with_time(time!(00:00)).assume_utc(); 374 | let end = date!(2025 - 06 - 30).with_time(time!(00:00)).assume_utc(); 375 | 376 | let starting_equity = 100_000.00; 377 | let exposure = 0.10; // % of the account to put on each trade. 378 | 379 | // Create a client for historical market data 380 | let client = databento::HistoricalClient::builder().key_from_env()?.build()?; 381 | 382 | // Fetch combined options and underlying data 383 | println!("Fetching crude oil options and underlying data..."); 384 | let schema = Schema::Trades; 385 | let symbol = "CL.c.0"; 386 | let csv_path = fetch_and_save_csv( 387 | client, 388 | "GLBX.MDP3", // Crude oil futures dataset 389 | symbol, // Crude oil continuous contract 390 | Some("LO.OPT"), // Light crude oil options 391 | schema, 392 | Some(InkBackSchema::CombinedOptionsUnderlying), 393 | start, 394 | end 395 | ).await?; 396 | 397 | // Set transaction costs for options trading 398 | let transaction_costs = TransactionCosts::options_trading(); 399 | 400 | // Define parameter ranges for the momentum strategy 401 | let lookback_periods = vec![3, 5]; // Momentum calculation periods 402 | let momentum_thresholds = vec![0.4]; // % momentum threshold 403 | let profit_targets = vec![0.20, 0.40]; // % profit targets 404 | let stop_losses = vec![0.20, 30.0]; // % stop losses 405 | let min_days_to_expiry = vec![2.0]; // Minimum days to expiration 406 | 407 | // Generate all parameter combinations 408 | let mut parameter_combinations = Vec::new(); 409 | for lookback in &lookback_periods { 410 | for threshold in &momentum_thresholds { 411 | for profit in &profit_targets { 412 | for stop in &stop_losses { 413 | for min_days in &min_days_to_expiry { 414 | let mut params = StrategyParams::new(); 415 | params.insert("lookback_periods", *lookback as f64); 416 | params.insert("momentum_threshold", *threshold); 417 | params.insert("profit_target", *profit); 418 | params.insert("stop_loss", *stop); 419 | params.insert("min_days_to_expiry", *min_days); 420 | parameter_combinations.push(params); 421 | } 422 | } 423 | } 424 | } 425 | } 426 | 427 | let sorted_results = run_parallel_backtest( 428 | parameter_combinations, 429 | &csv_path, 430 | &symbol, 431 | schema, 432 | Some(InkBackSchema::CombinedOptionsUnderlying), 433 | |params| Ok(Box::new(OptionsMomentumStrategy::new(params)?)), 434 | starting_equity, 435 | exposure, 436 | transaction_costs.clone(), 437 | ); 438 | 439 | display_results(sorted_results, &csv_path, &symbol, schema, Some(InkBackSchema::CombinedOptionsUnderlying), starting_equity, exposure); 440 | 441 | Ok(()) 442 | } 443 | -------------------------------------------------------------------------------- /src/backtester.rs: -------------------------------------------------------------------------------- 1 | use crate::{plot::plot_equity_curves, strategy::{Candle, Order, OrderType, Strategy, StrategyParams}, InkBackSchema}; 2 | use crate::slippage_models::TransactionCosts; 3 | use anyhow::Result; 4 | use rayon::prelude::*; 5 | use serde::{Deserialize, Serialize}; 6 | use databento::dbn::Schema; 7 | 8 | use crate::schema_handler::{get_schema_handler}; 9 | 10 | #[derive(Debug, PartialEq)] 11 | enum Position { 12 | Long { entry: f64, size: f64, entry_date: String }, 13 | Short { entry: f64, size: f64, entry_date: String }, 14 | Neutral, 15 | } 16 | 17 | enum FutureTraded { 18 | NQ, 19 | ES, 20 | YM, 21 | CL, 22 | GC, 23 | SI, 24 | } 25 | 26 | fn get_future_multiplier(future_traded: FutureTraded) -> f64 { 27 | match future_traded { 28 | FutureTraded::NQ => 5.00, // $5 per tick (0.25 tick size) 29 | FutureTraded::ES => 12.50, // $12.50 per tick (0.25 tick size) 30 | FutureTraded::YM => 5.00, // $5 per tick (1.00 tick size) 31 | FutureTraded::CL => 10.00, // $10 per tick (0.01 tick size) 32 | FutureTraded::GC => 10.00, // $10 per tick (0.10 tick size) 33 | FutureTraded::SI => 25.00, // $25 per tick (0.005 tick size) 34 | } 35 | } 36 | 37 | fn get_future_from_symbol(symbol: &str) -> Option { 38 | if symbol.starts_with("NQ") { 39 | Some(FutureTraded::NQ) 40 | } else if symbol.starts_with("ES") { 41 | Some(FutureTraded::ES) 42 | } else if symbol.starts_with("YM") { 43 | Some(FutureTraded::YM) 44 | } else if symbol.starts_with("CL") { 45 | Some(FutureTraded::CL) 46 | } else if symbol.starts_with("GC") { 47 | Some(FutureTraded::GC) 48 | } else if symbol.starts_with("SI") { 49 | Some(FutureTraded::SI) 50 | } else { 51 | None 52 | } 53 | } 54 | 55 | impl Position { 56 | fn calculate_pnl_with_costs( 57 | &self, 58 | exit_price: f64, 59 | costs: &TransactionCosts, 60 | avg_volume: f64, 61 | is_options: bool, 62 | futures_multiplier: Option, 63 | ) -> f64 { 64 | match self { 65 | Position::Long { entry, size, .. } => { 66 | let entry_cost = costs.calculate_entry_cost(*entry, *size, avg_volume); 67 | let exit_cost = costs.calculate_exit_cost(exit_price, *size, avg_volume); 68 | 69 | // Apply appropriate multiplier based on instrument type 70 | let multiplier = if is_options { 71 | 100.0 72 | } else if let Some(futures_mult) = futures_multiplier { 73 | futures_mult 74 | } else { 75 | 1.0 76 | }; 77 | let gross_pnl = (exit_price - entry) * size * multiplier; 78 | 79 | // Validate costs are finite - this is crucial 80 | if !entry_cost.is_finite() || !exit_cost.is_finite() || !gross_pnl.is_finite() { 81 | println!("Warning: Non-finite values in PnL calculation"); 82 | return 0.0; // Return 0 PnL if costs are infinite 83 | } 84 | 85 | gross_pnl - entry_cost - exit_cost 86 | } 87 | Position::Short { entry, size, .. } => { 88 | let entry_cost = costs.calculate_entry_cost(*entry, *size, avg_volume); 89 | let exit_cost = costs.calculate_exit_cost(exit_price, *size, avg_volume); 90 | 91 | let multiplier = if is_options { 92 | 100.0 93 | } else if let Some(futures_mult) = futures_multiplier { 94 | futures_mult 95 | } else { 96 | 1.0 97 | }; 98 | let gross_pnl = (entry - exit_price) * size * multiplier; 99 | 100 | if !entry_cost.is_finite() || !exit_cost.is_finite() || !gross_pnl.is_finite() { 101 | println!("Warning: Non-finite values in PnL calculation"); 102 | return 0.0; 103 | } 104 | 105 | gross_pnl - entry_cost - exit_cost 106 | } 107 | Position::Neutral => 0.0, 108 | } 109 | } 110 | } 111 | 112 | #[derive(Debug, Clone, Serialize, Deserialize)] 113 | pub struct Trade { 114 | pub entry_date: String, 115 | pub exit_date: String, 116 | pub entry_price: f64, 117 | pub exit_price: f64, 118 | pub size: f64, 119 | pub pnl: f64, 120 | pub pnl_pct: f64, 121 | pub trade_type: String, 122 | pub exit_reason: String, 123 | pub transaction_costs: f64, 124 | } 125 | 126 | #[derive(Debug, Serialize, Deserialize)] 127 | pub struct BacktestResult { 128 | pub starting_equity: f64, 129 | pub ending_equity: f64, 130 | pub total_return: f64, 131 | pub total_return_pct: f64, 132 | pub max_drawdown: f64, 133 | pub max_drawdown_pct: f64, 134 | pub win_rate: f64, 135 | pub profit_factor: f64, 136 | pub total_trades: usize, 137 | pub winning_trades: usize, 138 | pub losing_trades: usize, 139 | pub avg_win: f64, 140 | pub avg_loss: f64, 141 | pub largest_win: f64, 142 | pub largest_loss: f64, 143 | pub equity_curve: Vec, 144 | pub trades: Vec, 145 | pub total_transaction_costs: f64, 146 | } 147 | 148 | impl BacktestResult { 149 | fn calculate_metrics( 150 | starting_equity: f64, 151 | ending_equity: f64, 152 | equity_curve: Vec, 153 | trades: Vec, 154 | ) -> Self { 155 | let total_return = ending_equity - starting_equity; 156 | let total_return_pct = if starting_equity == 0.0 { 157 | 0.0 158 | } else { 159 | (ending_equity / starting_equity - 1.0) * 100.0 160 | }; 161 | 162 | // Calculate max drawdown 163 | let mut peak = starting_equity; 164 | let mut max_dd = 0.0; 165 | let mut max_dd_pct = 0.0; 166 | 167 | for point in &equity_curve { 168 | if point > &peak { 169 | peak = *point; 170 | } 171 | let dd = peak - point; 172 | let dd_pct = (dd / peak) * 100.0; 173 | 174 | if dd > max_dd { 175 | max_dd = dd; 176 | } 177 | if dd_pct > max_dd_pct { 178 | max_dd_pct = dd_pct; 179 | } 180 | } 181 | 182 | // Trade statistics 183 | let total_trades = trades.len(); 184 | let winning_trades = trades.iter().filter(|t| t.pnl > 0.0).count(); 185 | let losing_trades = trades.iter().filter(|t| t.pnl < 0.0).count(); 186 | let win_rate = if total_trades == 0 { 0.0 } else { (winning_trades as f64 / total_trades as f64) * 100.0 }; 187 | 188 | let gross_profit: f64 = trades.iter().filter(|t| t.pnl > 0.0).map(|t| t.pnl).sum(); 189 | let gross_loss: f64 = trades.iter().filter(|t| t.pnl < 0.0).map(|t| t.pnl.abs()).sum(); 190 | let profit_factor = if gross_loss == 0.0 { 191 | if gross_profit > 0.0 { 1000.0 } else { 0.0 } // Cap at 1000 instead of infinity 192 | } else { 193 | gross_profit / gross_loss 194 | }; 195 | 196 | let avg_win = if winning_trades == 0 { 0.0 } else { gross_profit / winning_trades as f64 }; 197 | let avg_loss = if losing_trades == 0 { 0.0 } else { gross_loss / losing_trades as f64 }; 198 | 199 | let largest_win = trades.iter().map(|t| t.pnl).fold(0.0, f64::max); 200 | let largest_loss = trades.iter().map(|t| t.pnl).fold(0.0, f64::min); 201 | 202 | let total_transaction_costs: f64 = trades.iter().map(|t| t.transaction_costs).sum(); 203 | 204 | Self { 205 | starting_equity, 206 | ending_equity, 207 | total_return, 208 | total_return_pct, 209 | max_drawdown: max_dd, 210 | max_drawdown_pct: max_dd_pct, 211 | win_rate, 212 | profit_factor, 213 | total_trades, 214 | winning_trades, 215 | losing_trades, 216 | avg_win, 217 | avg_loss, 218 | largest_win, 219 | largest_loss, 220 | equity_curve, 221 | trades, 222 | total_transaction_costs, 223 | } 224 | } 225 | } 226 | 227 | // Helper function to calculate average volume from candles 228 | fn calculate_average_volume(candles: &[Candle]) -> f64 { 229 | if candles.is_empty() { 230 | return 1_000_000.0; // Default fallback if no candles at all 231 | } 232 | 233 | let total_volume: f64 = candles.iter() 234 | .map(|candle| { 235 | candle.get("volume") 236 | .or_else(|| candle.get("size")) // fallback to "size" 237 | .unwrap_or(0.0) // default if neither 238 | }) 239 | .sum(); 240 | 241 | total_volume / candles.len() as f64 242 | } 243 | 244 | // Helper function to check if a limit order should be filled based on current candle 245 | fn should_fill_limit_order(order: &Order, candle: &Candle) -> bool { 246 | let high = candle.get("high").unwrap_or_else(|| candle.get("price").unwrap_or(order.price)); 247 | let low = candle.get("low").unwrap_or_else(|| candle.get("price").unwrap_or(order.price)); 248 | 249 | match order.order_type { 250 | OrderType::LimitBuy => low <= order.price, // Fill if price drops to or below limit price 251 | OrderType::LimitSell => high >= order.price, // Fill if price rises to or above limit price 252 | _ => false, // Not a limit order 253 | } 254 | } 255 | 256 | // Generic function that works with any schema 257 | pub fn run_backtest_with_schema( 258 | csv_path: &str, 259 | symbol: &str, 260 | schema: Schema, 261 | custom_schema: Option, 262 | strategy: &mut dyn Strategy, 263 | transaction_costs: TransactionCosts, 264 | starting_equity: f64, 265 | exposure: f64, 266 | ) -> Result { 267 | // Determine which schema to use for the handler 268 | let handler_schema = if let Some(ref custom_schema) = custom_schema { 269 | match custom_schema { 270 | InkBackSchema::FootPrint => Schema::Ohlcv1D, // FootPrint bars are stored as OHLCV format 271 | InkBackSchema::CombinedOptionsUnderlying => Schema::Definition, 272 | } 273 | } else { 274 | schema 275 | }; 276 | 277 | // Get the appropriate schema handler 278 | let handler = get_schema_handler(handler_schema); 279 | 280 | // Convert CSV data to candles 281 | let candles = handler.csv_to_candles(csv_path)?; 282 | 283 | // Run backtest with candles 284 | run_backtest_with_candles(symbol, candles, strategy, transaction_costs, starting_equity, exposure) 285 | } 286 | 287 | // Core backtesting logic that works with candles 288 | pub fn run_backtest_with_candles( 289 | symbol: &str, 290 | candles: Vec, 291 | strategy: &mut dyn Strategy, 292 | transaction_costs: TransactionCosts, 293 | starting_equity: f64, 294 | exposure: f64, 295 | ) -> Result { 296 | // Detect if we're trading options by checking for option-specific fields 297 | let is_options_trading = candles.iter().any(|candle| 298 | candle.get_string("instrument_class").is_some() && 299 | candle.get("strike_price").is_some() 300 | ); 301 | 302 | let is_futures_trading = symbol.ends_with(".v.0") || symbol.ends_with(".c.0"); 303 | let futures_multiplier = if is_futures_trading { 304 | get_future_from_symbol(symbol).map(|future| get_future_multiplier(future)) 305 | } else { 306 | None 307 | }; 308 | let mut equity = starting_equity; 309 | let mut position = Position::Neutral; 310 | let mut prev_candle: Option = None; 311 | let mut trades = Vec::new(); 312 | let mut equity_curve = Vec::new(); 313 | let mut pending_order: Option = None; // Store orders for next candle 314 | let mut pending_limit_orders: Vec = Vec::new(); // Store limit orders until filled or cancelled 315 | 316 | // Calculate average volume for transaction cost calculations 317 | let avg_volume = calculate_average_volume(&candles); 318 | 319 | // Add initial equity value 320 | equity_curve.push(starting_equity); 321 | 322 | for candle in candles.iter() { 323 | // check if any pending limit orders should be filled 324 | let mut filled_limit_orders = Vec::new(); 325 | pending_limit_orders.retain(|order| { 326 | if should_fill_limit_order(order, candle) { 327 | filled_limit_orders.push(*order); 328 | false // Remove from pending orders 329 | } else { 330 | true // Keep in pending orders 331 | } 332 | }); 333 | 334 | // Process filled limit orders (only process the first one if multiple are filled) 335 | if let Some(order) = filled_limit_orders.first() { 336 | if matches!(position, Position::Neutral) { 337 | let capital = equity * exposure; 338 | let size = if is_options_trading { 339 | let option_notional_value = order.price * 100.0; 340 | (capital / option_notional_value).floor() 341 | } else { 342 | (capital / order.price).floor() 343 | }; 344 | 345 | let adjusted_entry_price = transaction_costs.adjust_fill_price( 346 | order.price, 347 | size, 348 | avg_volume, 349 | matches!(order.order_type, OrderType::LimitBuy) 350 | ); 351 | 352 | match order.order_type { 353 | OrderType::LimitBuy => { 354 | position = Position::Long { 355 | entry: adjusted_entry_price, 356 | size, 357 | entry_date: candle.date.clone(), 358 | }; 359 | } 360 | OrderType::LimitSell => { 361 | position = Position::Short { 362 | entry: adjusted_entry_price, 363 | size, 364 | entry_date: candle.date.clone(), 365 | }; 366 | } 367 | _ => {} // Should not happen for limit orders 368 | } 369 | } 370 | } 371 | 372 | // Then, execute any pending market order from the previous candle 373 | if let Some(order) = pending_order.take() { 374 | match position { 375 | Position::Neutral => { 376 | let _entry_price = candle.get("open") 377 | .unwrap_or_else(|| candle.get("close") 378 | .unwrap_or_else(|| candle.get("price") 379 | .unwrap_or(order.price))); 380 | 381 | let capital = equity * exposure; 382 | let size = if is_options_trading { 383 | // Use the order price for position sizing 384 | let option_notional_value = order.price * 100.0; 385 | (capital / option_notional_value).floor() 386 | } else { 387 | (capital / order.price).floor() 388 | }; 389 | 390 | let adjusted_entry_price = transaction_costs.adjust_fill_price( 391 | order.price, 392 | size, 393 | avg_volume, 394 | order.order_type == OrderType::MarketBuy 395 | ); 396 | 397 | // Extract contract info for better logging 398 | let _contract_info = if is_options_trading { 399 | // Get contract details from the candle 400 | let instrument_class = candle.get_string("instrument_class").map(|s| s.as_str()).unwrap_or("UNK"); 401 | let strike_price = candle.get("strike_price").unwrap_or(0.0); 402 | let symbol = candle.get_string("raw_symbol") 403 | .or_else(|| candle.get_string("symbol_def")) 404 | .or_else(|| candle.get_string("symbol")) 405 | .map(|s| s.as_str()) 406 | .unwrap_or("UNKNOWN"); 407 | 408 | let option_type = match instrument_class.chars().next().unwrap_or('U') { 409 | 'C' => format!("C{:.0}", strike_price), 410 | 'P' => format!("P{:.0}", strike_price), 411 | _ => "UNK".to_string(), 412 | }; 413 | 414 | format!("{} {}", symbol, option_type) 415 | } else { 416 | "Stock".to_string() 417 | }; 418 | 419 | //println!("BUYING {} at order price {:.2}, adjusted price {:.2}, size {}, equity ${:.0}", 420 | // contract_info, order.price, adjusted_entry_price, size, equity); 421 | 422 | match order.order_type { 423 | OrderType::MarketBuy => { 424 | position = Position::Long { 425 | entry: adjusted_entry_price, 426 | size, 427 | entry_date: candle.date.clone(), 428 | }; 429 | } 430 | OrderType::MarketSell => { 431 | position = Position::Short { 432 | entry: adjusted_entry_price, 433 | size, 434 | entry_date: candle.date.clone(), 435 | }; 436 | } 437 | _ => {} // Limit orders handled above 438 | } 439 | } 440 | _ => { 441 | println!("Warning: Pending order while already in position"); 442 | } 443 | } 444 | } 445 | 446 | if let Some(order) = strategy.on_candle(&candle, prev_candle.as_ref()) { 447 | match position { 448 | // If we're in a position and get an order, it must be an exit 449 | Position::Long { entry, size, ref entry_date } => { 450 | if order.order_type == OrderType::MarketSell { 451 | let exit_price = transaction_costs.adjust_fill_price( 452 | order.price, 453 | size, 454 | avg_volume, 455 | false // selling, so we get worse price 456 | ); 457 | 458 | // Extract contract info for better logging 459 | let _contract_info = if is_options_trading { 460 | let instrument_class = candle.get_string("instrument_class").map(|s| s.as_str()).unwrap_or("UNK"); 461 | let strike_price = candle.get("strike_price").unwrap_or(0.0); 462 | let symbol = candle.get_string("raw_symbol") 463 | .or_else(|| candle.get_string("symbol_def")) 464 | .or_else(|| candle.get_string("symbol")) 465 | .map(|s| s.as_str()) 466 | .unwrap_or("UNKNOWN"); 467 | 468 | let option_type = match instrument_class.chars().next().unwrap_or('U') { 469 | 'C' => format!("C{:.0}", strike_price), 470 | 'P' => format!("P{:.0}", strike_price), 471 | _ => "UNK".to_string(), 472 | }; 473 | 474 | format!("{} {}", symbol, option_type) 475 | } else { 476 | "Stock".to_string() 477 | }; 478 | 479 | //println!("SELLING {} at order price {:.2}, adjusted price {:.2} (Entry: {:.2})", 480 | // contract_info, order.price, exit_price, entry); 481 | 482 | // Calculate PnL with transaction costs 483 | let pnl = position.calculate_pnl_with_costs(exit_price, &transaction_costs, avg_volume, is_options_trading, futures_multiplier); 484 | 485 | // Calculate gross PnL with appropriate multiplier 486 | let multiplier = if is_options_trading { 487 | 100.0 488 | } else if let Some(futures_mult) = futures_multiplier { 489 | futures_mult 490 | } else { 491 | 1.0 492 | }; 493 | let gross_pnl = (exit_price - entry) * size * multiplier; 494 | let transaction_cost = gross_pnl - pnl; 495 | 496 | // Check what's causing infinite PnL 497 | if !pnl.is_finite() { 498 | println!("Debug Long: Infinite PnL - entry: {}, exit: {}, size: {}, gross_pnl: {}, pnl: {}", 499 | entry, exit_price, size, gross_pnl, pnl); 500 | continue; // Skip adding this trade 501 | } 502 | 503 | equity += pnl; 504 | 505 | trades.push(Trade { 506 | entry_date: entry_date.clone(), 507 | exit_date: candle.date.clone(), 508 | entry_price: entry, 509 | exit_price, 510 | size, 511 | pnl, 512 | pnl_pct: ((exit_price / entry) - 1.0) * 100.0, 513 | trade_type: "Long".to_string(), 514 | exit_reason: "Strategy".to_string(), // Strategy decided to exit 515 | transaction_costs: transaction_cost, 516 | }); 517 | 518 | position = Position::Neutral; 519 | } 520 | } 521 | Position::Short { entry, size, ref entry_date } => { 522 | if order.order_type == OrderType::MarketBuy { 523 | let exit_price = transaction_costs.adjust_fill_price( 524 | order.price, 525 | size, 526 | avg_volume, 527 | true // buying to cover, so we get worse price 528 | ); 529 | 530 | // Extract contract info for better logging 531 | let _contract_info = if is_options_trading { 532 | let instrument_class = candle.get_string("instrument_class").map(|s| s.as_str()).unwrap_or("UNK"); 533 | let strike_price = candle.get("strike_price").unwrap_or(0.0); 534 | let symbol = candle.get_string("raw_symbol") 535 | .or_else(|| candle.get_string("symbol_def")) 536 | .or_else(|| candle.get_string("symbol")) 537 | .map(|s| s.as_str()) 538 | .unwrap_or("UNKNOWN"); 539 | 540 | let option_type = match instrument_class.chars().next().unwrap_or('U') { 541 | 'C' => format!("C{:.0}", strike_price), 542 | 'P' => format!("P{:.0}", strike_price), 543 | _ => "UNK".to_string(), 544 | }; 545 | 546 | format!("{} {}", symbol, option_type) 547 | } else { 548 | "Stock".to_string() 549 | }; 550 | 551 | //println!("COVERING {} at order price {:.2}, adjusted price {:.2} (Entry: {:.2})", 552 | // contract_info, order.price, exit_price, entry); 553 | 554 | // Calculate PnL with transaction costs 555 | let pnl = position.calculate_pnl_with_costs(exit_price, &transaction_costs, avg_volume, is_options_trading, futures_multiplier); 556 | 557 | // Calculate gross PnL with appropriate multiplier 558 | let multiplier = if is_options_trading { 559 | 100.0 560 | } else if let Some(futures_mult) = futures_multiplier { 561 | futures_mult 562 | } else { 563 | 1.0 564 | }; 565 | let gross_pnl = (entry - exit_price) * size * multiplier; 566 | let transaction_cost = gross_pnl - pnl; 567 | 568 | // Check what could cause infinite PnL 569 | if !pnl.is_finite() { 570 | println!("Debug Short: Infinite PnL - entry: {}, exit: {}, size: {}, gross_pnl: {}, pnl: {}", 571 | entry, exit_price, size, gross_pnl, pnl); 572 | continue; // Skip adding this trade 573 | } 574 | 575 | equity += pnl; 576 | 577 | trades.push(Trade { 578 | entry_date: entry_date.clone(), 579 | exit_date: candle.date.clone(), 580 | entry_price: entry, 581 | exit_price, 582 | size, 583 | pnl, 584 | pnl_pct: ((entry / exit_price) - 1.0) * 100.0, 585 | trade_type: "Short".to_string(), 586 | exit_reason: "Strategy".to_string(), // Strategy decided to exit 587 | transaction_costs: transaction_cost, 588 | }); 589 | 590 | position = Position::Neutral; 591 | } 592 | } 593 | // If we're neutral and get an order, it's a new entry 594 | Position::Neutral => { 595 | match order.order_type { 596 | OrderType::MarketBuy | OrderType::MarketSell => { 597 | // Market orders are executed next candle 598 | pending_order = Some(order); 599 | } 600 | OrderType::LimitBuy | OrderType::LimitSell => { 601 | // Limit orders are added to pending limit orders queue 602 | pending_limit_orders.push(order); 603 | } 604 | } 605 | } 606 | } 607 | } 608 | 609 | // Ensure equity is finite before adding to curve 610 | if equity.is_finite() { 611 | equity_curve.push(equity); 612 | } else { 613 | // Use the last finite equity value 614 | let last_equity = equity_curve.last().copied().unwrap_or(starting_equity); 615 | equity_curve.push(last_equity); 616 | equity = last_equity; // Reset equity to last valid value 617 | } 618 | prev_candle = Some(candle.clone()); 619 | } 620 | 621 | Ok(BacktestResult::calculate_metrics( 622 | starting_equity, 623 | equity, 624 | equity_curve, 625 | trades, 626 | )) 627 | } 628 | 629 | // Benchmark calculation that works with any schema 630 | pub fn calculate_benchmark_with_schema( 631 | csv_path: &str, 632 | symbol: &str, 633 | schema: Schema, 634 | custom_schema: Option, 635 | starting_equity: f64, 636 | exposure: f64, 637 | ) -> Result { 638 | 639 | if matches!(schema, Schema::Status | Schema::Definition | Schema::Statistics) { 640 | return Err(anyhow::anyhow!("Schema does not support price data")); 641 | } 642 | 643 | let handler = get_schema_handler(schema); 644 | let candles = handler.csv_to_candles(csv_path)?; 645 | 646 | if candles.is_empty() { 647 | return Err(anyhow::anyhow!("No candles found")); 648 | } 649 | 650 | // Check if we're trading futures and get the multiplier 651 | let is_futures_trading = symbol.ends_with(".v.0") || symbol.ends_with(".c.0"); 652 | let futures_multiplier = if is_futures_trading { 653 | get_future_from_symbol(symbol).map(|future| get_future_multiplier(future)) 654 | } else { 655 | None 656 | }; 657 | 658 | let first_candle = &candles[0]; 659 | let last_candle = &candles[candles.len() - 1]; 660 | 661 | let mut equity = starting_equity; 662 | let mut equity_curve = vec![starting_equity]; 663 | let mut trades = Vec::new(); 664 | 665 | // Determine the key based on custom schema or regular schema 666 | let key = if let Some(ref custom_schema) = custom_schema { 667 | match custom_schema { 668 | InkBackSchema::FootPrint => "close", // FootPrint bars have OHLCV format 669 | InkBackSchema::CombinedOptionsUnderlying => "underlying_ask", // for simplicity 670 | } 671 | } else { 672 | match schema { 673 | Schema::Ohlcv1S | Schema::Ohlcv1M | Schema::Ohlcv1H | Schema::Ohlcv1D | Schema::OhlcvEod => "close", 674 | Schema::Mbo | Schema::Trades => "price", 675 | Schema::Mbp1 | Schema::Tbbo | Schema::Cbbo | Schema::Cbbo1S | Schema::Cbbo1M | Schema::Tcbbo | Schema::Bbo1S | Schema::Bbo1M => "ask_price", // for simplicity 676 | Schema::Mbp10 => "level_0_ask_price", // for simplicity 677 | Schema::Imbalance => "ref_price", 678 | _ => unreachable!(), 679 | } 680 | }; 681 | 682 | let first_close = first_candle.get(key) 683 | .ok_or_else(|| anyhow::anyhow!("Missing {} in candle", key))?; 684 | 685 | let last_close = last_candle.get(key) 686 | .ok_or_else(|| anyhow::anyhow!("Missing {} in candle", key))?; 687 | 688 | let capital = equity * exposure; 689 | let size = capital / first_close; 690 | let entry_price = first_close; 691 | 692 | // Apply appropriate multiplier for different instrument types 693 | let multiplier = if let Some(futures_mult) = futures_multiplier { 694 | futures_mult 695 | } else { 696 | 1.0 // Default multiplier for stocks/other instruments 697 | }; 698 | 699 | // Calculate equity progression 700 | for candle in &candles[1..] { 701 | let close = candle.get(key) 702 | .ok_or_else(|| anyhow::anyhow!("Missing {} in candle", key))?; 703 | 704 | equity = (close - entry_price) * size * multiplier + starting_equity; 705 | equity_curve.push(equity); 706 | } 707 | 708 | let exit_price = last_close; 709 | let pnl = (exit_price - entry_price) * size * multiplier; 710 | let pnl_pct = ((exit_price / entry_price) - 1.0) * 100.0; 711 | 712 | trades.push(Trade { 713 | entry_date: first_candle.date.clone(), 714 | exit_date: last_candle.date.clone(), 715 | entry_price, 716 | exit_price, 717 | size, 718 | pnl, 719 | pnl_pct, 720 | trade_type: "Benchmark".to_string(), 721 | exit_reason: "EndOfPeriod".to_string(), 722 | transaction_costs: 0.0, 723 | }); 724 | 725 | Ok(BacktestResult::calculate_metrics( 726 | starting_equity, 727 | equity, 728 | equity_curve, 729 | trades, 730 | )) 731 | } 732 | 733 | pub fn run_individual_backtest( 734 | path: &str, 735 | symbol: &str, 736 | schema: Schema, 737 | custom_schema: Option, 738 | strategy: &mut dyn Strategy, 739 | starting_equity: f64, 740 | exposure: f64, 741 | transactions_model: TransactionCosts, 742 | ) -> Result { 743 | run_backtest_with_schema(path, symbol, schema, custom_schema, strategy, transactions_model, starting_equity, exposure) 744 | } 745 | 746 | pub fn run_parallel_backtest( 747 | parameter_combinations: Vec, 748 | path: &str, 749 | symbol: &str, 750 | schema: Schema, 751 | custom_schema: Option, 752 | strategy_constructor: F, 753 | starting_equity: f64, 754 | exposure: f64, 755 | transactions_model: TransactionCosts, 756 | ) -> Option)>> 757 | where 758 | F: Fn(&StrategyParams) -> anyhow::Result> + Sync + Send, 759 | { 760 | 761 | println!("Testing {} parameter combinations...", parameter_combinations.len()); 762 | 763 | // Run backtests in parallel 764 | let results: Vec<_> = parameter_combinations 765 | .par_iter() 766 | .enumerate() // Add enumeration to track which strategy 767 | .filter_map(|(index, params)| { 768 | // Create a fresh strategy instance for each parameter set 769 | let mut strategy = strategy_constructor(params).ok()?; 770 | 771 | println!("Testing strategy {} with params: {:?}", index + 1, params); 772 | 773 | let result = run_individual_backtest( 774 | &path, 775 | &symbol, 776 | schema, 777 | custom_schema.clone(), 778 | strategy.as_mut(), 779 | starting_equity, 780 | exposure, 781 | transactions_model.clone(), 782 | ).ok()?; 783 | 784 | // Validate equity curve has reasonable values 785 | if result.equity_curve.iter().any(|&val| !val.is_finite()) { 786 | println!("Warning: Strategy {} has non-finite equity values", index + 1); 787 | return None; 788 | } 789 | 790 | // Create parameter label 791 | let param_str = format!( 792 | "Strategy_{}_Lookback({})_Momentum({:.1}%)_TP({:.0}%)_SL({:.0}%)_MinDays({:.0})", 793 | index + 1, // Add strategy index to make each unique 794 | params.get("lookback_periods").unwrap_or(0.0) as usize, 795 | params.get("momentum_threshold").unwrap_or(0.0), 796 | params.get("profit_target").unwrap_or(0.0), 797 | params.get("stop_loss").unwrap_or(0.0), 798 | params.get("min_days_to_expiry").unwrap_or(0.0), 799 | ); 800 | 801 | // Store equity curve with validation 802 | let finite_curve: Vec = result.equity_curve.iter() 803 | .map(|&val| if val.is_finite() { val } else { starting_equity }) 804 | .collect(); 805 | 806 | Some((param_str, result, finite_curve)) 807 | }) 808 | .collect(); 809 | 810 | // Sort results by total return 811 | let mut sorted_results = results; 812 | sorted_results.sort_by(|a, b| b.1.total_return_pct.partial_cmp(&a.1.total_return_pct).unwrap_or(std::cmp::Ordering::Equal)); 813 | 814 | Some(sorted_results) 815 | } 816 | 817 | pub fn display_results( 818 | sorted_results: Option)>>, 819 | csv_path: &str, 820 | symbol: &str, 821 | schema: Schema, 822 | custom_schema: Option, 823 | starting_equity: f64, 824 | exposure: f64, 825 | ) { 826 | 827 | let mut equity_curves: Vec<(String, Vec)> = Vec::new(); 828 | 829 | // Run benchmark on underlying asset 830 | let benchmark = calculate_benchmark( 831 | &csv_path, 832 | symbol, 833 | schema, 834 | custom_schema, 835 | starting_equity, 836 | exposure 837 | ).unwrap(); 838 | 839 | println!("Benchmark Return: {:.2}%, Max Drawdown: {:.2}%", 840 | benchmark.total_return_pct, benchmark.max_drawdown_pct); 841 | 842 | // Print results for all strategies 843 | println!("\n=== ALL STRATEGY RESULTS ==="); 844 | println!("Benchmark: Return {:.2}%, Max DD: {:.2}%\n", 845 | benchmark.total_return_pct, benchmark.max_drawdown_pct); 846 | 847 | if let Some(sorted_results) = sorted_results { 848 | // Print results for all strategies 849 | println!("\n=== ALL STRATEGY RESULTS ==="); 850 | println!("Benchmark: Return {:.2}%, Max DD: {:.2}%\n", 851 | benchmark.total_return_pct, benchmark.max_drawdown_pct); 852 | 853 | for (i, (param_str, result, _)) in sorted_results.iter().enumerate() { 854 | println!( 855 | "{}. {}: Return: {:.2}%, Max DD: {:.2}%, Win Rate: {:.1}%, PF: {:.2}, Trades: {}, Fees: ${:.0}", 856 | i + 1, 857 | param_str, 858 | if result.total_return_pct.is_finite() { result.total_return_pct } else { 0.0 }, 859 | if result.max_drawdown_pct.is_finite() { result.max_drawdown_pct } else { 0.0 }, 860 | if result.win_rate.is_finite() { result.win_rate } else { 0.0 }, 861 | if result.profit_factor.is_finite() { result.profit_factor } else { 0.0 }, 862 | result.total_trades, 863 | if result.total_transaction_costs.is_finite() { result.total_transaction_costs } else { 0.0 } 864 | ); 865 | 866 | // Store equity curve for plotting 867 | equity_curves.push((param_str.clone(), sorted_results[i].2.clone())); 868 | } 869 | 870 | // Print summary statistics 871 | if !sorted_results.is_empty() { 872 | let profitable_strategies = sorted_results.iter() 873 | .filter(|(_, result, _)| result.total_return_pct > 0.0) 874 | .count(); 875 | 876 | let avg_return: f64 = sorted_results.iter() 877 | .map(|(_, result, _)| result.total_return_pct) 878 | .sum::() / sorted_results.len() as f64; 879 | 880 | let best_return = sorted_results.first().map(|(_, result, _)| result.total_return_pct).unwrap_or(0.0); 881 | let worst_return = sorted_results.last().map(|(_, result, _)| result.total_return_pct).unwrap_or(0.0); 882 | 883 | println!("\n=== SUMMARY STATISTICS ==="); 884 | println!("Total strategies tested: {}", sorted_results.len()); 885 | println!("Profitable strategies: {} ({:.1}%)", 886 | profitable_strategies, 887 | (profitable_strategies as f64 / sorted_results.len() as f64) * 100.0); 888 | println!("Average return: {:.2}%", avg_return); 889 | println!("Best return: {:.2}%", best_return); 890 | println!("Worst return: {:.2}%", worst_return); 891 | println!("Benchmark return: {:.2}%", benchmark.total_return_pct); 892 | 893 | let outperforming = sorted_results.iter() 894 | .filter(|(_, result, _)| result.total_return_pct > benchmark.total_return_pct) 895 | .count(); 896 | println!("Strategies beating benchmark: {} ({:.1}%)", 897 | outperforming, 898 | (outperforming as f64 / sorted_results.len() as f64) * 100.0); 899 | } 900 | 901 | // Plot equity curves 902 | if !equity_curves.is_empty() { 903 | println!("\nLaunching performance chart for all strategies..."); 904 | let finite_benchmark: Vec = benchmark.equity_curve.iter() 905 | .map(|&val| if val.is_finite() { val } else { starting_equity }) 906 | .collect(); 907 | 908 | // Limit the number of curves plotted to avoid clutter 909 | let max_curves = 20; 910 | let curves_to_plot = if equity_curves.len() > max_curves { 911 | println!("Too many equity curves ({}), plotting only the top {} strategies.", 912 | equity_curves.len(), max_curves); 913 | equity_curves.into_iter().take(max_curves).collect() 914 | } else { 915 | equity_curves 916 | }; 917 | 918 | plot_equity_curves(curves_to_plot, Some(finite_benchmark)); 919 | } 920 | } else { 921 | println!("Failed to run backtest - no results returned"); 922 | } 923 | } 924 | 925 | pub fn calculate_benchmark( 926 | path: &str, 927 | symbol: &str, 928 | schema: Schema, 929 | custom_schema: Option, 930 | starting_equity: f64, 931 | exposure: f64, 932 | ) -> Result { 933 | calculate_benchmark_with_schema(path, symbol, schema, custom_schema, starting_equity, exposure) 934 | } 935 | -------------------------------------------------------------------------------- /src/utils/fetch.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use std::fs::File; 3 | use std::path::Path; 4 | use csv::Writer; 5 | use time::OffsetDateTime; 6 | 7 | use databento::{ 8 | dbn::{InstrumentDefMsg, CbboMsg, ImbalanceMsg, MboMsg, Mbp10Msg, Mbp1Msg, OhlcvMsg, SType, Schema, StatMsg, TbboMsg, TradeMsg}, 9 | historical::{timeseries::GetRangeParams, symbology::ResolveParams}, 10 | HistoricalClient, 11 | }; 12 | 13 | use crate::InkBackSchema; 14 | 15 | // Helper function to convert i8 arrays to strings 16 | fn i8_array_to_string(arr: &[i8]) -> String { 17 | let bytes: Vec = arr.iter() 18 | .map(|&b| if b < 0 { 0 } else { b as u8 }) 19 | .collect(); 20 | 21 | std::str::from_utf8(&bytes) 22 | .unwrap_or("") 23 | .trim_end_matches('\0') 24 | .to_string() 25 | } 26 | 27 | // Helper function to convert single i8 to string 28 | fn i8_to_string(val: i8) -> String { 29 | if val < 0 { 30 | String::new() 31 | } else { 32 | std::str::from_utf8(&[val as u8]) 33 | .unwrap_or("") 34 | .to_string() 35 | } 36 | } 37 | 38 | pub async fn fetch_and_save_csv( 39 | mut client: HistoricalClient, 40 | dataset: &str, 41 | symbol: &str, 42 | option_symbol: Option<&str>, 43 | schema: Schema, 44 | custom_schema: Option, 45 | start: OffsetDateTime, 46 | end: OffsetDateTime, 47 | ) -> Result { 48 | 49 | let req_schema = if let Some(ref custom_schema) = custom_schema { 50 | match custom_schema { 51 | InkBackSchema::FootPrint => Schema::Trades, 52 | InkBackSchema::CombinedOptionsUnderlying => Schema::Trades, 53 | } 54 | } else { 55 | schema 56 | }; 57 | 58 | println!( 59 | "Fetching {} with schema {:?} for date {} - {}", 60 | symbol, req_schema, start, end 61 | ); 62 | 63 | let filename = if let Some(ref custom_schema) = custom_schema { 64 | match custom_schema { 65 | InkBackSchema::FootPrint => { 66 | format!("src/data/{}_FootPrint_{}-{}.csv", symbol, start.date(), end.date()) 67 | }, 68 | InkBackSchema::CombinedOptionsUnderlying => { 69 | format!("src/data/{}_CombinedOptionsUnderlying_{}-{}.csv", symbol, start.date(), end.date()) 70 | }, 71 | } 72 | } else { 73 | format!("src/data/{}_{}_{}-{}.csv", symbol, schema, start.date(), end.date()) 74 | }; 75 | 76 | if Path::new(&filename).exists() { 77 | println!("File already exists: {filename}, skipping fetch."); 78 | return Ok(filename); 79 | } 80 | 81 | // if we are not using combined options 82 | let decoder = match custom_schema { 83 | Some(InkBackSchema::CombinedOptionsUnderlying) => { 84 | // Get the decoder later for the combined 85 | None 86 | }, 87 | _ => { 88 | // Initialize decoder for FootPrint or non-custom schemas now 89 | Some(match dataset { 90 | "XNAS.ITCH" => client 91 | .timeseries() 92 | .get_range( 93 | &GetRangeParams::builder() 94 | .dataset(dataset) 95 | .date_time_range((start, end)) 96 | .symbols(symbol) 97 | .schema(req_schema) 98 | .build(), 99 | ) 100 | .await?, 101 | "GLBX.MDP3" => client 102 | .timeseries() 103 | .get_range( 104 | &GetRangeParams::builder() 105 | .dataset(dataset) 106 | .date_time_range((start, end)) 107 | .symbols(symbol) 108 | .stype_in(SType::Continuous) 109 | .schema(req_schema) 110 | .build(), 111 | ) 112 | .await?, 113 | _ => return Err(anyhow::anyhow!("Unsupported dataset: {}", dataset)), 114 | }) 115 | } 116 | }; 117 | 118 | let file = File::create(&filename)?; 119 | let mut writer = Writer::from_writer(file); 120 | 121 | // Calculate scaling factor 122 | let scaling_factor = 10.0_f64.powi(-9 as i32); // -9 is typical 123 | 124 | if let Some(ref custom_schema) = custom_schema { 125 | match custom_schema { 126 | InkBackSchema::CombinedOptionsUnderlying => { 127 | println!("Fetching combined options and underlying data..."); 128 | 129 | // Determine if we're dealing with futures or equities based on dataset 130 | let is_futures = matches!(dataset, "GLBX.MDP3" | "DBEQ.MAX" | "IFEU.IMPACT"); 131 | let underlying_id = if is_futures { 132 | println!("Processing futures options for {}", symbol); 133 | let resolve_params = ResolveParams::builder() 134 | .dataset(dataset) 135 | .symbols(vec![symbol.to_string()]) 136 | .stype_in(SType::Continuous) 137 | .stype_out(SType::InstrumentId) 138 | .date_range((start.date(), end.date())) 139 | .build(); 140 | 141 | let symbology_result = client.symbology().resolve(&resolve_params).await?; 142 | 143 | let front_month_symbol = symbology_result 144 | .mappings 145 | .get(symbol) 146 | .and_then(|mappings| mappings.first()) 147 | .map(|mapping| &mapping.symbol) 148 | .ok_or_else(|| anyhow::anyhow!("Could not resolve front month contract for {}", symbol))?; 149 | 150 | let front_month_id = front_month_symbol.parse::() 151 | .map_err(|_| anyhow::anyhow!("Could not parse instrument ID from symbol: {}", front_month_symbol))?; 152 | 153 | println!("Front month instrument ID: {}", front_month_id); 154 | front_month_id 155 | } else { 156 | println!("Processing equity options for {}", symbol); 157 | let resolve_params = ResolveParams::builder() 158 | .dataset(dataset) 159 | .symbols(vec![symbol.to_string()]) 160 | .stype_in(SType::RawSymbol) 161 | .stype_out(SType::InstrumentId) 162 | .date_range((start.date(), end.date())) 163 | .build(); 164 | 165 | let symbology_result = client.symbology().resolve(&resolve_params).await?; 166 | 167 | let equity_symbol = symbology_result 168 | .mappings 169 | .get(symbol) 170 | .and_then(|mappings| mappings.first()) 171 | .map(|mapping| &mapping.symbol) 172 | .ok_or_else(|| anyhow::anyhow!("Could not resolve instrument ID for equity {}", symbol))?; 173 | 174 | let equity_id = equity_symbol.parse::() 175 | .map_err(|_| anyhow::anyhow!("Could not parse instrument ID from symbol: {}", equity_symbol))?; 176 | 177 | println!("Equity instrument ID: {}", equity_id); 178 | equity_id 179 | }; 180 | 181 | let options_dataset = match dataset { 182 | "GLBX.MDP3" => "GLBX.MDP3", 183 | "XNAS.ITCH" => "OPRA.PILLAR", 184 | "ARCX.PILLAR" => "OPRA.PILLAR", 185 | "BATY.PITCH" => "OPRA.PILLAR", 186 | "BZX.PITCH" => "OPRA.PILLAR", 187 | "EDGA.PITCH" => "OPRA.PILLAR", 188 | "EDGX.PITCH" => "OPRA.PILLAR", 189 | "IEX.TOPS" => "OPRA.PILLAR", 190 | "LTSE.PITCH" => "OPRA.PILLAR", 191 | "MEMX.MEMOIR" => "OPRA.PILLAR", 192 | "MIAX.TOPS" => "OPRA.PILLAR", 193 | "MPRL.TOPS" => "OPRA.PILLAR", 194 | "NYSE.PILLAR" => "OPRA.PILLAR", 195 | _ => return Err(anyhow::anyhow!("Unsupported dataset for options: {}", dataset)), 196 | }; 197 | 198 | println!("Using options dataset: {}", options_dataset); 199 | 200 | // Get option definitions 201 | println!("Fetching option definitions..."); 202 | let mut opt_def_decoder = client 203 | .timeseries() 204 | .get_range( 205 | &GetRangeParams::builder() 206 | .dataset(options_dataset) 207 | .date_time_range((start, end)) 208 | .symbols(option_symbol.unwrap()) 209 | .stype_in(SType::Parent) 210 | .schema(Schema::Definition) 211 | .build(), 212 | ) 213 | .await?; 214 | 215 | // Collect option definitions and filter for the underlying 216 | let mut option_definitions = std::collections::HashMap::new(); 217 | let mut relevant_option_ids = std::collections::HashSet::new(); 218 | 219 | while let Some(definition) = opt_def_decoder.decode_record::().await? { 220 | let is_relevant = if is_futures { 221 | definition.underlying_id == underlying_id 222 | } else { 223 | let underlying_str = i8_array_to_string(&definition.underlying); 224 | underlying_str == symbol 225 | }; 226 | 227 | if is_relevant && (definition.instrument_class == b'C' as i8 || definition.instrument_class == b'P' as i8) { 228 | relevant_option_ids.insert(definition.hd.instrument_id); 229 | option_definitions.insert(definition.hd.instrument_id, definition.clone()); 230 | } 231 | } 232 | 233 | println!("Found {} relevant options for underlying {}", relevant_option_ids.len(), symbol); 234 | 235 | if relevant_option_ids.is_empty() { 236 | return Err(anyhow::anyhow!("No relevant options found for {}", symbol)); 237 | } 238 | 239 | // Get underlying market data 240 | println!("Fetching underlying market data..."); 241 | let underlying_symbols = if is_futures { 242 | vec![underlying_id] 243 | } else { 244 | vec![] 245 | }; 246 | 247 | let mut underlying_decoder = if is_futures { 248 | client 249 | .timeseries() 250 | .get_range( 251 | &GetRangeParams::builder() 252 | .dataset(dataset) 253 | .date_time_range((start, end)) 254 | .symbols(underlying_symbols) 255 | .stype_in(SType::InstrumentId) 256 | .schema(Schema::Mbp1) 257 | .build(), 258 | ) 259 | .await? 260 | } else { 261 | client 262 | .timeseries() 263 | .get_range( 264 | &GetRangeParams::builder() 265 | .dataset(dataset) 266 | .date_time_range((start, end)) 267 | .symbols(symbol) 268 | .stype_in(SType::RawSymbol) 269 | .schema(Schema::Mbp1) 270 | .build(), 271 | ) 272 | .await? 273 | }; 274 | 275 | // Collect underlying market data with timestamps 276 | let mut underlying_data = Vec::new(); 277 | while let Some(mbp1) = underlying_decoder.decode_record::().await? { 278 | let level = &mbp1.levels[0]; 279 | let bid_price = (level.bid_px as f64) * scaling_factor; 280 | let ask_price = (level.ask_px as f64) * scaling_factor; 281 | let bid_size = level.bid_sz as f64; 282 | let ask_size = level.ask_sz as f64; 283 | 284 | // Filter out invalid/sentinel values - typically huge numbers indicating missing data 285 | if bid_price.is_finite() && ask_price.is_finite() && 286 | bid_price > 0.0 && ask_price > 0.0 && 287 | bid_price < 1e7 && ask_price < 1e7 && 288 | bid_size > 0.0 && ask_size > 0.0 && 289 | bid_size < 1e7 && ask_size < 1e7 { 290 | underlying_data.push((mbp1.hd.ts_event, bid_price, ask_price, bid_size, ask_size)); 291 | } 292 | } 293 | 294 | println!("Collected {} underlying data points", underlying_data.len()); 295 | 296 | // Sort underlying data by timestamp for binary search 297 | underlying_data.sort_by_key(|&(ts, _, _, _, _)| ts); 298 | 299 | // Batch option trades requests 300 | println!("Fetching option trades in batches..."); 301 | const BATCH_SIZE: usize = 2000; // API limit 302 | let mut trades_processed = 0; 303 | 304 | // Create CSV writer 305 | let file = File::create(&filename)?; 306 | let mut writer = Writer::from_writer(file); 307 | 308 | // Write header 309 | writer.write_record(&[ 310 | "ts_event", "rtype", "publisher_id", "instrument_id", "action", "side", "depth", 311 | "price", "size", "flags", "ts_in_delta", "sequence", "symbol", "ts_event_def", 312 | "rtype_def", "publisher_id_def", "raw_symbol", "security_update_action", 313 | "instrument_class", "min_price_increment", "display_factor", "expiration", 314 | "activation", "high Bish_limit_price", "low_limit_price", "max_price_variation", 315 | "trading_reference_price", "unit_of_measure_qty", "min_price_increment_amount", 316 | "price_ratio", "inst_attrib_value", "underlying_id", "raw_instrument_id", 317 | "market_depth_implied", "market_depth", "market_segment_id", "max_trade_vol", 318 | "min_lot_size", "min_lot_size_block", "min_lot_size_round_lot", "min_trade_vol", 319 | "contract_multiplier", "decay_quantity", "original_contract_size", 320 | "trading_reference_date", "appl_id", "maturity_year", "decay_start_date", 321 | "channel_id", "currency", "settl_currency", "secsubtype", "group", "exchange", 322 | "asset", "cfi", "security_type", "unit_of_measure", "underlying", 323 | "strike_price_currency", "strike_price", "match_algorithm", 324 | "md_security_trading_status", "main_fraction", "price_display_format", 325 | "settl_price_type", "sub_fraction", "underlying_product", "maturity_month", 326 | "maturity_day", "maturity_week", 327 | "contract_multiplier_unit", "flow_schedule_type", "tick_rule", "symbol_def", 328 | "underlying_bid", "underlying_ask", "underlying_bid_size", "underlying_ask_size" 329 | ])?; 330 | 331 | // Process option IDs in batches 332 | let relevant_option_ids_vec: Vec = relevant_option_ids.into_iter().collect(); 333 | for chunk in relevant_option_ids_vec.chunks(BATCH_SIZE) { 334 | println!("Processing batch of {} option IDs", chunk.len()); 335 | let option_ids_vec: Vec = chunk.to_vec(); 336 | let mut opt_trades_decoder = client 337 | .timeseries() 338 | .get_range( 339 | &GetRangeParams::builder() 340 | .dataset(options_dataset) 341 | .date_time_range((start, end)) 342 | .symbols(option_ids_vec) 343 | .stype_in(SType::InstrumentId) 344 | .schema(Schema::Trades) 345 | .build(), 346 | ) 347 | .await?; 348 | 349 | // Process trades in this batch 350 | while let Some(trade) = opt_trades_decoder.decode_record::().await? { 351 | if let Some(definition) = option_definitions.get(&trade.hd.instrument_id) { 352 | // Find most recent underlying data 353 | let (underlying_bid, underlying_ask, underlying_bid_size, underlying_ask_size) = find_most_recent_underlying(&underlying_data, trade.ts_recv); 354 | 355 | // Create symbol_def from raw_symbol 356 | let symbol_def = i8_array_to_string(&definition.raw_symbol); 357 | 358 | writer.write_record(&[ 359 | trade.ts_recv.to_string(), 360 | "0".to_string(), 361 | trade.hd.publisher_id.to_string(), 362 | trade.hd.instrument_id.to_string(), 363 | i8_to_string(trade.action), 364 | i8_to_string(trade.side), 365 | "0".to_string(), 366 | ((trade.price as f64) * scaling_factor).to_string(), 367 | trade.size.to_string(), 368 | trade.flags.to_string(), 369 | "0".to_string(), 370 | trade.sequence.to_string(), 371 | trade.hd.instrument_id.to_string(), 372 | trade.hd.ts_event.to_string(), 373 | "19".to_string(), 374 | definition.hd.publisher_id.to_string(), 375 | i8_array_to_string(&definition.raw_symbol), 376 | i8_to_string(definition.security_update_action), 377 | i8_to_string(definition.instrument_class), 378 | ((definition.min_price_increment as f64) * scaling_factor).to_string(), 379 | definition.display_factor.to_string(), 380 | definition.expiration.to_string(), 381 | definition.activation.to_string(), 382 | ((definition.high_limit_price as f64) * scaling_factor).to_string(), 383 | ((definition.low_limit_price as f64) * scaling_factor).to_string(), 384 | ((definition.max_price_variation as f64) * scaling_factor).to_string(), 385 | ((definition.trading_reference_price as f64) * scaling_factor).to_string(), 386 | definition.unit_of_measure_qty.to_string(), 387 | definition.min_price_increment_amount.to_string(), 388 | definition.price_ratio.to_string(), 389 | definition.inst_attrib_value.to_string(), 390 | definition.underlying_id.to_string(), 391 | definition.hd.instrument_id.to_string(), 392 | definition.market_depth_implied.to_string(), 393 | definition.market_depth.to_string(), 394 | definition.market_segment_id.to_string(), 395 | definition.max_trade_vol.to_string(), 396 | definition.min_lot_size.to_string(), 397 | definition.min_lot_size_block.to_string(), 398 | definition.min_lot_size_round_lot.to_string(), 399 | definition.min_trade_vol.to_string(), 400 | definition.contract_multiplier.to_string(), 401 | definition.decay_quantity.to_string(), 402 | definition.original_contract_size.to_string(), 403 | definition.trading_reference_date.to_string(), 404 | definition.appl_id.to_string(), 405 | definition.maturity_year.to_string(), 406 | definition.decay_start_date.to_string(), 407 | definition.channel_id.to_string(), 408 | i8_array_to_string(&definition.currency), 409 | i8_array_to_string(&definition.settl_currency), 410 | i8_array_to_string(&definition.secsubtype), 411 | i8_array_to_string(&definition.group), 412 | i8_array_to_string(&definition.exchange), 413 | i8_array_to_string(&definition.asset), 414 | i8_array_to_string(&definition.cfi), 415 | i8_array_to_string(&definition.security_type), 416 | i8_array_to_string(&definition.unit_of_measure), 417 | i8_array_to_string(&definition.underlying), 418 | i8_array_to_string(&definition.strike_price_currency), 419 | ((definition.strike_price as f64) * scaling_factor).to_string(), 420 | i8_to_string(definition.match_algorithm), 421 | definition.md_security_trading_status.to_string(), 422 | definition.main_fraction.to_string(), 423 | definition.price_display_format.to_string(), 424 | definition.settl_price_type.to_string(), 425 | definition.sub_fraction.to_string(), 426 | definition.underlying_product.to_string(), 427 | definition.maturity_month.to_string(), 428 | definition.maturity_day.to_string(), 429 | definition.maturity_week.to_string(), 430 | definition.contract_multiplier_unit.to_string(), 431 | definition.flow_schedule_type.to_string(), 432 | definition.tick_rule.to_string(), 433 | symbol_def, 434 | underlying_bid.to_string(), 435 | underlying_ask.to_string(), 436 | underlying_bid_size.to_string(), 437 | underlying_ask_size.to_string(), 438 | ])?; 439 | 440 | trades_processed += 1; 441 | } 442 | } 443 | } 444 | 445 | println!("Processed {} option trades", trades_processed); 446 | writer.flush()?; 447 | }, 448 | InkBackSchema::FootPrint => { 449 | 450 | // Define bar interval (1 minute = 60_000_000_000 nanoseconds) 451 | let bar_interval_ns = 60_000_000_000u64; 452 | 453 | writer.write_record(&["ts_event", "open", "high", "low", "close", "volume", "footprint_data"])?; 454 | 455 | let mut current_bar_start: Option = None; 456 | let mut current_bar_trades: Vec = Vec::new(); 457 | 458 | let mut decoder = decoder.unwrap(); 459 | while let Some(trade) = decoder.decode_record::().await? { 460 | let trade_time = trade.ts_recv; 461 | 462 | // Determine which bar this trade belongs to 463 | let bar_start = (trade_time / bar_interval_ns) * bar_interval_ns; 464 | 465 | // If this is a new bar, process the previous bar 466 | if let Some(prev_bar_start) = current_bar_start { 467 | if bar_start != prev_bar_start { 468 | // Process the completed bar 469 | if !current_bar_trades.is_empty() { 470 | let footprint_bar = process_footprint_bar(¤t_bar_trades, scaling_factor); 471 | writer.write_record(&[ 472 | prev_bar_start.to_string(), 473 | footprint_bar.open.to_string(), 474 | footprint_bar.high.to_string(), 475 | footprint_bar.low.to_string(), 476 | footprint_bar.close.to_string(), 477 | footprint_bar.volume.to_string(), 478 | footprint_bar.footprint_data, 479 | ])?; 480 | } 481 | current_bar_trades.clear(); 482 | } 483 | } 484 | 485 | current_bar_start = Some(bar_start); 486 | current_bar_trades.push(trade.clone()); 487 | } 488 | 489 | // Process the final bar if it has trades 490 | if !current_bar_trades.is_empty() { 491 | if let Some(final_bar_start) = current_bar_start { 492 | let footprint_bar = process_footprint_bar(¤t_bar_trades, scaling_factor); 493 | writer.write_record(&[ 494 | final_bar_start.to_string(), 495 | footprint_bar.open.to_string(), 496 | footprint_bar.high.to_string(), 497 | footprint_bar.low.to_string(), 498 | footprint_bar.close.to_string(), 499 | footprint_bar.volume.to_string(), 500 | footprint_bar.footprint_data, 501 | ])?; 502 | } 503 | } 504 | } 505 | } 506 | 507 | } else { 508 | match schema { 509 | Schema::Ohlcv1S | Schema::Ohlcv1M | Schema::Ohlcv1H | Schema::Ohlcv1D | Schema::OhlcvEod => { 510 | writer.write_record(&["ts_event", "open", "high", "low", "close", "volume"])?; 511 | let mut date = start.date(); 512 | 513 | let mut decoder = decoder.unwrap(); 514 | while let Some(ohlcv) = decoder.decode_record::().await? { 515 | writer.write_record(&[ 516 | date.to_string(), 517 | ((ohlcv.open as f64) * scaling_factor).to_string(), 518 | ((ohlcv.high as f64) * scaling_factor).to_string(), 519 | ((ohlcv.low as f64) * scaling_factor).to_string(), 520 | ((ohlcv.close as f64) * scaling_factor).to_string(), 521 | ohlcv.volume.to_string(), 522 | ])?; 523 | 524 | date = date.next_day().unwrap(); 525 | } 526 | } 527 | 528 | Schema::Mbo => { 529 | writer.write_record(&["ts_event", "ts_recv", "sequence", "flags", "side", "price", "size", "channel_id", "order_id", "action"])?; 530 | 531 | let mut decoder = decoder.unwrap(); 532 | while let Some(mbo) = decoder.decode_record::().await? { 533 | writer.write_record(&[ 534 | mbo.hd.ts_event.to_string(), 535 | mbo.ts_recv.to_string(), 536 | mbo.sequence.to_string(), 537 | mbo.flags.to_string(), 538 | mbo.side.to_string(), 539 | ((mbo.price as f64) * scaling_factor).to_string(), 540 | mbo.size.to_string(), 541 | mbo.channel_id.to_string(), 542 | mbo.order_id.to_string(), 543 | mbo.action.to_string(), 544 | ])?; 545 | } 546 | } 547 | 548 | Schema::Mbp1 => { 549 | writer.write_record(&["ts_recv", "sequence", "flags", "bid_price", "ask_price", "bid_size", "ask_size", "bid_count", "ask_count"])?; 550 | 551 | let mut decoder = decoder.unwrap(); 552 | while let Some(mbp1) = decoder.decode_record::().await? { 553 | // MBP1 has one level, so we take the first (and only) level 554 | let level = &mbp1.levels[0]; 555 | 556 | writer.write_record(&[ 557 | mbp1.ts_recv.to_string(), 558 | mbp1.sequence.to_string(), 559 | mbp1.flags.to_string(), 560 | ((level.bid_px as f64) * scaling_factor).to_string(), 561 | ((level.ask_px as f64) * scaling_factor).to_string(), 562 | level.bid_sz.to_string(), 563 | level.ask_sz.to_string(), 564 | level.bid_ct.to_string(), 565 | level.ask_ct.to_string(), 566 | ])?; 567 | } 568 | } 569 | 570 | Schema::Mbp10 => { 571 | writer.write_record(&["ts_recv", "sequence", "flags", "level_0_bid_price", "level_0_bid_size", "level_0_bid_count", "level_0_ask_price", "level_0_ask_size", "level_0_ask_count", "level_1_bid_price", "level_1_bid_size", "level_1_bid_count", "level_1_ask_price", "level_1_ask_size", "level_1_ask_count", "level_2_bid_price", "level_2_bid_size", "level_2_bid_count", "level_2_ask_price", "level_2_ask_size", "level_2_ask_count", "level_3_bid_price", "level_3_bid_size", "level_3_bid_count", "level_3_ask_price", "level_3_ask_size", "level_3_ask_count", "level_4_bid_price", "level_4_bid_size", "level_4_bid_count", "level_4_ask_price", "level_4_ask_size", "level_4_ask_count", "level_5_bid_price", "level_5_bid_size", "level_5_bid_count", "level_5_ask_price", "level_5_ask_size", "level_5_ask_count", "level_6_bid_price", "level_6_bid_size", "level_6_bid_count", "level_6_ask_price", "level_6_ask_size", "level_6_ask_count", "level_7_bid_price", "level_7_bid_size", "level_7_bid_count", "level_7_ask_price", "level_7_ask_size", "level_7_ask_count", "level_8_bid_price", "level_8_bid_size", "level_8_bid_count", "level_8_ask_price", "level_8_ask_size", "level_8_ask_count", "level_9_bid_price", "level_9_bid_size", "level_9_bid_count", "level_9_ask_price", "level_9_ask_size", "level_9_ask_count"])?; 572 | 573 | let mut decoder = decoder.unwrap(); 574 | while let Some(mbp10) = decoder.decode_record::().await? { 575 | let mut record = vec![ 576 | mbp10.ts_recv.to_string(), 577 | mbp10.sequence.to_string(), 578 | mbp10.flags.to_string(), 579 | ]; 580 | 581 | // Iterate through all 10 levels (0-9 for bids, 10-19 for asks typically) 582 | for i in 0..10 { 583 | let level = &mbp10.levels[i]; 584 | 585 | record.push(((level.bid_px as f64) * scaling_factor).to_string()); 586 | record.push(level.bid_sz.to_string()); 587 | record.push(level.bid_ct.to_string()); 588 | record.push(((level.ask_px as f64) * scaling_factor).to_string()); 589 | record.push(level.ask_sz.to_string()); 590 | record.push(level.ask_ct.to_string()); 591 | } 592 | 593 | writer.write_record(&record)?; 594 | } 595 | } 596 | 597 | Schema::Tbbo => { 598 | writer.write_record(&["ts_event", "sequence", "flags", "bid_price", "ask_price", "bid_size", "ask_size", "bid_count", "ask_count"])?; 599 | 600 | let mut decoder = decoder.unwrap(); 601 | while let Some(tbbo) = decoder.decode_record::().await? { 602 | 603 | let level = &tbbo.levels[0]; 604 | 605 | writer.write_record(&[ 606 | tbbo.ts_recv.to_string(), 607 | tbbo.sequence.to_string(), 608 | tbbo.flags.to_string(), 609 | ((level.bid_px as f64) * scaling_factor).to_string(), 610 | ((level.ask_px as f64) * scaling_factor).to_string(), 611 | level.bid_sz.to_string(), 612 | level.ask_sz.to_string(), 613 | level.bid_ct.to_string(), 614 | level.ask_ct.to_string(), 615 | ])?; 616 | } 617 | } 618 | 619 | Schema::Trades => { 620 | writer.write_record(&["ts_event", "sequence", "flags", "price", "size", "action", "side"])?; 621 | 622 | let mut decoder = decoder.unwrap(); 623 | while let Some(trade) = decoder.decode_record::().await? { 624 | writer.write_record(&[ 625 | trade.ts_recv.to_string(), 626 | trade.sequence.to_string(), 627 | trade.flags.to_string(), 628 | ((trade.price as f64) * scaling_factor).to_string(), 629 | trade.size.to_string(), 630 | trade.action.to_string(), 631 | trade.side.to_string(), 632 | ])?; 633 | } 634 | } 635 | 636 | Schema::Statistics => { 637 | writer.write_record(&["ts_event","sequence", "stat_type", "ts_ref"])?; 638 | 639 | let mut decoder = decoder.unwrap(); 640 | while let Some(stat) = decoder.decode_record::().await? { 641 | writer.write_record(&[ 642 | stat.ts_recv.to_string(), 643 | stat.ts_recv.to_string(), 644 | stat.sequence.to_string(), 645 | stat.stat_type.to_string(), 646 | stat.ts_ref.to_string(), 647 | ])?; 648 | } 649 | } 650 | 651 | Schema::Imbalance => { 652 | writer.write_record(&["ts_event", "ref_price", "cont_book_clr_price", "auct_interest_clr_price", "ssr_filling_price", "ind_match_price", "upper_collar", "lower_collar", "paired_qty", "total_imbalance_qty", "market_imbalance_qty", "unpaired_qty", "auction_type", "side", "auction_status", "freeze_status", "num_extensions", "unpaired_side", "significant_imbalance"])?; 653 | 654 | let mut decoder = decoder.unwrap(); 655 | while let Some(imbalance) = decoder.decode_record::().await? { 656 | writer.write_record(&[ 657 | imbalance.ts_recv.to_string(), 658 | ((imbalance.ref_price as f64) * scaling_factor).to_string(), 659 | ((imbalance.cont_book_clr_price as f64) * scaling_factor).to_string(), 660 | ((imbalance.auct_interest_clr_price as f64) * scaling_factor).to_string(), 661 | ((imbalance.ssr_filling_price as f64) * scaling_factor).to_string(), 662 | ((imbalance.ind_match_price as f64) * scaling_factor).to_string(), 663 | imbalance.upper_collar.to_string(), 664 | imbalance.lower_collar.to_string(), 665 | imbalance.paired_qty.to_string(), 666 | imbalance.total_imbalance_qty.to_string(), 667 | imbalance.market_imbalance_qty.to_string(), 668 | imbalance.unpaired_qty.to_string(), 669 | imbalance.auction_type.to_string(), 670 | imbalance.side.to_string(), 671 | imbalance.auction_status.to_string(), 672 | imbalance.freeze_status.to_string(), 673 | imbalance.num_extensions.to_string(), 674 | imbalance.unpaired_side.to_string(), 675 | imbalance.significant_imbalance.to_string(), 676 | ])?; 677 | } 678 | } 679 | 680 | Schema::Cbbo | Schema::Cbbo1S | Schema::Cbbo1M => { 681 | writer.write_record(&["ts_event", "sequence", "flags", "bid_price", "ask_price", "bid_size", "ask_size", "bid_pb", "ask_pb"])?; 682 | 683 | let mut decoder = decoder.unwrap(); 684 | while let Some(cbbo) = decoder.decode_record::().await? { 685 | let level = &cbbo.levels[0]; 686 | 687 | writer.write_record(&[ 688 | cbbo.ts_recv.to_string(), 689 | cbbo.sequence.to_string(), 690 | cbbo.flags.to_string(), 691 | ((level.bid_px as f64) * scaling_factor).to_string(), 692 | ((level.ask_px as f64) * scaling_factor).to_string(), 693 | level.bid_sz.to_string(), 694 | level.ask_sz.to_string(), 695 | level.bid_pb.to_string(), 696 | level.ask_pb.to_string(), 697 | ])?; 698 | } 699 | } 700 | 701 | Schema::Tcbbo => { 702 | writer.write_record(&["ts_event", "sequence", "flags", "bid_price", "ask_price", "bid_size", "ask_size", "bid_pb", "ask_pb"])?; 703 | 704 | let mut decoder = decoder.unwrap(); 705 | while let Some(tcbbo) = decoder.decode_record::().await? { 706 | let level = &tcbbo.levels[0]; 707 | 708 | writer.write_record(&[ 709 | tcbbo.ts_recv.to_string(), 710 | tcbbo.sequence.to_string(), 711 | tcbbo.flags.to_string(), 712 | ((level.bid_px as f64) * scaling_factor).to_string(), 713 | ((level.ask_px as f64) * scaling_factor).to_string(), 714 | level.bid_sz.to_string(), 715 | level.ask_sz.to_string(), 716 | level.bid_pb.to_string(), 717 | level.ask_pb.to_string(), 718 | ])?; 719 | } 720 | } 721 | 722 | Schema::Bbo1S | Schema::Bbo1M => { 723 | writer.write_record(&["ts_event", "sequence", "flags", "bid_price", "ask_price", "bid_size", "ask_size", "bid_pb", "ask_pb"])?; 724 | 725 | let mut decoder = decoder.unwrap(); 726 | while let Some(bbo) = decoder.decode_record::().await? { 727 | let level = &bbo.levels[0]; 728 | 729 | writer.write_record(&[ 730 | bbo.ts_recv.to_string(), 731 | bbo.sequence.to_string(), 732 | bbo.flags.to_string(), 733 | ((level.bid_px as f64) * scaling_factor).to_string(), 734 | ((level.ask_px as f64) * scaling_factor).to_string(), 735 | level.bid_sz.to_string(), 736 | level.ask_sz.to_string(), 737 | level.bid_pb.to_string(), 738 | level.ask_pb.to_string(), 739 | ])?; 740 | } 741 | } 742 | 743 | Schema::Status | Schema::Definition => { 744 | writer.write_record(&["message"])?; 745 | writer.write_record(&["Definition schema not fully implemented - requires DefinitionMsg type"])?; 746 | } 747 | 748 | } 749 | } 750 | 751 | writer.flush()?; 752 | println!("Saved CSV: {filename}"); 753 | Ok(filename) 754 | } 755 | 756 | // Helper function to find most recent underlying data (equivalent to merge_asof) 757 | fn find_most_recent_underlying(underlying_data: &[(u64, f64, f64, f64, f64)], target_time: u64) -> (f64, f64, f64, f64) { 758 | let (bid, ask, bid_size, ask_size) = match underlying_data.binary_search_by_key(&target_time, |&(ts, _, _, _, _)| ts) { 759 | Ok(index) => (underlying_data[index].1, underlying_data[index].2, underlying_data[index].3, underlying_data[index].4), 760 | Err(index) => { 761 | if index == 0 { 762 | (0.0, 0.0, 0.0, 0.0) // No data before this time 763 | } else { 764 | (underlying_data[index - 1].1, underlying_data[index - 1].2, underlying_data[index - 1].3, underlying_data[index - 1].4) 765 | } 766 | } 767 | }; 768 | 769 | // Additional safety check for invalid values that might have slipped through 770 | if bid.is_finite() && ask.is_finite() && bid > 0.0 && ask > 0.0 && bid < 1e7 && ask < 1e7 && 771 | bid_size.is_finite() && ask_size.is_finite() && bid_size >= 0.0 && ask_size >= 0.0 && bid_size < 1e7 && ask_size < 1e7 { 772 | (bid, ask, bid_size, ask_size) 773 | } else { 774 | // Return reasonable fallback or previous good value 775 | (0.0, 0.0, 0.0, 0.0) 776 | } 777 | } 778 | 779 | #[derive(Debug)] 780 | struct FootprintBar { 781 | open: f64, 782 | high: f64, 783 | low: f64, 784 | close: f64, 785 | volume: u64, 786 | footprint_data: String, 787 | } 788 | 789 | fn process_footprint_bar(trades: &[TradeMsg], scaling_factor: f64) -> FootprintBar { 790 | use std::collections::HashMap; 791 | 792 | if trades.is_empty() { 793 | return FootprintBar { 794 | open: 0.0, 795 | high: 0.0, 796 | low: 0.0, 797 | close: 0.0, 798 | volume: 0, 799 | footprint_data: "{}".to_string(), 800 | }; 801 | } 802 | 803 | // Calculate OHLCV 804 | let first_price = (trades[0].price as f64) * scaling_factor; 805 | let last_price = (trades[trades.len() - 1].price as f64) * scaling_factor; 806 | 807 | let mut high = first_price; 808 | let mut low = first_price; 809 | let mut total_volume = 0u64; 810 | 811 | // Map to store footprint data: price -> (buy_volume, sell_volume) 812 | let mut footprint_map: HashMap = HashMap::new(); 813 | 814 | for trade in trades { 815 | let price = (trade.price as f64) * scaling_factor; 816 | let size = trade.size; 817 | 818 | // Update OHLC 819 | if price > high { high = price; } 820 | if price < low { low = price; } 821 | total_volume += size as u64; 822 | 823 | // Determine if trade is buy or sell 824 | // In your data, side 66 = 'B' (buy), side 83 = 'S' (sell) 825 | // side 65 = 'A' (ask/sell), side 78 = 'N' (unknown - we'll ignore) 826 | let price_key = format!("{:.4}", price); 827 | let entry = footprint_map.entry(price_key).or_insert((0, 0)); 828 | 829 | match trade.side { 830 | 66 => entry.0 += size as u64, // Buy side 831 | 65 | 83 => entry.1 += size as u64, // Sell side (Ask or Sell) 832 | _ => {} // Ignore other sides (like 'N') 833 | } 834 | } 835 | 836 | // Convert footprint map to JSON string 837 | let footprint_json = serde_json::to_string(&footprint_map).unwrap_or_else(|_| "{}".to_string()); 838 | 839 | FootprintBar { 840 | open: first_price, 841 | high, 842 | low, 843 | close: last_price, 844 | volume: total_volume, 845 | footprint_data: footprint_json, 846 | } 847 | } 848 | --------------------------------------------------------------------------------