feat: add instrument level config and bump to 0.3.0

This commit is contained in:
porcelaincode
2026-02-10 05:03:39 +05:30
parent aad09da117
commit bb6ce05c57
13 changed files with 413 additions and 31 deletions
+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.
#[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);
}
}
+1
View File
@@ -27,6 +27,7 @@ pub mod strategies;
fn _raptorbt(_py: Python<'_>, m: &PyModule) -> PyResult<()> {
// Register config classes
m.add_class::<python::bindings::PyBacktestConfig>()?;
m.add_class::<python::bindings::PyInstrumentConfig>()?;
m.add_class::<python::bindings::PyStopConfig>()?;
m.add_class::<python::bindings::PyTargetConfig>()?;
+72 -16
View File
@@ -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<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>) {
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, .. } => {
+105 -5
View File
@@ -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<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.
#[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<i64>,
@@ -435,6 +525,7 @@ pub fn run_single_backtest<'py>(
symbol: &str,
config: Option<&PyBacktestConfig>,
position_sizes: Option<PyReadonlyArray1<f64>>,
instrument_config: Option<&PyInstrumentConfig>,
) -> PyResult<PyBacktestResult> {
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<HashMap<String, PyInstrumentConfig>>,
) -> PyResult<PyBacktestResult> {
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<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 result = backtest.run(&rust_instruments);
let result =
backtest.run_with_instrument_configs(&rust_instruments, rust_inst_configs.as_ref());
Ok(convert_result(result))
}
+60 -5
View File
@@ -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<String, InstrumentConfig>>,
) -> BacktestResult {
if instruments.is_empty() {
return self.empty_result();
}
@@ -168,7 +187,15 @@ impl BasketBacktest {
// Calculate position sizes
let prices: Vec<f64> = instruments.iter().map(|(o, _)| o.close[i]).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
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<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 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()
}
+21 -1
View File
@@ -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