use crate::monte_carlo::engine::{
MonteCarloRng, PathMetadata, SimulationModel, SimulationPath, TimeHorizon,
};
#[derive(Debug, Clone)]
pub struct EmpiricalBootstrap {
pub initial_price: f64,
pub historical_returns: Vec<f64>,
pub block_size: Option<usize>,
}
impl EmpiricalBootstrap {
#[must_use]
pub fn new(initial_price: f64, historical_returns: Vec<f64>) -> Self {
Self {
initial_price,
historical_returns,
block_size: None,
}
}
#[must_use]
pub fn with_block_size(mut self, block_size: usize) -> Self {
self.block_size = Some(block_size);
self
}
#[must_use]
pub fn historical_stats(&self) -> HistoricalStats {
if self.historical_returns.is_empty() {
return HistoricalStats::default();
}
let n = self.historical_returns.len() as f64;
let mean = self.historical_returns.iter().sum::<f64>() / n;
let variance = self
.historical_returns
.iter()
.map(|r| (r - mean).powi(2))
.sum::<f64>()
/ (n - 1.0).max(1.0);
let std = variance.sqrt();
let min = self
.historical_returns
.iter()
.copied()
.fold(f64::INFINITY, f64::min);
let max = self
.historical_returns
.iter()
.copied()
.fold(f64::NEG_INFINITY, f64::max);
HistoricalStats {
mean,
std,
min,
max,
n: self.historical_returns.len(),
}
}
fn sample_return(&self, rng: &mut MonteCarloRng) -> f64 {
if self.historical_returns.is_empty() {
return 0.0;
}
let idx = (rng.uniform() * self.historical_returns.len() as f64) as usize;
self.historical_returns[idx.min(self.historical_returns.len() - 1)]
}
fn sample_block(&self, rng: &mut MonteCarloRng, block_size: usize) -> Vec<f64> {
if self.historical_returns.is_empty() {
return vec![0.0; block_size];
}
let max_start = self.historical_returns.len().saturating_sub(block_size);
let start = if max_start > 0 {
(rng.uniform() * max_start as f64) as usize
} else {
0
};
let end = (start + block_size).min(self.historical_returns.len());
self.historical_returns[start..end].to_vec()
}
}
#[derive(Debug, Clone, Default)]
pub struct HistoricalStats {
pub mean: f64,
pub std: f64,
pub min: f64,
pub max: f64,
pub n: usize,
}
impl SimulationModel for EmpiricalBootstrap {
fn name(&self) -> &'static str {
"EmpiricalBootstrap"
}
fn generate_path(
&self,
rng: &mut MonteCarloRng,
time_horizon: &TimeHorizon,
path_id: usize,
) -> SimulationPath {
let n = time_horizon.n_steps();
let mut values = Vec::with_capacity(n + 1);
let mut price = self.initial_price;
values.push(price);
if let Some(block_size) = self.block_size {
let mut remaining = n;
while remaining > 0 {
let block = self.sample_block(rng, block_size.min(remaining));
for &ret in &block {
price *= 1.0 + ret;
values.push(price);
}
remaining = remaining.saturating_sub(block.len());
}
} else {
for _ in 0..n {
let ret = self.sample_return(rng);
price *= 1.0 + ret;
values.push(price);
}
}
SimulationPath::new(
time_horizon.time_points(),
values,
PathMetadata {
path_id,
seed: rng.seed(),
is_antithetic: false,
},
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::monte_carlo::engine::MonteCarloEngine;
fn sample_returns() -> Vec<f64> {
vec![
0.02, -0.01, 0.03, 0.01, -0.02, 0.04, -0.03, 0.02, 0.01, -0.01, 0.02, 0.03,
]
}
#[test]
fn test_bootstrap_basic() {
let model = EmpiricalBootstrap::new(100.0, sample_returns());
let engine = MonteCarloEngine::reproducible(42).with_n_simulations(1000);
let horizon = TimeHorizon::months(12);
let result = engine.simulate(&model, &horizon);
assert_eq!(result.n_paths(), 1000);
assert!(result.final_value_statistics().mean > 0.0);
}
#[test]
fn test_bootstrap_positive_prices() {
let model = EmpiricalBootstrap::new(100.0, sample_returns());
let engine = MonteCarloEngine::reproducible(42).with_n_simulations(100);
let horizon = TimeHorizon::years(1);
let result = engine.simulate(&model, &horizon);
for path in &result.paths {
for &value in &path.values {
assert!(value > 0.0, "Prices must stay positive");
}
}
}
#[test]
fn test_bootstrap_reproducibility() {
let model = EmpiricalBootstrap::new(100.0, sample_returns());
let horizon = TimeHorizon::months(12);
let result1 = MonteCarloEngine::reproducible(42)
.with_n_simulations(100)
.simulate(&model, &horizon);
let result2 = MonteCarloEngine::reproducible(42)
.with_n_simulations(100)
.simulate(&model, &horizon);
for (p1, p2) in result1.paths.iter().zip(result2.paths.iter()) {
for (v1, v2) in p1.values.iter().zip(p2.values.iter()) {
assert!((v1 - v2).abs() < 1e-10);
}
}
}
#[test]
fn test_bootstrap_block() {
let model = EmpiricalBootstrap::new(100.0, sample_returns()).with_block_size(3);
let engine = MonteCarloEngine::reproducible(42).with_n_simulations(100);
let horizon = TimeHorizon::months(12);
let result = engine.simulate(&model, &horizon);
assert!(result.n_paths() > 0);
}
#[test]
fn test_historical_stats() {
let model = EmpiricalBootstrap::new(100.0, sample_returns());
let stats = model.historical_stats();
assert_eq!(stats.n, 12);
assert!(stats.std > 0.0);
assert!(stats.min < stats.max);
}
#[test]
fn test_empty_returns() {
let model = EmpiricalBootstrap::new(100.0, vec![]);
let engine = MonteCarloEngine::reproducible(42).with_n_simulations(10);
let horizon = TimeHorizon::months(6);
let result = engine.simulate(&model, &horizon);
for path in &result.paths {
assert!((path.values[0] - 100.0).abs() < 1e-10);
}
}
#[test]
fn test_preserves_return_distribution() {
use crate::monte_carlo::engine::TimeStep;
let returns = sample_returns();
let n = returns.len() as f64;
let mean = returns.iter().sum::<f64>() / n;
let model = EmpiricalBootstrap::new(100.0, returns);
let engine = MonteCarloEngine::reproducible(42).with_n_simulations(10000);
let horizon = TimeHorizon::months(1).with_step(TimeStep::Monthly);
let result = engine.simulate(&model, &horizon);
let realized_returns: Vec<f64> = result
.paths
.iter()
.filter_map(|p| p.total_return())
.collect();
let realized_mean = realized_returns.iter().sum::<f64>() / realized_returns.len() as f64;
assert!(
(realized_mean - mean).abs() < 0.01,
"Realized mean {} should be close to historical mean {}",
realized_mean,
mean
);
}
#[cfg(test)]
mod proptests {
use super::*;
use proptest::prelude::*;
proptest! {
#[test]
fn prop_bootstrap_positive_prices(
initial in 10.0..1000.0f64,
returns in prop::collection::vec(-0.2..0.2f64, 10..50),
seed: u64,
) {
let model = EmpiricalBootstrap::new(initial, returns);
let engine = MonteCarloEngine::reproducible(seed).with_n_simulations(50);
let horizon = TimeHorizon::months(12);
let result = engine.simulate(&model, &horizon);
for path in &result.paths {
for &value in &path.values {
prop_assert!(value > 0.0, "Prices must be positive: {value}");
}
}
}
}
}
}