diff --git a/Cargo.lock b/Cargo.lock index ff45d14..e8471f5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -502,7 +502,7 @@ dependencies = [ [[package]] name = "raptorbt" -version = "0.1.0" +version = "0.2.1" dependencies = [ "approx", "criterion", diff --git a/Cargo.toml b/Cargo.toml index 17159f5..b6a6924 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "raptorbt" -version = "0.1.0" +version = "0.2.1" 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/pyproject.toml b/pyproject.toml index 9eeba4c..bb66548 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "raptorbt" -version = "0.1.0" +version = "0.2.1" 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 62e906f..74c9f49 100644 --- a/python/raptorbt/__init__.py +++ b/python/raptorbt/__init__.py @@ -37,7 +37,7 @@ from raptorbt._raptorbt import ( supertrend, ) -__version__ = "0.1.0" +__version__ = "0.2.1" __all__ = [ # Config classes diff --git a/python/raptorbt/__pycache__/__init__.cpython-311.pyc b/python/raptorbt/__pycache__/__init__.cpython-311.pyc index b49869a..c0db6a6 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 e29fff4..0840fa6 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/mod.rs b/src/core/mod.rs index c8ebc70..653cb7a 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,9 +1,11 @@ //! Core types and utilities for RaptorBT. pub mod error; +pub mod session; pub mod timeseries; pub mod types; pub use error::{RaptorError, Result}; +pub use session::{SessionConfig, SessionTracker}; pub use timeseries::TimeSeries; pub use types::*; diff --git a/src/core/session.rs b/src/core/session.rs new file mode 100644 index 0000000..76f648a --- /dev/null +++ b/src/core/session.rs @@ -0,0 +1,397 @@ +//! Session tracking for intraday strategies. +//! +//! Handles: +//! - Session boundary detection (market open/close) +//! - Squareoff time enforcement +//! - Session high/low tracking for ORB and session-based indicators +//! - Timezone handling for IST (India Standard Time) + +use serde::{Deserialize, Serialize}; + +/// Session configuration for trading hours. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionConfig { + /// Market open hour (24-hour format). + pub market_open_hour: u32, + /// Market open minute. + pub market_open_minute: u32, + /// Market close hour (24-hour format). + pub market_close_hour: u32, + /// Market close minute. + pub market_close_minute: u32, + /// Squareoff minutes before market close. + pub squareoff_minutes_before_close: u32, + /// Timezone offset in hours from UTC (5 for IST = UTC+5:30). + pub timezone_offset_hours: i32, + /// Timezone offset minutes (30 for IST). + pub timezone_offset_minutes: i32, +} + +impl Default for SessionConfig { + fn default() -> Self { + // Default: NSE equity session (9:15 - 15:30 IST, squareoff at 15:25) + Self { + market_open_hour: 9, + market_open_minute: 15, + market_close_hour: 15, + market_close_minute: 30, + squareoff_minutes_before_close: 5, + timezone_offset_hours: 5, + timezone_offset_minutes: 30, + } + } +} + +impl SessionConfig { + /// Create NSE equity session config (9:15 - 15:30). + pub fn nse_equity() -> Self { + Self::default() + } + + /// Create MCX commodity session config (9:00 - 23:30). + pub fn mcx_commodity() -> Self { + Self { + market_open_hour: 9, + market_open_minute: 0, + market_close_hour: 23, + market_close_minute: 30, + squareoff_minutes_before_close: 5, + timezone_offset_hours: 5, + timezone_offset_minutes: 30, + } + } + + /// Create CDS currency session config (9:00 - 17:00). + pub fn cds_currency() -> Self { + Self { + market_open_hour: 9, + market_open_minute: 0, + market_close_hour: 17, + market_close_minute: 0, + squareoff_minutes_before_close: 5, + timezone_offset_hours: 5, + timezone_offset_minutes: 30, + } + } + + /// Get market open time in minutes from midnight. + pub fn market_open_minutes(&self) -> u32 { + self.market_open_hour * 60 + self.market_open_minute + } + + /// Get market close time in minutes from midnight. + pub fn market_close_minutes(&self) -> u32 { + self.market_close_hour * 60 + self.market_close_minute + } + + /// Get squareoff time in minutes from midnight. + pub fn squareoff_minutes(&self) -> u32 { + self.market_close_minutes().saturating_sub(self.squareoff_minutes_before_close) + } + + /// Get timezone offset in seconds. + pub fn timezone_offset_seconds(&self) -> i64 { + (self.timezone_offset_hours as i64 * 3600) + (self.timezone_offset_minutes as i64 * 60) + } +} + +/// Session tracker for managing intraday session state. +#[derive(Debug, Clone)] +pub struct SessionTracker { + config: SessionConfig, + /// Current session date (days since epoch in local timezone). + current_session_date: i64, + /// Session high price. + session_high: f64, + /// Session low price. + session_low: f64, + /// Session open price. + session_open: f64, + /// Bar index at session start. + session_start_idx: usize, + /// Whether we're currently in a trading session. + in_session: bool, + /// Whether squareoff has been triggered today. + squareoff_triggered: bool, +} + +impl SessionTracker { + /// Create a new session tracker. + pub fn new(config: SessionConfig) -> Self { + Self { + config, + current_session_date: -1, + session_high: f64::NEG_INFINITY, + session_low: f64::INFINITY, + session_open: 0.0, + session_start_idx: 0, + in_session: false, + squareoff_triggered: false, + } + } + + /// Convert nanosecond timestamp to local time components. + fn timestamp_to_local(&self, timestamp_ns: i64) -> (i64, u32, u32, u32) { + // Convert to seconds + let timestamp_s = timestamp_ns / 1_000_000_000; + + // Apply timezone offset + let local_s = timestamp_s + self.config.timezone_offset_seconds(); + + // Calculate date (days since epoch) + let days = local_s / 86400; + + // Calculate time within day + let time_in_day = (local_s % 86400) as u32; + let hours = time_in_day / 3600; + let minutes = (time_in_day % 3600) / 60; + let seconds = time_in_day % 60; + + (days, hours, minutes, seconds) + } + + /// Get minutes from midnight for a timestamp. + fn get_minutes_from_midnight(&self, timestamp_ns: i64) -> u32 { + let (_, hours, minutes, _) = self.timestamp_to_local(timestamp_ns); + hours * 60 + minutes + } + + /// Check if timestamp is within trading hours. + pub fn is_within_trading_hours(&self, timestamp_ns: i64) -> bool { + let minutes = self.get_minutes_from_midnight(timestamp_ns); + minutes >= self.config.market_open_minutes() && minutes < self.config.market_close_minutes() + } + + /// Check if it's squareoff time. + pub fn is_squareoff_time(&self, timestamp_ns: i64) -> bool { + let minutes = self.get_minutes_from_midnight(timestamp_ns); + minutes >= self.config.squareoff_minutes() + } + + /// Check if this bar starts a new session. + pub fn is_session_start(&self, prev_ts_ns: i64, curr_ts_ns: i64) -> bool { + let (prev_date, prev_h, prev_m, _) = self.timestamp_to_local(prev_ts_ns); + let (curr_date, curr_h, curr_m, _) = self.timestamp_to_local(curr_ts_ns); + + // New day + if curr_date != prev_date { + let curr_minutes = curr_h * 60 + curr_m; + return curr_minutes >= self.config.market_open_minutes(); + } + + // Same day, but crossed market open + let prev_minutes = prev_h * 60 + prev_m; + let curr_minutes = curr_h * 60 + curr_m; + + prev_minutes < self.config.market_open_minutes() + && curr_minutes >= self.config.market_open_minutes() + } + + /// Check if this bar ends the session. + pub fn is_session_end(&self, curr_ts_ns: i64, next_ts_ns: Option) -> bool { + let (curr_date, curr_h, curr_m, _) = self.timestamp_to_local(curr_ts_ns); + let curr_minutes = curr_h * 60 + curr_m; + + // At or past market close + if curr_minutes >= self.config.market_close_minutes() { + return true; + } + + // Check if next bar is in a new session + if let Some(next_ts) = next_ts_ns { + let (next_date, _, _, _) = self.timestamp_to_local(next_ts); + if next_date != curr_date { + return true; + } + } + + false + } + + /// Update session state for a new bar. + /// + /// Returns tuple of (is_new_session, is_squareoff_time, is_session_end). + pub fn update( + &mut self, + idx: usize, + timestamp_ns: i64, + open: f64, + high: f64, + low: f64, + _close: f64, + prev_timestamp_ns: Option, + next_timestamp_ns: Option, + ) -> (bool, bool, bool) { + let (date, hours, minutes, _) = self.timestamp_to_local(timestamp_ns); + let time_minutes = hours * 60 + minutes; + + // Check for new session + let is_new_session = if self.current_session_date != date { + // New date - check if within trading hours + if time_minutes >= self.config.market_open_minutes() + && time_minutes < self.config.market_close_minutes() + { + self.reset_session(idx, date, open); + true + } else { + false + } + } else if let Some(prev_ts) = prev_timestamp_ns { + if self.is_session_start(prev_ts, timestamp_ns) { + self.reset_session(idx, date, open); + true + } else { + false + } + } else { + // First bar - start session if within hours + if time_minutes >= self.config.market_open_minutes() + && time_minutes < self.config.market_close_minutes() + { + self.reset_session(idx, date, open); + true + } else { + false + } + }; + + // Update session high/low + if self.in_session { + if high > self.session_high { + self.session_high = high; + } + if low < self.session_low { + self.session_low = low; + } + } + + // Check squareoff time + let is_squareoff = + if time_minutes >= self.config.squareoff_minutes() && !self.squareoff_triggered { + self.squareoff_triggered = true; + self.in_session + } else { + false + }; + + // Check session end + let is_session_end = self.is_session_end(timestamp_ns, next_timestamp_ns); + if is_session_end { + self.in_session = false; + } + + (is_new_session, is_squareoff, is_session_end) + } + + /// Reset session state for a new trading day. + fn reset_session(&mut self, idx: usize, date: i64, open_price: f64) { + self.current_session_date = date; + self.session_start_idx = idx; + self.session_open = open_price; + self.session_high = open_price; + self.session_low = open_price; + self.in_session = true; + self.squareoff_triggered = false; + } + + /// Get current session high. + pub fn session_high(&self) -> f64 { + self.session_high + } + + /// Get current session low. + pub fn session_low(&self) -> f64 { + self.session_low + } + + /// Get current session open. + pub fn session_open(&self) -> f64 { + self.session_open + } + + /// Get session start index. + pub fn session_start_idx(&self) -> usize { + self.session_start_idx + } + + /// Check if currently in a trading session. + pub fn in_session(&self) -> bool { + self.in_session + } + + /// Get opening range (high - low) for the session. + pub fn opening_range(&self) -> f64 { + self.session_high - self.session_low + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_timestamp(year: i32, month: u32, day: u32, hour: u32, minute: u32) -> i64 { + // Simplified: calculate seconds from 1970-01-01 and convert to nanoseconds + // This is approximate for testing + let days_since_epoch = (year - 1970) as i64 * 365 + (month - 1) as i64 * 30 + day as i64; + let seconds = days_since_epoch * 86400 + hour as i64 * 3600 + minute as i64 * 60; + // Subtract IST offset to get UTC + let utc_seconds = seconds - (5 * 3600 + 30 * 60); + utc_seconds * 1_000_000_000 + } + + #[test] + fn test_session_config_defaults() { + let config = SessionConfig::default(); + assert_eq!(config.market_open_hour, 9); + assert_eq!(config.market_open_minute, 15); + assert_eq!(config.market_close_hour, 15); + assert_eq!(config.market_close_minute, 30); + assert_eq!(config.squareoff_minutes_before_close, 5); + } + + #[test] + fn test_squareoff_minutes() { + let config = SessionConfig::default(); + // 15:30 - 5 minutes = 15:25 = 925 minutes + assert_eq!(config.squareoff_minutes(), 925); + } + + #[test] + fn test_mcx_session() { + let config = SessionConfig::mcx_commodity(); + assert_eq!(config.market_open_hour, 9); + assert_eq!(config.market_close_hour, 23); + assert_eq!(config.market_close_minute, 30); + } + + #[test] + fn test_session_tracker_new_session() { + let config = SessionConfig::default(); + let mut tracker = SessionTracker::new(config); + + // Simulate market open at 9:15 IST + let ts = make_timestamp(2024, 1, 15, 9, 15); + let (is_new, _, _) = tracker.update(0, ts, 100.0, 101.0, 99.0, 100.5, None, None); + + assert!(is_new); + assert!(tracker.in_session()); + assert_eq!(tracker.session_open(), 100.0); + } + + #[test] + fn test_session_high_low() { + let config = SessionConfig::default(); + let mut tracker = SessionTracker::new(config); + + // First bar + let ts1 = make_timestamp(2024, 1, 15, 9, 15); + tracker.update(0, ts1, 100.0, 105.0, 95.0, 102.0, None, None); + + // Second bar + let ts2 = make_timestamp(2024, 1, 15, 9, 30); + tracker.update(1, ts2, 102.0, 110.0, 100.0, 108.0, Some(ts1), None); + + assert_eq!(tracker.session_high(), 110.0); + assert_eq!(tracker.session_low(), 95.0); + } +} diff --git a/src/indicators/mod.rs b/src/indicators/mod.rs index f22ca22..acb40b7 100644 --- a/src/indicators/mod.rs +++ b/src/indicators/mod.rs @@ -4,12 +4,14 @@ //! and return Vec outputs. NaN values are used for the warmup period. pub mod momentum; +pub mod rolling; pub mod strength; pub mod trend; pub mod volatility; pub mod volume; pub use momentum::{macd, rsi, stochastic, MacdResult, StochasticResult}; +pub use rolling::{rolling_max, rolling_min}; pub use strength::adx; pub use trend::{ema, sma, supertrend, SupertrendResult}; pub use volatility::{atr, bollinger_bands, BollingerBandsResult}; diff --git a/src/indicators/rolling.rs b/src/indicators/rolling.rs new file mode 100644 index 0000000..ee872fd --- /dev/null +++ b/src/indicators/rolling.rs @@ -0,0 +1,106 @@ +//! Rolling min/max indicators for LLV/HHV support. +//! +//! Provides rolling minimum and maximum calculations for Lowest Low Value (LLV) +//! and Highest High Value (HHV) expressions. + +use crate::core::error::RaptorError; + +/// Calculate rolling minimum (Lowest Low Value) over a period. +/// +/// Returns NaN for the first (period - 1) values where insufficient data exists. +/// +/// # Arguments +/// * `data` - Input data slice +/// * `period` - Lookback period +/// +/// # Returns +/// Vec of rolling minimum values +pub fn rolling_min(data: &[f64], period: usize) -> Result, RaptorError> { + if period == 0 { + return Err(RaptorError::invalid_parameter("period must be at least 1")); + } + + let n = data.len(); + let mut result = vec![f64::NAN; n]; + + for i in (period - 1)..n { + let start = i + 1 - period; + let min_val = + data[start..=i] + .iter() + .fold(f64::INFINITY, |a, &b| if b.is_nan() { a } else { a.min(b) }); + result[i] = if min_val == f64::INFINITY { f64::NAN } else { min_val }; + } + + Ok(result) +} + +/// Calculate rolling maximum (Highest High Value) over a period. +/// +/// Returns NaN for the first (period - 1) values where insufficient data exists. +/// +/// # Arguments +/// * `data` - Input data slice +/// * `period` - Lookback period +/// +/// # Returns +/// Vec of rolling maximum values +pub fn rolling_max(data: &[f64], period: usize) -> Result, RaptorError> { + if period == 0 { + return Err(RaptorError::invalid_parameter("period must be at least 1")); + } + + let n = data.len(); + let mut result = vec![f64::NAN; n]; + + for i in (period - 1)..n { + let start = i + 1 - period; + let max_val = + data[start..=i] + .iter() + .fold(f64::NEG_INFINITY, |a, &b| if b.is_nan() { a } else { a.max(b) }); + result[i] = if max_val == f64::NEG_INFINITY { f64::NAN } else { max_val }; + } + + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_rolling_min() { + let data = vec![5.0, 3.0, 8.0, 2.0, 7.0, 1.0, 9.0]; + let result = rolling_min(&data, 3).unwrap(); + + assert!(result[0].is_nan()); + assert!(result[1].is_nan()); + assert!((result[2] - 3.0).abs() < f64::EPSILON); // min(5, 3, 8) + assert!((result[3] - 2.0).abs() < f64::EPSILON); // min(3, 8, 2) + assert!((result[4] - 2.0).abs() < f64::EPSILON); // min(8, 2, 7) + assert!((result[5] - 1.0).abs() < f64::EPSILON); // min(2, 7, 1) + assert!((result[6] - 1.0).abs() < f64::EPSILON); // min(7, 1, 9) + } + + #[test] + fn test_rolling_max() { + let data = vec![5.0, 3.0, 8.0, 2.0, 7.0, 1.0, 9.0]; + let result = rolling_max(&data, 3).unwrap(); + + assert!(result[0].is_nan()); + assert!(result[1].is_nan()); + assert!((result[2] - 8.0).abs() < f64::EPSILON); // max(5, 3, 8) + assert!((result[3] - 8.0).abs() < f64::EPSILON); // max(3, 8, 2) + assert!((result[4] - 8.0).abs() < f64::EPSILON); // max(8, 2, 7) + assert!((result[5] - 7.0).abs() < f64::EPSILON); // max(2, 7, 1) + assert!((result[6] - 9.0).abs() < f64::EPSILON); // max(7, 1, 9) + } + + #[test] + fn test_invalid_period() { + let data = vec![1.0, 2.0, 3.0]; + assert!(rolling_min(&data, 0).is_err()); + assert!(rolling_max(&data, 0).is_err()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 186d5c9..b116fc6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,6 +41,7 @@ fn _raptorbt(_py: Python<'_>, m: &PyModule) -> PyResult<()> { m.add_function(wrap_pyfunction!(python::bindings::run_options_backtest, m)?)?; m.add_function(wrap_pyfunction!(python::bindings::run_pairs_backtest, m)?)?; m.add_function(wrap_pyfunction!(python::bindings::run_multi_backtest, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::run_spread_backtest, m)?)?; // Register indicator functions m.add_function(wrap_pyfunction!(python::bindings::sma, m)?)?; @@ -53,6 +54,8 @@ fn _raptorbt(_py: Python<'_>, m: &PyModule) -> PyResult<()> { m.add_function(wrap_pyfunction!(python::bindings::adx, m)?)?; m.add_function(wrap_pyfunction!(python::bindings::vwap, m)?)?; m.add_function(wrap_pyfunction!(python::bindings::supertrend, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::rolling_min, m)?)?; + m.add_function(wrap_pyfunction!(python::bindings::rolling_max, m)?)?; Ok(()) } diff --git a/src/metrics/streaming.rs b/src/metrics/streaming.rs index 7a2276f..77bb280 100644 --- a/src/metrics/streaming.rs +++ b/src/metrics/streaming.rs @@ -2,9 +2,12 @@ //! //! Enables single-pass calculation of mean, variance, Sharpe ratio, and Sortino ratio. +use crate::core::types::BacktestMetrics; + /// Streaming metrics calculator using Welford's algorithm. /// /// Allows incremental calculation of statistics without storing all values. +/// Also tracks equity and drawdown for backtesting. #[derive(Debug, Clone)] pub struct StreamingMetrics { /// Number of observations. @@ -27,6 +30,59 @@ pub struct StreamingMetrics { count_positive: usize, /// Count of negative returns. count_negative: usize, + + // === Equity and drawdown tracking === + /// Initial capital. + #[allow(dead_code)] + initial_capital: f64, + /// Peak equity value (for drawdown calculation). + peak_equity: f64, + /// Current equity value. + current_equity: f64, + /// Maximum drawdown percentage. + max_drawdown_pct: f64, + /// Current drawdown percentage. + current_drawdown: f64, + /// Bars since peak (for max drawdown duration). + bars_since_peak: usize, + /// Maximum drawdown duration in bars. + max_drawdown_duration: usize, + + // === Trade tracking === + /// Number of trades. + trade_count: usize, + /// Number of winning trades. + winning_trades: usize, + /// Number of losing trades. + losing_trades: usize, + /// Sum of winning trade P&L. + sum_wins: f64, + /// Sum of losing trade P&L. + sum_losses: f64, + /// Sum of trade return percentages. + sum_trade_returns: f64, + /// Sum of squared trade return percentages (for SQN). + sum_trade_returns_sq: f64, + /// Best trade return percentage. + best_trade_pct: f64, + /// Worst trade return percentage. + worst_trade_pct: f64, + /// Sum of winning trade durations. + sum_winning_duration: usize, + /// Sum of losing trade durations. + sum_losing_duration: usize, + /// Current consecutive wins. + current_consecutive_wins: usize, + /// Current consecutive losses. + current_consecutive_losses: usize, + /// Maximum consecutive wins. + max_consecutive_wins: usize, + /// Maximum consecutive losses. + max_consecutive_losses: usize, + /// Total holding period (bars). + total_holding_period: usize, + /// Total fees paid. + total_fees: f64, } impl Default for StreamingMetrics { @@ -38,6 +94,11 @@ impl Default for StreamingMetrics { impl StreamingMetrics { /// Create a new streaming metrics calculator. pub fn new() -> Self { + Self::with_initial_capital(0.0) + } + + /// Create a new streaming metrics calculator with initial capital. + pub fn with_initial_capital(initial_capital: f64) -> Self { Self { count: 0, mean: 0.0, @@ -49,6 +110,32 @@ impl StreamingMetrics { sum_negative: 0.0, count_positive: 0, count_negative: 0, + // Equity tracking + initial_capital, + peak_equity: initial_capital, + current_equity: initial_capital, + max_drawdown_pct: 0.0, + current_drawdown: 0.0, + bars_since_peak: 0, + max_drawdown_duration: 0, + // Trade tracking + trade_count: 0, + winning_trades: 0, + losing_trades: 0, + sum_wins: 0.0, + sum_losses: 0.0, + sum_trade_returns: 0.0, + sum_trade_returns_sq: 0.0, + best_trade_pct: f64::NEG_INFINITY, + worst_trade_pct: f64::INFINITY, + sum_winning_duration: 0, + sum_losing_duration: 0, + current_consecutive_wins: 0, + current_consecutive_losses: 0, + max_consecutive_wins: 0, + max_consecutive_losses: 0, + total_holding_period: 0, + total_fees: 0.0, } } @@ -218,6 +305,249 @@ impl StreamingMetrics { self.profit_factor() } + // === Equity tracking methods === + + /// Update equity and calculate drawdown. + pub fn update_equity(&mut self, equity: f64) { + self.current_equity = equity; + + if equity > self.peak_equity { + self.peak_equity = equity; + self.bars_since_peak = 0; + } else { + self.bars_since_peak += 1; + if self.bars_since_peak > self.max_drawdown_duration { + self.max_drawdown_duration = self.bars_since_peak; + } + } + + // Calculate current drawdown percentage + if self.peak_equity > 0.0 { + self.current_drawdown = (self.peak_equity - equity) / self.peak_equity * 100.0; + if self.current_drawdown > self.max_drawdown_pct { + self.max_drawdown_pct = self.current_drawdown; + } + } + } + + /// Get current drawdown percentage. + #[inline] + pub fn current_drawdown_pct(&self) -> f64 { + self.current_drawdown + } + + /// Get maximum drawdown percentage. + #[inline] + pub fn max_drawdown_pct(&self) -> f64 { + self.max_drawdown_pct + } + + // === Trade tracking methods === + + /// Record a completed trade. + /// + /// # Arguments + /// * `pnl` - Trade profit/loss + /// * `return_pct` - Trade return percentage + /// * `duration` - Trade duration in bars + pub fn record_trade(&mut self, pnl: f64, return_pct: f64, duration: usize) { + self.trade_count += 1; + self.sum_trade_returns += return_pct; + self.sum_trade_returns_sq += return_pct * return_pct; + self.total_holding_period += duration; + + // Track best/worst trades + if return_pct > self.best_trade_pct { + self.best_trade_pct = return_pct; + } + if return_pct < self.worst_trade_pct { + self.worst_trade_pct = return_pct; + } + + if pnl > 0.0 { + self.winning_trades += 1; + self.sum_wins += pnl; + self.sum_winning_duration += duration; + self.current_consecutive_wins += 1; + self.current_consecutive_losses = 0; + if self.current_consecutive_wins > self.max_consecutive_wins { + self.max_consecutive_wins = self.current_consecutive_wins; + } + } else if pnl < 0.0 { + self.losing_trades += 1; + self.sum_losses += pnl.abs(); + self.sum_losing_duration += duration; + self.current_consecutive_losses += 1; + self.current_consecutive_wins = 0; + if self.current_consecutive_losses > self.max_consecutive_losses { + self.max_consecutive_losses = self.current_consecutive_losses; + } + } + } + + /// Record fees paid. + pub fn record_fees(&mut self, fees: f64) { + self.total_fees += fees; + } + + /// Finalize metrics and produce BacktestMetrics. + /// + /// # Arguments + /// * `initial_capital` - Starting capital + /// * `final_value` - Ending portfolio value + /// * `returns` - Array of period returns for ratio calculations + pub fn finalize( + &self, + initial_capital: f64, + final_value: f64, + returns: &[f64], + ) -> BacktestMetrics { + // Calculate return metrics from the returns array + let mut return_metrics = StreamingMetrics::new(); + for &r in returns { + if !r.is_nan() { + return_metrics.update(r); + } + } + + let total_return_pct = if initial_capital > 0.0 { + (final_value - initial_capital) / initial_capital * 100.0 + } else { + 0.0 + }; + + // Calculate trade-based metrics + let win_rate_pct = if self.trade_count > 0 { + self.winning_trades as f64 / self.trade_count as f64 * 100.0 + } else { + 0.0 + }; + + let profit_factor = if self.sum_losses > 0.0 { + self.sum_wins / self.sum_losses + } else if self.sum_wins > 0.0 { + f64::INFINITY + } else { + 0.0 + }; + + let avg_trade_return_pct = if self.trade_count > 0 { + self.sum_trade_returns / self.trade_count as f64 + } else { + 0.0 + }; + + let avg_win_pct = if self.winning_trades > 0 { + self.sum_wins / self.winning_trades as f64 / initial_capital * 100.0 + } else { + 0.0 + }; + + let avg_loss_pct = if self.losing_trades > 0 { + -(self.sum_losses / self.losing_trades as f64 / initial_capital * 100.0) + } else { + 0.0 + }; + + let avg_winning_duration = if self.winning_trades > 0 { + self.sum_winning_duration as f64 / self.winning_trades as f64 + } else { + 0.0 + }; + + let avg_losing_duration = if self.losing_trades > 0 { + self.sum_losing_duration as f64 / self.losing_trades as f64 + } else { + 0.0 + }; + + let avg_holding_period = if self.trade_count > 0 { + self.total_holding_period as f64 / self.trade_count as f64 + } else { + 0.0 + }; + + // Expectancy: average profit per trade + let expectancy = if self.trade_count > 0 { + (self.sum_wins - self.sum_losses) / self.trade_count as f64 + } else { + 0.0 + }; + + // SQN (System Quality Number) + let sqn = if self.trade_count > 1 { + let mean_return = self.sum_trade_returns / self.trade_count as f64; + let variance = + (self.sum_trade_returns_sq / self.trade_count as f64) - (mean_return * mean_return); + let std_dev = variance.max(0.0).sqrt(); + if std_dev > 0.0 { + (mean_return / std_dev) * (self.trade_count as f64).sqrt() + } else { + 0.0 + } + } else { + 0.0 + }; + + // Sharpe ratio (annualized, assuming 252 trading days) + let sharpe_ratio = return_metrics.sharpe_ratio(252.0); + + // Sortino ratio (annualized) + let sortino_ratio = return_metrics.sortino_ratio(252.0); + + // Calmar ratio (annualized return / max drawdown) + let calmar_ratio = if self.max_drawdown_pct > 0.0 { + total_return_pct / self.max_drawdown_pct + } else if total_return_pct > 0.0 { + f64::INFINITY + } else { + 0.0 + }; + + // Omega ratio + let omega_ratio = return_metrics.omega_ratio(); + + // Best/worst trade handling (handle edge cases) + let best_trade_pct = + if self.best_trade_pct == f64::NEG_INFINITY { 0.0 } else { self.best_trade_pct }; + let worst_trade_pct = + if self.worst_trade_pct == f64::INFINITY { 0.0 } else { self.worst_trade_pct }; + + BacktestMetrics { + total_return_pct, + sharpe_ratio, + sortino_ratio, + calmar_ratio, + omega_ratio, + max_drawdown_pct: self.max_drawdown_pct, + max_drawdown_duration: self.max_drawdown_duration, + win_rate_pct, + profit_factor, + expectancy, + sqn, + total_trades: self.trade_count, + total_closed_trades: self.trade_count, + total_open_trades: 0, + open_trade_pnl: 0.0, + winning_trades: self.winning_trades, + losing_trades: self.losing_trades, + start_value: initial_capital, + end_value: final_value, + total_fees_paid: self.total_fees, + best_trade_pct, + worst_trade_pct, + avg_trade_return_pct, + avg_win_pct, + avg_loss_pct, + avg_winning_duration, + avg_losing_duration, + max_consecutive_wins: self.max_consecutive_wins, + max_consecutive_losses: self.max_consecutive_losses, + avg_holding_period, + exposure_pct: 0.0, // TODO: calculate based on time in market + } + } + /// Reset all metrics. pub fn reset(&mut self) { *self = Self::new(); diff --git a/src/python/bindings.rs b/src/python/bindings.rs index 734d5b9..4fca545 100644 --- a/src/python/bindings.rs +++ b/src/python/bindings.rs @@ -15,6 +15,9 @@ use crate::strategies::options::{ }; use crate::strategies::pairs::{PairsBacktest, PairsConfig}; use crate::strategies::single::SingleBacktest; +use crate::strategies::spreads::{ + LegConfig, OptionType as SpreadOptionType, SpreadBacktest, SpreadConfig, SpreadType, +}; use super::numpy_bridge::*; @@ -673,6 +676,68 @@ pub fn run_pairs_backtest<'py>( Ok(convert_result(result)) } +/// Run spread backtest (multi-leg options). +#[pyfunction] +#[pyo3(signature = (timestamps, underlying_close, legs_premiums, leg_configs, entries, exits, config=None, spread_type="custom", max_loss=None, target_profit=None))] +pub fn run_spread_backtest<'py>( + _py: Python<'py>, + timestamps: PyReadonlyArray1, + underlying_close: PyReadonlyArray1, + legs_premiums: Vec>, + leg_configs: Vec<(String, f64, i32, usize)>, // (option_type, strike, quantity, lot_size) + entries: PyReadonlyArray1, + exits: PyReadonlyArray1, + config: Option<&PyBacktestConfig>, + spread_type: &str, + max_loss: Option, + target_profit: Option, +) -> PyResult { + let ts = numpy_to_vec_i64(timestamps); + let underlying = numpy_to_vec_f64(underlying_close); + let premiums: Vec> = legs_premiums.into_iter().map(numpy_to_vec_f64).collect(); + let entry_signals = numpy_to_vec_bool(entries); + let exit_signals = numpy_to_vec_bool(exits); + + // Convert leg configs + let rust_leg_configs: Vec = leg_configs + .into_iter() + .map(|(opt_type, strike, quantity, lot_size)| { + let option_type = + SpreadOptionType::from_str(&opt_type).unwrap_or(SpreadOptionType::Call); + LegConfig::new(option_type, strike, quantity, lot_size) + }) + .collect(); + + // Parse spread type + let spread_type_enum = match spread_type.to_lowercase().as_str() { + "straddle" => SpreadType::Straddle, + "strangle" => SpreadType::Strangle, + "vertical_call" | "verticalcall" => SpreadType::VerticalCall, + "vertical_put" | "verticalput" => SpreadType::VerticalPut, + "iron_condor" | "ironcondor" => SpreadType::IronCondor, + "iron_butterfly" | "ironbutterfly" => SpreadType::IronButterfly, + "butterfly_call" | "butterflycall" => SpreadType::ButterflyCall, + "butterfly_put" | "butterflyput" => SpreadType::ButterflyPut, + "calendar" => SpreadType::Calendar, + "diagonal" => SpreadType::Diagonal, + _ => SpreadType::Custom, + }; + + let spread_config = SpreadConfig { + base: config.map(|c| BacktestConfig::from(c)).unwrap_or_default(), + spread_type: spread_type_enum, + leg_configs: rust_leg_configs, + max_loss, + target_profit, + close_at_eod: false, + }; + + let backtest = SpreadBacktest::new(spread_config); + let result = backtest.run(&ts, &underlying, &premiums, &entry_signals, &exit_signals); + + Ok(convert_result(result)) +} + /// Run multi-strategy backtest. #[pyfunction] #[pyo3(signature = (timestamps, open, high, low, close, volume, strategies, config=None, combine_mode="any"))] @@ -903,6 +968,32 @@ pub fn supertrend<'py>( Ok((vec_to_numpy_f64(py, result.supertrend), direction_array)) } +/// Rolling minimum (Lowest Low Value). +#[pyfunction] +pub fn rolling_min<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::rolling::rolling_min(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + +/// Rolling maximum (Highest High Value). +#[pyfunction] +pub fn rolling_max<'py>( + py: Python<'py>, + data: PyReadonlyArray1, + period: usize, +) -> PyResult<&'py PyArray1> { + let vec = numpy_to_vec_f64(data); + let result = indicators::rolling::rolling_max(&vec, period) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(vec_to_numpy_f64(py, result)) +} + // ============================================================================ // Helper Functions // ============================================================================ diff --git a/src/strategies/mod.rs b/src/strategies/mod.rs index 88e54e9..4c5f920 100644 --- a/src/strategies/mod.rs +++ b/src/strategies/mod.rs @@ -5,9 +5,13 @@ pub mod multi; pub mod options; pub mod pairs; pub mod single; +pub mod spreads; pub use basket::BasketBacktest; pub use multi::MultiStrategyBacktest; pub use options::OptionsBacktest; pub use pairs::PairsBacktest; pub use single::SingleBacktest; +pub use spreads::{ + LegConfig, OptionType as SpreadOptionType, SpreadBacktest, SpreadConfig, SpreadType, +}; diff --git a/src/strategies/spreads.rs b/src/strategies/spreads.rs new file mode 100644 index 0000000..71769ee --- /dev/null +++ b/src/strategies/spreads.rs @@ -0,0 +1,585 @@ +//! Multi-leg options spread backtesting implementation. +//! +//! Provides high-performance spread backtesting for: +//! - Straddles and Strangles +//! - Vertical spreads (bull/bear call/put) +//! - Iron Condors and Iron Butterflies +//! - Calendar and Diagonal spreads +//! +//! Key features: +//! - Single-pass O(n) algorithm +//! - Coordinated entry/exit across all legs +//! - Net premium P&L calculation +//! - Combined Greeks tracking + +use crate::core::types::{ + BacktestConfig, BacktestMetrics, BacktestResult, Direction, ExitReason, Trade, +}; +use crate::metrics::streaming::StreamingMetrics; +use serde::{Deserialize, Serialize}; + +/// Spread type enumeration. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum SpreadType { + Straddle, + Strangle, + VerticalCall, + VerticalPut, + IronCondor, + IronButterfly, + ButterflyCall, + ButterflyPut, + Calendar, + Diagonal, + Custom, +} + +/// Option type for a leg. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum OptionType { + Call, + Put, +} + +impl OptionType { + pub fn from_str(s: &str) -> Option { + match s.to_uppercase().as_str() { + "CE" | "CALL" | "C" => Some(OptionType::Call), + "PE" | "PUT" | "P" => Some(OptionType::Put), + _ => None, + } + } +} + +/// Configuration for a single leg of a spread. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LegConfig { + /// Option type (Call or Put). + pub option_type: OptionType, + /// Strike price. + pub strike: f64, + /// Position quantity (+1 long, -1 short). + pub quantity: i32, + /// Lot size for the option. + pub lot_size: usize, +} + +impl LegConfig { + pub fn new(option_type: OptionType, strike: f64, quantity: i32, lot_size: usize) -> Self { + Self { option_type, strike, quantity, lot_size } + } + + /// Check if this is a long position. + pub fn is_long(&self) -> bool { + self.quantity > 0 + } + + /// Check if this is a short position. + pub fn is_short(&self) -> bool { + self.quantity < 0 + } +} + +/// Configuration for spread backtest. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpreadConfig { + /// Base backtest configuration. + pub base: BacktestConfig, + /// Spread type. + pub spread_type: SpreadType, + /// Leg configurations. + pub leg_configs: Vec, + /// Maximum loss threshold (optional, for early exit). + pub max_loss: Option, + /// Target profit threshold (optional, for early exit). + pub target_profit: Option, + /// Whether to close at end of day. + pub close_at_eod: bool, +} + +impl Default for SpreadConfig { + fn default() -> Self { + Self { + base: BacktestConfig::default(), + spread_type: SpreadType::Custom, + leg_configs: Vec::new(), + max_loss: None, + target_profit: None, + close_at_eod: false, + } + } +} + +/// State for a single leg position. +#[derive(Debug, Clone)] +struct LegPosition { + /// Entry premium price. + pub entry_premium: f64, + /// Entry index. + #[allow(dead_code)] + pub entry_idx: usize, + /// Current premium price. + pub current_premium: f64, + /// Leg configuration. + pub config: LegConfig, +} + +impl LegPosition { + fn new(config: LegConfig, entry_premium: f64, entry_idx: usize) -> Self { + Self { entry_premium, entry_idx, current_premium: entry_premium, config } + } + + /// Calculate unrealized P&L for this leg. + fn unrealized_pnl(&self) -> f64 { + // For short positions: profit when premium decreases + // For long positions: profit when premium increases + let premium_change = self.current_premium - self.entry_premium; + let quantity = self.config.quantity as f64; + let lot_size = self.config.lot_size as f64; + -quantity * premium_change * lot_size + } +} + +/// Spread position state. +#[derive(Debug, Clone)] +struct SpreadPosition { + /// Individual leg positions. + pub legs: Vec, + /// Entry bar index. + pub entry_idx: usize, + /// Entry net premium (positive = credit, negative = debit). + pub entry_net_premium: f64, + /// Entry timestamp. + pub entry_time: i64, + /// Whether position is open. + pub is_open: bool, +} + +impl SpreadPosition { + fn new(legs: Vec, entry_idx: usize, entry_time: i64) -> Self { + let entry_net_premium: f64 = legs + .iter() + .map(|leg| leg.entry_premium * leg.config.quantity as f64 * leg.config.lot_size as f64) + .sum(); + + Self { legs, entry_idx, entry_net_premium, entry_time, is_open: true } + } + + /// Calculate total unrealized P&L across all legs. + fn total_unrealized_pnl(&self) -> f64 { + self.legs.iter().map(|leg| leg.unrealized_pnl()).sum() + } + + /// Update leg premiums. + fn update_premiums(&mut self, leg_premiums: &[f64]) { + for (leg, &premium) in self.legs.iter_mut().zip(leg_premiums.iter()) { + leg.current_premium = premium; + } + } + + /// Close the position and return P&L. + fn close(&mut self) -> f64 { + self.is_open = false; + self.total_unrealized_pnl() + } +} + +/// Spread backtest runner. +pub struct SpreadBacktest { + config: SpreadConfig, +} + +impl SpreadBacktest { + /// Create a new spread backtest. + pub fn new(config: SpreadConfig) -> Self { + Self { config } + } + + /// Run the spread backtest. + /// + /// # Arguments + /// * `timestamps` - Timestamp array + /// * `underlying_close` - Underlying close prices + /// * `legs_premiums` - Premium series for each leg (Vec of Vec) + /// * `entries` - Entry signals + /// * `exits` - Exit signals + /// + /// # Returns + /// Backtest result with metrics, trades, and equity curve + pub fn run( + &self, + timestamps: &[i64], + _underlying_close: &[f64], + legs_premiums: &[Vec], + entries: &[bool], + exits: &[bool], + ) -> BacktestResult { + let n = timestamps.len(); + + // Validate inputs + if legs_premiums.len() != self.config.leg_configs.len() { + return self.empty_result(n); + } + + for premiums in legs_premiums { + if premiums.len() != n { + return self.empty_result(n); + } + } + + let mut metrics = StreamingMetrics::with_initial_capital(self.config.base.initial_capital); + let mut equity_curve = Vec::with_capacity(n); + let mut drawdown_curve = Vec::with_capacity(n); + let mut returns = Vec::with_capacity(n); + let mut trades: Vec = Vec::new(); + let mut trade_id: u64 = 0; + + let mut cash = self.config.base.initial_capital; + let mut position: Option = None; + let mut prev_equity = cash; + + // Single-pass O(n) algorithm + for i in 0..n { + // Get current leg premiums + let current_premiums: Vec = legs_premiums.iter().map(|p| p[i]).collect(); + + // Update position premiums if open + if let Some(ref mut pos) = position { + pos.update_premiums(¤t_premiums); + } + + // Calculate unrealized P&L for exit checks + let unrealized_pnl = position.as_ref().map(|p| p.total_unrealized_pnl()).unwrap_or(0.0); + + // Check for exit signals or conditions + let should_exit = position.is_some() + && (exits[i] + || self.check_max_loss(&position, unrealized_pnl) + || self.check_target_profit(&position, unrealized_pnl)); + + if should_exit { + if let Some(mut pos) = position.take() { + let pnl = pos.close(); + let fees = self.calculate_fees(&pos); + let net_pnl = pnl - fees; + + cash += net_pnl; + + // Record trade + trade_id += 1; + let exit_reason = if exits[i] { + ExitReason::Signal + } else if self.check_max_loss(&Some(pos.clone()), pnl) { + ExitReason::StopLoss + } else { + ExitReason::TakeProfit + }; + + let entry_premium = pos.entry_net_premium; + let exit_premium: f64 = current_premiums + .iter() + .zip(self.config.leg_configs.iter()) + .map(|(&p, cfg)| p * cfg.quantity as f64 * cfg.lot_size as f64) + .sum(); + + trades.push(Trade { + id: trade_id, + symbol: "SPREAD".to_string(), + entry_idx: pos.entry_idx, + exit_idx: i, + entry_price: entry_premium, + exit_price: exit_premium, + size: 1.0, + direction: Direction::Long, // Spreads are treated as "long spread" + pnl: net_pnl, + return_pct: if entry_premium.abs() > 0.0 { + net_pnl / entry_premium.abs() * 100.0 + } else { + 0.0 + }, + entry_time: pos.entry_time, + exit_time: timestamps[i], + fees, + exit_reason, + }); + + metrics.record_trade( + net_pnl, + net_pnl / entry_premium.abs() * 100.0, + i - pos.entry_idx, + ); + } + } + + // Check for entry signals + if position.is_none() && entries[i] { + let legs: Vec = self + .config + .leg_configs + .iter() + .zip(current_premiums.iter()) + .map(|(cfg, &premium)| LegPosition::new(cfg.clone(), premium, i)) + .collect(); + + let new_position = SpreadPosition::new(legs, i, timestamps[i]); + + // Calculate entry fees + let entry_fees = self.calculate_entry_fees(&new_position); + cash -= entry_fees; + + position = Some(new_position); + } + + // Update equity tracking + let equity = cash + position.as_ref().map(|p| p.total_unrealized_pnl()).unwrap_or(0.0); + equity_curve.push(equity); + + let daily_return = + if prev_equity > 0.0 { (equity - prev_equity) / prev_equity } else { 0.0 }; + returns.push(daily_return); + prev_equity = equity; + + // Update drawdown + metrics.update_equity(equity); + drawdown_curve.push(metrics.current_drawdown_pct()); + } + + // Close any remaining open position at end + if let Some(mut pos) = position.take() { + let pnl = pos.close(); + let fees = self.calculate_fees(&pos); + cash += pnl - fees; + } + + // Finalize metrics + let final_metrics = metrics.finalize(self.config.base.initial_capital, cash, &returns); + + BacktestResult { metrics: final_metrics, equity_curve, drawdown_curve, trades, returns } + } + + /// Check if max loss threshold is hit. + fn check_max_loss(&self, _position: &Option, unrealized_pnl: f64) -> bool { + if let Some(max_loss) = self.config.max_loss { + if unrealized_pnl < -max_loss { + return true; + } + } + false + } + + /// Check if target profit threshold is hit. + fn check_target_profit(&self, _position: &Option, unrealized_pnl: f64) -> bool { + if let Some(target) = self.config.target_profit { + if unrealized_pnl > target { + return true; + } + } + false + } + + /// Calculate entry fees for a position. + fn calculate_entry_fees(&self, position: &SpreadPosition) -> f64 { + let total_premium: f64 = position + .legs + .iter() + .map(|leg| leg.entry_premium.abs() * leg.config.lot_size as f64) + .sum(); + total_premium * self.config.base.fees + } + + /// Calculate exit fees for a position. + fn calculate_fees(&self, position: &SpreadPosition) -> f64 { + let total_premium: f64 = position + .legs + .iter() + .map(|leg| leg.current_premium.abs() * leg.config.lot_size as f64) + .sum(); + total_premium * self.config.base.fees * 2.0 // Entry + Exit + } + + /// Create an empty result (used for validation failures). + fn empty_result(&self, n: usize) -> BacktestResult { + BacktestResult { + metrics: BacktestMetrics::default(), + equity_curve: vec![self.config.base.initial_capital; n], + drawdown_curve: vec![0.0; n], + trades: Vec::new(), + returns: vec![0.0; n], + } + } +} + +/// Convenience function to create a straddle spread config. +pub fn create_straddle_config( + base: BacktestConfig, + strike: f64, + lot_size: usize, + short: bool, +) -> SpreadConfig { + let quantity = if short { -1 } else { 1 }; + SpreadConfig { + base, + spread_type: SpreadType::Straddle, + leg_configs: vec![ + LegConfig::new(OptionType::Call, strike, quantity, lot_size), + LegConfig::new(OptionType::Put, strike, quantity, lot_size), + ], + ..Default::default() + } +} + +/// Convenience function to create a strangle spread config. +pub fn create_strangle_config( + base: BacktestConfig, + call_strike: f64, + put_strike: f64, + lot_size: usize, + short: bool, +) -> SpreadConfig { + let quantity = if short { -1 } else { 1 }; + SpreadConfig { + base, + spread_type: SpreadType::Strangle, + leg_configs: vec![ + LegConfig::new(OptionType::Call, call_strike, quantity, lot_size), + LegConfig::new(OptionType::Put, put_strike, quantity, lot_size), + ], + ..Default::default() + } +} + +/// Convenience function to create an iron condor spread config. +pub fn create_iron_condor_config( + base: BacktestConfig, + short_put_strike: f64, + long_put_strike: f64, + short_call_strike: f64, + long_call_strike: f64, + lot_size: usize, +) -> SpreadConfig { + SpreadConfig { + base, + spread_type: SpreadType::IronCondor, + leg_configs: vec![ + LegConfig::new(OptionType::Put, short_put_strike, -1, lot_size), + LegConfig::new(OptionType::Put, long_put_strike, 1, lot_size), + LegConfig::new(OptionType::Call, short_call_strike, -1, lot_size), + LegConfig::new(OptionType::Call, long_call_strike, 1, lot_size), + ], + ..Default::default() + } +} + +/// Convenience function to create a vertical spread config. +pub fn create_vertical_spread_config( + base: BacktestConfig, + option_type: OptionType, + long_strike: f64, + short_strike: f64, + lot_size: usize, +) -> SpreadConfig { + let spread_type = match option_type { + OptionType::Call => SpreadType::VerticalCall, + OptionType::Put => SpreadType::VerticalPut, + }; + + SpreadConfig { + base, + spread_type, + leg_configs: vec![ + LegConfig::new(option_type, long_strike, 1, lot_size), + LegConfig::new(option_type, short_strike, -1, lot_size), + ], + ..Default::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::types::StopConfig; + use crate::core::types::TargetConfig; + + fn sample_data() -> (Vec, Vec, Vec>, Vec, Vec) { + let n = 20; + let timestamps: Vec = (0..n as i64).collect(); + let underlying: Vec = (100..120).map(|x| x as f64).collect(); + + // Call and Put premiums + let call_premiums: Vec = (0..n).map(|i| 5.0 + (i as f64 * 0.2)).collect(); + let put_premiums: Vec = (0..n).map(|i| 5.0 - (i as f64 * 0.1)).collect(); + + let legs_premiums = vec![call_premiums, put_premiums]; + + let entries = vec![ + false, true, false, false, false, false, false, false, false, false, false, false, + false, false, false, false, false, false, false, false, + ]; + let exits = vec![ + false, false, false, false, false, false, false, false, false, true, false, false, + false, false, false, false, false, false, false, false, + ]; + + (timestamps, underlying, legs_premiums, entries, exits) + } + + #[test] + fn test_straddle_backtest() { + let base_config = BacktestConfig { + initial_capital: 100_000.0, + fees: 0.001, + slippage: 0.0, + stop: StopConfig::None, + target: TargetConfig::None, + upon_bar_close: true, + }; + + let config = create_straddle_config(base_config, 100.0, 50, true); + let backtest = SpreadBacktest::new(config); + + let (timestamps, underlying, legs_premiums, entries, exits) = sample_data(); + + let result = backtest.run(×tamps, &underlying, &legs_premiums, &entries, &exits); + + assert_eq!(result.trades.len(), 1); + assert!(result.equity_curve.len() == timestamps.len()); + } + + #[test] + fn test_iron_condor_backtest() { + let base_config = BacktestConfig::default(); + + let config = create_iron_condor_config( + base_config, + 95.0, // short put + 90.0, // long put + 105.0, // short call + 110.0, // long call + 50, + ); + + let backtest = SpreadBacktest::new(config); + + let n = 20; + let timestamps: Vec = (0..n as i64).collect(); + let underlying: Vec = vec![100.0; n]; + + // Four legs: short put, long put, short call, long call + let legs_premiums = vec![ + vec![3.0; n], // short put + vec![1.5; n], // long put + vec![3.0; n], // short call + vec![1.5; n], // long call + ]; + + let mut entries = vec![false; n]; + entries[1] = true; + + let mut exits = vec![false; n]; + exits[15] = true; + + let result = backtest.run(×tamps, &underlying, &legs_premiums, &entries, &exits); + + assert_eq!(result.trades.len(), 1); + } +} diff --git a/uv.lock b/uv.lock index 8a412b3..c1170e5 100644 --- a/uv.lock +++ b/uv.lock @@ -4,5 +4,5 @@ requires-python = ">=3.10" [[package]] name = "raptorbt" -version = "0.1.0" +version = "0.2.0" source = { editable = "." }