scirs2_integrate/specialized/finance/derivatives/
structured.rs

1//! Structured products pricing and risk management
2//!
3//! This module implements pricing and analysis tools for structured products including
4//! autocallable notes, principal-protected notes, and multi-asset derivatives.
5
6use 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/// Autocallable note with barrier monitoring
13#[derive(Debug, Clone)]
14pub struct AutocallableNote {
15    /// Initial spot prices for each underlying
16    pub spots: Vec<f64>,
17    /// Autocall barrier levels (as % of initial spot)
18    pub autocall_barriers: Vec<f64>,
19    /// Observation dates (in years)
20    pub observation_dates: Vec<f64>,
21    /// Coupon rates at each observation
22    pub coupon_rates: Vec<f64>,
23    /// Downside barrier (knock-in level)
24    pub knock_in_barrier: f64,
25    /// Principal amount
26    pub principal: f64,
27    /// Risk-free rate
28    pub rate: f64,
29    /// Volatilities for each underlying
30    pub volatilities: Vec<f64>,
31    /// Correlation matrix
32    pub correlation: Array2<f64>,
33}
34
35impl AutocallableNote {
36    /// Create a new autocallable note
37    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    /// Price autocallable note using Monte Carlo simulation
81    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        // Cholesky decomposition for correlation
86        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                // Generate correlated random numbers
104                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                // Update prices
112                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                    // Check knock-in barrier
118                    if prices[i] / self.spots[i] <= self.knock_in_barrier {
119                        knock_in_hit = true;
120                    }
121                }
122
123                // Check autocall condition (all assets above barrier)
124                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                    // Autocall triggered
131                    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 not autocalled, calculate maturity payoff
142            if !autocalled {
143                let final_time = self.observation_dates.last().unwrap();
144
145                if knock_in_hit {
146                    // Worst-of performance
147                    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                    // Return principal plus all coupons
156                    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/// Principal-protected note with participation in upside
171#[derive(Debug, Clone)]
172pub struct PrincipalProtectedNote {
173    /// Principal amount (guaranteed)
174    pub principal: f64,
175    /// Underlying spot price
176    pub spot: f64,
177    /// Strike price
178    pub strike: f64,
179    /// Time to maturity
180    pub maturity: f64,
181    /// Risk-free rate
182    pub rate: f64,
183    /// Volatility
184    pub volatility: f64,
185    /// Participation rate in upside
186    pub participation_rate: f64,
187    /// Cap on returns (optional)
188    pub cap: Option<f64>,
189}
190
191impl PrincipalProtectedNote {
192    /// Create a new principal-protected note
193    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    /// Price the principal-protected note
228    pub fn price(&self) -> f64 {
229        // Zero-coupon bond for principal protection
230        let bond_value = self.principal * (-self.rate * self.maturity).exp();
231
232        // Call option for upside participation
233        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            // If capped, subtract value of call at cap level
245            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    /// Calculate fair participation rate given note price
263    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/// Basket option on multiple underlyings
295#[derive(Debug, Clone)]
296pub struct BasketOption {
297    /// Spot prices of underlyings
298    pub spots: Vec<f64>,
299    /// Weights for basket composition
300    pub weights: Vec<f64>,
301    /// Strike price
302    pub strike: f64,
303    /// Time to maturity
304    pub maturity: f64,
305    /// Risk-free rate
306    pub rate: f64,
307    /// Volatilities
308    pub volatilities: Vec<f64>,
309    /// Correlation matrix
310    pub correlation: Array2<f64>,
311    /// Option type
312    pub option_type: OptionType,
313}
314
315impl BasketOption {
316    /// Create a new basket option
317    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        // Normalize weights
342        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    /// Price basket option using Monte Carlo
358    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            // Generate correlated random numbers
368            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            // Compute terminal basket value
376            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            // Calculate payoff
385            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/// Range accrual note - accrues interest when underlying stays in range
399#[derive(Debug, Clone)]
400pub struct RangeAccrualNote {
401    /// Principal amount
402    pub principal: f64,
403    /// Underlying spot price
404    pub spot: f64,
405    /// Lower barrier
406    pub lower_barrier: f64,
407    /// Upper barrier
408    pub upper_barrier: f64,
409    /// Accrual rate per day (annualized)
410    pub accrual_rate: f64,
411    /// Time to maturity
412    pub maturity: f64,
413    /// Risk-free rate
414    pub rate: f64,
415    /// Volatility
416    pub volatility: f64,
417    /// Number of observation days
418    pub n_observations: usize,
419}
420
421impl RangeAccrualNote {
422    /// Create a new range accrual note
423    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    /// Price range accrual note using Monte Carlo
460    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
496/// Cholesky decomposition for correlation matrix
497fn 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
530/// Apply Cholesky matrix to generate correlated random numbers
531fn 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        // Should be close to principal plus some coupon value
590        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        // Should be at least the discounted principal
601        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        // With low volatility and reasonable range, should get most of interest
675        let min_price = 100.0 * (-0.03_f64).exp(); // Just principal discounted
676        let max_price = (100.0 + 100.0 * 0.05) * (-0.03_f64).exp(); // Principal + full interest
677
678        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        // Verify L * L^T = corr
688        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        // Capped should be cheaper
729        assert!(
730            price_capped < price_uncapped,
731            "Capped {} should be < uncapped {}",
732            price_capped,
733            price_uncapped
734        );
735    }
736}