Initial commit
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
//! Scenario generation using world models.
|
||||
//!
|
||||
//! Generates multiple price paths for market scenarios.
|
||||
|
||||
use crate::MarketSimError;
|
||||
use crate::world_model::MarketWorldModel;
|
||||
use marketsim_shared::{
|
||||
AssetPathStats, AssetPricePaths, PathStatistics, ScenarioRequest, ScenarioResult,
|
||||
SimulationMetadata,
|
||||
};
|
||||
use std::time::Instant;
|
||||
|
||||
/// Scenario generator.
|
||||
#[derive(Debug)]
|
||||
pub struct ScenarioGenerator {
|
||||
/// Default number of paths if not specified.
|
||||
#[allow(dead_code)]
|
||||
default_paths: usize,
|
||||
}
|
||||
|
||||
impl Default for ScenarioGenerator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ScenarioGenerator {
|
||||
/// Create a new generator.
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self { default_paths: 100 }
|
||||
}
|
||||
|
||||
/// Generate scenarios.
|
||||
pub fn generate(
|
||||
&self,
|
||||
request: &ScenarioRequest,
|
||||
world_model: &MarketWorldModel,
|
||||
) -> Result<ScenarioResult, MarketSimError> {
|
||||
let start = Instant::now();
|
||||
|
||||
// Get scenario-adjusted parameters
|
||||
let params = world_model
|
||||
.get_scenario_params(request.scenario.scenario_type, request.scenario.severity);
|
||||
|
||||
// Initialize RNG with seed
|
||||
let seed = request.seed.unwrap_or(42);
|
||||
let mut rng = SimpleRng::new(seed);
|
||||
|
||||
// Time step (daily)
|
||||
let dt = 1.0 / 252.0;
|
||||
|
||||
// Generate paths for each asset
|
||||
let mut price_paths = Vec::with_capacity(request.assets.len());
|
||||
|
||||
for asset in &request.assets {
|
||||
// Get initial price from state
|
||||
let initial_price = request
|
||||
.initial_state
|
||||
.prices
|
||||
.iter()
|
||||
.find(|p| p.symbol == asset.symbol)
|
||||
.map_or(asset.current_price, |p| p.price);
|
||||
|
||||
// Generate paths
|
||||
let mut paths = Vec::with_capacity(request.num_paths);
|
||||
let mut return_paths = Vec::with_capacity(request.num_paths);
|
||||
|
||||
for _ in 0..request.num_paths {
|
||||
let mut path = Vec::with_capacity(request.horizon_days + 1);
|
||||
let mut returns = Vec::with_capacity(request.horizon_days);
|
||||
|
||||
path.push(initial_price);
|
||||
|
||||
for _ in 0..request.horizon_days {
|
||||
let z = rng.normal();
|
||||
let z_jump = rng.normal();
|
||||
let u_jump = rng.uniform();
|
||||
|
||||
let new_price =
|
||||
world_model.step(*path.last().unwrap(), ¶ms, dt, z, z_jump, u_jump);
|
||||
|
||||
let ret = (new_price / path.last().unwrap()).ln();
|
||||
returns.push(ret);
|
||||
path.push(new_price);
|
||||
}
|
||||
|
||||
paths.push(path);
|
||||
return_paths.push(returns);
|
||||
}
|
||||
|
||||
price_paths.push(AssetPricePaths {
|
||||
symbol: asset.symbol.clone(),
|
||||
paths,
|
||||
return_paths,
|
||||
});
|
||||
}
|
||||
|
||||
// Calculate statistics
|
||||
let statistics = self.calculate_statistics(&price_paths, request.horizon_days);
|
||||
|
||||
let computation_time = start.elapsed().as_millis() as u64;
|
||||
|
||||
Ok(ScenarioResult {
|
||||
scenario: request.scenario.clone(),
|
||||
price_paths,
|
||||
statistics,
|
||||
metadata: SimulationMetadata {
|
||||
computation_time_ms: computation_time,
|
||||
model_version: "1.0.0".to_string(),
|
||||
seed_used: seed,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Calculate statistics from price paths.
|
||||
fn calculate_statistics(
|
||||
&self,
|
||||
price_paths: &[AssetPricePaths],
|
||||
horizon: usize,
|
||||
) -> PathStatistics {
|
||||
let mut asset_stats = Vec::with_capacity(price_paths.len());
|
||||
|
||||
for asset_paths in price_paths {
|
||||
// Collect final prices
|
||||
let mut final_prices: Vec<f64> = asset_paths.paths.iter().map(|p| p[horizon]).collect();
|
||||
|
||||
final_prices.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
|
||||
let n = final_prices.len();
|
||||
let initial_price = asset_paths.paths[0][0];
|
||||
|
||||
// Calculate returns
|
||||
let returns: Vec<f64> = final_prices
|
||||
.iter()
|
||||
.map(|p| (p / initial_price).ln())
|
||||
.collect();
|
||||
|
||||
let mean_return: f64 = returns.iter().sum::<f64>() / n as f64;
|
||||
|
||||
// Volatility from return paths
|
||||
let all_returns: Vec<f64> = asset_paths
|
||||
.return_paths
|
||||
.iter()
|
||||
.flat_map(|r| r.iter())
|
||||
.copied()
|
||||
.collect();
|
||||
|
||||
let mean_daily: f64 = all_returns.iter().sum::<f64>() / all_returns.len() as f64;
|
||||
let variance: f64 = all_returns
|
||||
.iter()
|
||||
.map(|r| (r - mean_daily).powi(2))
|
||||
.sum::<f64>()
|
||||
/ all_returns.len() as f64;
|
||||
let volatility = variance.sqrt() * (252.0_f64).sqrt();
|
||||
|
||||
// Max drawdown
|
||||
let mean_max_dd: f64 = asset_paths
|
||||
.paths
|
||||
.iter()
|
||||
.map(|path| self.max_drawdown(path))
|
||||
.sum::<f64>()
|
||||
/ n as f64;
|
||||
|
||||
asset_stats.push(AssetPathStats {
|
||||
symbol: asset_paths.symbol.clone(),
|
||||
mean_final_price: final_prices.iter().sum::<f64>() / n as f64,
|
||||
median_final_price: final_prices[n / 2],
|
||||
pct_5_final_price: final_prices[(0.05 * n as f64) as usize],
|
||||
pct_95_final_price: final_prices[(0.95 * n as f64).min((n - 1) as f64) as usize],
|
||||
mean_return,
|
||||
volatility,
|
||||
mean_max_drawdown: mean_max_dd,
|
||||
});
|
||||
}
|
||||
|
||||
// Correlation at final time (simplified - just returning identity for now)
|
||||
let n_assets = price_paths.len();
|
||||
let final_correlation = (0..n_assets)
|
||||
.map(|i| {
|
||||
(0..n_assets)
|
||||
.map(|j| if i == j { 1.0 } else { 0.5 })
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
|
||||
PathStatistics {
|
||||
asset_stats,
|
||||
final_correlation,
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate maximum drawdown for a price path.
|
||||
fn max_drawdown(&self, path: &[f64]) -> f64 {
|
||||
let mut peak: f64 = path[0];
|
||||
let mut max_dd: f64 = 0.0;
|
||||
|
||||
for &price in path.iter().skip(1) {
|
||||
peak = peak.max(price);
|
||||
let dd = (peak - price) / peak;
|
||||
max_dd = max_dd.max(dd);
|
||||
}
|
||||
|
||||
max_dd
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple pseudo-random number generator.
|
||||
struct SimpleRng {
|
||||
state: u64,
|
||||
}
|
||||
|
||||
impl SimpleRng {
|
||||
fn new(seed: u64) -> Self {
|
||||
Self { state: seed }
|
||||
}
|
||||
|
||||
fn next(&mut self) -> u64 {
|
||||
self.state = self
|
||||
.state
|
||||
.wrapping_mul(6364136223846793005)
|
||||
.wrapping_add(1442695040888963407);
|
||||
self.state
|
||||
}
|
||||
|
||||
fn uniform(&mut self) -> f64 {
|
||||
(self.next() >> 11) as f64 / (1u64 << 53) as f64
|
||||
}
|
||||
|
||||
fn normal(&mut self) -> f64 {
|
||||
// Box-Muller transform
|
||||
let u1 = self.uniform() + 1e-10;
|
||||
let u2 = self.uniform();
|
||||
(-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use marketsim_shared::{
|
||||
Asset, AssetPrice, AssetType, EconomicFactors, MarketIndicators, MarketState,
|
||||
ScenarioDescription, ScenarioType,
|
||||
};
|
||||
|
||||
fn create_test_request() -> ScenarioRequest {
|
||||
ScenarioRequest {
|
||||
assets: vec![Asset {
|
||||
symbol: "SPY".to_string(),
|
||||
name: "S&P 500".to_string(),
|
||||
asset_type: AssetType::Index,
|
||||
current_price: 500.0,
|
||||
}],
|
||||
historical_data: vec![],
|
||||
initial_state: MarketState {
|
||||
timestamp: "2024-01-01T00:00:00Z".to_string(),
|
||||
prices: vec![AssetPrice {
|
||||
symbol: "SPY".to_string(),
|
||||
price: 500.0,
|
||||
change_pct: 0.0,
|
||||
}],
|
||||
indicators: MarketIndicators::default(),
|
||||
economic_factors: EconomicFactors::default(),
|
||||
},
|
||||
scenario: ScenarioDescription {
|
||||
name: "Test".to_string(),
|
||||
scenario_type: ScenarioType::Crisis,
|
||||
description: "Test".to_string(),
|
||||
severity: 0.5,
|
||||
duration_days: 20,
|
||||
},
|
||||
num_paths: 100,
|
||||
horizon_days: 20,
|
||||
seed: Some(42),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generator_creation() {
|
||||
let generator = ScenarioGenerator::new();
|
||||
assert!(std::mem::size_of_val(&generator) > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate() {
|
||||
let generator = ScenarioGenerator::new();
|
||||
let model = MarketWorldModel::new();
|
||||
let request = create_test_request();
|
||||
|
||||
let result = generator.generate(&request, &model);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let scenario = result.unwrap();
|
||||
assert_eq!(scenario.price_paths.len(), 1);
|
||||
assert_eq!(scenario.price_paths[0].paths.len(), 100);
|
||||
assert_eq!(scenario.price_paths[0].paths[0].len(), 21); // 20 days + initial
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_statistics() {
|
||||
let generator = ScenarioGenerator::new();
|
||||
let model = MarketWorldModel::new();
|
||||
let request = create_test_request();
|
||||
|
||||
let scenario = generator.generate(&request, &model).unwrap();
|
||||
|
||||
assert_eq!(scenario.statistics.asset_stats.len(), 1);
|
||||
assert!(scenario.statistics.asset_stats[0].volatility > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_max_drawdown() {
|
||||
let generator = ScenarioGenerator::new();
|
||||
let path = vec![100.0, 110.0, 105.0, 95.0, 100.0];
|
||||
let dd = generator.max_drawdown(&path);
|
||||
|
||||
// Max drawdown from 110 to 95 = (110-95)/110 ≈ 0.136
|
||||
assert!((dd - 0.136).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_reproducibility() {
|
||||
let generator = ScenarioGenerator::new();
|
||||
let model = MarketWorldModel::new();
|
||||
let request = create_test_request();
|
||||
|
||||
let result1 = generator.generate(&request, &model).unwrap();
|
||||
let result2 = generator.generate(&request, &model).unwrap();
|
||||
|
||||
// With same seed, should get same results
|
||||
assert_eq!(
|
||||
result1.price_paths[0].paths[0][5],
|
||||
result2.price_paths[0].paths[0][5]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user