Skip to main content

rustyqlib/equity/
rainbow.rs

1//! Rainbow (multi-asset) options: best-of, worst-of, spread, basket and
2//! exchange payoffs on n correlated lognormal assets.
3//!
4//! Engines:
5//! - **Analytic**: Margrabe (exchange, exact), Kirk's approximation
6//!   (spread), moment-matched lognormal (basket). Best-of / worst-of have
7//!   no analytic pricer yet (Stulz for n = 2 is future work) and price on
8//!   Monte Carlo.
9//! - **Monte Carlo**: correlated terminal GBM (Cholesky), low-discrepancy
10//!   or antithetic pseudo-random sampling, deterministic parallel
11//!   reduction, standard errors.
12//!
13//! Greeks: per-asset `deltas` and `vegas` by common-random-number bumps;
14//! scalar theta and rho. Each asset carries a flat vol; per-asset smiles
15//! for multi-asset payoffs are future work.
16
17use chrono::{Local, NaiveDate};
18use libm::exp;
19use rayon::prelude::*;
20use serde::{Deserialize, Serialize};
21
22use crate::core::curves::{Compounding, YieldCurve};
23use crate::core::daycount::DayCountConvention;
24use crate::core::trade::PutOrCall;
25use crate::core::utils::N;
26use crate::equity::montecarlo::{McStats, Sampler};
27use crate::equity::utils::Engine;
28use crate::utils::RNG::{path_normals, QmcSequence};
29
30const PATH_CHUNK: usize = 4096;
31
32// ── Contract data (JSON) ────────────────────────────────────────────────
33
34#[derive(Clone, Debug, Deserialize, Serialize)]
35pub struct RainbowAssetData {
36    pub symbol: String,
37    pub spot: f64,
38    pub volatility: f64,
39    pub dividend: Option<f64>,
40}
41
42#[derive(Clone, Debug, Deserialize, Serialize)]
43pub struct RainbowOptionData {
44    pub symbol: String,
45    /// "best_of" | "worst_of" | "spread" | "basket" | "exchange"
46    pub rainbow_type: String,
47    pub put_or_call: Option<String>,
48    pub assets: Vec<RainbowAssetData>,
49    /// Full correlation matrix, n x n.
50    pub correlations: Vec<Vec<f64>>,
51    pub strike_price: Option<f64>,
52    /// Basket weights (defaults to equal weights).
53    pub weights: Option<Vec<f64>>,
54    pub maturity: String,
55    pub risk_free_rate: Option<f64>,
56    pub discount_curve: Option<crate::core::curves::CurveInput>,
57    pub pricer: Option<String>,
58    pub simulation: Option<u64>,
59    pub mc_sampler: Option<String>,
60    pub mc_seed: Option<u64>,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum RainbowType {
65    BestOf,
66    WorstOf,
67    Spread,
68    Basket,
69    Exchange,
70}
71
72// ── Instrument ──────────────────────────────────────────────────────────
73
74#[derive(Debug)]
75pub struct RainbowOption {
76    pub symbol: String,
77    pub rainbow_type: RainbowType,
78    pub put_or_call: PutOrCall,
79    pub spots: Vec<f64>,
80    pub vols: Vec<f64>,
81    pub dividends: Vec<f64>,
82    pub correlations: Vec<Vec<f64>>,
83    pub strike_price: f64,
84    pub weights: Vec<f64>,
85    pub maturity_date: NaiveDate,
86    pub valuation_date: NaiveDate,
87    pub discount_curve: YieldCurve,
88    pub engine: Engine,
89    pub paths: usize,
90    pub sampler: Sampler,
91    pub seed: u64,
92    /// Cholesky factor of the correlation matrix (lower triangular).
93    chol: Vec<Vec<f64>>,
94}
95
96/// Market snapshot bumped by the Greeks (common random numbers).
97#[derive(Clone)]
98struct Params {
99    spots: Vec<f64>,
100    vols: Vec<f64>,
101    r: f64,
102    t: f64,
103}
104
105impl RainbowOption {
106    pub fn from_json(data: &RainbowOptionData) -> Box<RainbowOption> {
107        let valuation_date = Local::now().date_naive();
108        let n = data.assets.len();
109        assert!(n >= 2, "rainbow options need at least two assets");
110        let rainbow_type = match data.rainbow_type.trim().to_lowercase().as_str() {
111            "best_of" | "bestof" | "max" => RainbowType::BestOf,
112            "worst_of" | "worstof" | "min" => RainbowType::WorstOf,
113            "spread" => RainbowType::Spread,
114            "basket" => RainbowType::Basket,
115            "exchange" | "margrabe" => RainbowType::Exchange,
116            other => panic!("Invalid rainbow_type '{other}'"),
117        };
118        if matches!(rainbow_type, RainbowType::Spread | RainbowType::Exchange) {
119            assert!(n == 2, "spread and exchange options take exactly two assets");
120        }
121        let put_or_call = match data.put_or_call.as_deref().unwrap_or("C").trim() {
122            "C" | "c" | "Call" | "call" => PutOrCall::Call,
123            "P" | "p" | "Put" | "put" => PutOrCall::Put,
124            other => panic!("Invalid put_or_call '{other}'"),
125        };
126        let strike_price = data.strike_price.unwrap_or(0.0);
127        if rainbow_type != RainbowType::Exchange {
128            assert!(data.strike_price.is_some(), "strike_price is required");
129        }
130        let weights = match &data.weights {
131            Some(w) => {
132                assert!(w.len() == n, "weights must match the number of assets");
133                w.clone()
134            }
135            None => vec![1.0 / n as f64; n],
136        };
137        assert!(
138            data.correlations.len() == n && data.correlations.iter().all(|row| row.len() == n),
139            "correlations must be an n x n matrix"
140        );
141        let chol = cholesky(&data.correlations)
142            .expect("correlation matrix must be symmetric positive definite with unit diagonal");
143        let discount_curve = match &data.discount_curve {
144            Some(input) => YieldCurve::from_input(input, valuation_date)
145                .expect("Invalid discount curve"),
146            None => YieldCurve::flat(
147                data.risk_free_rate.unwrap_or(0.0),
148                valuation_date,
149                DayCountConvention::Act365,
150                Compounding::Continuous,
151            )
152            .expect("Invalid risk free rate"),
153        };
154        let maturity_date =
155            NaiveDate::parse_from_str(&data.maturity, "%Y-%m-%d").expect("Invalid date format");
156        Box::new(RainbowOption {
157            symbol: data.symbol.clone(),
158            rainbow_type,
159            put_or_call,
160            spots: data.assets.iter().map(|a| a.spot).collect(),
161            vols: data.assets.iter().map(|a| a.volatility).collect(),
162            dividends: data.assets.iter().map(|a| a.dividend.unwrap_or(0.0)).collect(),
163            correlations: data.correlations.clone(),
164            strike_price,
165            weights,
166            maturity_date,
167            valuation_date,
168            discount_curve,
169            engine: match data.pricer.as_deref().map_or("MC", |v| v).trim() {
170                "Analytical" | "analytical" => Engine::BlackScholes,
171                "MonteCarlo" | "montecarlo" | "MC" | "mc" => Engine::MonteCarlo,
172                other => panic!("Invalid pricer '{other}' for rainbow (Analytical or MC)"),
173            },
174            paths: data.simulation.unwrap_or(100_000) as usize,
175            sampler: data
176                .mc_sampler
177                .as_deref()
178                .map(|s| s.parse::<Sampler>().expect("Invalid mc_sampler"))
179                .unwrap_or(Sampler::Sobol),
180            seed: data.mc_seed.unwrap_or(42),
181            chol,
182        })
183    }
184
185    pub fn time_to_maturity(&self) -> f64 {
186        (self.maturity_date - self.valuation_date).num_days() as f64 / 365.0
187    }
188
189    fn params(&self) -> Params {
190        let t = self.time_to_maturity();
191        Params {
192            spots: self.spots.clone(),
193            vols: self.vols.clone(),
194            r: self.discount_curve.zero_rate_with(t, Compounding::Continuous),
195            t,
196        }
197    }
198
199    /// Terminal payoff on realized asset levels.
200    fn payoff(&self, terminal: &[f64]) -> f64 {
201        let phi = match self.put_or_call {
202            PutOrCall::Call => 1.0,
203            PutOrCall::Put => -1.0,
204        };
205        let k = self.strike_price;
206        match self.rainbow_type {
207            RainbowType::BestOf => {
208                let best = terminal.iter().cloned().fold(f64::MIN, f64::max);
209                (phi * (best - k)).max(0.0)
210            }
211            RainbowType::WorstOf => {
212                let worst = terminal.iter().cloned().fold(f64::MAX, f64::min);
213                (phi * (worst - k)).max(0.0)
214            }
215            RainbowType::Spread => (phi * (terminal[0] - terminal[1] - k)).max(0.0),
216            RainbowType::Basket => {
217                let basket: f64 =
218                    self.weights.iter().zip(terminal).map(|(w, s)| w * s).sum();
219                (phi * (basket - k)).max(0.0)
220            }
221            RainbowType::Exchange => match self.put_or_call {
222                PutOrCall::Call => (terminal[0] - terminal[1]).max(0.0),
223                PutOrCall::Put => (terminal[1] - terminal[0]).max(0.0),
224            },
225        }
226    }
227
228    // ── Pricing ─────────────────────────────────────────────────────────
229
230    pub fn npv(&self) -> f64 {
231        match self.engine {
232            Engine::BlackScholes => self.analytic_npv_with(&self.params()),
233            Engine::MonteCarlo => self.mc_stats_with(&self.params()).pv,
234            _ => panic!("Rainbow options price on the Analytical or MonteCarlo engines"),
235        }
236    }
237
238    pub fn npv_with_stats(&self) -> Option<McStats> {
239        match self.engine {
240            Engine::MonteCarlo => Some(self.mc_stats_with(&self.params())),
241            _ => None,
242        }
243    }
244
245    fn price_with(&self, p: &Params) -> f64 {
246        match self.engine {
247            Engine::BlackScholes => self.analytic_npv_with(p),
248            _ => self.mc_stats_with(p).pv,
249        }
250    }
251
252    /// Per-asset spot deltas (central bumps, common random numbers).
253    pub fn deltas(&self) -> Vec<f64> {
254        let base = self.params();
255        (0..self.spots.len())
256            .map(|i| {
257                let h = base.spots[i] * 0.01;
258                let mut up = base.clone();
259                up.spots[i] += h;
260                let mut dn = base.clone();
261                dn.spots[i] -= h;
262                (self.price_with(&up) - self.price_with(&dn)) / (2.0 * h)
263            })
264            .collect()
265    }
266
267    /// Per-asset vegas (central bumps of each asset's vol).
268    pub fn vegas(&self) -> Vec<f64> {
269        let base = self.params();
270        (0..self.vols.len())
271            .map(|i| {
272                let h = 0.01;
273                let mut up = base.clone();
274                up.vols[i] += h;
275                let mut dn = base.clone();
276                dn.vols[i] = (dn.vols[i] - h).max(1e-6);
277                (self.price_with(&up) - self.price_with(&dn)) / (2.0 * h)
278            })
279            .collect()
280    }
281
282    pub fn theta(&self) -> f64 {
283        let base = self.params();
284        let h = (1.0 / 365.0_f64).min(0.5 * base.t);
285        let mut up = base.clone();
286        up.t += h;
287        let mut dn = base.clone();
288        dn.t -= h;
289        -(self.price_with(&up) - self.price_with(&dn)) / (2.0 * h)
290    }
291
292    pub fn rho(&self) -> f64 {
293        let base = self.params();
294        let h = 1e-4;
295        let mut up = base.clone();
296        up.r += h;
297        let mut dn = base.clone();
298        dn.r -= h;
299        (self.price_with(&up) - self.price_with(&dn)) / (2.0 * h)
300    }
301
302    // ── Analytic pricers ────────────────────────────────────────────────
303
304    fn analytic_npv_with(&self, p: &Params) -> f64 {
305        match self.rainbow_type {
306            RainbowType::Exchange => self.margrabe(p),
307            RainbowType::Spread => self.kirk(p),
308            RainbowType::Basket => self.basket_moment_match(p),
309            RainbowType::BestOf | RainbowType::WorstOf => panic!(
310                "best_of / worst_of have no analytic pricer yet; use the MonteCarlo engine"
311            ),
312        }
313    }
314
315    /// Margrabe (1978), exact: exchange option pays (S1 - S2)^+.
316    fn margrabe(&self, p: &Params) -> f64 {
317        let (i, j) = match self.put_or_call {
318            PutOrCall::Call => (0, 1),
319            PutOrCall::Put => (1, 0),
320        };
321        let rho = self.correlations[0][1];
322        let sigma = (p.vols[i] * p.vols[i] + p.vols[j] * p.vols[j]
323            - 2.0 * rho * p.vols[i] * p.vols[j])
324            .sqrt();
325        let (q_i, q_j) = (self.dividends[i], self.dividends[j]);
326        let st = sigma * p.t.sqrt();
327        if st < 1e-12 {
328            // perfectly correlated identical dynamics: the exchange is
329            // deterministic — discounted positive forward difference
330            return (p.spots[i] * exp(-q_i * p.t) - p.spots[j] * exp(-q_j * p.t)).max(0.0);
331        }
332        let d1 = ((p.spots[i] / p.spots[j]).ln() + (q_j - q_i + 0.5 * sigma * sigma) * p.t) / st;
333        let d2 = d1 - st;
334        p.spots[i] * exp(-q_i * p.t) * N(d1) - p.spots[j] * exp(-q_j * p.t) * N(d2)
335    }
336
337    /// Kirk's (1995) approximation for spread options (S1 - S2 - K)^+.
338    fn kirk(&self, p: &Params) -> f64 {
339        let f1 = p.spots[0] * exp((p.r - self.dividends[0]) * p.t);
340        let f2 = p.spots[1] * exp((p.r - self.dividends[1]) * p.t);
341        let k = self.strike_price;
342        let rho = self.correlations[0][1];
343        let w = f2 / (f2 + k);
344        let sigma = (p.vols[0] * p.vols[0] - 2.0 * rho * p.vols[0] * p.vols[1] * w
345            + p.vols[1] * p.vols[1] * w * w)
346            .sqrt();
347        let st = sigma * p.t.sqrt();
348        let d1 = ((f1 / (f2 + k)).ln() + 0.5 * sigma * sigma * p.t) / st;
349        let d2 = d1 - st;
350        let df = exp(-p.r * p.t);
351        match self.put_or_call {
352            PutOrCall::Call => df * (f1 * N(d1) - (f2 + k) * N(d2)),
353            PutOrCall::Put => df * ((f2 + k) * N(-d2) - f1 * N(-d1)),
354        }
355    }
356
357    /// Lognormal moment matching for basket options (Levy / Turnbull-Wakeman
358    /// style): match the basket forward's first two moments, price with
359    /// Black's formula.
360    fn basket_moment_match(&self, p: &Params) -> f64 {
361        let n = p.spots.len();
362        let fwds: Vec<f64> = (0..n)
363            .map(|i| self.weights[i] * p.spots[i] * exp((p.r - self.dividends[i]) * p.t))
364            .collect();
365        let m1: f64 = fwds.iter().sum();
366        let mut m2 = 0.0;
367        for i in 0..n {
368            for j in 0..n {
369                m2 += fwds[i]
370                    * fwds[j]
371                    * exp(self.correlations[i][j] * p.vols[i] * p.vols[j] * p.t);
372            }
373        }
374        let log_var = (m2 / (m1 * m1)).ln().max(1e-12);
375        let sqrt_v = log_var.sqrt();
376        let k = self.strike_price;
377        let d1 = ((m1 / k).ln() + 0.5 * log_var) / sqrt_v;
378        let d2 = d1 - sqrt_v;
379        let df = exp(-p.r * p.t);
380        match self.put_or_call {
381            PutOrCall::Call => df * (m1 * N(d1) - k * N(d2)),
382            PutOrCall::Put => df * (k * N(-d2) - m1 * N(-d1)),
383        }
384    }
385
386    // ── Monte Carlo (correlated terminal GBM) ───────────────────────────
387
388    fn mc_stats_with(&self, p: &Params) -> McStats {
389        let n = self.spots.len();
390        let t = p.t;
391        let df = exp(-p.r * t);
392        let sqrt_t = t.sqrt();
393        let drifts: Vec<f64> = (0..n)
394            .map(|i| (p.r - self.dividends[i] - 0.5 * p.vols[i] * p.vols[i]) * t)
395            .collect();
396        let qmc = match self.sampler {
397            Sampler::Sobol => Some(QmcSequence::new(n, self.seed)),
398            Sampler::PseudoRandom => None,
399        };
400        let chunks = self.paths.div_ceil(PATH_CHUNK);
401        let partials: Vec<(f64, f64)> = (0..chunks)
402            .into_par_iter()
403            .map(|chunk| {
404                let mut eps = vec![0.0; n];
405                let mut terminal = vec![0.0; n];
406                let (mut sum, mut sum_sq) = (0.0, 0.0);
407                for path in chunk * PATH_CHUNK..((chunk + 1) * PATH_CHUNK).min(self.paths) {
408                    match &qmc {
409                        Some(seq) => seq.normals(path as u64 + 1, &mut eps),
410                        None => {
411                            // antithetic pairs from per-pair streams
412                            path_normals(self.seed, (path / 2) as u64, &mut eps);
413                            if path % 2 == 1 {
414                                for e in eps.iter_mut() {
415                                    *e = -*e;
416                                }
417                            }
418                        }
419                    }
420                    for i in 0..n {
421                        // z_i = sum_j L[i][j] eps_j (Cholesky-correlated)
422                        let z: f64 =
423                            (0..=i).map(|j| self.chol[i][j] * eps[j]).sum();
424                        terminal[i] = p.spots[i] * exp(drifts[i] + p.vols[i] * sqrt_t * z);
425                    }
426                    let v = df * self.payoff(&terminal);
427                    sum += v;
428                    sum_sq += v * v;
429                }
430                (sum, sum_sq)
431            })
432            .collect();
433        let (sum, sum_sq) =
434            partials.into_iter().fold((0.0, 0.0), |a, b| (a.0 + b.0, a.1 + b.1));
435        let nf = self.paths as f64;
436        let mean = sum / nf;
437        let var = (sum_sq / nf - mean * mean).max(0.0);
438        McStats { pv: mean, std_err: (var / nf).sqrt(), paths: self.paths, steps: 1 }
439    }
440}
441
442/// Cholesky decomposition of a correlation matrix; Err if the matrix is
443/// not symmetric positive **semi**-definite with a unit diagonal
444/// (perfectly correlated assets are allowed).
445fn cholesky(m: &[Vec<f64>]) -> Result<Vec<Vec<f64>>, String> {
446    let n = m.len();
447    for i in 0..n {
448        if (m[i][i] - 1.0).abs() > 1e-10 {
449            return Err(format!("diagonal element [{i}][{i}] must be 1"));
450        }
451        for j in 0..n {
452            if (m[i][j] - m[j][i]).abs() > 1e-10 {
453                return Err("matrix must be symmetric".to_string());
454            }
455        }
456    }
457    let mut l = vec![vec![0.0; n]; n];
458    for i in 0..n {
459        for j in 0..=i {
460            let s: f64 = (0..j).map(|k| l[i][k] * l[j][k]).sum();
461            if i == j {
462                let d = m[i][i] - s;
463                if d < -1e-10 {
464                    return Err("matrix is not positive semi-definite".to_string());
465                }
466                l[i][j] = d.max(0.0).sqrt();
467            } else if l[j][j] > 1e-12 {
468                l[i][j] = (m[i][j] - s) / l[j][j];
469            } else {
470                l[i][j] = 0.0;
471            }
472        }
473    }
474    Ok(l)
475}
476
477#[cfg(test)]
478mod tests {
479    use super::*;
480    use crate::equity::blackscholes::bs_price;
481
482    fn two_asset(rainbow_type: &str, pc: &str, strike: Option<f64>, rho: f64) -> Box<RainbowOption> {
483        RainbowOption::from_json(&RainbowOptionData {
484            symbol: "RB".to_string(),
485            rainbow_type: rainbow_type.to_string(),
486            put_or_call: Some(pc.to_string()),
487            assets: vec![
488                RainbowAssetData {
489                    symbol: "A".into(),
490                    spot: 100.0,
491                    volatility: 0.3,
492                    dividend: Some(0.02),
493                },
494                RainbowAssetData {
495                    symbol: "B".into(),
496                    spot: 95.0,
497                    volatility: 0.25,
498                    dividend: Some(0.01),
499                },
500            ],
501            correlations: vec![vec![1.0, rho], vec![rho, 1.0]],
502            strike_price: strike,
503            weights: None,
504            maturity: maturity_1y(),
505            risk_free_rate: Some(0.05),
506            discount_curve: None,
507            pricer: Some("MC".to_string()),
508            simulation: Some(100_000),
509            mc_sampler: None,
510            mc_seed: None,
511        })
512    }
513
514    fn maturity_1y() -> String {
515        let d = Local::now().date_naive() + chrono::Duration::days(365);
516        d.format("%Y-%m-%d").to_string()
517    }
518
519    #[test]
520    fn margrabe_matches_monte_carlo() {
521        let mut option = two_asset("exchange", "C", None, 0.6);
522        option.engine = Engine::BlackScholes;
523        let analytic = option.npv();
524        option.engine = Engine::MonteCarlo;
525        let mc = option.npv();
526        assert!((mc - analytic).abs() < 0.05, "mc={mc} margrabe={analytic}");
527        assert!(analytic > 0.0);
528    }
529
530    #[test]
531    fn margrabe_vanishes_for_identical_assets() {
532        let mut option = two_asset("exchange", "C", None, 1.0);
533        option.spots = vec![100.0, 100.0];
534        option.vols = vec![0.3, 0.3];
535        option.dividends = vec![0.02, 0.02];
536        option.engine = Engine::BlackScholes;
537        assert!(option.npv().abs() < 1e-10);
538    }
539
540    #[test]
541    fn kirk_close_to_monte_carlo() {
542        let mut option = two_asset("spread", "C", Some(5.0), 0.6);
543        option.engine = Engine::BlackScholes;
544        let kirk = option.npv();
545        option.engine = Engine::MonteCarlo;
546        let mc = option.npv();
547        // Kirk is an approximation: agreement at the few-cents level
548        assert!((mc - kirk).abs() < 0.10, "mc={mc} kirk={kirk}");
549    }
550
551    #[test]
552    fn spread_with_zero_strike_equals_margrabe() {
553        let mut spread = two_asset("spread", "C", Some(0.0), 0.6);
554        spread.engine = Engine::BlackScholes;
555        let mut exchange = two_asset("exchange", "C", None, 0.6);
556        exchange.engine = Engine::BlackScholes;
557        assert!((spread.npv() - exchange.npv()).abs() < 1e-10);
558    }
559
560    #[test]
561    fn basket_moment_match_close_to_monte_carlo() {
562        let data = RainbowOptionData {
563            symbol: "BK".into(),
564            rainbow_type: "basket".into(),
565            put_or_call: Some("C".into()),
566            assets: vec![
567                RainbowAssetData { symbol: "A".into(), spot: 100.0, volatility: 0.3, dividend: None },
568                RainbowAssetData { symbol: "B".into(), spot: 90.0, volatility: 0.25, dividend: None },
569                RainbowAssetData { symbol: "C".into(), spot: 110.0, volatility: 0.35, dividend: None },
570            ],
571            correlations: vec![
572                vec![1.0, 0.5, 0.3],
573                vec![0.5, 1.0, 0.4],
574                vec![0.3, 0.4, 1.0],
575            ],
576            strike_price: Some(100.0),
577            weights: None,
578            maturity: maturity_1y(),
579            risk_free_rate: Some(0.05),
580            discount_curve: None,
581            pricer: Some("Analytical".into()),
582            simulation: Some(100_000),
583            mc_sampler: None,
584            mc_seed: None,
585        };
586        let mut option = RainbowOption::from_json(&data);
587        let analytic = option.npv();
588        option.engine = Engine::MonteCarlo;
589        let mc = option.npv();
590        assert!((mc - analytic).abs() < 0.15, "mc={mc} moment-match={analytic}");
591    }
592
593    #[test]
594    fn best_of_plus_worst_of_equals_sum_of_vanillas() {
595        // max + min = S1 + S2 pathwise, so (max-K)+ + (min-K)+ = (S1-K)+ + (S2-K)+
596        let k = 100.0;
597        let best = two_asset("best_of", "C", Some(k), 0.6).npv();
598        let worst = two_asset("worst_of", "C", Some(k), 0.6).npv();
599        let t = two_asset("best_of", "C", Some(k), 0.6).time_to_maturity();
600        let vanillas = bs_price(100.0, k, 0.05, 0.02, 0.3, t, PutOrCall::Call)
601            + bs_price(95.0, k, 0.05, 0.01, 0.25, t, PutOrCall::Call);
602        assert!(
603            (best + worst - vanillas).abs() < 0.1,
604            "best {best} + worst {worst} vs vanillas {vanillas}"
605        );
606    }
607
608    #[test]
609    fn worst_of_call_at_zero_strike_is_forward_minus_margrabe() {
610        // min(S1, S2) = S2 - (S2 - S1)^+
611        let worst = two_asset("worst_of", "C", Some(1e-9), 0.6);
612        let t = worst.time_to_maturity();
613        let worst_pv = worst.npv();
614        let mut exchange_21 = two_asset("exchange", "P", None, 0.6); // pays (S2 - S1)^+
615        exchange_21.engine = Engine::BlackScholes;
616        let expected = 95.0 * (-0.01 * t as f64).exp() - exchange_21.npv();
617        assert!((worst_pv - expected).abs() < 0.05, "{worst_pv} vs {expected}");
618    }
619
620    #[test]
621    fn correlation_orders_worst_of_prices() {
622        // higher correlation raises the worst-of call (the min rises)
623        let low = two_asset("worst_of", "C", Some(100.0), 0.0).npv();
624        let high = two_asset("worst_of", "C", Some(100.0), 0.9).npv();
625        assert!(high > low, "high-corr {high} must exceed low-corr {low}");
626    }
627
628    #[test]
629    fn monte_carlo_is_reproducible_and_reports_stats() {
630        let option = two_asset("worst_of", "C", Some(100.0), 0.6);
631        assert_eq!(option.npv(), option.npv());
632        let stats = option.npv_with_stats().unwrap();
633        assert!(stats.std_err > 0.0 && stats.std_err < 0.5);
634    }
635
636    #[test]
637    fn deltas_and_vegas_have_sensible_signs() {
638        let option = two_asset("spread", "C", Some(5.0), 0.6);
639        let deltas = option.deltas();
640        assert!(deltas[0] > 0.0, "long asset 1: {deltas:?}");
641        assert!(deltas[1] < 0.0, "short asset 2: {deltas:?}");
642        let vegas = option.vegas();
643        assert!(vegas[0] > 0.0);
644    }
645
646    #[test]
647    fn cholesky_rejects_invalid_correlations() {
648        assert!(cholesky(&[vec![1.0, 0.5], vec![0.4, 1.0]]).is_err()); // asymmetric
649        assert!(cholesky(&[vec![2.0, 0.0], vec![0.0, 1.0]]).is_err()); // diagonal != 1
650        // correlation > 1 in disguise: not positive definite
651        assert!(cholesky(&[
652            vec![1.0, 0.9, -0.9],
653            vec![0.9, 1.0, 0.9],
654            vec![-0.9, 0.9, 1.0]
655        ])
656        .is_err());
657        assert!(cholesky(&[vec![1.0, 0.5], vec![0.5, 1.0]]).is_ok());
658    }
659}