1use chrono::NaiveDate;
23use libm::exp;
24use rayon::prelude::*;
25
26use crate::core::curves::{Compounding, YieldCurve};
27use crate::core::errors::RustyQLibError;
28use crate::core::linalg::{cholesky, nearest_correlation};
29use crate::core::montecarlo::paths::{FactorScratch, MultiDraws};
30use crate::core::montecarlo::process::StochasticProcess;
31use crate::core::results::PricingResult;
32use crate::core::traits::Instrument;
33use crate::equity::autocallable::AutocallablePayoff;
34use crate::equity::montecarlo::{McStats, MonteCarloConfig, PATH_DEPENDENT_MIN_STEPS};
35use crate::equity::processes::MultiAssetGbmProcess;
36
37pub struct WorstOfAutocallable {
39 pub symbol: String,
40 pub spots: Vec<f64>,
45 pub vols: Vec<f64>,
46 pub dividends: Vec<f64>,
47 pub correlations: Vec<Vec<f64>>,
48 pub payoff: AutocallablePayoff,
52 pub maturity_date: NaiveDate,
53 pub valuation_date: NaiveDate,
54 pub discount_curve: YieldCurve,
55 pub mc: MonteCarloConfig,
56 chol: Vec<Vec<f64>>,
58}
59
60#[derive(Clone)]
63struct Params {
64 spots: Vec<f64>,
65 vols: Vec<f64>,
66 dr: f64,
68 t: f64,
69}
70
71impl WorstOfAutocallable {
72 #[allow(clippy::too_many_arguments)]
77 pub fn new(
78 symbol: &str,
79 spots: Vec<f64>,
80 vols: Vec<f64>,
81 dividends: Vec<f64>,
82 correlations: Vec<Vec<f64>>,
83 payoff: AutocallablePayoff,
84 maturity_date: NaiveDate,
85 valuation_date: NaiveDate,
86 discount_curve: YieldCurve,
87 mc: MonteCarloConfig,
88 ) -> Result<Self, RustyQLibError> {
89 let n = spots.len();
90 if n < 2 {
91 return Err(RustyQLibError::invalid_input(
92 "assets",
93 "worst-of autocallables need at least two assets",
94 ));
95 }
96 if vols.len() != n || dividends.len() != n {
97 return Err(RustyQLibError::invalid_input(
98 "assets",
99 "spots, vols and dividends must have the same length",
100 ));
101 }
102 if correlations.len() != n || correlations.iter().any(|row| row.len() != n) {
103 return Err(RustyQLibError::invalid_input(
104 "correlations",
105 "correlations must be an n x n matrix",
106 ));
107 }
108 let chol = match cholesky(&correlations) {
109 Ok(l) => l,
110 Err(RustyQLibError::NumericalError(ref msg))
111 if msg.contains("positive semi-definite") =>
112 {
113 log::warn!(
114 "correlation matrix is not PSD; \
115 projecting to the nearest correlation matrix (Higham)"
116 );
117 let repaired = nearest_correlation(&correlations, 1e-12, 200)?;
118 cholesky(&repaired)?
119 }
120 Err(e) => return Err(e),
121 };
122 Ok(WorstOfAutocallable {
123 symbol: symbol.to_string(),
124 spots,
125 vols,
126 dividends,
127 correlations,
128 payoff,
129 maturity_date,
130 valuation_date,
131 discount_curve,
132 mc,
133 chol,
134 })
135 }
136
137 pub fn time_to_maturity(&self) -> f64 {
138 (self.maturity_date - self.valuation_date).num_days() as f64 / 365.0
139 }
140
141 fn params(&self) -> Params {
142 Params {
143 spots: self.spots.clone(),
144 vols: self.vols.clone(),
145 dr: 0.0,
146 t: self.time_to_maturity(),
147 }
148 }
149
150 fn observation_grid(&self, t: f64, dr: f64, steps: usize) -> (Vec<usize>, Vec<f64>) {
154 let n_obs = self.payoff.observations.max(1);
155 let (obs_idx, obs_times): (Vec<usize>, Vec<f64>) = match &self.payoff.observation_times {
156 Some(times) => {
157 let mut idx = Vec::with_capacity(times.len());
158 let mut prev: i64 = 0;
159 for &tm in times {
160 let i = ((tm / t) * steps as f64).round().max(1.0) as i64;
161 let i = i.max(prev + 1).min(steps as i64);
162 idx.push(i as usize - 1);
163 prev = i;
164 }
165 (idx, times.clone())
166 }
167 None => {
168 let dt = t / steps as f64;
169 let idx: Vec<usize> = (1..=n_obs).map(|m| m * steps / n_obs - 1).collect();
170 let times = idx.iter().map(|&i| (i + 1) as f64 * dt).collect();
171 (idx, times)
172 }
173 };
174 let dfs = obs_times
175 .iter()
176 .map(|&tm| self.discount_curve.df(tm) * exp(-dr * tm))
177 .collect();
178 (obs_idx, dfs)
179 }
180
181 pub fn npv_with_stats(&self) -> McStats {
182 self.mc_stats_with(&self.params())
183 }
184
185 fn mc_stats_with(&self, p: &Params) -> McStats {
186 let n = self.spots.len();
187 let t = p.t;
188 let n_obs = self.payoff.observations.max(1);
189 let steps =
191 self.mc.time_steps.max(PATH_DEPENDENT_MIN_STEPS).div_ceil(n_obs) * n_obs;
192 let dt = t / steps as f64;
193 let (obs_idx, dfs) = self.observation_grid(t, p.dr, steps);
194 let r = self.discount_curve.zero_rate_with(t, Compounding::Continuous) + p.dr;
195 let process = MultiAssetGbmProcess {
196 drift_rates: self.dividends.iter().map(|q| r - q).collect(),
197 vols: p.vols.clone(),
198 chol: self.chol.clone(),
199 };
200 let draws = MultiDraws::new(self.mc.sampler, self.mc.seed, n, steps, dt);
201 let fixing = self.payoff.initial_fixing;
202
203 const CHUNK: usize = 4096;
204 let chunks = self.mc.paths.div_ceil(CHUNK);
205 let partials: Vec<(f64, f64)> = (0..chunks)
206 .into_par_iter()
207 .map(|chunk| {
208 let mut scratch = FactorScratch::new(n, steps);
209 let mut dw = vec![0.0; n * steps];
210 let mut x = vec![0.0; n];
211 let mut x_next = vec![0.0; n];
212 let mut worst = vec![0.0; steps];
213 let (mut sum, mut sum_sq) = (0.0, 0.0);
214 for i in chunk * CHUNK..((chunk + 1) * CHUNK).min(self.mc.paths) {
215 draws.fill(i, n, steps, &mut scratch, &mut dw);
216 x.copy_from_slice(&p.spots);
217 for j in 0..steps {
218 process.evolve(
219 j as f64 * dt,
220 &x,
221 dt,
222 &dw[j * n..(j + 1) * n],
223 &mut x_next,
224 );
225 x.copy_from_slice(&x_next);
226 let w = x
232 .iter()
233 .zip(&self.spots)
234 .map(|(s, s0)| s / s0)
235 .fold(f64::MAX, f64::min);
236 worst[j] = fixing * w;
237 }
238 let v = self.payoff.path_value(&worst, &obs_idx, &dfs);
239 sum += v;
240 sum_sq += v * v;
241 }
242 (sum, sum_sq)
243 })
244 .collect();
245 let (sum, sum_sq) =
246 partials.into_iter().fold((0.0, 0.0), |a, b| (a.0 + b.0, a.1 + b.1));
247 let nf = self.mc.paths as f64;
248 let mean = sum / nf;
249 let var = (sum_sq / nf - mean * mean).max(0.0);
250 McStats { pv: mean, std_err: (var / nf).sqrt(), paths: self.mc.paths, steps }
251 }
252
253 fn price_with(&self, p: &Params) -> f64 {
254 self.mc_stats_with(p).pv
255 }
256
257 pub fn deltas(&self) -> Vec<f64> {
259 let base = self.params();
260 (0..self.spots.len())
261 .map(|i| {
262 let h = base.spots[i] * 0.01;
263 let mut up = base.clone();
264 up.spots[i] += h;
265 let mut dn = base.clone();
266 dn.spots[i] -= h;
267 (self.price_with(&up) - self.price_with(&dn)) / (2.0 * h)
268 })
269 .collect()
270 }
271
272 pub fn vegas(&self) -> Vec<f64> {
274 let base = self.params();
275 (0..self.vols.len())
276 .map(|i| {
277 let h = 0.01;
278 let mut up = base.clone();
279 up.vols[i] += h;
280 let mut dn = base.clone();
281 dn.vols[i] = (dn.vols[i] - h).max(1e-6);
282 (self.price_with(&up) - self.price_with(&dn)) / (2.0 * h)
283 })
284 .collect()
285 }
286
287 pub fn theta(&self) -> f64 {
288 let base = self.params();
289 let h = (1.0 / 365.0_f64).min(0.5 * base.t);
290 let mut up = base.clone();
291 up.t += h;
292 let mut dn = base.clone();
293 dn.t -= h;
294 -(self.price_with(&up) - self.price_with(&dn)) / (2.0 * h)
295 }
296
297 pub fn rho(&self) -> f64 {
298 let base = self.params();
299 let h = 1e-4;
300 let mut up = base.clone();
301 up.dr += h;
302 let mut dn = base.clone();
303 dn.dr -= h;
304 (self.price_with(&up) - self.price_with(&dn)) / (2.0 * h)
305 }
306}
307
308impl Instrument for WorstOfAutocallable {
309 fn try_npv(&self) -> Result<f64, RustyQLibError> {
310 Ok(self.npv_with_stats().pv)
311 }
312
313 fn price(&self) -> Result<PricingResult, RustyQLibError> {
314 let stats = self.npv_with_stats();
315 Ok(PricingResult {
316 pv: stats.pv,
317 greeks: crate::core::results::Greeks {
318 theta: self.theta(),
319 rho: self.rho(),
320 ..Default::default()
321 },
322 std_err: Some(stats.std_err),
323 })
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330 use crate::core::daycount::DayCountConvention;
331 use crate::core::utils::ContractStyle;
332 use crate::equity::builder::EquityOptionBuilder;
333 use crate::equity::montecarlo::Sampler;
334 use crate::equity::utils::Engine;
335
336 fn dates() -> (NaiveDate, NaiveDate) {
337 (
338 NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
339 NaiveDate::from_ymd_opt(2029, 1, 1).unwrap(),
340 )
341 }
342
343 fn payoff() -> AutocallablePayoff {
344 AutocallablePayoff {
345 exercise_style: ContractStyle::European,
346 autocall_barrier: 100.0,
347 protection_barrier: 70.0,
348 coupon: 6.0,
349 observations: 6,
350 observation_times: None,
351 notional: 100.0,
352 initial_fixing: 100.0,
353 coupon_barrier: None,
354 memory: false,
355 }
356 }
357
358 fn note(n: usize, rho: f64, paths: usize) -> WorstOfAutocallable {
359 let (val, mat) = dates();
360 let correlations: Vec<Vec<f64>> = (0..n)
361 .map(|i| (0..n).map(|j| if i == j { 1.0 } else { rho }).collect())
362 .collect();
363 WorstOfAutocallable::new(
364 "WOF",
365 vec![100.0; n],
366 vec![0.25; n],
367 vec![0.02; n],
368 correlations,
369 payoff(),
370 mat,
371 val,
372 YieldCurve::flat(0.03, val, DayCountConvention::Act365, Compounding::Continuous)
373 .unwrap(),
374 MonteCarloConfig {
375 paths,
376 sampler: Sampler::PseudoRandom,
377 seed: 42,
378 ..Default::default()
379 },
380 )
381 .unwrap()
382 }
383
384 #[test]
385 fn perfect_correlation_degenerates_to_the_single_asset_note() {
386 let (val, mat) = dates();
389 let single = EquityOptionBuilder::new()
390 .spot(100.0)
391 .strike(100.0)
392 .flat_vol(0.25)
393 .flat_rate(0.03)
394 .dividend_yield(0.02)
395 .valuation_date(val)
396 .maturity_date(mat)
397 .autocallable(100.0, 70.0, 6.0, 6, 100.0)
398 .engine(Engine::MonteCarlo)
399 .build()
400 .expect("single-asset note must build")
401 .npv();
402 let wof = note(2, 1.0, 100_000);
403 let stats = wof.npv_with_stats();
404 assert!(
405 (stats.pv - single).abs() < 4.0 * stats.std_err.max(0.05),
406 "worst-of {} vs single-asset {} (se {})",
407 stats.pv,
408 single,
409 stats.std_err
410 );
411 }
412
413 #[test]
414 fn the_note_is_long_correlation() {
415 let low = note(2, 0.2, 60_000).npv();
419 let high = note(2, 0.8, 60_000).npv();
420 let degenerate = note(2, 1.0, 60_000).npv();
421 assert!(high > low + 0.1, "rho=0.8 {high} vs rho=0.2 {low}");
422 assert!(degenerate > high, "rho=1 {degenerate} vs rho=0.8 {high}");
423 }
424
425 #[test]
426 fn adding_an_asset_cheapens_the_note() {
427 let two = note(2, 0.5, 60_000).npv();
429 let three = note(3, 0.5, 60_000).npv();
430 assert!(three < two - 0.1, "3-asset {three} vs 2-asset {two}");
431 }
432
433 #[test]
434 fn deltas_are_positive_and_the_price_reports_stats() {
435 let wof = note(2, 0.6, 20_000);
436 for (i, d) in wof.deltas().iter().enumerate() {
438 assert!(*d > 0.0, "delta[{i}] = {d}");
439 }
440 let result = wof.price().unwrap();
441 assert!(result.std_err.unwrap() > 0.0);
442 assert!(result.pv > 0.0);
443 }
444}