diff --git a/Cargo.lock b/Cargo.lock index e8471f5..d6b508d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -502,7 +502,7 @@ dependencies = [ [[package]] name = "raptorbt" -version = "0.2.1" +version = "0.3.0" dependencies = [ "approx", "criterion", diff --git a/Cargo.toml b/Cargo.toml index 9633fdc..600b1f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "raptorbt" -version = "0.2.2" +version = "0.3.0" edition = "2021" description = "High-performance Rust backtesting engine with Python bindings. Drop-in VectorBT replacement with up insanely faster performance at fractional memory footprint." authors = ["Alphabench "] diff --git a/README.md b/README.md index 0fda813..85a9e15 100644 --- a/README.md +++ b/README.md @@ -265,6 +265,9 @@ trades = result.trades() # Returns list of PyTrade objects Basic long or short strategy on a single instrument. ```python +# Optional: Instrument-specific configuration +inst_config = raptorbt.PyInstrumentConfig(lot_size=1.0) + result = raptorbt.run_single_backtest( timestamps=timestamps, open=open_prices, high=high_prices, low=low_prices, @@ -274,6 +277,7 @@ result = raptorbt.run_single_backtest( weight=1.0, symbol="SYMBOL", config=config, + instrument_config=inst_config, # Optional: lot_size rounding, capital caps ) ``` @@ -288,10 +292,18 @@ instruments = [ (timestamps, open3, high3, low3, close3, volume3, entries3, exits3, 1, 0.34, "MSFT"), ] +# Optional: Per-instrument configs for lot_size and capital allocation +instrument_configs = { + "AAPL": raptorbt.PyInstrumentConfig(lot_size=1.0, alloted_capital=33000), + "GOOGL": raptorbt.PyInstrumentConfig(lot_size=1.0, alloted_capital=33000), + "MSFT": raptorbt.PyInstrumentConfig(lot_size=1.0, alloted_capital=34000), +} + result = raptorbt.run_basket_backtest( instruments=instruments, config=config, sync_mode="all", # "all", "any", "majority", "master" + instrument_configs=instrument_configs, # Optional ) ``` @@ -612,6 +624,29 @@ config.set_atr_target(multiplier: float, period: int) config.set_risk_reward_target(ratio: float) ``` +### PyInstrumentConfig + +Per-instrument configuration for position sizing and risk management. + +```python +inst_config = raptorbt.PyInstrumentConfig( + lot_size=1.0, # Min tradeable quantity (1 for equity, 50 for NIFTY F&O) + alloted_capital=50000.0, # Capital allocated to this instrument (optional) + existing_qty=None, # Existing position quantity (future use) + avg_price=None, # Existing position avg price (future use) +) + +# Optional: per-instrument stop/target overrides +inst_config.set_fixed_stop(0.02) +inst_config.set_trailing_stop(0.03) +inst_config.set_fixed_target(0.05) +``` + +**Fields:** +- `lot_size` - Minimum tradeable quantity. Position sizes are rounded down to nearest lot_size multiple. Use `1.0` for equities, `50.0` for NIFTY F&O, `0.01` for forex. +- `alloted_capital` - Per-instrument capital cap (capped at available cash). +- `existing_qty` / `avg_price` - Reserved for future live-to-backtest transitions. + ### PyBacktestResult ```python @@ -798,6 +833,32 @@ MIT License - see [LICENSE](LICENSE) for details. ## Changelog +### v0.3.0 + +- Per-instrument configuration via `PyInstrumentConfig` (lot_size, alloted_capital, stop/target overrides) +- Position sizes now correctly rounded to lot_size multiples +- Support for per-instrument capital allocation in basket backtests +- Future-ready fields: existing_qty, avg_price for live-to-backtest transitions + +### v0.2.2 + +- Export `run_spread_backtest` Python binding for multi-leg options spread strategies +- Export `rolling_min` and `rolling_max` indicator functions to Python + +### v0.2.1 + +- Add `rolling_min` and `rolling_max` indicators for LLV (Lowest Low Value) and HHV (Highest High Value) support +- NaN handling for warmup period + +### v0.2.0 + +- Add multi-leg spread backtesting (`run_spread_backtest`) supporting straddles, strangles, vertical spreads, iron condors, iron butterflies, butterfly spreads, calendar spreads, and diagonal spreads +- Coordinated entry/exit across all legs with net premium P&L calculation +- Max loss and target profit exit thresholds for spreads +- Add `SessionTracker` for intraday session management: market hours detection, squareoff time enforcement, session high/low/open tracking +- Pre-built session configs for NSE equity (9:15-15:30), MCX commodity (9:00-23:30), and CDS currency (9:00-17:00) +- Extend `StreamingMetrics` with equity/drawdown tracking, trade recording, and `finalize()` method + ### v0.1.0 - Initial release diff --git a/pyproject.toml b/pyproject.toml index 5765668..97b7450 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "raptorbt" -version = "0.2.2" +version = "0.3.0" description = "High-performance Rust backtesting engine with Python bindings. Drop-in VectorBT replacement with up insanely faster performance at fractional memory footprint." readme = "README.md" requires-python = ">=3.10" diff --git a/python/raptorbt/__init__.py b/python/raptorbt/__init__.py index 5507a7c..3c704c0 100644 --- a/python/raptorbt/__init__.py +++ b/python/raptorbt/__init__.py @@ -12,6 +12,7 @@ offering significant performance improvements over vectorbt: from raptorbt._raptorbt import ( # Config classes PyBacktestConfig, + PyInstrumentConfig, PyStopConfig, PyTargetConfig, # Result classes @@ -40,11 +41,12 @@ from raptorbt._raptorbt import ( rolling_max, ) -__version__ = "0.2.2" +__version__ = "0.3.0" __all__ = [ # Config classes "PyBacktestConfig", + "PyInstrumentConfig", "PyStopConfig", "PyTargetConfig", # Result classes diff --git a/python/raptorbt/__pycache__/__init__.cpython-311.pyc b/python/raptorbt/__pycache__/__init__.cpython-311.pyc index a5696f1..8ddd478 100644 Binary files a/python/raptorbt/__pycache__/__init__.cpython-311.pyc and b/python/raptorbt/__pycache__/__init__.cpython-311.pyc differ diff --git a/python/raptorbt/_raptorbt.cpython-311-darwin.so b/python/raptorbt/_raptorbt.cpython-311-darwin.so index 00ee130..c6f8b56 100755 Binary files a/python/raptorbt/_raptorbt.cpython-311-darwin.so and b/python/raptorbt/_raptorbt.cpython-311-darwin.so differ diff --git a/src/core/types.rs b/src/core/types.rs index 34a6d4b..79f4c28 100644 --- a/src/core/types.rs +++ b/src/core/types.rs @@ -244,6 +244,47 @@ impl Default for BacktestConfig { } } +/// Per-instrument configuration for position sizing and risk management. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstrumentConfig { + /// Minimum tradeable quantity (1.0 for NSE EQ, 50.0 for NIFTY F&O, 0.01 for forex). + pub lot_size: Option, + /// Per-instrument capital cap. + pub alloted_capital: Option, + /// Per-instrument stop override. + pub stop: Option, + /// Per-instrument target override. + pub target: Option, + /// Existing position quantity (future use). + pub existing_qty: Option, + /// Existing position average price (future use). + pub avg_price: Option, +} + +impl InstrumentConfig { + /// Round a raw position size down to the nearest lot_size multiple. + /// Returns raw_size unchanged if lot_size is None or <= 0. + pub fn round_to_lot(&self, raw_size: f64) -> f64 { + match self.lot_size { + Some(lot) if lot > 0.0 => (raw_size / lot).floor() * lot, + _ => raw_size, + } + } +} + +impl Default for InstrumentConfig { + fn default() -> Self { + Self { + lot_size: None, + alloted_capital: None, + stop: None, + target: None, + existing_qty: None, + avg_price: None, + } + } +} + /// Stop-loss configuration. #[derive(Debug, Clone, Copy, Serialize, Deserialize)] pub enum StopConfig { @@ -460,3 +501,49 @@ impl Default for Position { Self::new() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_round_to_lot_whole_shares() { + let config = InstrumentConfig { lot_size: Some(1.0), ..Default::default() }; + assert_eq!(config.round_to_lot(242.47), 242.0); + assert_eq!(config.round_to_lot(1.0), 1.0); + assert_eq!(config.round_to_lot(0.5), 0.0); + } + + #[test] + fn test_round_to_lot_nifty_fo() { + let config = InstrumentConfig { lot_size: Some(50.0), ..Default::default() }; + assert_eq!(config.round_to_lot(242.0), 200.0); + assert_eq!(config.round_to_lot(50.0), 50.0); + assert_eq!(config.round_to_lot(49.0), 0.0); + assert_eq!(config.round_to_lot(150.0), 150.0); + } + + #[test] + fn test_round_to_lot_fractional() { + let config = InstrumentConfig { lot_size: Some(0.01), ..Default::default() }; + assert!((config.round_to_lot(1.234) - 1.23).abs() < 1e-10); + } + + #[test] + fn test_round_to_lot_none() { + let config = InstrumentConfig::default(); + assert_eq!(config.round_to_lot(242.47), 242.47); + } + + #[test] + fn test_round_to_lot_zero() { + let config = InstrumentConfig { lot_size: Some(0.0), ..Default::default() }; + assert_eq!(config.round_to_lot(242.47), 242.47); + } + + #[test] + fn test_round_to_lot_negative() { + let config = InstrumentConfig { lot_size: Some(-1.0), ..Default::default() }; + assert_eq!(config.round_to_lot(242.47), 242.47); + } +} diff --git a/src/lib.rs b/src/lib.rs index b116fc6..59aa21d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,7 @@ pub mod strategies; fn _raptorbt(_py: Python<'_>, m: &PyModule) -> PyResult<()> { // Register config classes m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/src/portfolio/engine.rs b/src/portfolio/engine.rs index 3046f34..f587af5 100644 --- a/src/portfolio/engine.rs +++ b/src/portfolio/engine.rs @@ -2,7 +2,7 @@ use crate::core::types::{ BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, Direction, ExitReason, - OhlcvData, Price, StopConfig, TargetConfig, Trade, + InstrumentConfig, OhlcvData, Price, StopConfig, TargetConfig, Trade, }; use crate::execution::{FeeModel, FillPrice, SlippageModel}; use crate::indicators::volatility::atr; @@ -69,6 +69,24 @@ impl PortfolioEngine { /// # Returns /// Backtest result pub fn run_single(&self, ohlcv: &OhlcvData, signals: &CompiledSignals) -> BacktestResult { + self.run_single_with_instrument_config(ohlcv, signals, None) + } + + /// Run backtest on single instrument with optional per-instrument configuration. + /// + /// # Arguments + /// * `ohlcv` - OHLCV data + /// * `signals` - Compiled trading signals + /// * `inst_config` - Optional per-instrument config (lot_size, capital cap, stop/target overrides) + /// + /// # Returns + /// Backtest result + pub fn run_single_with_instrument_config( + &self, + ohlcv: &OhlcvData, + signals: &CompiledSignals, + inst_config: Option<&InstrumentConfig>, + ) -> BacktestResult { let n = ohlcv.len(); assert_eq!(n, signals.len(), "OHLCV and signals must have same length"); @@ -86,14 +104,20 @@ impl PortfolioEngine { let mut streaming = StreamingMetrics::new(); let mut peak_equity = cash; + // Determine effective stop/target configs (per-instrument overrides take precedence) + let effective_stop = + inst_config.and_then(|ic| ic.stop.as_ref()).unwrap_or(&self.config.stop); + let effective_target = + inst_config.and_then(|ic| ic.target.as_ref()).unwrap_or(&self.config.target); + // Pre-calculate ATR for ATR-based stops - let atr_values = if matches!(self.config.stop, StopConfig::Atr { .. }) - || matches!(self.config.target, TargetConfig::Atr { .. }) + let atr_values = if matches!(effective_stop, StopConfig::Atr { .. }) + || matches!(effective_target, TargetConfig::Atr { .. }) { - let period = match self.config.stop { - StopConfig::Atr { period, .. } => period, - _ => match self.config.target { - TargetConfig::Atr { period, .. } => period, + let period = match effective_stop { + StopConfig::Atr { period, .. } => *period, + _ => match effective_target { + TargetConfig::Atr { period, .. } => *period, _ => 14, }, }; @@ -188,8 +212,8 @@ impl PortfolioEngine { // Update trailing stop if position still open if position.is_in_position() { - if let StopConfig::Trailing { percent } = self.config.stop { - position.update_trailing_stop(percent); + if let StopConfig::Trailing { percent } = effective_stop { + position.update_trailing_stop(*percent); } } } @@ -207,26 +231,37 @@ impl PortfolioEngine { ); // Calculate position size + // Use per-instrument capital if set, capped at available cash + let available = inst_config + .and_then(|ic| ic.alloted_capital) + .map(|cap| cap.min(cash)) + .unwrap_or(cash); + // VectorBT formula: size = cash / (price * (1 + fees)) // This ensures the position value plus entry fee equals available cash let fee_rate = self.config.fees; - let size = if let Some(ref sizes) = signals.position_sizes { - sizes[i] * cash / (adjusted_price * (1.0 + fee_rate)) + let raw_size = if let Some(ref sizes) = signals.position_sizes { + sizes[i] * available / (adjusted_price * (1.0 + fee_rate)) } else { - cash / (adjusted_price * (1.0 + fee_rate)) + available / (adjusted_price * (1.0 + fee_rate)) }; + // Round to lot_size + let size = inst_config.map(|ic| ic.round_to_lot(raw_size)).unwrap_or(raw_size); + if size > 0.0 { // Calculate entry fees let entry_fees = self.fee_model.calculate(adjusted_price, size, signals.direction); // Calculate stop and target prices - let (stop_price, target_price) = self.calculate_stop_target( + let (stop_price, target_price) = self.calculate_stop_target_with_config( adjusted_price, signals.direction, &atr_values, i, + effective_stop, + effective_target, ); // Open position (passing entry_fees for trade PnL tracking) @@ -310,18 +345,39 @@ impl PortfolioEngine { ) } - /// Calculate stop and target prices. + /// Calculate stop and target prices using the global config. + #[allow(dead_code)] fn calculate_stop_target( &self, entry_price: Price, direction: Direction, atr_values: &[f64], idx: usize, + ) -> (Option, Option) { + self.calculate_stop_target_with_config( + entry_price, + direction, + atr_values, + idx, + &self.config.stop, + &self.config.target, + ) + } + + /// Calculate stop and target prices with explicit stop/target configs. + fn calculate_stop_target_with_config( + &self, + entry_price: Price, + direction: Direction, + atr_values: &[f64], + idx: usize, + stop_config: &StopConfig, + target_config: &TargetConfig, ) -> (Option, Option) { let multiplier = direction.multiplier(); // Calculate stop price - let stop_price = match self.config.stop { + let stop_price = match stop_config { StopConfig::None => None, StopConfig::Fixed { percent } => Some(entry_price * (1.0 - multiplier * percent)), StopConfig::Atr { multiplier: m, .. } => { @@ -336,7 +392,7 @@ impl PortfolioEngine { }; // Calculate target price - let target_price = match self.config.target { + let target_price = match target_config { TargetConfig::None => None, TargetConfig::Fixed { percent } => Some(entry_price * (1.0 + multiplier * percent)), TargetConfig::Atr { multiplier: m, .. } => { diff --git a/src/python/bindings.rs b/src/python/bindings.rs index 4fca545..f6f0ca7 100644 --- a/src/python/bindings.rs +++ b/src/python/bindings.rs @@ -3,8 +3,11 @@ use numpy::{PyArray1, PyReadonlyArray1}; use pyo3::prelude::*; +use std::collections::HashMap; + use crate::core::types::{ - BacktestConfig, CompiledSignals, Direction, OhlcvData, StopConfig, TargetConfig, + BacktestConfig, CompiledSignals, Direction, InstrumentConfig, OhlcvData, StopConfig, + TargetConfig, }; use crate::indicators; use crate::signals::synchronizer::SyncMode; @@ -100,6 +103,93 @@ impl From<&PyBacktestConfig> for BacktestConfig { } } +/// Python-exposed per-instrument configuration. +#[pyclass] +#[derive(Debug, Clone)] +pub struct PyInstrumentConfig { + #[pyo3(get, set)] + pub lot_size: Option, + #[pyo3(get, set)] + pub alloted_capital: Option, + #[pyo3(get, set)] + pub existing_qty: Option, + #[pyo3(get, set)] + pub avg_price: Option, + stop_config: Option, + target_config: Option, +} + +#[pymethods] +impl PyInstrumentConfig { + #[new] + #[pyo3(signature = (lot_size=None, alloted_capital=None, existing_qty=None, avg_price=None))] + fn new( + lot_size: Option, + alloted_capital: Option, + existing_qty: Option, + avg_price: Option, + ) -> Self { + Self { + lot_size, + alloted_capital, + existing_qty, + avg_price, + stop_config: None, + target_config: None, + } + } + + /// Set fixed percentage stop-loss override. + fn set_fixed_stop(&mut self, percent: f64) { + self.stop_config = Some(StopConfig::Fixed { percent }); + } + + /// Set ATR-based stop-loss override. + fn set_atr_stop(&mut self, multiplier: f64, period: usize) { + self.stop_config = Some(StopConfig::Atr { multiplier, period }); + } + + /// Set trailing stop-loss override. + fn set_trailing_stop(&mut self, percent: f64) { + self.stop_config = Some(StopConfig::Trailing { percent }); + } + + /// Set fixed percentage take-profit override. + fn set_fixed_target(&mut self, percent: f64) { + self.target_config = Some(TargetConfig::Fixed { percent }); + } + + /// Set ATR-based take-profit override. + fn set_atr_target(&mut self, multiplier: f64, period: usize) { + self.target_config = Some(TargetConfig::Atr { multiplier, period }); + } + + /// Set risk-reward based take-profit override. + fn set_risk_reward_target(&mut self, ratio: f64) { + self.target_config = Some(TargetConfig::RiskReward { ratio }); + } + + fn __repr__(&self) -> String { + format!( + "InstrumentConfig(lot_size={:?}, alloted_capital={:?})", + self.lot_size, self.alloted_capital + ) + } +} + +impl From<&PyInstrumentConfig> for InstrumentConfig { + fn from(py_config: &PyInstrumentConfig) -> Self { + InstrumentConfig { + lot_size: py_config.lot_size, + alloted_capital: py_config.alloted_capital, + stop: py_config.stop_config, + target: py_config.target_config, + existing_qty: py_config.existing_qty, + avg_price: py_config.avg_price, + } + } +} + /// Python-exposed stop configuration. #[pyclass] #[derive(Debug, Clone)] @@ -419,7 +509,7 @@ impl PyBacktestResult { /// Run single instrument backtest. #[pyfunction] -#[pyo3(signature = (timestamps, open, high, low, close, volume, entries, exits, direction=1, weight=1.0, symbol="UNKNOWN", config=None, position_sizes=None))] +#[pyo3(signature = (timestamps, open, high, low, close, volume, entries, exits, direction=1, weight=1.0, symbol="UNKNOWN", config=None, position_sizes=None, instrument_config=None))] pub fn run_single_backtest<'py>( _py: Python<'py>, timestamps: PyReadonlyArray1, @@ -435,6 +525,7 @@ pub fn run_single_backtest<'py>( symbol: &str, config: Option<&PyBacktestConfig>, position_sizes: Option>, + instrument_config: Option<&PyInstrumentConfig>, ) -> PyResult { let ohlcv = OhlcvData { timestamps: numpy_to_vec_i64(timestamps), @@ -457,16 +548,17 @@ pub fn run_single_backtest<'py>( }; let rust_config = config.map(|c| BacktestConfig::from(c)).unwrap_or_default(); + let inst_config = instrument_config.map(InstrumentConfig::from); let backtest = SingleBacktest::new(rust_config); - let result = backtest.run(&ohlcv, &signals); + let result = backtest.run_with_instrument_config(&ohlcv, &signals, inst_config.as_ref()); Ok(convert_result(result)) } /// Run basket/collective backtest. #[pyfunction] -#[pyo3(signature = (instruments, config=None, sync_mode="all"))] +#[pyo3(signature = (instruments, config=None, sync_mode="all", instrument_configs=None))] pub fn run_basket_backtest<'py>( _py: Python<'py>, instruments: Vec<( @@ -484,6 +576,7 @@ pub fn run_basket_backtest<'py>( )>, config: Option<&PyBacktestConfig>, sync_mode: &str, + instrument_configs: Option>, ) -> PyResult { let rust_instruments: Vec<(OhlcvData, CompiledSignals)> = instruments .into_iter() @@ -521,8 +614,15 @@ pub fn run_basket_backtest<'py>( ..Default::default() }; + // Convert PyInstrumentConfig map to InstrumentConfig map + let rust_inst_configs: Option> = + instrument_configs.map(|configs| { + configs.iter().map(|(k, v)| (k.clone(), InstrumentConfig::from(v))).collect() + }); + let backtest = BasketBacktest::new(basket_config); - let result = backtest.run(&rust_instruments); + let result = + backtest.run_with_instrument_configs(&rust_instruments, rust_inst_configs.as_ref()); Ok(convert_result(result)) } diff --git a/src/strategies/basket.rs b/src/strategies/basket.rs index 50e07f0..4e8985a 100644 --- a/src/strategies/basket.rs +++ b/src/strategies/basket.rs @@ -2,8 +2,11 @@ //! //! Supports multiple instruments with synchronized signals. +use std::collections::HashMap; + use crate::core::types::{ - BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, ExitReason, OhlcvData, Trade, + BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, ExitReason, InstrumentConfig, + OhlcvData, Trade, }; use crate::execution::FeeModel; use crate::metrics::streaming::StreamingMetrics; @@ -74,6 +77,22 @@ impl BasketBacktest { /// # Returns /// Combined backtest result pub fn run(&self, instruments: &[(OhlcvData, CompiledSignals)]) -> BacktestResult { + self.run_with_instrument_configs(instruments, None) + } + + /// Run basket backtest with optional per-instrument configurations. + /// + /// # Arguments + /// * `instruments` - Vector of (OhlcvData, CompiledSignals) pairs for each instrument + /// * `instrument_configs` - Optional map of symbol -> InstrumentConfig + /// + /// # Returns + /// Combined backtest result + pub fn run_with_instrument_configs( + &self, + instruments: &[(OhlcvData, CompiledSignals)], + instrument_configs: Option<&HashMap>, + ) -> BacktestResult { if instruments.is_empty() { return self.empty_result(); } @@ -168,7 +187,15 @@ impl BasketBacktest { // Calculate position sizes let prices: Vec = instruments.iter().map(|(o, _)| o.close[i]).collect(); let weights: Vec = instruments.iter().map(|(_, s)| s.weight).collect(); - let sizes = self.calculate_sizes(&prices, &weights, cash); + let symbols: Vec<&str> = + instruments.iter().map(|(_, s)| s.symbol.as_str()).collect(); + let sizes = self.calculate_sizes_with_configs( + &prices, + &weights, + cash, + &symbols, + instrument_configs, + ); // Enter positions for (inst_idx, (ohlcv, signals)) in instruments.iter().enumerate() { @@ -249,7 +276,21 @@ impl BasketBacktest { } /// Calculate position sizes for each instrument. + #[allow(dead_code)] fn calculate_sizes(&self, prices: &[f64], weights: &[f64], available_capital: f64) -> Vec { + let symbols: Vec<&str> = vec![""; prices.len()]; + self.calculate_sizes_with_configs(prices, weights, available_capital, &symbols, None) + } + + /// Calculate position sizes with optional per-instrument config (lot_size rounding, capital caps). + fn calculate_sizes_with_configs( + &self, + prices: &[f64], + weights: &[f64], + available_capital: f64, + symbols: &[&str], + instrument_configs: Option<&HashMap>, + ) -> Vec { let n = prices.len(); let total_weight: f64 = weights.iter().sum(); @@ -260,12 +301,26 @@ impl BasketBacktest { prices .iter() .zip(weights.iter()) - .map(|(&price, &weight)| { + .enumerate() + .map(|(idx, (&price, &weight))| { if price <= 0.0 { return 0.0; } - let allocation = available_capital * (weight / total_weight); - allocation / price + let default_allocation = available_capital * (weight / total_weight); + + // Use per-instrument alloted_capital if set, capped at default allocation + let inst_config = instrument_configs + .and_then(|configs| symbols.get(idx).and_then(|sym| configs.get(*sym))); + + let allocation = inst_config + .and_then(|ic| ic.alloted_capital) + .map(|cap| cap.min(default_allocation)) + .unwrap_or(default_allocation); + + let raw_size = allocation / price; + + // Round to lot_size + inst_config.map(|ic| ic.round_to_lot(raw_size)).unwrap_or(raw_size) }) .collect() } diff --git a/src/strategies/single.rs b/src/strategies/single.rs index f9b1d5a..84ef85f 100644 --- a/src/strategies/single.rs +++ b/src/strategies/single.rs @@ -1,6 +1,8 @@ //! Single instrument backtest implementation. -use crate::core::types::{BacktestConfig, BacktestResult, CompiledSignals, OhlcvData}; +use crate::core::types::{ + BacktestConfig, BacktestResult, CompiledSignals, InstrumentConfig, OhlcvData, +}; use crate::portfolio::engine::PortfolioEngine; /// Single instrument backtest runner. @@ -28,6 +30,24 @@ impl SingleBacktest { self.engine.run_single(ohlcv, signals) } + /// Run the backtest with per-instrument configuration. + /// + /// # Arguments + /// * `ohlcv` - OHLCV price data + /// * `signals` - Compiled trading signals + /// * `inst_config` - Optional per-instrument config (lot_size, capital cap, stop/target overrides) + /// + /// # Returns + /// Backtest result with metrics, trades, and equity curve + pub fn run_with_instrument_config( + &self, + ohlcv: &OhlcvData, + signals: &CompiledSignals, + inst_config: Option<&InstrumentConfig>, + ) -> BacktestResult { + self.engine.run_single_with_instrument_config(ohlcv, signals, inst_config) + } + /// Run backtest from raw arrays. /// /// # Arguments