feat: update version to 0.2.1 and add rolling min/max indicators (#6)
* feat: add session tracking and multi-leg spread backtesting Add SessionTracker for trading session management: - Market hours detection (pre-open, trading, squareoff, post-close) - Session boundary tracking with configurable timezone - Squareoff time support for intraday strategies - Session high/low/open price tracking Add SpreadBacktest for multi-leg options strategies: - Support for straddles, strangles, vertical spreads, iron condors - Coordinated entry/exit across all legs - Net premium P&L calculation with max loss/target profit exits - Helper functions for common spread configurations Extend StreamingMetrics for backtest integration: - Add equity and drawdown tracking (update_equity, current_drawdown_pct) - Add trade recording (record_trade, record_fees) - Add finalize() method to produce BacktestMetrics - Add with_initial_capital() constructor Bump version to 0.2.0. * chore: bump up version to 0.2.0 * feat: update version to 0.2.1 and add rolling min/max indicators * fix: formatting
This commit is contained in:
Generated
+1
-1
@@ -502,7 +502,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "raptorbt"
|
||||
version = "0.1.0"
|
||||
version = "0.2.1"
|
||||
dependencies = [
|
||||
"approx",
|
||||
"criterion",
|
||||
|
||||
+1
-1
@@ -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 <contact@alphabench.in>"]
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -37,7 +37,7 @@ from raptorbt._raptorbt import (
|
||||
supertrend,
|
||||
)
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.2.1"
|
||||
|
||||
__all__ = [
|
||||
# Config classes
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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::*;
|
||||
|
||||
@@ -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<i64>) -> 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<i64>,
|
||||
next_timestamp_ns: Option<i64>,
|
||||
) -> (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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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<Vec<f64>, 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<Vec<f64>, 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());
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<i64>,
|
||||
underlying_close: PyReadonlyArray1<f64>,
|
||||
legs_premiums: Vec<PyReadonlyArray1<f64>>,
|
||||
leg_configs: Vec<(String, f64, i32, usize)>, // (option_type, strike, quantity, lot_size)
|
||||
entries: PyReadonlyArray1<bool>,
|
||||
exits: PyReadonlyArray1<bool>,
|
||||
config: Option<&PyBacktestConfig>,
|
||||
spread_type: &str,
|
||||
max_loss: Option<f64>,
|
||||
target_profit: Option<f64>,
|
||||
) -> PyResult<PyBacktestResult> {
|
||||
let ts = numpy_to_vec_i64(timestamps);
|
||||
let underlying = numpy_to_vec_f64(underlying_close);
|
||||
let premiums: Vec<Vec<f64>> = 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<LegConfig> = 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<f64>,
|
||||
period: usize,
|
||||
) -> PyResult<&'py PyArray1<f64>> {
|
||||
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<f64>,
|
||||
period: usize,
|
||||
) -> PyResult<&'py PyArray1<f64>> {
|
||||
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
|
||||
// ============================================================================
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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<Self> {
|
||||
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<LegConfig>,
|
||||
/// Maximum loss threshold (optional, for early exit).
|
||||
pub max_loss: Option<f64>,
|
||||
/// Target profit threshold (optional, for early exit).
|
||||
pub target_profit: Option<f64>,
|
||||
/// 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<LegPosition>,
|
||||
/// 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<LegPosition>, 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<f64>],
|
||||
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<Trade> = Vec::new();
|
||||
let mut trade_id: u64 = 0;
|
||||
|
||||
let mut cash = self.config.base.initial_capital;
|
||||
let mut position: Option<SpreadPosition> = None;
|
||||
let mut prev_equity = cash;
|
||||
|
||||
// Single-pass O(n) algorithm
|
||||
for i in 0..n {
|
||||
// Get current leg premiums
|
||||
let current_premiums: Vec<f64> = 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<LegPosition> = 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<SpreadPosition>, 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<SpreadPosition>, 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<i64>, Vec<f64>, Vec<Vec<f64>>, Vec<bool>, Vec<bool>) {
|
||||
let n = 20;
|
||||
let timestamps: Vec<i64> = (0..n as i64).collect();
|
||||
let underlying: Vec<f64> = (100..120).map(|x| x as f64).collect();
|
||||
|
||||
// Call and Put premiums
|
||||
let call_premiums: Vec<f64> = (0..n).map(|i| 5.0 + (i as f64 * 0.2)).collect();
|
||||
let put_premiums: Vec<f64> = (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<i64> = (0..n as i64).collect();
|
||||
let underlying: Vec<f64> = 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user