1use 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#[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 pub rainbow_type: String,
47 pub put_or_call: Option<String>,
48 pub assets: Vec<RainbowAssetData>,
49 pub correlations: Vec<Vec<f64>>,
51 pub strike_price: Option<f64>,
52 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#[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 chol: Vec<Vec<f64>>,
94}
95
96#[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 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 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 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 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 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 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 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 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 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 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 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 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
442fn 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 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 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 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); 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 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()); assert!(cholesky(&[vec![2.0, 0.0], vec![0.0, 1.0]]).is_err()); 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}