1use crate::messages::{
9 MonteCarloVaRInput, MonteCarloVaROutput, PortfolioRiskAggregationInput,
10 PortfolioRiskAggregationOutput,
11};
12use crate::ring_messages::{
13 K2KMarketUpdate, K2KMarketUpdateAck, K2KVaRAggregation, K2KVaRAggregationResponse,
14 QueryVaRResponse, QueryVaRRing, RecalculateVaRResponse, RecalculateVaRRing,
15 UpdatePositionResponse, UpdatePositionRing, from_currency_fp, from_fixed_point, to_currency_fp,
16};
17use crate::types::{Portfolio, PortfolioRiskResult, VaRParams, VaRResult};
18use async_trait::async_trait;
19use ringkernel_core::RingContext;
20use rustkernel_core::error::Result;
21use rustkernel_core::traits::{BatchKernel, RingKernelHandler};
22use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
23use std::time::Instant;
24
25#[derive(Debug, Clone, Default)]
31pub struct MonteCarloVaRState {
32 pub portfolio: Option<Portfolio>,
34 pub var: f64,
36 pub es: f64,
38 pub confidence_level: f64,
40 pub holding_period: u32,
42 pub is_stale: bool,
44 pub n_simulations: u32,
46}
47
48#[derive(Debug)]
52pub struct MonteCarloVaR {
53 metadata: KernelMetadata,
54 state: std::sync::RwLock<MonteCarloVaRState>,
56}
57
58impl Clone for MonteCarloVaR {
59 fn clone(&self) -> Self {
60 Self {
61 metadata: self.metadata.clone(),
62 state: std::sync::RwLock::new(self.state.read().unwrap().clone()),
63 }
64 }
65}
66
67impl Default for MonteCarloVaR {
68 fn default() -> Self {
69 Self::new()
70 }
71}
72
73impl MonteCarloVaR {
74 #[must_use]
76 pub fn new() -> Self {
77 Self {
78 metadata: KernelMetadata::ring("risk/monte-carlo-var", Domain::RiskAnalytics)
79 .with_description("Monte Carlo Value at Risk simulation")
80 .with_throughput(100_000)
81 .with_latency_us(1000.0),
82 state: std::sync::RwLock::new(MonteCarloVaRState::default()),
83 }
84 }
85
86 pub fn initialize(&self, portfolio: Portfolio) {
88 let mut state = self.state.write().unwrap();
89 state.portfolio = Some(portfolio);
90 state.is_stale = true;
91 }
92
93 pub fn update_position(&self, asset_id: u64, new_value: f64) -> bool {
95 let mut state = self.state.write().unwrap();
96 if let Some(ref mut portfolio) = state.portfolio {
97 for (i, &id) in portfolio.asset_ids.iter().enumerate() {
99 if id == asset_id {
100 portfolio.values[i] = new_value;
101 state.is_stale = true;
102 return true;
103 }
104 }
105 }
106 false
107 }
108
109 pub fn cached_var(&self) -> (f64, f64, bool) {
111 let state = self.state.read().unwrap();
112 (state.var, state.es, !state.is_stale)
113 }
114
115 pub fn recalculate(
117 &self,
118 confidence_level: f64,
119 holding_period: u32,
120 n_simulations: u32,
121 ) -> (f64, f64) {
122 let mut state = self.state.write().unwrap();
123 let Some(ref portfolio) = state.portfolio else {
124 return (0.0, 0.0);
125 };
126
127 let params = VaRParams::new(confidence_level, holding_period, n_simulations);
128 let result = Self::compute(portfolio, params);
129
130 state.var = result.var;
131 state.es = result.expected_shortfall;
132 state.confidence_level = confidence_level;
133 state.holding_period = holding_period;
134 state.n_simulations = n_simulations;
135 state.is_stale = false;
136
137 (result.var, result.expected_shortfall)
138 }
139
140 pub fn compute(portfolio: &Portfolio, params: VaRParams) -> VaRResult {
146 if portfolio.n_assets() == 0 {
147 return VaRResult {
148 var: 0.0,
149 expected_shortfall: 0.0,
150 confidence_level: params.confidence_level,
151 holding_period: params.holding_period,
152 component_var: Vec::new(),
153 marginal_var: Vec::new(),
154 percentiles: Vec::new(),
155 };
156 }
157
158 let n_sims = params.n_simulations as usize;
159 let holding_factor = (params.holding_period as f64).sqrt();
160
161 let cholesky = Self::cholesky_decomposition(portfolio);
163
164 let mut pnl_scenarios = Vec::with_capacity(n_sims);
166 let mut rng = SimpleRng::new(42); for _ in 0..n_sims {
169 let z: Vec<f64> = (0..portfolio.n_assets()).map(|_| rng.normal()).collect();
171
172 let correlated = Self::apply_cholesky(&cholesky, &z, portfolio.n_assets());
174
175 let mut scenario_pnl = 0.0;
177 for (i, (&z_corr, (&vol, &value))) in correlated
178 .iter()
179 .zip(portfolio.volatilities.iter().zip(portfolio.values.iter()))
180 .enumerate()
181 {
182 let ret = portfolio.expected_returns[i] * params.holding_period as f64 / 252.0
183 + vol * holding_factor / (252.0_f64).sqrt() * z_corr;
184 scenario_pnl += value * ret;
185 }
186
187 pnl_scenarios.push(scenario_pnl);
188 }
189
190 pnl_scenarios.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
192
193 let var_idx = ((1.0 - params.confidence_level) * n_sims as f64) as usize;
195 let var = -pnl_scenarios[var_idx.min(n_sims - 1)]; let tail_start = var_idx.min(n_sims - 1);
199 let expected_shortfall = if tail_start > 0 {
200 -pnl_scenarios[..tail_start].iter().sum::<f64>() / tail_start as f64
201 } else {
202 var
203 };
204
205 let component_var = Self::calculate_component_var(portfolio, var, &cholesky);
207
208 let marginal_var = Self::calculate_marginal_var(portfolio, params, &cholesky);
210
211 let percentiles: Vec<(f64, f64)> = [0.01, 0.05, 0.10, 0.25, 0.50, 0.75, 0.90, 0.95, 0.99]
213 .iter()
214 .map(|&p| {
215 let idx = ((1.0 - p) * n_sims as f64) as usize;
216 (p, pnl_scenarios[idx.min(n_sims - 1)])
217 })
218 .collect();
219
220 VaRResult {
221 var,
222 expected_shortfall,
223 confidence_level: params.confidence_level,
224 holding_period: params.holding_period,
225 component_var,
226 marginal_var,
227 percentiles,
228 }
229 }
230
231 fn cholesky_decomposition(portfolio: &Portfolio) -> Vec<f64> {
233 let n = portfolio.n_assets();
234 let mut l = vec![0.0; n * n];
235
236 for i in 0..n {
237 for j in 0..=i {
238 let mut sum = 0.0;
239 for k in 0..j {
240 sum += l[i * n + k] * l[j * n + k];
241 }
242
243 if i == j {
244 let diag = portfolio.correlation(i, i) - sum;
245 l[i * n + j] = if diag > 0.0 { diag.sqrt() } else { 0.0 };
246 } else {
247 let l_jj = l[j * n + j];
248 l[i * n + j] = if l_jj.abs() > 1e-10 {
249 (portfolio.correlation(i, j) - sum) / l_jj
250 } else {
251 0.0
252 };
253 }
254 }
255 }
256
257 l
258 }
259
260 fn apply_cholesky(l: &[f64], z: &[f64], n: usize) -> Vec<f64> {
262 let mut result = vec![0.0; n];
263 for i in 0..n {
264 for j in 0..=i {
265 result[i] += l[i * n + j] * z[j];
266 }
267 }
268 result
269 }
270
271 fn calculate_component_var(
273 portfolio: &Portfolio,
274 total_var: f64,
275 _cholesky: &[f64],
276 ) -> Vec<f64> {
277 let weights = portfolio.weights();
279 let n = portfolio.n_assets();
280
281 let port_var = Self::portfolio_variance(portfolio);
283 let port_vol = port_var.sqrt();
284
285 if port_vol < 1e-10 {
286 return vec![0.0; n];
287 }
288
289 let mut component_vars = Vec::with_capacity(n);
291
292 for i in 0..n {
293 let mut cov_i_p = 0.0;
294 for j in 0..n {
295 cov_i_p += weights[j]
296 * portfolio.volatilities[i]
297 * portfolio.volatilities[j]
298 * portfolio.correlation(i, j);
299 }
300
301 let beta_i = cov_i_p / port_var;
302 let component_var_i = weights[i] * beta_i * total_var;
303 component_vars.push(component_var_i);
304 }
305
306 component_vars
307 }
308
309 fn calculate_marginal_var(
311 portfolio: &Portfolio,
312 params: VaRParams,
313 _cholesky: &[f64],
314 ) -> Vec<f64> {
315 let n = portfolio.n_assets();
316 let weights = portfolio.weights();
317 let holding_factor = (params.holding_period as f64).sqrt();
318
319 let z = Self::norm_inv(params.confidence_level);
321
322 let port_vol = Self::portfolio_variance(portfolio).sqrt();
324
325 if port_vol < 1e-10 {
326 return vec![0.0; n];
327 }
328
329 let mut marginal_vars = Vec::with_capacity(n);
331
332 for i in 0..n {
333 let mut cov_i_p = 0.0;
334 for j in 0..n {
335 cov_i_p += weights[j]
336 * portfolio.volatilities[i]
337 * portfolio.volatilities[j]
338 * portfolio.correlation(i, j);
339 }
340
341 let d_sigma_dw = cov_i_p / port_vol;
342 let marginal_var_i = z * d_sigma_dw * holding_factor / (252.0_f64).sqrt();
343 marginal_vars.push(marginal_var_i * portfolio.total_value());
344 }
345
346 marginal_vars
347 }
348
349 fn portfolio_variance(portfolio: &Portfolio) -> f64 {
351 let weights = portfolio.weights();
352 let n = portfolio.n_assets();
353 let mut var = 0.0;
354
355 for i in 0..n {
356 for j in 0..n {
357 var += weights[i]
358 * weights[j]
359 * portfolio.volatilities[i]
360 * portfolio.volatilities[j]
361 * portfolio.correlation(i, j);
362 }
363 }
364
365 var
366 }
367
368 fn norm_inv(p: f64) -> f64 {
370 let p_clamped = p.clamp(1e-10, 1.0 - 1e-10);
372 let t = if p_clamped < 0.5 {
373 (-2.0 * p_clamped.ln()).sqrt()
374 } else {
375 (-2.0 * (1.0 - p_clamped).ln()).sqrt()
376 };
377
378 let c0 = 2.515517;
379 let c1 = 0.802853;
380 let c2 = 0.010328;
381 let d1 = 1.432788;
382 let d2 = 0.189269;
383 let d3 = 0.001308;
384
385 let result = t - (c0 + c1 * t + c2 * t * t) / (1.0 + d1 * t + d2 * t * t + d3 * t * t * t);
386
387 if p_clamped < 0.5 { -result } else { result }
388 }
389}
390
391impl GpuKernel for MonteCarloVaR {
392 fn metadata(&self) -> &KernelMetadata {
393 &self.metadata
394 }
395}
396
397#[async_trait]
405impl RingKernelHandler<UpdatePositionRing, UpdatePositionResponse> for MonteCarloVaR {
406 async fn handle(
407 &self,
408 _ctx: &mut RingContext,
409 msg: UpdatePositionRing,
410 ) -> Result<UpdatePositionResponse> {
411 let new_value = from_currency_fp(msg.value_fp);
413 let updated = self.update_position(msg.asset_id, new_value);
414
415 Ok(UpdatePositionResponse {
417 request_id: msg.id.0,
418 asset_id: msg.asset_id,
419 var_stale: updated,
420 })
421 }
422}
423
424#[async_trait]
428impl RingKernelHandler<QueryVaRRing, QueryVaRResponse> for MonteCarloVaR {
429 async fn handle(&self, _ctx: &mut RingContext, msg: QueryVaRRing) -> Result<QueryVaRResponse> {
430 let (var, es, is_fresh) = self.cached_var();
432
433 Ok(QueryVaRResponse {
434 request_id: msg.id.0,
435 var_fp: to_currency_fp(var),
436 es_fp: to_currency_fp(es),
437 confidence_fp: msg.confidence_fp,
438 holding_period: msg.holding_period,
439 is_fresh,
440 })
441 }
442}
443
444#[async_trait]
448impl RingKernelHandler<RecalculateVaRRing, RecalculateVaRResponse> for MonteCarloVaR {
449 async fn handle(
450 &self,
451 _ctx: &mut RingContext,
452 msg: RecalculateVaRRing,
453 ) -> Result<RecalculateVaRResponse> {
454 let start = Instant::now();
455 let confidence = from_fixed_point(msg.confidence_fp);
456
457 let (var, es) = self.recalculate(confidence, msg.holding_period, msg.n_simulations);
459 let compute_time_us = start.elapsed().as_micros() as u64;
460
461 Ok(RecalculateVaRResponse {
462 request_id: msg.id.0,
463 var_fp: to_currency_fp(var),
464 es_fp: to_currency_fp(es),
465 compute_time_us,
466 n_simulations: msg.n_simulations,
467 })
468 }
469}
470
471#[async_trait]
475impl RingKernelHandler<K2KMarketUpdate, K2KMarketUpdateAck> for MonteCarloVaR {
476 async fn handle(
477 &self,
478 _ctx: &mut RingContext,
479 msg: K2KMarketUpdate,
480 ) -> Result<K2KMarketUpdateAck> {
481 let portfolio_value = {
483 let state = self.state.read().unwrap();
484 state
485 .portfolio
486 .as_ref()
487 .map(|p| p.total_value())
488 .unwrap_or(0.0)
489 };
490
491 let vol_delta = from_fixed_point(msg.vol_delta_fp);
494 let z_95 = 1.645; let var_impact = portfolio_value * z_95 * vol_delta.abs();
496
497 {
499 let mut state = self.state.write().unwrap();
500 state.is_stale = true;
501 }
502
503 Ok(K2KMarketUpdateAck {
504 request_id: msg.id.0,
505 worker_id: 0, var_impact_fp: to_currency_fp(var_impact),
507 })
508 }
509}
510
511#[async_trait]
515impl RingKernelHandler<K2KVaRAggregation, K2KVaRAggregationResponse> for MonteCarloVaR {
516 async fn handle(
517 &self,
518 _ctx: &mut RingContext,
519 msg: K2KVaRAggregation,
520 ) -> Result<K2KVaRAggregationResponse> {
521 let complete = msg.workers_reported >= msg.expected_workers;
522 let aggregated_var = from_currency_fp(msg.aggregated_var_fp);
523
524 let diversification_factor = {
528 let state = self.state.read().unwrap();
529 if let Some(ref portfolio) = state.portfolio {
530 let n = portfolio.n_assets();
531 if n > 1 {
532 let mut avg_corr = 0.0;
534 let mut count = 0;
535 for i in 0..n {
536 for j in (i + 1)..n {
537 avg_corr += portfolio.correlation(i, j);
538 count += 1;
539 }
540 }
541 if count > 0 {
542 avg_corr /= count as f64;
543 }
544 (1.0 - avg_corr.max(0.0).sqrt()) * 0.3 } else {
548 0.0
549 }
550 } else {
551 0.15 }
553 };
554
555 let diversification_benefit = aggregated_var * diversification_factor;
556 let final_var = aggregated_var - diversification_benefit;
557
558 let es_ratio = 1.25;
560 let final_es = final_var * es_ratio;
561
562 Ok(K2KVaRAggregationResponse {
563 correlation_id: msg.correlation_id,
564 complete,
565 final_var_fp: to_currency_fp(final_var),
566 final_es_fp: to_currency_fp(final_es),
567 diversification_benefit_fp: to_currency_fp(diversification_benefit),
568 })
569 }
570}
571
572#[async_trait]
573impl BatchKernel<MonteCarloVaRInput, MonteCarloVaROutput> for MonteCarloVaR {
574 async fn execute(&self, input: MonteCarloVaRInput) -> Result<MonteCarloVaROutput> {
575 let start = Instant::now();
576 let result = Self::compute(&input.portfolio, input.params);
577 Ok(MonteCarloVaROutput {
578 result,
579 compute_time_us: start.elapsed().as_micros() as u64,
580 })
581 }
582}
583
584#[derive(Debug, Clone)]
592pub struct PortfolioRiskAggregation {
593 metadata: KernelMetadata,
594}
595
596impl Default for PortfolioRiskAggregation {
597 fn default() -> Self {
598 Self::new()
599 }
600}
601
602impl PortfolioRiskAggregation {
603 #[must_use]
605 pub fn new() -> Self {
606 Self {
607 metadata: KernelMetadata::ring("risk/portfolio-aggregation", Domain::RiskAnalytics)
608 .with_description("Correlation-adjusted portfolio risk")
609 .with_throughput(10_000)
610 .with_latency_us(500.0),
611 }
612 }
613
614 pub fn compute(
621 portfolio: &Portfolio,
622 confidence_level: f64,
623 holding_period: u32,
624 ) -> PortfolioRiskResult {
625 let n = portfolio.n_assets();
626 if n == 0 {
627 return PortfolioRiskResult {
628 portfolio_var: 0.0,
629 portfolio_es: 0.0,
630 undiversified_var: 0.0,
631 diversification_benefit: 0.0,
632 asset_vars: Vec::new(),
633 risk_contributions: Vec::new(),
634 covariance_matrix: Vec::new(),
635 };
636 }
637
638 let z = Self::norm_inv(confidence_level);
639 let holding_factor = (holding_period as f64).sqrt() / (252.0_f64).sqrt();
640
641 let mut cov_matrix = vec![0.0; n * n];
643 for i in 0..n {
644 for j in 0..n {
645 cov_matrix[i * n + j] = portfolio.volatilities[i]
646 * portfolio.volatilities[j]
647 * portfolio.correlation(i, j);
648 }
649 }
650
651 let asset_vars: Vec<f64> = portfolio
653 .values
654 .iter()
655 .zip(portfolio.volatilities.iter())
656 .map(|(&value, &vol)| value * z * vol * holding_factor)
657 .collect();
658
659 let undiversified_var: f64 = asset_vars.iter().sum();
661
662 let weights = portfolio.weights();
664 let mut portfolio_variance = 0.0;
665 for i in 0..n {
666 for j in 0..n {
667 portfolio_variance += weights[i] * weights[j] * cov_matrix[i * n + j];
668 }
669 }
670
671 let portfolio_var =
673 portfolio.total_value() * z * portfolio_variance.sqrt() * holding_factor;
674
675 let diversification_benefit = undiversified_var - portfolio_var;
677
678 let port_vol = portfolio_variance.sqrt();
680 let risk_contributions: Vec<f64> = if port_vol > 1e-10 {
681 (0..n)
682 .map(|i| {
683 let mut cov_i_p = 0.0;
684 for j in 0..n {
685 cov_i_p += weights[j] * cov_matrix[i * n + j];
686 }
687 weights[i] * cov_i_p / port_vol * z * holding_factor * portfolio.total_value()
688 })
689 .collect()
690 } else {
691 vec![0.0; n]
692 };
693
694 let pdf_at_z = (-z * z / 2.0).exp() / (2.0 * std::f64::consts::PI).sqrt();
696 let portfolio_es = portfolio.total_value() * port_vol * holding_factor * pdf_at_z
697 / (1.0 - confidence_level);
698
699 PortfolioRiskResult {
700 portfolio_var,
701 portfolio_es,
702 undiversified_var,
703 diversification_benefit,
704 asset_vars,
705 risk_contributions,
706 covariance_matrix: cov_matrix,
707 }
708 }
709
710 fn norm_inv(p: f64) -> f64 {
712 MonteCarloVaR::norm_inv(p)
713 }
714}
715
716impl GpuKernel for PortfolioRiskAggregation {
717 fn metadata(&self) -> &KernelMetadata {
718 &self.metadata
719 }
720}
721
722#[async_trait]
723impl BatchKernel<PortfolioRiskAggregationInput, PortfolioRiskAggregationOutput>
724 for PortfolioRiskAggregation
725{
726 async fn execute(
727 &self,
728 input: PortfolioRiskAggregationInput,
729 ) -> Result<PortfolioRiskAggregationOutput> {
730 let start = Instant::now();
731 let result = Self::compute(
732 &input.portfolio,
733 input.confidence_level,
734 input.holding_period,
735 );
736 Ok(PortfolioRiskAggregationOutput {
737 result,
738 compute_time_us: start.elapsed().as_micros() as u64,
739 })
740 }
741}
742
743struct SimpleRng {
749 state: u64,
750}
751
752impl SimpleRng {
753 fn new(seed: u64) -> Self {
754 Self { state: seed.max(1) }
755 }
756
757 fn next_u64(&mut self) -> u64 {
758 self.state ^= self.state << 13;
759 self.state ^= self.state >> 7;
760 self.state ^= self.state << 17;
761 self.state
762 }
763
764 fn next_f64(&mut self) -> f64 {
765 (self.next_u64() as f64) / (u64::MAX as f64)
766 }
767
768 fn normal(&mut self) -> f64 {
770 let u1 = self.next_f64().max(1e-10);
771 let u2 = self.next_f64();
772 (-2.0 * u1.ln()).sqrt() * (2.0 * std::f64::consts::PI * u2).cos()
773 }
774}
775
776#[cfg(test)]
777mod tests {
778 use super::*;
779
780 fn create_simple_portfolio() -> Portfolio {
781 Portfolio::new(
783 vec![1, 2],
784 vec![100_000.0, 100_000.0],
785 vec![0.08, 0.10], vec![0.15, 0.20], vec![1.0, 0.5, 0.5, 1.0], )
789 }
790
791 fn create_diversified_portfolio() -> Portfolio {
792 Portfolio::new(
794 vec![1, 2, 3, 4],
795 vec![50_000.0, 30_000.0, 15_000.0, 5_000.0],
796 vec![0.06, 0.08, 0.10, 0.12],
797 vec![0.10, 0.15, 0.20, 0.25],
798 vec![
799 1.0, 0.2, 0.1, 0.0, 0.2, 1.0, 0.3, 0.1, 0.1, 0.3, 1.0, 0.2, 0.0, 0.1, 0.2, 1.0,
800 ],
801 )
802 }
803
804 #[test]
805 fn test_monte_carlo_var_metadata() {
806 let kernel = MonteCarloVaR::new();
807 assert_eq!(kernel.metadata().id, "risk/monte-carlo-var");
808 assert_eq!(kernel.metadata().domain, Domain::RiskAnalytics);
809 }
810
811 #[test]
812 fn test_monte_carlo_var_calculation() {
813 let portfolio = create_simple_portfolio();
814 let params = VaRParams::new(0.95, 10, 10_000);
815
816 let result = MonteCarloVaR::compute(&portfolio, params);
817
818 assert!(result.var > 0.0, "VaR should be positive");
819 assert!(result.expected_shortfall >= result.var, "ES >= VaR");
820 assert_eq!(result.confidence_level, 0.95);
821 assert_eq!(result.holding_period, 10);
822
823 assert!(
825 result.var < 100_000.0,
826 "VaR seems too large: {}",
827 result.var
828 );
829 }
830
831 #[test]
832 fn test_var_increases_with_holding_period() {
833 let portfolio = create_simple_portfolio();
834
835 let var_1d = MonteCarloVaR::compute(&portfolio, VaRParams::new(0.95, 1, 10_000));
836 let var_10d = MonteCarloVaR::compute(&portfolio, VaRParams::new(0.95, 10, 10_000));
837
838 let ratio = var_10d.var / var_1d.var;
840 assert!(
841 ratio > 2.5 && ratio < 4.0,
842 "VaR scaling ratio should be ~sqrt(10): {}",
843 ratio
844 );
845 }
846
847 #[test]
848 fn test_var_increases_with_confidence() {
849 let portfolio = create_simple_portfolio();
850
851 let var_95 = MonteCarloVaR::compute(&portfolio, VaRParams::new(0.95, 10, 10_000));
852 let var_99 = MonteCarloVaR::compute(&portfolio, VaRParams::new(0.99, 10, 10_000));
853
854 assert!(
855 var_99.var > var_95.var,
856 "99% VaR should exceed 95% VaR: {} vs {}",
857 var_99.var,
858 var_95.var
859 );
860 }
861
862 #[test]
863 fn test_component_var_sums_to_total() {
864 let portfolio = create_diversified_portfolio();
865 let params = VaRParams::new(0.95, 10, 10_000);
866
867 let result = MonteCarloVaR::compute(&portfolio, params);
868
869 let component_sum: f64 = result.component_var.iter().sum();
871 let diff = (component_sum - result.var).abs() / result.var;
872
873 assert!(
874 diff < 0.20, "Component VaR sum should be close to total: {} vs {}",
876 component_sum,
877 result.var
878 );
879 }
880
881 #[test]
882 fn test_portfolio_aggregation_metadata() {
883 let kernel = PortfolioRiskAggregation::new();
884 assert_eq!(kernel.metadata().id, "risk/portfolio-aggregation");
885 }
886
887 #[test]
888 fn test_portfolio_aggregation() {
889 let portfolio = create_diversified_portfolio();
890 let result = PortfolioRiskAggregation::compute(&portfolio, 0.95, 10);
891
892 assert!(result.portfolio_var > 0.0);
893 assert!(result.undiversified_var > result.portfolio_var);
894 assert!(result.diversification_benefit > 0.0);
895 }
896
897 #[test]
898 fn test_diversification_benefit() {
899 let portfolio = create_diversified_portfolio();
900 let result = PortfolioRiskAggregation::compute(&portfolio, 0.95, 10);
901
902 assert!(
904 (result.diversification_benefit - (result.undiversified_var - result.portfolio_var))
905 .abs()
906 < 1.0
907 );
908
909 let benefit_pct = result.diversification_benefit / result.undiversified_var;
911 assert!(
912 benefit_pct > 0.10,
913 "Should have >10% diversification benefit: {}%",
914 benefit_pct * 100.0
915 );
916 }
917
918 #[test]
919 fn test_risk_contributions_sum() {
920 let portfolio = create_diversified_portfolio();
921 let result = PortfolioRiskAggregation::compute(&portfolio, 0.95, 10);
922
923 let contrib_sum: f64 = result.risk_contributions.iter().sum();
924
925 let diff = (contrib_sum - result.portfolio_var).abs() / result.portfolio_var;
927 assert!(
928 diff < 0.01,
929 "Risk contributions should sum to portfolio VaR: {} vs {}",
930 contrib_sum,
931 result.portfolio_var
932 );
933 }
934
935 #[test]
936 fn test_empty_portfolio() {
937 let empty = Portfolio::new(Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
938
939 let var_result = MonteCarloVaR::compute(&empty, VaRParams::default());
940 assert_eq!(var_result.var, 0.0);
941
942 let agg_result = PortfolioRiskAggregation::compute(&empty, 0.95, 10);
943 assert_eq!(agg_result.portfolio_var, 0.0);
944 }
945
946 #[test]
947 fn test_covariance_matrix() {
948 let portfolio = create_simple_portfolio();
949 let result = PortfolioRiskAggregation::compute(&portfolio, 0.95, 10);
950
951 assert_eq!(result.covariance_matrix.len(), 4);
953 assert!((result.covariance_matrix[1] - result.covariance_matrix[2]).abs() < 1e-10);
954
955 let var1 = 0.15 * 0.15;
957 let var2 = 0.20 * 0.20;
958 assert!((result.covariance_matrix[0] - var1).abs() < 1e-10);
959 assert!((result.covariance_matrix[3] - var2).abs() < 1e-10);
960 }
961}