Skip to main content

rustyqlib/equity/
worst_of.rs

1//! Worst-of autocallable: the structured-products flagship — an
2//! autocallable note observed on the **worst performer** of a basket.
3//!
4//! At each observation the worst-of performance
5//! `W(t) = min_i S_i(t) / S_i(0)` is compared against the barriers; the
6//! coupon, autocall, knock-in and downside-participation logic is the
7//! single-asset [`AutocallablePayoff`] evaluated on the worst-of path
8//! expressed in `initial_fixing` units, so every payoff variant (Athena
9//! accrued coupons, Phoenix conditional coupons with memory, explicit
10//! observation schedules) carries over unchanged.
11//!
12//! Paths are the correlated multi-asset lognormal dynamics of
13//! [`MultiAssetGbmProcess`] — exact joint transitions, so step count only
14//! sets monitoring resolution — driven by the shared multi-factor draw
15//! machinery (seeded pseudo-random antithetic pairs, or the
16//! low-discrepancy sequence with one Brownian bridge per asset).
17//!
18//! Economics worth testing against: the note is **long correlation**
19//! (a tighter basket has a better worst performer), and adding an asset
20//! can only cheapen it.
21
22use 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
37/// Autocallable note on the worst-of performance of a correlated basket.
38pub struct WorstOfAutocallable {
39    pub symbol: String,
40    /// Current spots — also the contractual initial fixings that
41    /// normalize the worst-of performance (the note is assumed priced
42    /// from inception levels; Greek bumps move the market spot, never
43    /// the fixing).
44    pub spots: Vec<f64>,
45    pub vols: Vec<f64>,
46    pub dividends: Vec<f64>,
47    pub correlations: Vec<Vec<f64>>,
48    /// Redemption logic; its barriers are worst-of performance levels in
49    /// `initial_fixing` units (e.g. fixing 100, autocall 100 = 100% of
50    /// initial, protection 70 = 70% of initial).
51    pub payoff: AutocallablePayoff,
52    pub maturity_date: NaiveDate,
53    pub valuation_date: NaiveDate,
54    pub discount_curve: YieldCurve,
55    pub mc: MonteCarloConfig,
56    /// Lower-triangular Cholesky factor of the correlation matrix.
57    chol: Vec<Vec<f64>>,
58}
59
60/// Market snapshot the Greeks bump (common random numbers: every reprice
61/// reuses the same deterministic draws).
62#[derive(Clone)]
63struct Params {
64    spots: Vec<f64>,
65    vols: Vec<f64>,
66    /// Parallel shift of the discount/drift rate (rho bumps).
67    dr: f64,
68    t: f64,
69}
70
71impl WorstOfAutocallable {
72    /// Validate and construct: dimensions must agree, and a correlation
73    /// matrix that fails PSD is repaired with Higham's projection (an
74    /// asymmetric or non-unit-diagonal matrix is a data error and still
75    /// rejected).
76    #[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    /// Observation grid on a path of `steps` steps over life `t`:
151    /// per-observation path indices (strictly increasing) and discount
152    /// factors at the exact observation times.
153    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        // every observation lands exactly on a simulation step
190        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                        // worst-of performance in initial_fixing units,
227                        // normalized by the *contractual* fixings
228                        // (self.spots): market bumps move the path start,
229                        // never the denominators — otherwise delta would
230                        // cancel to zero by homogeneity
231                        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    /// Per-asset spot deltas (central bumps, common random numbers).
258    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    /// Per-asset vegas (central bumps of each asset's vol).
273    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        // identical assets at rho = 1 share one path, so the worst-of note
387        // must price like the single-asset autocallable on the same terms
388        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        // a tighter basket has a better worst performer: value must rise
416        // with correlation, and even the tightest basket stays below the
417        // rho = 1 degenerate case
418        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        // min over three is never better than min over two of the same
428        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        // the holder is long each asset (higher spot => better worst-of)
437        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}