1use crate::engine::rng::SimRng;
10use crate::error::{SimError, SimResult};
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct AssetConfig {
16 pub name: String,
18 pub price: f64,
20 pub drift: f64,
22 pub volatility: f64,
24 pub position: f64,
26}
27
28impl AssetConfig {
29 #[must_use]
31 pub fn new(name: &str, price: f64, drift: f64, volatility: f64, position: f64) -> Self {
32 Self {
33 name: name.to_string(),
34 price,
35 drift,
36 volatility,
37 position,
38 }
39 }
40
41 #[must_use]
43 pub fn stock(name: &str, price: f64, position: f64) -> Self {
44 Self::new(name, price, 0.08, 0.20, position) }
46
47 #[must_use]
49 pub fn bond(name: &str, price: f64, position: f64) -> Self {
50 Self::new(name, price, 0.03, 0.05, position) }
52
53 #[must_use]
55 pub fn value(&self) -> f64 {
56 self.price * self.position
57 }
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct PortfolioConfig {
63 pub assets: Vec<AssetConfig>,
65 pub correlations: Vec<f64>,
68 pub risk_free_rate: f64,
70 pub time_horizon: f64,
72}
73
74impl Default for PortfolioConfig {
75 fn default() -> Self {
76 Self {
77 assets: vec![
78 AssetConfig::stock("SPY", 450.0, 100.0),
79 AssetConfig::bond("TLT", 100.0, 500.0),
80 ],
81 correlations: vec![1.0, -0.3, -0.3, 1.0], risk_free_rate: 0.04,
83 time_horizon: 1.0 / 252.0, }
85 }
86}
87
88impl PortfolioConfig {
89 #[must_use]
91 pub fn equity_only() -> Self {
92 Self {
93 assets: vec![
94 AssetConfig::stock("SPY", 450.0, 100.0),
95 AssetConfig::stock("QQQ", 380.0, 50.0),
96 AssetConfig::stock("IWM", 200.0, 75.0),
97 ],
98 correlations: vec![1.0, 0.85, 0.80, 0.85, 1.0, 0.75, 0.80, 0.75, 1.0],
99 risk_free_rate: 0.04,
100 time_horizon: 1.0 / 252.0,
101 }
102 }
103
104 #[must_use]
106 pub fn balanced_60_40() -> Self {
107 Self {
108 assets: vec![
109 AssetConfig::stock("Equity", 100.0, 600.0),
110 AssetConfig::bond("Bonds", 100.0, 400.0),
111 ],
112 correlations: vec![1.0, -0.2, -0.2, 1.0],
113 risk_free_rate: 0.04,
114 time_horizon: 1.0 / 252.0,
115 }
116 }
117
118 #[must_use]
120 pub fn total_value(&self) -> f64 {
121 self.assets.iter().map(AssetConfig::value).sum()
122 }
123
124 #[must_use]
126 pub fn num_assets(&self) -> usize {
127 self.assets.len()
128 }
129
130 #[must_use]
132 pub fn correlation(&self, i: usize, j: usize) -> f64 {
133 let n = self.num_assets();
134 if self.correlations.is_empty() {
135 return if i == j { 1.0 } else { 0.0 };
136 }
137 self.correlations[i * n + j]
138 }
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize)]
143pub struct PathResult {
144 pub final_prices: Vec<f64>,
146 pub final_value: f64,
148 pub portfolio_return: f64,
150 pub pnl: f64,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct VaRResult {
157 pub var: f64,
159 pub cvar: f64,
161 pub confidence: f64,
163 pub n_simulations: usize,
165 pub mean_return: f64,
167 pub std_return: f64,
169 pub min_return: f64,
171 pub max_return: f64,
173}
174
175#[derive(Debug, Clone)]
177pub struct PortfolioScenario {
178 config: PortfolioConfig,
179 cholesky: Vec<f64>,
181}
182
183impl PortfolioScenario {
184 pub fn new(config: PortfolioConfig) -> SimResult<Self> {
190 let cholesky = Self::cholesky_decomposition(&config)?;
191 Ok(Self { config, cholesky })
192 }
193
194 fn cholesky_decomposition(config: &PortfolioConfig) -> SimResult<Vec<f64>> {
196 let n = config.num_assets();
197
198 if config.correlations.is_empty() {
199 let mut l = vec![0.0; n * n];
201 for i in 0..n {
202 l[i * n + i] = 1.0;
203 }
204 return Ok(l);
205 }
206
207 let mut l = vec![0.0; n * n];
208
209 for i in 0..n {
210 for j in 0..=i {
211 let mut sum = 0.0;
212
213 if i == j {
214 for k in 0..j {
215 sum += l[j * n + k] * l[j * n + k];
216 }
217 let diag = config.correlations[j * n + j] - sum;
218 if diag <= 0.0 {
219 return Err(SimError::config(
220 "Correlation matrix is not positive definite".to_string(),
221 ));
222 }
223 l[j * n + j] = diag.sqrt();
224 } else {
225 for k in 0..j {
226 sum += l[i * n + k] * l[j * n + k];
227 }
228 l[i * n + j] = (config.correlations[i * n + j] - sum) / l[j * n + j];
229 }
230 }
231 }
232
233 Ok(l)
234 }
235
236 fn generate_correlated_normals(&self, rng: &mut SimRng) -> Vec<f64> {
238 let n = self.config.num_assets();
239 let mut z = Vec::with_capacity(n);
240
241 for _ in 0..n {
243 z.push(rng.gen_standard_normal());
244 }
245
246 let mut correlated = vec![0.0; n];
248 for i in 0..n {
249 for j in 0..=i {
250 correlated[i] += self.cholesky[i * n + j] * z[j];
251 }
252 }
253
254 correlated
255 }
256
257 pub fn simulate_path(&self, rng: &mut SimRng) -> PathResult {
259 contract_pre_iterator!();
260 let dt = self.config.time_horizon;
261 let sqrt_dt = dt.sqrt();
262 let initial_value = self.config.total_value();
263
264 let z = self.generate_correlated_normals(rng);
266
267 let final_prices: Vec<f64> = self
269 .config
270 .assets
271 .iter()
272 .zip(z.iter())
273 .map(|(asset, &shock)| {
274 let drift_term = (asset.drift - 0.5 * asset.volatility.powi(2)) * dt;
276 let diffusion_term = asset.volatility * sqrt_dt * shock;
277 asset.price * (drift_term + diffusion_term).exp()
278 })
279 .collect();
280
281 let final_value: f64 = self
283 .config
284 .assets
285 .iter()
286 .zip(final_prices.iter())
287 .map(|(asset, &price)| price * asset.position)
288 .sum();
289
290 let portfolio_return = (final_value - initial_value) / initial_value;
291 let pnl = final_value - initial_value;
292
293 PathResult {
294 final_prices,
295 final_value,
296 portfolio_return,
297 pnl,
298 }
299 }
300
301 pub fn calculate_var(
307 &self,
308 n_simulations: usize,
309 confidence: f64,
310 rng: &mut SimRng,
311 ) -> SimResult<VaRResult> {
312 if confidence <= 0.0 || confidence >= 1.0 {
313 return Err(SimError::config(format!(
314 "Confidence must be between 0 and 1, got {confidence}"
315 )));
316 }
317
318 let mut returns: Vec<f64> = (0..n_simulations)
320 .map(|_| self.simulate_path(rng).portfolio_return)
321 .collect();
322
323 returns.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
325
326 let mean_return = returns.iter().sum::<f64>() / n_simulations as f64;
328 let variance = returns
329 .iter()
330 .map(|r| (r - mean_return).powi(2))
331 .sum::<f64>()
332 / n_simulations as f64;
333 let std_return = variance.sqrt();
334
335 let var_index = ((1.0 - confidence) * n_simulations as f64) as usize;
337 let var = -returns[var_index] * self.config.total_value();
338
339 let cvar = -returns[..=var_index].iter().sum::<f64>() / (var_index + 1) as f64
341 * self.config.total_value();
342
343 Ok(VaRResult {
344 var,
345 cvar,
346 confidence,
347 n_simulations,
348 mean_return,
349 std_return,
350 min_return: returns[0],
351 max_return: returns[n_simulations - 1],
352 })
353 }
354
355 #[must_use]
357 pub const fn config(&self) -> &PortfolioConfig {
358 &self.config
359 }
360
361 #[must_use]
363 pub fn portfolio_delta(&self) -> Vec<f64> {
364 self.config
365 .assets
366 .iter()
367 .map(|asset| asset.position)
368 .collect()
369 }
370
371 #[must_use]
374 pub fn portfolio_beta(&self) -> f64 {
375 if self.config.assets.is_empty() {
376 return 0.0;
377 }
378
379 let market_vol = self.config.assets[0].volatility;
380 let total_value = self.config.total_value();
381
382 self.config
383 .assets
384 .iter()
385 .enumerate()
386 .map(|(i, asset)| {
387 let weight = asset.value() / total_value;
388 let correlation = self.config.correlation(i, 0);
389 let beta = correlation * asset.volatility / market_vol;
390 weight * beta
391 })
392 .sum()
393 }
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 #[test]
401 fn test_asset_config() {
402 let asset = AssetConfig::stock("AAPL", 150.0, 100.0);
403
404 assert_eq!(asset.name, "AAPL");
405 assert!((asset.price - 150.0).abs() < f64::EPSILON);
406 assert!((asset.value() - 15000.0).abs() < f64::EPSILON);
407 }
408
409 #[test]
410 fn test_portfolio_config_default() {
411 let config = PortfolioConfig::default();
412
413 assert_eq!(config.num_assets(), 2);
414 assert!(config.total_value() > 0.0);
415 }
416
417 #[test]
418 fn test_portfolio_config_correlation() {
419 let config = PortfolioConfig::default();
420
421 assert!((config.correlation(0, 0) - 1.0).abs() < f64::EPSILON);
423 assert!((config.correlation(1, 1) - 1.0).abs() < f64::EPSILON);
424
425 assert!((config.correlation(0, 1) - config.correlation(1, 0)).abs() < f64::EPSILON);
427 }
428
429 #[test]
430 fn test_portfolio_scenario_creation() {
431 let config = PortfolioConfig::default();
432 let scenario = PortfolioScenario::new(config).unwrap();
433
434 assert_eq!(scenario.config().num_assets(), 2);
435 }
436
437 #[test]
438 fn test_portfolio_simulate_path() {
439 let config = PortfolioConfig::default();
440 let scenario = PortfolioScenario::new(config).unwrap();
441 let mut rng = SimRng::new(42);
442
443 let result = scenario.simulate_path(&mut rng);
444
445 for price in &result.final_prices {
447 assert!(*price > 0.0, "Price should be positive: {price}");
448 }
449
450 assert!(result.final_value > 0.0);
452 }
453
454 #[test]
455 fn test_portfolio_var_calculation() {
456 let config = PortfolioConfig::default();
457 let scenario = PortfolioScenario::new(config).unwrap();
458 let mut rng = SimRng::new(42);
459
460 let var_result = scenario.calculate_var(10000, 0.95, &mut rng).unwrap();
461
462 assert!(var_result.var >= 0.0, "VaR = {}", var_result.var);
464
465 assert!(
467 var_result.cvar >= var_result.var,
468 "CVaR ({}) should be >= VaR ({})",
469 var_result.cvar,
470 var_result.var
471 );
472
473 assert!(var_result.max_return > var_result.min_return);
475 }
476
477 #[test]
478 fn test_portfolio_var_invalid_confidence() {
479 let config = PortfolioConfig::default();
480 let scenario = PortfolioScenario::new(config).unwrap();
481 let mut rng = SimRng::new(42);
482
483 assert!(scenario.calculate_var(1000, 0.0, &mut rng).is_err());
485 assert!(scenario.calculate_var(1000, 1.0, &mut rng).is_err());
486 assert!(scenario.calculate_var(1000, 1.5, &mut rng).is_err());
487 }
488
489 #[test]
490 fn test_portfolio_equity_only() {
491 let config = PortfolioConfig::equity_only();
492 let scenario = PortfolioScenario::new(config).unwrap();
493 let mut rng = SimRng::new(42);
494
495 let var_result = scenario.calculate_var(5000, 0.99, &mut rng).unwrap();
496
497 assert!(var_result.var > 0.0);
500 }
501
502 #[test]
503 fn test_portfolio_balanced() {
504 let config = PortfolioConfig::balanced_60_40();
505 let scenario = PortfolioScenario::new(config).unwrap();
506 let mut rng = SimRng::new(42);
507
508 let var_result = scenario.calculate_var(5000, 0.95, &mut rng).unwrap();
509
510 assert!(var_result.var > 0.0);
513 }
514
515 #[test]
516 fn test_portfolio_delta() {
517 let config = PortfolioConfig::default();
518 let scenario = PortfolioScenario::new(config).unwrap();
519
520 let delta = scenario.portfolio_delta();
521
522 assert_eq!(delta.len(), 2);
523 assert!((delta[0] - 100.0).abs() < f64::EPSILON);
524 assert!((delta[1] - 500.0).abs() < f64::EPSILON);
525 }
526
527 #[test]
528 fn test_portfolio_beta() {
529 let config = PortfolioConfig::default();
530 let scenario = PortfolioScenario::new(config).unwrap();
531
532 let beta = scenario.portfolio_beta();
533
534 assert!(beta > -1.0 && beta < 2.0, "Beta = {beta}");
536 }
537
538 #[test]
539 fn test_cholesky_identity() {
540 let config = PortfolioConfig {
541 assets: vec![
542 AssetConfig::stock("A", 100.0, 10.0),
543 AssetConfig::stock("B", 100.0, 10.0),
544 ],
545 correlations: vec![], ..Default::default()
547 };
548
549 let scenario = PortfolioScenario::new(config).unwrap();
550
551 assert!((scenario.cholesky[0] - 1.0).abs() < f64::EPSILON);
553 assert!((scenario.cholesky[3] - 1.0).abs() < f64::EPSILON);
554 }
555
556 #[test]
557 fn test_path_result_consistency() {
558 let config = PortfolioConfig::default();
559 let scenario = PortfolioScenario::new(config.clone()).unwrap();
560 let mut rng = SimRng::new(42);
561
562 let result = scenario.simulate_path(&mut rng);
563
564 let initial_value = config.total_value();
566 let expected_pnl = result.final_value - initial_value;
567 assert!((result.pnl - expected_pnl).abs() < 0.01);
568
569 let expected_return = expected_pnl / initial_value;
571 assert!((result.portfolio_return - expected_return).abs() < 1e-10);
572 }
573
574 #[test]
575 fn test_gbm_properties() {
576 let config = PortfolioConfig {
578 assets: vec![AssetConfig::new("Test", 100.0, 0.10, 0.20, 1.0)],
579 correlations: vec![1.0],
580 risk_free_rate: 0.0,
581 time_horizon: 1.0, };
583
584 let scenario = PortfolioScenario::new(config).unwrap();
585 let mut rng = SimRng::new(42);
586
587 let n = 10000;
588 let log_returns: Vec<f64> = (0..n)
589 .map(|_| {
590 let result = scenario.simulate_path(&mut rng);
591 (result.final_prices[0] / 100.0).ln()
592 })
593 .collect();
594
595 let mean_log_return = log_returns.iter().sum::<f64>() / n as f64;
597 assert!(
598 (mean_log_return - 0.08).abs() < 0.02,
599 "Mean log return = {mean_log_return}, expected ~0.08"
600 );
601
602 let variance = log_returns
604 .iter()
605 .map(|r| (r - mean_log_return).powi(2))
606 .sum::<f64>()
607 / n as f64;
608 let std = variance.sqrt();
609 assert!(
610 (std - 0.20).abs() < 0.02,
611 "Std of log returns = {std}, expected ~0.20"
612 );
613 }
614}
615
616#[cfg(test)]
617mod proptests {
618 use super::*;
619 use proptest::prelude::*;
620
621 proptest! {
622 #[test]
624 fn prop_weights_sum_to_one(
625 w1 in 0.0f64..0.5,
626 w2 in 0.0f64..0.5,
627 ) {
628 let w3 = 1.0 - w1 - w2;
629 if w3 >= 0.0 {
630 let sum = w1 + w2 + w3;
631 prop_assert!((sum - 1.0).abs() < 1e-10);
632 }
633 }
634
635 #[test]
637 fn prop_var_nonpositive(
638 confidence in 0.9f64..0.999,
639 ) {
640 let config = PortfolioConfig::default();
641 let _scenario = PortfolioScenario::new(config);
642
643 let _confidence_level = confidence;
646 }
648
649 #[test]
651 fn prop_higher_vol_higher_var(
652 vol1 in 0.1f64..0.3,
653 vol2 in 0.1f64..0.3,
654 ) {
655 if vol1 > vol2 {
658 prop_assert!(vol1 > vol2);
660 }
661 }
662
663 #[test]
665 fn prop_prices_positive(
666 price in 10.0f64..1000.0,
667 ) {
668 let config = AssetConfig {
669 name: "Test".to_string(),
670 price,
671 drift: 0.10,
672 volatility: 0.20,
673 position: 100.0,
674 };
675
676 prop_assert!(config.price > 0.0);
677 }
678
679 #[test]
681 fn prop_drift_sign(
682 expected_return in -0.2f64..0.3,
683 volatility in 0.1f64..0.4,
684 ) {
685 let drift = expected_return - volatility.powi(2) / 2.0;
687
688 if expected_return > volatility.powi(2) / 2.0 {
690 prop_assert!(drift > 0.0);
691 } else if expected_return < volatility.powi(2) / 2.0 {
692 prop_assert!(drift < 0.0);
693 }
694 }
695
696 #[test]
698 fn prop_correlation_bounds(
699 corr in -1.0f64..1.0,
700 ) {
701 prop_assert!(corr >= -1.0);
702 prop_assert!(corr <= 1.0);
703 }
704 }
705}