mod convergence;
mod rng;
mod types;
mod variance;
pub use convergence::{autocorrelation, calculate_ess, ess_autocorr, ConvergenceDiagnostics};
pub use rng::MonteCarloRng;
pub use types::{
percentile, Budget, PathMetadata, Percentiles, SimulationPath, Statistics, TimeHorizon,
TimeStep,
};
pub use variance::{empirical_variance_reduction, inverse_normal_cdf, VarianceReduction};
pub trait SimulationModel: Send + Sync {
fn name(&self) -> &'static str;
fn generate_path(
&self,
rng: &mut MonteCarloRng,
time_horizon: &TimeHorizon,
path_id: usize,
) -> SimulationPath;
fn generate_antithetic_path(
&self,
rng: &mut MonteCarloRng,
time_horizon: &TimeHorizon,
path_id: usize,
) -> SimulationPath {
self.generate_path(rng, time_horizon, path_id)
}
}
#[derive(Debug, Clone)]
pub struct MonteCarloEngine {
pub n_simulations: usize,
pub seed: u64,
pub variance_reduction: VarianceReduction,
pub target_precision: f64,
pub max_simulations: usize,
}
impl MonteCarloEngine {
#[must_use]
pub fn new(seed: u64) -> Self {
Self {
n_simulations: 10_000,
seed,
variance_reduction: VarianceReduction::Antithetic,
target_precision: 0.01,
max_simulations: 100_000,
}
}
#[must_use]
pub fn reproducible(seed: u64) -> Self {
Self::new(seed)
}
#[must_use]
pub fn with_n_simulations(mut self, n: usize) -> Self {
self.n_simulations = n;
self
}
#[must_use]
pub fn with_variance_reduction(mut self, vr: VarianceReduction) -> Self {
self.variance_reduction = vr;
self
}
#[must_use]
pub fn with_target_precision(mut self, precision: f64) -> Self {
self.target_precision = precision;
self
}
#[must_use]
pub fn with_max_simulations(mut self, max: usize) -> Self {
self.max_simulations = max;
self
}
pub fn simulate<M: SimulationModel>(
&self,
model: &M,
time_horizon: &TimeHorizon,
) -> SimulationResult {
let mut rng = MonteCarloRng::new(self.seed);
let use_antithetic = matches!(self.variance_reduction, VarianceReduction::Antithetic);
let effective_n = if use_antithetic {
self.n_simulations / 2
} else {
self.n_simulations
};
let mut paths = Vec::with_capacity(self.n_simulations);
let mut diagnostics = ConvergenceDiagnostics::new();
for i in 0..effective_n {
let path = model.generate_path(&mut rng, time_horizon, i);
paths.push(path);
if use_antithetic {
let antithetic = model.generate_antithetic_path(&mut rng, time_horizon, i);
paths.push(antithetic);
}
if (i + 1) % 100 == 0 || i == effective_n - 1 {
diagnostics.update(&paths);
if diagnostics.is_converged(self.target_precision) && i >= 100 {
break;
}
}
}
SimulationResult {
paths,
diagnostics,
model_name: model.name().to_string(),
seed: self.seed,
}
}
pub fn simulate_with_budget<M: SimulationModel>(
&self,
model: &M,
time_horizon: &TimeHorizon,
budget: &Budget,
) -> SimulationResult {
let mut rng = MonteCarloRng::new(self.seed);
let use_antithetic = matches!(self.variance_reduction, VarianceReduction::Antithetic);
let max_n = budget.max_simulations();
let effective_n = if use_antithetic { max_n / 2 } else { max_n };
let mut paths = Vec::with_capacity(max_n);
let mut diagnostics = ConvergenceDiagnostics::new();
let mut converged = false;
for i in 0..effective_n {
let path = model.generate_path(&mut rng, time_horizon, i);
paths.push(path);
if use_antithetic {
let antithetic = model.generate_antithetic_path(&mut rng, time_horizon, i);
paths.push(antithetic);
}
if let Budget::Convergence {
patience,
min_delta,
..
} = &budget
{
if (i + 1) % 100 == 0 {
diagnostics.update(&paths);
if diagnostics.running_mean_history.len() >= *patience {
let recent = &diagnostics.running_mean_history
[diagnostics.running_mean_history.len() - patience..];
let range = recent.iter().copied().fold(f64::NEG_INFINITY, f64::max)
- recent.iter().copied().fold(f64::INFINITY, f64::min);
if range < *min_delta {
converged = true;
break;
}
}
}
}
}
if !converged {
diagnostics.update(&paths);
}
SimulationResult {
paths,
diagnostics,
model_name: model.name().to_string(),
seed: self.seed,
}
}
}
impl Default for MonteCarloEngine {
fn default() -> Self {
Self::new(42)
}
}
#[derive(Debug, Clone)]
pub struct SimulationResult {
pub paths: Vec<SimulationPath>,
pub diagnostics: ConvergenceDiagnostics,
pub model_name: String,
pub seed: u64,
}
impl SimulationResult {
#[must_use]
pub fn n_paths(&self) -> usize {
self.paths.len()
}
#[must_use]
pub fn final_values(&self) -> Vec<f64> {
self.paths
.iter()
.filter_map(SimulationPath::final_value)
.collect()
}
#[must_use]
pub fn total_returns(&self) -> Vec<f64> {
self.paths
.iter()
.filter_map(SimulationPath::total_return)
.collect()
}
#[must_use]
pub fn final_value_statistics(&self) -> Statistics {
Statistics::from_values(&self.final_values())
}
#[must_use]
pub fn return_statistics(&self) -> Statistics {
Statistics::from_values(&self.total_returns())
}
#[must_use]
pub fn values_at_time(&self, time_idx: usize) -> Vec<f64> {
self.paths
.iter()
.filter_map(|p| p.values.get(time_idx).copied())
.collect()
}
#[must_use]
pub fn statistics_over_time(&self) -> Vec<Statistics> {
if self.paths.is_empty() {
return Vec::new();
}
let n_times = self.paths.first().map_or(0, |p| p.values.len());
(0..n_times)
.map(|t| Statistics::from_values(&self.values_at_time(t)))
.collect()
}
#[must_use]
pub fn final_value_percentiles(&self) -> Percentiles {
Percentiles::from_values(&self.final_values())
}
#[must_use]
pub fn return_percentiles(&self) -> Percentiles {
Percentiles::from_values(&self.total_returns())
}
}
#[cfg(test)]
#[path = "engine_tests.rs"]
mod tests;