2 Commits
Author SHA1 Message Date
vatsalandGitHub a82ddc598a Merge pull request #8 from alphabench/feat/instrument-level-config
feat: add instrument level config and bump to 0.3.0
2026-02-10 05:05:38 +05:30
porcelaincode bb6ce05c57 feat: add instrument level config and bump to 0.3.0 2026-02-10 05:03:39 +05:30
13 changed files with 413 additions and 31 deletions
Generated
+1 -1
View File
@@ -502,7 +502,7 @@ dependencies = [
[[package]] [[package]]
name = "raptorbt" name = "raptorbt"
version = "0.2.1" version = "0.3.0"
dependencies = [ dependencies = [
"approx", "approx",
"criterion", "criterion",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "raptorbt" name = "raptorbt"
version = "0.2.2" version = "0.3.0"
edition = "2021" edition = "2021"
description = "High-performance Rust backtesting engine with Python bindings. Drop-in VectorBT replacement with up insanely faster performance at fractional memory footprint." description = "High-performance Rust backtesting engine with Python bindings. Drop-in VectorBT replacement with up insanely faster performance at fractional memory footprint."
authors = ["Alphabench <contact@alphabench.in>"] authors = ["Alphabench <contact@alphabench.in>"]
+61
View File
@@ -265,6 +265,9 @@ trades = result.trades() # Returns list of PyTrade objects
Basic long or short strategy on a single instrument. Basic long or short strategy on a single instrument.
```python ```python
# Optional: Instrument-specific configuration
inst_config = raptorbt.PyInstrumentConfig(lot_size=1.0)
result = raptorbt.run_single_backtest( result = raptorbt.run_single_backtest(
timestamps=timestamps, timestamps=timestamps,
open=open_prices, high=high_prices, low=low_prices, open=open_prices, high=high_prices, low=low_prices,
@@ -274,6 +277,7 @@ result = raptorbt.run_single_backtest(
weight=1.0, weight=1.0,
symbol="SYMBOL", symbol="SYMBOL",
config=config, 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"), (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( result = raptorbt.run_basket_backtest(
instruments=instruments, instruments=instruments,
config=config, config=config,
sync_mode="all", # "all", "any", "majority", "master" 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) 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 ### PyBacktestResult
```python ```python
@@ -798,6 +833,32 @@ MIT License - see [LICENSE](LICENSE) for details.
## Changelog ## 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 ### v0.1.0
- Initial release - Initial release
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "maturin"
[project] [project]
name = "raptorbt" 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." 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" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
+3 -1
View File
@@ -12,6 +12,7 @@ offering significant performance improvements over vectorbt:
from raptorbt._raptorbt import ( from raptorbt._raptorbt import (
# Config classes # Config classes
PyBacktestConfig, PyBacktestConfig,
PyInstrumentConfig,
PyStopConfig, PyStopConfig,
PyTargetConfig, PyTargetConfig,
# Result classes # Result classes
@@ -40,11 +41,12 @@ from raptorbt._raptorbt import (
rolling_max, rolling_max,
) )
__version__ = "0.2.2" __version__ = "0.3.0"
__all__ = [ __all__ = [
# Config classes # Config classes
"PyBacktestConfig", "PyBacktestConfig",
"PyInstrumentConfig",
"PyStopConfig", "PyStopConfig",
"PyTargetConfig", "PyTargetConfig",
# Result classes # Result classes
Binary file not shown.
Binary file not shown.
+87
View File
@@ -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<f64>,
/// Per-instrument capital cap.
pub alloted_capital: Option<f64>,
/// Per-instrument stop override.
pub stop: Option<StopConfig>,
/// Per-instrument target override.
pub target: Option<TargetConfig>,
/// Existing position quantity (future use).
pub existing_qty: Option<f64>,
/// Existing position average price (future use).
pub avg_price: Option<f64>,
}
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. /// Stop-loss configuration.
#[derive(Debug, Clone, Copy, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum StopConfig { pub enum StopConfig {
@@ -460,3 +501,49 @@ impl Default for Position {
Self::new() 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);
}
}
+1
View File
@@ -27,6 +27,7 @@ pub mod strategies;
fn _raptorbt(_py: Python<'_>, m: &PyModule) -> PyResult<()> { fn _raptorbt(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
// Register config classes // Register config classes
m.add_class::<python::bindings::PyBacktestConfig>()?; m.add_class::<python::bindings::PyBacktestConfig>()?;
m.add_class::<python::bindings::PyInstrumentConfig>()?;
m.add_class::<python::bindings::PyStopConfig>()?; m.add_class::<python::bindings::PyStopConfig>()?;
m.add_class::<python::bindings::PyTargetConfig>()?; m.add_class::<python::bindings::PyTargetConfig>()?;
+72 -16
View File
@@ -2,7 +2,7 @@
use crate::core::types::{ use crate::core::types::{
BacktestConfig, BacktestMetrics, BacktestResult, CompiledSignals, Direction, ExitReason, 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::execution::{FeeModel, FillPrice, SlippageModel};
use crate::indicators::volatility::atr; use crate::indicators::volatility::atr;
@@ -69,6 +69,24 @@ impl PortfolioEngine {
/// # Returns /// # Returns
/// Backtest result /// Backtest result
pub fn run_single(&self, ohlcv: &OhlcvData, signals: &CompiledSignals) -> BacktestResult { 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(); let n = ohlcv.len();
assert_eq!(n, signals.len(), "OHLCV and signals must have same length"); 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 streaming = StreamingMetrics::new();
let mut peak_equity = cash; 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 // Pre-calculate ATR for ATR-based stops
let atr_values = if matches!(self.config.stop, StopConfig::Atr { .. }) let atr_values = if matches!(effective_stop, StopConfig::Atr { .. })
|| matches!(self.config.target, TargetConfig::Atr { .. }) || matches!(effective_target, TargetConfig::Atr { .. })
{ {
let period = match self.config.stop { let period = match effective_stop {
StopConfig::Atr { period, .. } => period, StopConfig::Atr { period, .. } => *period,
_ => match self.config.target { _ => match effective_target {
TargetConfig::Atr { period, .. } => period, TargetConfig::Atr { period, .. } => *period,
_ => 14, _ => 14,
}, },
}; };
@@ -188,8 +212,8 @@ impl PortfolioEngine {
// Update trailing stop if position still open // Update trailing stop if position still open
if position.is_in_position() { if position.is_in_position() {
if let StopConfig::Trailing { percent } = self.config.stop { if let StopConfig::Trailing { percent } = effective_stop {
position.update_trailing_stop(percent); position.update_trailing_stop(*percent);
} }
} }
} }
@@ -207,26 +231,37 @@ impl PortfolioEngine {
); );
// Calculate position size // 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)) // VectorBT formula: size = cash / (price * (1 + fees))
// This ensures the position value plus entry fee equals available cash // This ensures the position value plus entry fee equals available cash
let fee_rate = self.config.fees; let fee_rate = self.config.fees;
let size = if let Some(ref sizes) = signals.position_sizes { let raw_size = if let Some(ref sizes) = signals.position_sizes {
sizes[i] * cash / (adjusted_price * (1.0 + fee_rate)) sizes[i] * available / (adjusted_price * (1.0 + fee_rate))
} else { } 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 { if size > 0.0 {
// Calculate entry fees // Calculate entry fees
let entry_fees = let entry_fees =
self.fee_model.calculate(adjusted_price, size, signals.direction); self.fee_model.calculate(adjusted_price, size, signals.direction);
// Calculate stop and target prices // 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, adjusted_price,
signals.direction, signals.direction,
&atr_values, &atr_values,
i, i,
effective_stop,
effective_target,
); );
// Open position (passing entry_fees for trade PnL tracking) // 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( fn calculate_stop_target(
&self, &self,
entry_price: Price, entry_price: Price,
direction: Direction, direction: Direction,
atr_values: &[f64], atr_values: &[f64],
idx: usize, idx: usize,
) -> (Option<Price>, Option<Price>) {
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<Price>, Option<Price>) { ) -> (Option<Price>, Option<Price>) {
let multiplier = direction.multiplier(); let multiplier = direction.multiplier();
// Calculate stop price // Calculate stop price
let stop_price = match self.config.stop { let stop_price = match stop_config {
StopConfig::None => None, StopConfig::None => None,
StopConfig::Fixed { percent } => Some(entry_price * (1.0 - multiplier * percent)), StopConfig::Fixed { percent } => Some(entry_price * (1.0 - multiplier * percent)),
StopConfig::Atr { multiplier: m, .. } => { StopConfig::Atr { multiplier: m, .. } => {
@@ -336,7 +392,7 @@ impl PortfolioEngine {
}; };
// Calculate target price // Calculate target price
let target_price = match self.config.target { let target_price = match target_config {
TargetConfig::None => None, TargetConfig::None => None,
TargetConfig::Fixed { percent } => Some(entry_price * (1.0 + multiplier * percent)), TargetConfig::Fixed { percent } => Some(entry_price * (1.0 + multiplier * percent)),
TargetConfig::Atr { multiplier: m, .. } => { TargetConfig::Atr { multiplier: m, .. } => {
+105 -5
View File
@@ -3,8 +3,11 @@
use numpy::{PyArray1, PyReadonlyArray1}; use numpy::{PyArray1, PyReadonlyArray1};
use pyo3::prelude::*; use pyo3::prelude::*;
use std::collections::HashMap;
use crate::core::types::{ use crate::core::types::{
BacktestConfig, CompiledSignals, Direction, OhlcvData, StopConfig, TargetConfig, BacktestConfig, CompiledSignals, Direction, InstrumentConfig, OhlcvData, StopConfig,
TargetConfig,
}; };
use crate::indicators; use crate::indicators;
use crate::signals::synchronizer::SyncMode; 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<f64>,
#[pyo3(get, set)]
pub alloted_capital: Option<f64>,
#[pyo3(get, set)]
pub existing_qty: Option<f64>,
#[pyo3(get, set)]
pub avg_price: Option<f64>,
stop_config: Option<StopConfig>,
target_config: Option<TargetConfig>,
}
#[pymethods]
impl PyInstrumentConfig {
#[new]
#[pyo3(signature = (lot_size=None, alloted_capital=None, existing_qty=None, avg_price=None))]
fn new(
lot_size: Option<f64>,
alloted_capital: Option<f64>,
existing_qty: Option<f64>,
avg_price: Option<f64>,
) -> 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. /// Python-exposed stop configuration.
#[pyclass] #[pyclass]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -419,7 +509,7 @@ impl PyBacktestResult {
/// Run single instrument backtest. /// Run single instrument backtest.
#[pyfunction] #[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>( pub fn run_single_backtest<'py>(
_py: Python<'py>, _py: Python<'py>,
timestamps: PyReadonlyArray1<i64>, timestamps: PyReadonlyArray1<i64>,
@@ -435,6 +525,7 @@ pub fn run_single_backtest<'py>(
symbol: &str, symbol: &str,
config: Option<&PyBacktestConfig>, config: Option<&PyBacktestConfig>,
position_sizes: Option<PyReadonlyArray1<f64>>, position_sizes: Option<PyReadonlyArray1<f64>>,
instrument_config: Option<&PyInstrumentConfig>,
) -> PyResult<PyBacktestResult> { ) -> PyResult<PyBacktestResult> {
let ohlcv = OhlcvData { let ohlcv = OhlcvData {
timestamps: numpy_to_vec_i64(timestamps), 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 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 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)) Ok(convert_result(result))
} }
/// Run basket/collective backtest. /// Run basket/collective backtest.
#[pyfunction] #[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>( pub fn run_basket_backtest<'py>(
_py: Python<'py>, _py: Python<'py>,
instruments: Vec<( instruments: Vec<(
@@ -484,6 +576,7 @@ pub fn run_basket_backtest<'py>(
)>, )>,
config: Option<&PyBacktestConfig>, config: Option<&PyBacktestConfig>,
sync_mode: &str, sync_mode: &str,
instrument_configs: Option<HashMap<String, PyInstrumentConfig>>,
) -> PyResult<PyBacktestResult> { ) -> PyResult<PyBacktestResult> {
let rust_instruments: Vec<(OhlcvData, CompiledSignals)> = instruments let rust_instruments: Vec<(OhlcvData, CompiledSignals)> = instruments
.into_iter() .into_iter()
@@ -521,8 +614,15 @@ pub fn run_basket_backtest<'py>(
..Default::default() ..Default::default()
}; };
// Convert PyInstrumentConfig map to InstrumentConfig map
let rust_inst_configs: Option<HashMap<String, InstrumentConfig>> =
instrument_configs.map(|configs| {
configs.iter().map(|(k, v)| (k.clone(), InstrumentConfig::from(v))).collect()
});
let backtest = BasketBacktest::new(basket_config); 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)) Ok(convert_result(result))
} }
+60 -5
View File
@@ -2,8 +2,11 @@
//! //!
//! Supports multiple instruments with synchronized signals. //! Supports multiple instruments with synchronized signals.
use std::collections::HashMap;
use crate::core::types::{ 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::execution::FeeModel;
use crate::metrics::streaming::StreamingMetrics; use crate::metrics::streaming::StreamingMetrics;
@@ -74,6 +77,22 @@ impl BasketBacktest {
/// # Returns /// # Returns
/// Combined backtest result /// Combined backtest result
pub fn run(&self, instruments: &[(OhlcvData, CompiledSignals)]) -> BacktestResult { 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<String, InstrumentConfig>>,
) -> BacktestResult {
if instruments.is_empty() { if instruments.is_empty() {
return self.empty_result(); return self.empty_result();
} }
@@ -168,7 +187,15 @@ impl BasketBacktest {
// Calculate position sizes // Calculate position sizes
let prices: Vec<f64> = instruments.iter().map(|(o, _)| o.close[i]).collect(); let prices: Vec<f64> = instruments.iter().map(|(o, _)| o.close[i]).collect();
let weights: Vec<f64> = instruments.iter().map(|(_, s)| s.weight).collect(); let weights: Vec<f64> = 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 // Enter positions
for (inst_idx, (ohlcv, signals)) in instruments.iter().enumerate() { for (inst_idx, (ohlcv, signals)) in instruments.iter().enumerate() {
@@ -249,7 +276,21 @@ impl BasketBacktest {
} }
/// Calculate position sizes for each instrument. /// Calculate position sizes for each instrument.
#[allow(dead_code)]
fn calculate_sizes(&self, prices: &[f64], weights: &[f64], available_capital: f64) -> Vec<f64> { fn calculate_sizes(&self, prices: &[f64], weights: &[f64], available_capital: f64) -> Vec<f64> {
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<String, InstrumentConfig>>,
) -> Vec<f64> {
let n = prices.len(); let n = prices.len();
let total_weight: f64 = weights.iter().sum(); let total_weight: f64 = weights.iter().sum();
@@ -260,12 +301,26 @@ impl BasketBacktest {
prices prices
.iter() .iter()
.zip(weights.iter()) .zip(weights.iter())
.map(|(&price, &weight)| { .enumerate()
.map(|(idx, (&price, &weight))| {
if price <= 0.0 { if price <= 0.0 {
return 0.0; return 0.0;
} }
let allocation = available_capital * (weight / total_weight); let default_allocation = available_capital * (weight / total_weight);
allocation / price
// 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() .collect()
} }
+21 -1
View File
@@ -1,6 +1,8 @@
//! Single instrument backtest implementation. //! 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; use crate::portfolio::engine::PortfolioEngine;
/// Single instrument backtest runner. /// Single instrument backtest runner.
@@ -28,6 +30,24 @@ impl SingleBacktest {
self.engine.run_single(ohlcv, signals) 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. /// Run backtest from raw arrays.
/// ///
/// # Arguments /// # Arguments