1use crate::error::{IntegrateError, IntegrateResult};
7use crate::specialized::finance::pricing::black_scholes::black_scholes_price;
8use crate::specialized::finance::types::OptionType;
9use scirs2_core::ndarray::{Array1, Array2};
10use scirs2_core::random::{thread_rng, Rng, StandardNormal};
11
12#[derive(Debug, Clone)]
14pub struct AutocallableNote {
15 pub spots: Vec<f64>,
17 pub autocall_barriers: Vec<f64>,
19 pub observation_dates: Vec<f64>,
21 pub coupon_rates: Vec<f64>,
23 pub knock_in_barrier: f64,
25 pub principal: f64,
27 pub rate: f64,
29 pub volatilities: Vec<f64>,
31 pub correlation: Array2<f64>,
33}
34
35impl AutocallableNote {
36 pub fn new(
38 spots: Vec<f64>,
39 autocall_barriers: Vec<f64>,
40 observation_dates: Vec<f64>,
41 coupon_rates: Vec<f64>,
42 knock_in_barrier: f64,
43 principal: f64,
44 rate: f64,
45 volatilities: Vec<f64>,
46 correlation: Array2<f64>,
47 ) -> IntegrateResult<Self> {
48 if spots.len() != volatilities.len() {
49 return Err(IntegrateError::ValueError(
50 "Number of spots and volatilities must match".to_string(),
51 ));
52 }
53
54 if observation_dates.len() != coupon_rates.len() {
55 return Err(IntegrateError::ValueError(
56 "Number of observations and coupons must match".to_string(),
57 ));
58 }
59
60 let n = spots.len();
61 if correlation.nrows() != n || correlation.ncols() != n {
62 return Err(IntegrateError::ValueError(
63 "Correlation matrix dimensions must match number of underlyings".to_string(),
64 ));
65 }
66
67 Ok(Self {
68 spots,
69 autocall_barriers,
70 observation_dates,
71 coupon_rates,
72 knock_in_barrier,
73 principal,
74 rate,
75 volatilities,
76 correlation,
77 })
78 }
79
80 pub fn price_monte_carlo(&self, n_paths: usize) -> IntegrateResult<f64> {
82 let n_assets = self.spots.len();
83 let n_obs = self.observation_dates.len();
84
85 let chol = cholesky_decomposition(&self.correlation)?;
87
88 let mut rng = thread_rng();
89 let normal = StandardNormal;
90
91 let mut total_payoff = 0.0;
92
93 for _ in 0..n_paths {
94 let mut prices = self.spots.clone();
95 let mut autocalled = false;
96 let mut knock_in_hit = false;
97 let mut payoff = 0.0;
98 let mut prev_time = 0.0;
99
100 for (obs_idx, &obs_time) in self.observation_dates.iter().enumerate() {
101 let dt = obs_time - prev_time;
102
103 let mut z = vec![0.0; n_assets];
105 for i in 0..n_assets {
106 z[i] = rng.sample(normal);
107 }
108
109 let corr_z = apply_cholesky(&chol, &z);
110
111 for i in 0..n_assets {
113 let drift = (self.rate - 0.5 * self.volatilities[i].powi(2)) * dt;
114 let diffusion = self.volatilities[i] * dt.sqrt() * corr_z[i];
115 prices[i] *= (drift + diffusion).exp();
116
117 if prices[i] / self.spots[i] <= self.knock_in_barrier {
119 knock_in_hit = true;
120 }
121 }
122
123 let all_above_barrier = prices
125 .iter()
126 .zip(&self.spots)
127 .all(|(&p, &s)| p / s >= self.autocall_barriers[obs_idx]);
128
129 if all_above_barrier {
130 let cumulative_coupons: f64 = self.coupon_rates[..=obs_idx].iter().sum();
132 payoff = self.principal * (1.0 + cumulative_coupons);
133 payoff *= (-self.rate * obs_time).exp();
134 autocalled = true;
135 break;
136 }
137
138 prev_time = obs_time;
139 }
140
141 if !autocalled {
143 let final_time = self.observation_dates.last().unwrap();
144
145 if knock_in_hit {
146 let worst_performance = prices
148 .iter()
149 .zip(&self.spots)
150 .map(|(&p, &s)| p / s)
151 .fold(f64::INFINITY, f64::min);
152
153 payoff = self.principal * worst_performance;
154 } else {
155 let total_coupons: f64 = self.coupon_rates.iter().sum();
157 payoff = self.principal * (1.0 + total_coupons);
158 }
159
160 payoff *= (-self.rate * final_time).exp();
161 }
162
163 total_payoff += payoff;
164 }
165
166 Ok(total_payoff / n_paths as f64)
167 }
168}
169
170#[derive(Debug, Clone)]
172pub struct PrincipalProtectedNote {
173 pub principal: f64,
175 pub spot: f64,
177 pub strike: f64,
179 pub maturity: f64,
181 pub rate: f64,
183 pub volatility: f64,
185 pub participation_rate: f64,
187 pub cap: Option<f64>,
189}
190
191impl PrincipalProtectedNote {
192 pub fn new(
194 principal: f64,
195 spot: f64,
196 strike: f64,
197 maturity: f64,
198 rate: f64,
199 volatility: f64,
200 participation_rate: f64,
201 cap: Option<f64>,
202 ) -> IntegrateResult<Self> {
203 if principal <= 0.0 {
204 return Err(IntegrateError::ValueError(
205 "Principal must be positive".to_string(),
206 ));
207 }
208
209 if participation_rate < 0.0 {
210 return Err(IntegrateError::ValueError(
211 "Participation rate must be non-negative".to_string(),
212 ));
213 }
214
215 Ok(Self {
216 principal,
217 spot,
218 strike,
219 maturity,
220 rate,
221 volatility,
222 participation_rate,
223 cap,
224 })
225 }
226
227 pub fn price(&self) -> f64 {
229 let bond_value = self.principal * (-self.rate * self.maturity).exp();
231
232 let call_value = black_scholes_price(
234 self.spot,
235 self.strike,
236 self.rate,
237 0.0,
238 self.volatility,
239 self.maturity,
240 OptionType::Call,
241 );
242
243 let upside_value = if let Some(cap_level) = self.cap {
244 let cap_call = black_scholes_price(
246 self.spot,
247 cap_level,
248 self.rate,
249 0.0,
250 self.volatility,
251 self.maturity,
252 OptionType::Call,
253 );
254 self.participation_rate * (call_value - cap_call)
255 } else {
256 self.participation_rate * call_value
257 };
258
259 bond_value + upside_value
260 }
261
262 pub fn fair_participation_rate(&self, target_price: f64) -> IntegrateResult<f64> {
264 let bond_value = self.principal * (-self.rate * self.maturity).exp();
265
266 if target_price < bond_value {
267 return Err(IntegrateError::ValueError(
268 "Target price must be at least the bond value".to_string(),
269 ));
270 }
271
272 let call_value = black_scholes_price(
273 self.spot,
274 self.strike,
275 self.rate,
276 0.0,
277 self.volatility,
278 self.maturity,
279 OptionType::Call,
280 );
281
282 let available_for_upside = target_price - bond_value;
283
284 if call_value < 1e-10 {
285 return Err(IntegrateError::ValueError(
286 "Call option has negligible value".to_string(),
287 ));
288 }
289
290 Ok(available_for_upside / call_value)
291 }
292}
293
294#[derive(Debug, Clone)]
296pub struct BasketOption {
297 pub spots: Vec<f64>,
299 pub weights: Vec<f64>,
301 pub strike: f64,
303 pub maturity: f64,
305 pub rate: f64,
307 pub volatilities: Vec<f64>,
309 pub correlation: Array2<f64>,
311 pub option_type: OptionType,
313}
314
315impl BasketOption {
316 pub fn new(
318 spots: Vec<f64>,
319 weights: Vec<f64>,
320 strike: f64,
321 maturity: f64,
322 rate: f64,
323 volatilities: Vec<f64>,
324 correlation: Array2<f64>,
325 option_type: OptionType,
326 ) -> IntegrateResult<Self> {
327 let n = spots.len();
328
329 if weights.len() != n || volatilities.len() != n {
330 return Err(IntegrateError::ValueError(
331 "Spots, weights, and volatilities must have same length".to_string(),
332 ));
333 }
334
335 if correlation.nrows() != n || correlation.ncols() != n {
336 return Err(IntegrateError::ValueError(
337 "Correlation matrix dimensions must match number of assets".to_string(),
338 ));
339 }
340
341 let weight_sum: f64 = weights.iter().sum();
343 let normalized_weights: Vec<f64> = weights.iter().map(|w| w / weight_sum).collect();
344
345 Ok(Self {
346 spots,
347 weights: normalized_weights,
348 strike,
349 maturity,
350 rate,
351 volatilities,
352 correlation,
353 option_type,
354 })
355 }
356
357 pub fn price_monte_carlo(&self, n_paths: usize) -> IntegrateResult<f64> {
359 let chol = cholesky_decomposition(&self.correlation)?;
360
361 let mut rng = thread_rng();
362 let normal = StandardNormal;
363
364 let mut payoff_sum = 0.0;
365
366 for _ in 0..n_paths {
367 let mut z = vec![0.0; self.spots.len()];
369 for i in 0..self.spots.len() {
370 z[i] = rng.sample(normal);
371 }
372
373 let corr_z = apply_cholesky(&chol, &z);
374
375 let mut basket_value = 0.0;
377 for i in 0..self.spots.len() {
378 let drift = (self.rate - 0.5 * self.volatilities[i].powi(2)) * self.maturity;
379 let diffusion = self.volatilities[i] * self.maturity.sqrt() * corr_z[i];
380 let terminal_price = self.spots[i] * (drift + diffusion).exp();
381 basket_value += self.weights[i] * terminal_price;
382 }
383
384 let payoff = match self.option_type {
386 OptionType::Call => (basket_value - self.strike).max(0.0),
387 OptionType::Put => (self.strike - basket_value).max(0.0),
388 };
389
390 payoff_sum += payoff;
391 }
392
393 let avg_payoff = payoff_sum / n_paths as f64;
394 Ok(avg_payoff * (-self.rate * self.maturity).exp())
395 }
396}
397
398#[derive(Debug, Clone)]
400pub struct RangeAccrualNote {
401 pub principal: f64,
403 pub spot: f64,
405 pub lower_barrier: f64,
407 pub upper_barrier: f64,
409 pub accrual_rate: f64,
411 pub maturity: f64,
413 pub rate: f64,
415 pub volatility: f64,
417 pub n_observations: usize,
419}
420
421impl RangeAccrualNote {
422 pub fn new(
424 principal: f64,
425 spot: f64,
426 lower_barrier: f64,
427 upper_barrier: f64,
428 accrual_rate: f64,
429 maturity: f64,
430 rate: f64,
431 volatility: f64,
432 n_observations: usize,
433 ) -> IntegrateResult<Self> {
434 if lower_barrier >= upper_barrier {
435 return Err(IntegrateError::ValueError(
436 "Lower barrier must be less than upper barrier".to_string(),
437 ));
438 }
439
440 if n_observations == 0 {
441 return Err(IntegrateError::ValueError(
442 "Number of observations must be positive".to_string(),
443 ));
444 }
445
446 Ok(Self {
447 principal,
448 spot,
449 lower_barrier,
450 upper_barrier,
451 accrual_rate,
452 maturity,
453 rate,
454 volatility,
455 n_observations,
456 })
457 }
458
459 pub fn price_monte_carlo(&self, n_paths: usize) -> IntegrateResult<f64> {
461 let mut rng = thread_rng();
462 let normal = StandardNormal;
463
464 let dt = self.maturity / self.n_observations as f64;
465 let sqrt_dt = dt.sqrt();
466 let drift = (self.rate - 0.5 * self.volatility.powi(2)) * dt;
467 let diffusion = self.volatility * sqrt_dt;
468
469 let mut total_payoff = 0.0;
470
471 for _ in 0..n_paths {
472 let mut s = self.spot;
473 let mut days_in_range = 0;
474
475 for _ in 0..self.n_observations {
476 let z: f64 = rng.sample(normal);
477 s *= (drift + diffusion * z).exp();
478
479 if s >= self.lower_barrier && s <= self.upper_barrier {
480 days_in_range += 1;
481 }
482 }
483
484 let accrual_fraction = days_in_range as f64 / self.n_observations as f64;
485 let interest = self.principal * self.accrual_rate * self.maturity * accrual_fraction;
486 let payoff = self.principal + interest;
487
488 total_payoff += payoff;
489 }
490
491 let avg_payoff = total_payoff / n_paths as f64;
492 Ok(avg_payoff * (-self.rate * self.maturity).exp())
493 }
494}
495
496fn cholesky_decomposition(corr: &Array2<f64>) -> IntegrateResult<Array2<f64>> {
498 let n = corr.nrows();
499 let mut l = Array2::<f64>::zeros((n, n));
500
501 for i in 0..n {
502 for j in 0..=i {
503 let mut sum = 0.0;
504 for k in 0..j {
505 sum += l[[i, k]] * l[[j, k]];
506 }
507
508 if i == j {
509 let diag = corr[[i, i]] - sum;
510 if diag < 0.0 {
511 return Err(IntegrateError::ValueError(
512 "Correlation matrix is not positive definite".to_string(),
513 ));
514 }
515 l[[i, j]] = diag.sqrt();
516 } else {
517 if l[[j, j]].abs() < 1e-10 {
518 return Err(IntegrateError::ValueError(
519 "Cholesky decomposition failed: zero diagonal".to_string(),
520 ));
521 }
522 l[[i, j]] = (corr[[i, j]] - sum) / l[[j, j]];
523 }
524 }
525 }
526
527 Ok(l)
528}
529
530fn apply_cholesky(chol: &Array2<f64>, z: &[f64]) -> Vec<f64> {
532 let n = chol.nrows();
533 let mut result = vec![0.0; n];
534
535 for i in 0..n {
536 for j in 0..=i {
537 result[i] += chol[[i, j]] * z[j];
538 }
539 }
540
541 result
542}
543
544#[cfg(test)]
545mod tests {
546 use super::*;
547 use scirs2_core::ndarray::arr2;
548
549 #[test]
550 fn test_autocallable_creation() {
551 let corr = arr2(&[[1.0, 0.5], [0.5, 1.0]]);
552
553 let note = AutocallableNote::new(
554 vec![100.0, 100.0],
555 vec![1.0, 0.95, 0.90],
556 vec![0.5, 1.0, 1.5],
557 vec![0.05, 0.05, 0.05],
558 0.7,
559 100.0,
560 0.03,
561 vec![0.2, 0.25],
562 corr,
563 )
564 .unwrap();
565
566 assert_eq!(note.spots.len(), 2);
567 assert_eq!(note.observation_dates.len(), 3);
568 }
569
570 #[test]
571 fn test_autocallable_pricing() {
572 let corr = arr2(&[[1.0, 0.6], [0.6, 1.0]]);
573
574 let note = AutocallableNote::new(
575 vec![100.0, 100.0],
576 vec![1.05, 1.05, 1.05],
577 vec![0.5, 1.0, 1.5],
578 vec![0.06, 0.06, 0.06],
579 0.65,
580 100.0,
581 0.03,
582 vec![0.2, 0.2],
583 corr,
584 )
585 .unwrap();
586
587 let price = note.price_monte_carlo(10000).unwrap();
588
589 assert!(price > 95.0 && price < 110.0, "Price: {}", price);
591 }
592
593 #[test]
594 fn test_principal_protected_note() {
595 let ppn =
596 PrincipalProtectedNote::new(100.0, 100.0, 100.0, 1.0, 0.05, 0.2, 0.8, None).unwrap();
597
598 let price = ppn.price();
599
600 let bond_value = 100.0 * (-0.05_f64).exp();
602 assert!(
603 price >= bond_value,
604 "Price {} < bond value {}",
605 price,
606 bond_value
607 );
608 assert!(price < 110.0, "Price too high: {}", price);
609 }
610
611 #[test]
612 fn test_ppn_fair_participation() {
613 let ppn =
614 PrincipalProtectedNote::new(100.0, 100.0, 100.0, 1.0, 0.05, 0.2, 0.0, None).unwrap();
615
616 let target_price = 100.0;
617 let fair_part = ppn.fair_participation_rate(target_price).unwrap();
618
619 assert!(
620 fair_part > 0.0 && fair_part < 2.0,
621 "Fair participation: {}",
622 fair_part
623 );
624 }
625
626 #[test]
627 fn test_basket_option_creation() {
628 let corr = arr2(&[[1.0, 0.3], [0.3, 1.0]]);
629
630 let basket = BasketOption::new(
631 vec![100.0, 100.0],
632 vec![0.5, 0.5],
633 100.0,
634 1.0,
635 0.05,
636 vec![0.2, 0.25],
637 corr,
638 OptionType::Call,
639 )
640 .unwrap();
641
642 assert_eq!(basket.spots.len(), 2);
643 assert!((basket.weights.iter().sum::<f64>() - 1.0).abs() < 1e-10);
644 }
645
646 #[test]
647 fn test_basket_option_pricing() {
648 let corr = arr2(&[[1.0, 0.5], [0.5, 1.0]]);
649
650 let basket = BasketOption::new(
651 vec![100.0, 100.0],
652 vec![0.5, 0.5],
653 100.0,
654 1.0,
655 0.05,
656 vec![0.2, 0.2],
657 corr,
658 OptionType::Call,
659 )
660 .unwrap();
661
662 let price = basket.price_monte_carlo(10000).unwrap();
663
664 assert!(price > 5.0 && price < 15.0, "Price: {}", price);
665 }
666
667 #[test]
668 fn test_range_accrual_note() {
669 let note =
670 RangeAccrualNote::new(100.0, 100.0, 90.0, 110.0, 0.05, 1.0, 0.03, 0.15, 252).unwrap();
671
672 let price = note.price_monte_carlo(5000).unwrap();
673
674 let min_price = 100.0 * (-0.03_f64).exp(); let max_price = (100.0 + 100.0 * 0.05) * (-0.03_f64).exp(); assert!(price > min_price && price < max_price, "Price: {}", price);
679 }
680
681 #[test]
682 fn test_cholesky_decomposition() {
683 let corr = arr2(&[[1.0, 0.5], [0.5, 1.0]]);
684
685 let chol = cholesky_decomposition(&corr).unwrap();
686
687 let mut reconstructed = Array2::<f64>::zeros((2, 2));
689 for i in 0..2 {
690 for j in 0..2 {
691 for k in 0..2 {
692 reconstructed[[i, j]] += chol[[i, k]] * chol[[j, k]];
693 }
694 }
695 }
696
697 for i in 0..2 {
698 for j in 0..2 {
699 assert!((reconstructed[[i, j]] - corr[[i, j]]).abs() < 1e-10);
700 }
701 }
702 }
703
704 #[test]
705 fn test_apply_cholesky() {
706 let chol = arr2(&[[1.0, 0.0], [0.5, 0.866025]]);
707 let z = vec![1.0, 1.0];
708
709 let result = apply_cholesky(&chol, &z);
710
711 assert!((result[0] - 1.0).abs() < 1e-6);
712 assert!((result[1] - 1.366025).abs() < 1e-5);
713 }
714
715 #[test]
716 fn test_ppn_with_cap() {
717 let ppn =
718 PrincipalProtectedNote::new(100.0, 100.0, 100.0, 1.0, 0.05, 0.2, 0.8, Some(120.0))
719 .unwrap();
720
721 let price_capped = ppn.price();
722
723 let ppn_uncapped =
724 PrincipalProtectedNote::new(100.0, 100.0, 100.0, 1.0, 0.05, 0.2, 0.8, None).unwrap();
725
726 let price_uncapped = ppn_uncapped.price();
727
728 assert!(
730 price_capped < price_uncapped,
731 "Capped {} should be < uncapped {}",
732 price_capped,
733 price_uncapped
734 );
735 }
736}