diff --git a/Cargo.lock b/Cargo.lock index d6b508d..69b7e81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -502,7 +502,7 @@ dependencies = [ [[package]] name = "raptorbt" -version = "0.3.0" +version = "0.3.1" dependencies = [ "approx", "criterion", diff --git a/Cargo.toml b/Cargo.toml index 600b1f1..314d352 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "raptorbt" -version = "0.3.0" +version = "0.3.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 97b7450..bdc9046 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "maturin" [project] name = "raptorbt" -version = "0.3.0" +version = "0.3.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 3c704c0..628efe5 100644 --- a/python/raptorbt/__init__.py +++ b/python/raptorbt/__init__.py @@ -26,6 +26,8 @@ from raptorbt._raptorbt import ( run_pairs_backtest, run_multi_backtest, run_spread_backtest, + # Monte Carlo simulation + simulate_portfolio_mc, # Indicator functions sma, ema, @@ -41,7 +43,7 @@ from raptorbt._raptorbt import ( rolling_max, ) -__version__ = "0.3.0" +__version__ = "0.3.1" __all__ = [ # Config classes @@ -60,6 +62,8 @@ __all__ = [ "run_pairs_backtest", "run_multi_backtest", "run_spread_backtest", + # Monte Carlo simulation + "simulate_portfolio_mc", # Indicator functions "sma", "ema", diff --git a/python/raptorbt/__pycache__/__init__.cpython-311.pyc b/python/raptorbt/__pycache__/__init__.cpython-311.pyc index 8ddd478..eb2484f 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 c6f8b56..cd56367 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/lib.rs b/src/lib.rs index 59aa21d..788eb8f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,6 +44,9 @@ fn _raptorbt(_py: Python<'_>, m: &PyModule) -> PyResult<()> { m.add_function(wrap_pyfunction!(python::bindings::run_multi_backtest, m)?)?; m.add_function(wrap_pyfunction!(python::bindings::run_spread_backtest, m)?)?; + // Register Monte Carlo simulation + m.add_function(wrap_pyfunction!(python::bindings::simulate_portfolio_mc, m)?)?; + // Register indicator functions m.add_function(wrap_pyfunction!(python::bindings::sma, m)?)?; m.add_function(wrap_pyfunction!(python::bindings::ema, m)?)?; diff --git a/src/portfolio/mod.rs b/src/portfolio/mod.rs index 8338c30..00890d0 100644 --- a/src/portfolio/mod.rs +++ b/src/portfolio/mod.rs @@ -2,8 +2,10 @@ pub mod allocation; pub mod engine; +pub mod monte_carlo; pub mod position; pub use allocation::{AllocationStrategy, CapitalAllocator}; pub use engine::PortfolioEngine; +pub use monte_carlo::{simulate_portfolio_forward, MonteCarloConfig, MonteCarloResult}; pub use position::PositionManager; diff --git a/src/portfolio/monte_carlo.rs b/src/portfolio/monte_carlo.rs new file mode 100644 index 0000000..1ee95c1 --- /dev/null +++ b/src/portfolio/monte_carlo.rs @@ -0,0 +1,361 @@ +//! Monte Carlo forward simulation for portfolio projection. +//! +//! Uses Geometric Brownian Motion (GBM) with Cholesky decomposition +//! for correlated multi-asset simulation. Parallelized via Rayon. + +use rayon::prelude::*; + +/// Configuration for Monte Carlo simulation. +#[derive(Debug, Clone)] +pub struct MonteCarloConfig { + pub n_simulations: usize, + pub horizon_days: usize, + pub seed: u64, +} + +impl Default for MonteCarloConfig { + fn default() -> Self { + Self { n_simulations: 10_000, horizon_days: 252, seed: 42 } + } +} + +/// Result of a Monte Carlo simulation. +#[derive(Debug, Clone)] +pub struct MonteCarloResult { + /// Percentile paths: Vec of (percentile, path_values) + pub percentile_paths: Vec<(f64, Vec)>, + /// Terminal value for each simulation + pub final_values: Vec, + /// Expected annualized return + pub expected_return: f64, + /// Probability of loss (final value < initial value) + pub probability_of_loss: f64, + /// Value at Risk at 95% confidence + pub var_95: f64, + /// Conditional Value at Risk at 95% confidence + pub cvar_95: f64, +} + +/// Cholesky decomposition of a symmetric positive-definite matrix. +/// Returns lower-triangular matrix L such that A = L * L^T. +fn cholesky(matrix: &[Vec]) -> Result>, &'static str> { + let n = matrix.len(); + let mut l = vec![vec![0.0; n]; n]; + + for i in 0..n { + for j in 0..=i { + let mut sum = 0.0; + for k in 0..j { + sum += l[i][k] * l[j][k]; + } + + if i == j { + let diag = matrix[i][i] - sum; + if diag <= 0.0 { + // Matrix is not positive definite; use a small epsilon + l[i][j] = (diag.abs().max(1e-10)).sqrt(); + } else { + l[i][j] = diag.sqrt(); + } + } else { + if l[j][j].abs() < 1e-15 { + l[i][j] = 0.0; + } else { + l[i][j] = (matrix[i][j] - sum) / l[j][j]; + } + } + } + } + + Ok(l) +} + +/// Simple xoshiro256** PRNG for deterministic parallel simulation. +#[derive(Clone)] +struct Xoshiro256 { + s: [u64; 4], +} + +impl Xoshiro256 { + fn new(seed: u64) -> Self { + // SplitMix64 to seed all 4 state words + let mut z = seed; + let mut s = [0u64; 4]; + for item in &mut s { + z = z.wrapping_add(0x9e3779b97f4a7c15); + z = (z ^ (z >> 30)).wrapping_mul(0xbf58476d1ce4e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d049bb133111eb); + *item = z ^ (z >> 31); + } + Self { s } + } + + fn jump(&mut self) { + // Jump function: advances state by 2^128 calls + const JUMP: [u64; 4] = + [0x180ec6d33cfd0aba, 0xd5a61266f0c9392c, 0xa9582618e03fc9aa, 0x39abdc4529b1661c]; + let mut s0: u64 = 0; + let mut s1: u64 = 0; + let mut s2: u64 = 0; + let mut s3: u64 = 0; + for j in &JUMP { + for b in 0..64 { + if j & (1u64 << b) != 0 { + s0 ^= self.s[0]; + s1 ^= self.s[1]; + s2 ^= self.s[2]; + s3 ^= self.s[3]; + } + self.next_u64(); + } + } + self.s[0] = s0; + self.s[1] = s1; + self.s[2] = s2; + self.s[3] = s3; + } + + fn next_u64(&mut self) -> u64 { + let result = (self.s[1].wrapping_mul(5)).rotate_left(7).wrapping_mul(9); + let t = self.s[1] << 17; + self.s[2] ^= self.s[0]; + self.s[3] ^= self.s[1]; + self.s[1] ^= self.s[2]; + self.s[0] ^= self.s[3]; + self.s[2] ^= t; + self.s[3] = self.s[3].rotate_left(45); + result + } + + /// Generate uniform f64 in [0, 1). + fn next_f64(&mut self) -> f64 { + (self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64) + } + + /// Box-Muller transform for standard normal. + fn next_normal(&mut self) -> f64 { + let u1 = self.next_f64().max(1e-15); + let u2 = self.next_f64(); + (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos() + } +} + +/// Core Monte Carlo simulation function. +/// +/// # Arguments +/// * `returns` - Per-strategy daily returns (N strategies x T days each) +/// * `weights` - Portfolio weights (length N, must sum to 1) +/// * `correlation_matrix` - N x N correlation matrix +/// * `initial_value` - Starting portfolio value +/// * `config` - Simulation configuration +pub fn simulate_portfolio_forward( + returns: &[Vec], + weights: &[f64], + correlation_matrix: &[Vec], + initial_value: f64, + config: &MonteCarloConfig, +) -> MonteCarloResult { + let n_assets = returns.len(); + let dt = 1.0; // daily time step + + // Compute per-asset mean and std of historical returns + let mut mus = vec![0.0; n_assets]; + let mut sigmas = vec![0.0; n_assets]; + for (i, ret) in returns.iter().enumerate() { + if ret.is_empty() { + continue; + } + let mean = ret.iter().sum::() / ret.len() as f64; + let var = ret.iter().map(|r| (r - mean).powi(2)).sum::() / ret.len() as f64; + mus[i] = mean; + sigmas[i] = var.sqrt().max(1e-10); + } + + // Cholesky decomposition of correlation matrix + let chol = cholesky(correlation_matrix).unwrap_or_else(|_| { + // Fallback: identity matrix (independent assets) + let mut identity = vec![vec![0.0; n_assets]; n_assets]; + for i in 0..n_assets { + identity[i][i] = 1.0; + } + identity + }); + + // Prepare a base RNG and create per-chunk seeds via jumping + let mut base_rng = Xoshiro256::new(config.seed); + let n_chunks = rayon::current_num_threads().max(1); + let chunk_size = (config.n_simulations + n_chunks - 1) / n_chunks; + + let chunk_rngs: Vec = (0..n_chunks) + .map(|_| { + let rng = base_rng.clone(); + base_rng.jump(); + rng + }) + .collect(); + + // Run simulations in parallel chunks + let all_paths: Vec> = chunk_rngs + .into_par_iter() + .enumerate() + .flat_map(|(chunk_idx, mut rng)| { + let start = chunk_idx * chunk_size; + let end = (start + chunk_size).min(config.n_simulations); + let mut chunk_paths = Vec::with_capacity(end - start); + + for _ in start..end { + let mut portfolio_value = initial_value; + let mut path = Vec::with_capacity(config.horizon_days + 1); + path.push(portfolio_value); + + for _ in 0..config.horizon_days { + // Generate N independent standard normals + let z_indep: Vec = (0..n_assets).map(|_| rng.next_normal()).collect(); + + // Correlate via Cholesky: z_corr = L * z_indep + let mut z_corr = vec![0.0; n_assets]; + for i in 0..n_assets { + for j in 0..=i { + z_corr[i] += chol[i][j] * z_indep[j]; + } + } + + // GBM per asset, then weighted portfolio return + let mut portfolio_return = 0.0; + for i in 0..n_assets { + let drift = (mus[i] - 0.5 * sigmas[i].powi(2)) * dt; + let diffusion = sigmas[i] * dt.sqrt() * z_corr[i]; + let asset_return = (drift + diffusion).exp() - 1.0; + portfolio_return += weights[i] * asset_return; + } + + portfolio_value *= 1.0 + portfolio_return; + path.push(portfolio_value); + } + + chunk_paths.push(path); + } + + chunk_paths + }) + .collect(); + + // Extract final values + let mut final_values: Vec = all_paths.iter().map(|p| *p.last().unwrap()).collect(); + final_values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + + let n = final_values.len(); + + // Percentile paths: find simulations closest to each percentile's final value + let percentiles = [5.0, 25.0, 50.0, 75.0, 95.0]; + let percentile_paths: Vec<(f64, Vec)> = percentiles + .iter() + .map(|&pct| { + let idx = ((pct / 100.0) * (n as f64 - 1.0)).round() as usize; + let target_final = final_values[idx.min(n - 1)]; + + // Find the simulation path whose final value is closest to target + let best_idx = all_paths + .iter() + .enumerate() + .min_by(|(_, a), (_, b)| { + let da = (a.last().unwrap() - target_final).abs(); + let db = (b.last().unwrap() - target_final).abs(); + da.partial_cmp(&db).unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(i, _)| i) + .unwrap_or(0); + + (pct, all_paths[best_idx].clone()) + }) + .collect(); + + // Expected return (annualized from mean of final values) + let mean_final = final_values.iter().sum::() / n as f64; + let expected_return = (mean_final / initial_value - 1.0) * 100.0; + + // Probability of loss + let n_loss = final_values.iter().filter(|&&v| v < initial_value).count(); + let probability_of_loss = n_loss as f64 / n as f64; + + // VaR 95%: 5th percentile loss + let p5_idx = ((0.05 * (n as f64 - 1.0)).round() as usize).min(n - 1); + let var_95 = ((initial_value - final_values[p5_idx]) / initial_value * 100.0).max(0.0); + + // CVaR 95%: average of losses below VaR + let cvar_values = &final_values[..=p5_idx]; + let cvar_95 = if cvar_values.is_empty() { + var_95 + } else { + let avg_tail = cvar_values.iter().sum::() / cvar_values.len() as f64; + ((initial_value - avg_tail) / initial_value * 100.0).max(0.0) + }; + + MonteCarloResult { + percentile_paths, + final_values, + expected_return, + probability_of_loss, + var_95, + cvar_95, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cholesky_identity() { + let matrix = vec![vec![1.0, 0.0], vec![0.0, 1.0]]; + let l = cholesky(&matrix).unwrap(); + assert!((l[0][0] - 1.0).abs() < 1e-10); + assert!((l[1][1] - 1.0).abs() < 1e-10); + assert!(l[0][1].abs() < 1e-10); + assert!(l[1][0].abs() < 1e-10); + } + + #[test] + fn test_cholesky_correlated() { + let matrix = vec![vec![1.0, 0.5], vec![0.5, 1.0]]; + let l = cholesky(&matrix).unwrap(); + // Verify L * L^T = matrix + let reconstructed_00 = l[0][0] * l[0][0]; + let reconstructed_01 = l[1][0] * l[0][0]; + let reconstructed_11 = l[1][0] * l[1][0] + l[1][1] * l[1][1]; + assert!((reconstructed_00 - 1.0).abs() < 1e-10); + assert!((reconstructed_01 - 0.5).abs() < 1e-10); + assert!((reconstructed_11 - 1.0).abs() < 1e-10); + } + + #[test] + fn test_simulate_basic() { + // Two assets with identical positive returns + let returns = vec![vec![0.001; 252], vec![0.001; 252]]; + let weights = vec![0.5, 0.5]; + let corr = vec![vec![1.0, 0.0], vec![0.0, 1.0]]; + let config = MonteCarloConfig { n_simulations: 100, horizon_days: 10, seed: 42 }; + + let result = simulate_portfolio_forward(&returns, &weights, &corr, 100000.0, &config); + + assert_eq!(result.final_values.len(), 100); + assert_eq!(result.percentile_paths.len(), 5); + // Expected return should be positive given positive drift + assert!(result.expected_return > -50.0); // Sanity check + } + + #[test] + fn test_deterministic() { + let returns = vec![vec![0.001; 100], vec![-0.0005; 100]]; + let weights = vec![0.6, 0.4]; + let corr = vec![vec![1.0, -0.3], vec![-0.3, 1.0]]; + let config = MonteCarloConfig { n_simulations: 50, horizon_days: 20, seed: 123 }; + + let r1 = simulate_portfolio_forward(&returns, &weights, &corr, 100000.0, &config); + let r2 = simulate_portfolio_forward(&returns, &weights, &corr, 100000.0, &config); + + // Same seed should produce same final values (single-threaded determinism) + // Note: with rayon, parallelism may affect order but not values + assert!((r1.expected_return - r2.expected_return).abs() < 1e-6); + } +} diff --git a/src/python/bindings.rs b/src/python/bindings.rs index f6f0ca7..5c657ea 100644 --- a/src/python/bindings.rs +++ b/src/python/bindings.rs @@ -1098,6 +1098,77 @@ pub fn rolling_max<'py>( // Helper Functions // ============================================================================ +// ============================================================================ +// Monte Carlo Forward Simulation +// ============================================================================ + +/// Run Monte Carlo forward simulation for a portfolio. +/// +/// Uses Geometric Brownian Motion with Cholesky-decomposed correlated random +/// draws, parallelized via Rayon. +/// +/// # Arguments +/// * `returns` - List of per-strategy return arrays (N strategies) +/// * `weights` - Portfolio weight vector (length N, sums to 1) +/// * `correlation_matrix` - N x N correlation matrix (flattened row-major as 2D list) +/// * `initial_value` - Starting portfolio value +/// * `n_simulations` - Number of simulation paths (default: 10000) +/// * `horizon_days` - Forward simulation horizon in trading days (default: 252) +/// * `seed` - Random seed for reproducibility (default: 42) +#[pyfunction] +#[pyo3(signature = (returns, weights, correlation_matrix, initial_value, n_simulations=10000, horizon_days=252, seed=42))] +pub fn simulate_portfolio_mc( + py: Python<'_>, + returns: Vec>, + weights: PyReadonlyArray1<'_, f64>, + correlation_matrix: Vec>, + initial_value: f64, + n_simulations: usize, + horizon_days: usize, + seed: u64, +) -> PyResult { + use crate::portfolio::monte_carlo::{simulate_portfolio_forward, MonteCarloConfig}; + + // Convert numpy arrays to Rust vecs + let rust_returns: Vec> = + returns.iter().map(|arr| arr.as_slice().unwrap().to_vec()).collect(); + + let rust_weights: Vec = weights.as_slice().unwrap().to_vec(); + + let rust_corr: Vec> = + correlation_matrix.iter().map(|arr| arr.as_slice().unwrap().to_vec()).collect(); + + let config = MonteCarloConfig { n_simulations, horizon_days, seed }; + + // Run simulation (releases GIL for Rayon parallelism) + let result = py.allow_threads(|| { + simulate_portfolio_forward(&rust_returns, &rust_weights, &rust_corr, initial_value, &config) + }); + + // Build Python dict result + let dict = pyo3::types::PyDict::new(py); + + // percentile_paths: list of (percentile, list[float]) + let paths_list = pyo3::types::PyList::empty(py); + for (pct, path) in &result.percentile_paths { + let path_list = pyo3::types::PyList::new(py, path); + let tuple = pyo3::types::PyTuple::new(py, &[pct.to_object(py), path_list.to_object(py)]); + paths_list.append(tuple)?; + } + dict.set_item("percentile_paths", paths_list)?; + + // final_values as numpy array for efficiency + let final_arr = PyArray1::from_vec(py, result.final_values); + dict.set_item("final_values", final_arr)?; + + dict.set_item("expected_return", result.expected_return)?; + dict.set_item("probability_of_loss", result.probability_of_loss)?; + dict.set_item("var_95", result.var_95)?; + dict.set_item("cvar_95", result.cvar_95)?; + + Ok(dict.into()) +} + /// Convert Rust BacktestResult to Python PyBacktestResult. fn convert_result(result: crate::core::types::BacktestResult) -> PyBacktestResult { let metrics = PyBacktestMetrics {