Skip to main content

rustyqlib/equity/
accumulator.rs

1//! Accumulators and decumulators — the daily-accrual structured
2//! products ("I-kill-you-later"): the holder is committed to trade a
3//! fixed quantity at a fixed price on every observation date while the
4//! structure is alive, with a knock-out barrier on the favorable side
5//! and **geared** (typically doubled) quantity on the adverse side.
6//!
7//! - **Accumulator**: buy `shares_per_day` at strike `K < S_0` each day;
8//!   knocked out when the spot rises to the barrier `H > S_0`; when the
9//!   spot closes below `K` the holder must buy `gearing x` the quantity.
10//!   Day value while alive: `q [ (S_i - K)+ - gearing (K - S_i)+ ]`.
11//! - **Decumulator**: the mirror — sell at `K > S_0`, knocked out at
12//!   `H < S_0`, geared when the spot closes above `K`.
13//!
14//! Priced two ways:
15//! - **Analytical**: each observation day is a pair of Reiner-Rubinstein
16//!   knock-out barrier options maturing on that day (up-and-out call
17//!   minus geared up-and-out put for the accumulator; down-and-out put
18//!   minus geared down-and-out call for the decumulator), so the value
19//!   is a strip of closed forms. The barrier is **continuously**
20//!   monitored in this representation.
21//! - **Monte Carlo**: simulates the observation grid directly, with the
22//!   knock-out checked **discretely** at each observation — the usual
23//!   contractual convention. The discrete knockout survives slightly
24//!   longer than the continuous one, so the two conventions bracket the
25//!   product; the tests assert exact agreement in the barrier-free
26//!   degenerate cases and closeness with dense observations.
27
28use chrono::NaiveDate;
29use serde::{Deserialize, Serialize};
30
31use crate::core::montecarlo::{mean_std_err, path_rng};
32use crate::core::traits::Instrument;
33use crate::equity::barrier::{barrier_price, BarrierDirection, KnockType};
34use rand::Rng;
35use rand_distr::StandardNormal;
36use crate::core::errors::RustyQLibError;
37
38/// Which side of the trade the holder accrues.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum AccumulatorSide {
41    /// Daily buyer at a discount, knocked out above.
42    Accumulator,
43    /// Daily seller at a premium, knocked out below.
44    Decumulator,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum AccumulatorPricer {
49    Analytical,
50    MonteCarlo,
51}
52
53/// JSON contract data (`"product_type": "accumulator"`).
54#[derive(Clone, Debug, Deserialize, Serialize)]
55pub struct AccumulatorData {
56    pub symbol: String,
57    /// "accumulator" | "decumulator".
58    pub side: String,
59    pub underlying_price: f64,
60    /// Contractual trade price (below spot for accumulators).
61    pub strike: f64,
62    /// Knock-out level (above spot for accumulators, below for decumulators).
63    pub barrier: f64,
64    /// Number of equally spaced observation days (last = maturity).
65    pub observations: usize,
66    /// Maturity date, `YYYY-MM-DD`.
67    pub maturity: String,
68    /// Shares traded per observation (default 1).
69    pub shares_per_day: Option<f64>,
70    /// Quantity multiplier on the adverse side (default 2 = double-up).
71    pub gearing: Option<f64>,
72    pub risk_free_rate: f64,
73    pub dividend: Option<f64>,
74    pub volatility: f64,
75    pub pricer: Option<String>,
76    pub simulation: Option<u64>,
77    pub mc_seed: Option<u64>,
78    /// Pricing as-of date (`YYYY-MM-DD`); defaults to today.
79    pub valuation_date: Option<String>,
80}
81
82/// An accumulator/decumulator on equally spaced daily observations.
83#[derive(Debug, Clone)]
84pub struct Accumulator {
85    pub side: AccumulatorSide,
86    pub s0: f64,
87    pub strike: f64,
88    pub barrier: f64,
89    pub observations: usize,
90    /// Year fraction to maturity.
91    pub t: f64,
92    pub r: f64,
93    pub q: f64,
94    pub sigma: f64,
95    pub shares_per_day: f64,
96    pub gearing: f64,
97    pub pricer: AccumulatorPricer,
98    pub paths: usize,
99    pub seed: u64,
100}
101
102impl Accumulator {
103    fn validate(&self) -> Result<(), RustyQLibError> {
104        if self.observations < 1 || self.t <= 0.0 || self.sigma <= 0.0 {
105            return Err(RustyQLibError::invalid_input(
106                "accumulator",
107                "observations must be >= 1, maturity and volatility must be positive",
108            ));
109        }
110        if self.gearing < 0.0 || self.shares_per_day <= 0.0 {
111            return Err(RustyQLibError::invalid_input(
112                "accumulator",
113                "gearing must be non-negative and shares_per_day positive",
114            ));
115        }
116        match self.side {
117            AccumulatorSide::Accumulator if self.barrier <= self.s0 => {
118                Err(RustyQLibError::invalid_input(
119                    "barrier",
120                    "accumulator knock-out must be above the spot",
121                ))
122            }
123            AccumulatorSide::Decumulator if self.barrier >= self.s0 => {
124                Err(RustyQLibError::invalid_input(
125                    "barrier",
126                    "decumulator knock-out must be below the spot",
127                ))
128            }
129            _ => Ok(()),
130        }
131    }
132
133    /// Strip-of-barrier-options closed form (continuous knock-out
134    /// monitoring). Each observation contributes a knock-out pair
135    /// maturing on its date.
136    pub fn analytic_npv(&self) -> f64 {
137        let dt = self.t / self.observations as f64;
138        let mut value = 0.0;
139        for i in 1..=self.observations {
140            let ti = i as f64 * dt;
141            value += match self.side {
142                AccumulatorSide::Accumulator => {
143                    let uoc = barrier_price(
144                        self.s0, self.strike, self.barrier, self.r, self.q, self.sigma,
145                        ti, BarrierDirection::Up, KnockType::Out, crate::core::trade::PutOrCall::Call,
146                    );
147                    let uop = barrier_price(
148                        self.s0, self.strike, self.barrier, self.r, self.q, self.sigma,
149                        ti, BarrierDirection::Up, KnockType::Out, crate::core::trade::PutOrCall::Put,
150                    );
151                    uoc - self.gearing * uop
152                }
153                AccumulatorSide::Decumulator => {
154                    let dop = barrier_price(
155                        self.s0, self.strike, self.barrier, self.r, self.q, self.sigma,
156                        ti, BarrierDirection::Down, KnockType::Out, crate::core::trade::PutOrCall::Put,
157                    );
158                    let doc = barrier_price(
159                        self.s0, self.strike, self.barrier, self.r, self.q, self.sigma,
160                        ti, BarrierDirection::Down, KnockType::Out, crate::core::trade::PutOrCall::Call,
161                    );
162                    dop - self.gearing * doc
163                }
164            };
165        }
166        self.shares_per_day * value
167    }
168
169    /// Monte Carlo on the observation grid: discrete knock-out at each
170    /// observation (the contractual daily-close convention), accrual up
171    /// to but excluding the knock-out day. Deterministic per seed.
172    pub fn mc_npv(&self) -> (f64, f64) {
173        let dt = self.t / self.observations as f64;
174        let drift = (self.r - self.q - 0.5 * self.sigma * self.sigma) * dt;
175        let vol = self.sigma * dt.sqrt();
176        let mut sum = 0.0;
177        let mut sum_sq = 0.0;
178        for i in 0..self.paths {
179            let mut rng = path_rng(self.seed, i as u64);
180            let mut s = self.s0;
181            let mut value = 0.0;
182            for obs in 1..=self.observations {
183                let z: f64 = rng.sample(StandardNormal);
184                s *= (drift + vol * z).exp();
185                let knocked = match self.side {
186                    AccumulatorSide::Accumulator => s >= self.barrier,
187                    AccumulatorSide::Decumulator => s <= self.barrier,
188                };
189                if knocked {
190                    break;
191                }
192                let ti = obs as f64 * dt;
193                let df = (-self.r * ti).exp();
194                let day = match self.side {
195                    AccumulatorSide::Accumulator => {
196                        (s - self.strike).max(0.0) - self.gearing * (self.strike - s).max(0.0)
197                    }
198                    AccumulatorSide::Decumulator => {
199                        (self.strike - s).max(0.0) - self.gearing * (s - self.strike).max(0.0)
200                    }
201                };
202                value += self.shares_per_day * day * df;
203            }
204            sum += value;
205            sum_sq += value * value;
206        }
207        mean_std_err(sum, sum_sq, self.paths)
208    }
209
210    /// Build from contract data, panicking on any invalid field. Fallible
211    /// callers should use [`Accumulator::try_from_json`].
212    pub fn from_json(data: &AccumulatorData) -> Box<Accumulator> {
213        Self::try_from_json(data).unwrap_or_else(|e| panic!("{e}"))
214    }
215
216    pub fn try_from_json(data: &AccumulatorData) -> Result<Box<Accumulator>, RustyQLibError> {
217        let today =
218            crate::core::data_models::parse_valuation_date(data.valuation_date.as_deref())?;
219        let maturity = NaiveDate::parse_from_str(&data.maturity, "%Y-%m-%d")
220            .map_err(|_| RustyQLibError::invalid_input(
221                "maturity",
222                format!("invalid date '{}' (expected YYYY-MM-DD)", data.maturity),
223            ))?;
224        let t = (maturity - today).num_days() as f64 / 365.0;
225        if t <= 0.0 {
226            return Err(RustyQLibError::invalid_input("maturity", "accumulator is expired"));
227        }
228        let side = match data.side.trim().to_lowercase().as_str() {
229            "accumulator" | "accu" => AccumulatorSide::Accumulator,
230            "decumulator" | "decu" => AccumulatorSide::Decumulator,
231            other => return Err(RustyQLibError::invalid_input(
232                "side",
233                format!("invalid accumulator side '{other}' (use accumulator or decumulator)"),
234            )),
235        };
236        let pricer = match data.pricer.as_deref().map(str::trim) {
237            None | Some("Analytical") | Some("analytical") => AccumulatorPricer::Analytical,
238            Some("MonteCarlo") | Some("montecarlo") | Some("MC") | Some("mc") => {
239                AccumulatorPricer::MonteCarlo
240            }
241            Some(other) => return Err(RustyQLibError::invalid_input(
242                "pricer",
243                format!("invalid accumulator pricer '{other}' (use Analytical or MonteCarlo)"),
244            )),
245        };
246        let out = Accumulator {
247            side,
248            s0: data.underlying_price,
249            strike: data.strike,
250            barrier: data.barrier,
251            observations: data.observations,
252            t,
253            r: data.risk_free_rate,
254            q: data.dividend.unwrap_or(0.0),
255            sigma: data.volatility,
256            shares_per_day: data.shares_per_day.unwrap_or(1.0),
257            gearing: data.gearing.unwrap_or(2.0),
258            pricer,
259            paths: data.simulation.unwrap_or(100_000) as usize,
260            seed: data.mc_seed.unwrap_or(42),
261        };
262        out.validate()?;
263        Ok(Box::new(out))
264    }
265}
266
267/// The accumulator as a mainline [`Payoff`], pricing inside
268/// [`EquityOption`](crate::equity::vanilla_option::EquityOption) on the
269/// shared Monte Carlo engine (GBM, local vol and Heston via QE-M) — which
270/// gives it the market context for free: `snapshot_market`/`npv_in`
271/// rebinding, portfolio membership and the stress runner. The strike is
272/// the contract's `strike_price`; spot, curve and surface come from the
273/// bound market. The standalone [`Accumulator`] remains the closed-form
274/// (continuously monitored) validation reference.
275///
276/// Cash flows land on their own observation dates, so like
277/// [`AutocallablePayoff`](crate::equity::autocallable::AutocallablePayoff)
278/// it is valued per path through [`path_value`](Self::path_value) with
279/// per-date discount factors, not through `path_payoff`.
280#[derive(Debug, Clone)]
281pub struct AccumulatorPayoff {
282    pub exercise_style: crate::core::utils::ContractStyle,
283    pub side: AccumulatorSide,
284    /// Knock-out level (above spot for accumulators, below for
285    /// decumulators; enforced at build).
286    pub barrier: f64,
287    /// Equally spaced observation days over the life (last = maturity).
288    pub observations: usize,
289    pub shares_per_day: f64,
290    /// Quantity multiplier on the adverse side (2 = classic double-up).
291    pub gearing: f64,
292}
293
294impl AccumulatorPayoff {
295    /// Value of one simulated path: daily accrual `q [ (S_i - K)+ -
296    /// gearing (K - S_i)+ ]` (mirrored for decumulators), each day
297    /// discounted on its own date, stopping — without accruing — on the
298    /// first observation at or through the knock-out. `obs_idx` maps
299    /// observation m to its path step; `dfs[m]` discounts its date.
300    pub fn path_value(&self, path: &[f64], obs_idx: &[usize], dfs: &[f64], strike: f64) -> f64 {
301        let mut value = 0.0;
302        for (m, &idx) in obs_idx.iter().enumerate() {
303            let s = path[idx];
304            let (knocked, day) = match self.side {
305                AccumulatorSide::Accumulator => (
306                    s >= self.barrier,
307                    (s - strike).max(0.0) - self.gearing * (strike - s).max(0.0),
308                ),
309                AccumulatorSide::Decumulator => (
310                    s <= self.barrier,
311                    (strike - s).max(0.0) - self.gearing * (s - strike).max(0.0),
312                ),
313            };
314            if knocked {
315                break;
316            }
317            value += self.shares_per_day * day * dfs[m];
318        }
319        value
320    }
321}
322
323impl crate::equity::utils::Payoff for AccumulatorPayoff {
324    /// Degenerate single-point value: zero (all value is schedule- and
325    /// path-dependent).
326    fn payoff(&self, _spot: f64, _strike: f64) -> f64 {
327        0.0
328    }
329    fn path_payoff(&self, _path: &[f64], _strike: f64) -> f64 {
330        panic!(
331            "Accumulators pay at multiple dates and cannot be valued through \
332             path_payoff; the Monte Carlo engine prices them via path_value"
333        );
334    }
335    fn is_path_dependent(&self) -> bool {
336        true
337    }
338    fn payoff_kind(&self) -> crate::equity::utils::PayoffType {
339        crate::equity::utils::PayoffType::Accumulator
340    }
341    fn put_or_call(&self) -> &crate::core::trade::PutOrCall {
342        // the holder's daily optionality is call-shaped for accumulators
343        // (buy below), put-shaped for decumulators; not used by pricing
344        match self.side {
345            AccumulatorSide::Accumulator => &crate::core::trade::PutOrCall::Call,
346            AccumulatorSide::Decumulator => &crate::core::trade::PutOrCall::Put,
347        }
348    }
349    fn exercise_style(&self) -> &crate::core::utils::ContractStyle {
350        &self.exercise_style
351    }
352    fn as_any(&self) -> &dyn std::any::Any {
353        self
354    }
355    fn clone_box(&self) -> Box<dyn crate::equity::utils::Payoff> {
356        Box::new(self.clone())
357    }
358}
359
360impl Instrument for Accumulator {
361    fn try_npv(&self) -> Result<f64, RustyQLibError> {
362        Ok(self.price()?.pv)
363    }
364
365    fn price(&self) -> Result<crate::core::results::PricingResult, RustyQLibError> {
366        // typed rejection for directly constructed accumulators;
367        // try_from_json validates at construction
368        self.validate()?;
369        let (pv, std_err) = match self.pricer {
370            AccumulatorPricer::Analytical => (self.analytic_npv(), None),
371            AccumulatorPricer::MonteCarlo => {
372                let (pv, se) = self.mc_npv();
373                (pv, Some(se))
374            }
375        };
376        Ok(crate::core::results::PricingResult { pv, greeks: Default::default(), std_err })
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use crate::core::trade::PutOrCall;
384    use crate::equity::blackscholes::bs_price;
385
386    fn base() -> Accumulator {
387        Accumulator {
388            side: AccumulatorSide::Accumulator,
389            s0: 100.0,
390            strike: 95.0,
391            barrier: 110.0,
392            observations: 252,
393            t: 1.0,
394            r: 0.03,
395            q: 0.01,
396            sigma: 0.25,
397            shares_per_day: 1.0,
398            gearing: 2.0,
399            pricer: AccumulatorPricer::Analytical,
400            paths: 40_000,
401            seed: 42,
402        }
403    }
404
405    #[test]
406    fn barrier_free_accumulator_is_an_exact_vanilla_strip() {
407        // barrier far away: each day is exactly call(K) - gearing put(K)
408        let mut a = base();
409        a.barrier = 1e6;
410        a.observations = 12;
411        let dt = a.t / 12.0;
412        let strip: f64 = (1..=12)
413            .map(|i| {
414                let ti = i as f64 * dt;
415                bs_price(a.s0, a.strike, a.r, a.q, a.sigma, ti, PutOrCall::Call)
416                    - a.gearing * bs_price(a.s0, a.strike, a.r, a.q, a.sigma, ti, PutOrCall::Put)
417            })
418            .sum();
419        assert!((a.analytic_npv() - strip).abs() < 1e-8, "{} vs {strip}", a.analytic_npv());
420        // and MC agrees with the exact strip within noise
421        let (mc, se) = a.mc_npv();
422        assert!((mc - strip).abs() < 3.0 * se + 0.05, "mc {mc} +/- {se} vs {strip}");
423    }
424
425    #[test]
426    fn no_gearing_no_barrier_is_a_forward_strip() {
427        // gearing 1, no barrier: day value is S_i - K, a pure forward
428        let mut a = base();
429        a.barrier = 1e6;
430        a.gearing = 1.0;
431        a.observations = 4;
432        let dt = a.t / 4.0;
433        let forwards: f64 = (1..=4)
434            .map(|i| {
435                let ti = i as f64 * dt;
436                a.s0 * (-a.q * ti).exp() - a.strike * (-a.r * ti).exp()
437            })
438            .sum();
439        assert!((a.analytic_npv() - forwards).abs() < 1e-8);
440        let (mc, se) = a.mc_npv();
441        assert!((mc - forwards).abs() < 3.0 * se + 0.05, "mc {mc} vs {forwards}");
442    }
443
444    #[test]
445    fn analytic_strip_tracks_dense_monte_carlo_with_the_barrier() {
446        // dense observations shrink the discrete-vs-continuous knockout
447        // gap; the discretely monitored MC survives longer, so it sits
448        // above the continuous strip for this (positive-value) structure
449        let a = base(); // 252 daily observations
450        let analytic = a.analytic_npv();
451        let (mc, se) = a.mc_npv();
452        assert!(mc > analytic - 3.0 * se, "discrete KO should not lose value");
453        assert!(
454            (mc - analytic).abs() < 0.05 * analytic.abs().max(5.0) + 3.0 * se,
455            "mc {mc} +/- {se} vs analytic {analytic}"
456        );
457    }
458
459    #[test]
460    fn decumulator_mirrors_and_orders_sensibly() {
461        let mut d = base();
462        d.side = AccumulatorSide::Decumulator;
463        d.strike = 105.0;
464        d.barrier = 90.0;
465        let analytic = d.analytic_npv();
466        let (mc, se) = d.mc_npv();
467        assert!((mc - analytic).abs() < 0.05 * analytic.abs().max(5.0) + 3.0 * se,
468            "mc {mc} vs analytic {analytic}");
469        // the discount/premium is what the knockout takes away: without
470        // gearing and barrier the holder would simply be long value
471        let mut favorable = d.clone();
472        favorable.barrier = 1e-6;
473        favorable.gearing = 1.0;
474        assert!(favorable.analytic_npv() > analytic);
475    }
476
477    #[test]
478    fn risk_features_move_the_price_the_right_way() {
479        let a = base();
480        let baseline = a.analytic_npv();
481        // more gearing hurts the holder
482        let mut geared = base();
483        geared.gearing = 3.0;
484        assert!(geared.analytic_npv() < baseline);
485        // barrier direction is regime-dependent for the geared holder:
486        // surviving paths are skewed below the strike (the toxic tail the
487        // nickname warns about), so here a TIGHTER knockout helps by
488        // killing the structure faster
489        let mut tight = base();
490        tight.barrier = 103.0;
491        assert!(tight.analytic_npv() > baseline, "tight KO should truncate the toxic tail");
492        // ... whereas for the ungeared long-call strip the theorem holds:
493        // a tighter up-and-out barrier can only remove value
494        let mut long_only = base();
495        long_only.gearing = 0.0;
496        let mut long_only_tight = long_only.clone();
497        long_only_tight.barrier = 103.0;
498        assert!(long_only_tight.analytic_npv() < long_only.analytic_npv());
499        // a deeper strike discount helps
500        let mut cheap = base();
501        cheap.strike = 90.0;
502        assert!(cheap.analytic_npv() > baseline);
503        // higher vol hurts the geared holder (short the wings)
504        let mut vol = base();
505        vol.sigma = 0.40;
506        assert!(vol.analytic_npv() < baseline, "accumulator holder is short vol");
507    }
508
509    // ── the mainline payoff: EquityOption integration ───────────────────
510
511    use crate::core::market::{BumpMode, RiskFactor, Shock};
512    use crate::core::utils::ContractStyle;
513    use crate::equity::builder::EquityOptionBuilder;
514    use crate::equity::portfolio::EquityPortfolio;
515    use crate::equity::utils::Engine;
516    use crate::equity::vanilla_option::EquityOption;
517    use crate::risk::stress::{stress_mtm, ArbitrageCheck, StressConfig, StressScenario};
518
519    fn payoff() -> AccumulatorPayoff {
520        AccumulatorPayoff {
521            exercise_style: ContractStyle::European,
522            side: AccumulatorSide::Accumulator,
523            barrier: 110.0,
524            observations: 4,
525            shares_per_day: 1.0,
526            gearing: 2.0,
527        }
528    }
529
530    fn option_accumulator(observations: usize, paths: usize) -> EquityOption {
531        // the builder twin of `base()`: same market and contract terms
532        EquityOptionBuilder::new()
533            .symbol("ACCU")
534            .spot(100.0)
535            .strike(95.0)
536            .flat_vol(0.25)
537            .flat_rate(0.03)
538            .dividend_yield(0.01)
539            .years_to_maturity(1.0)
540            .accumulator(110.0, observations, 1.0, 2.0)
541            .engine(Engine::MonteCarlo)
542            .paths(paths)
543            .seed(42)
544            .build()
545            .expect("accumulator option must build")
546    }
547
548    #[test]
549    fn payoff_accrues_daily_and_stops_without_accruing_at_knockout() {
550        let accu = payoff();
551        let obs_idx = [0, 1, 2, 3];
552        let dfs = [0.99, 0.98, 0.97, 0.96];
553        // day 1: +5; day 2: 0 - 2*(95-94) = -2 (geared); day 3: knocked
554        // at 112 >= 110 with no accrual; day 4 never reached
555        let path = [100.0, 94.0, 112.0, 120.0];
556        let value = accu.path_value(&path, &obs_idx, &dfs, 95.0);
557        assert!((value - (5.0 * 0.99 - 2.0 * 0.98)).abs() < 1e-12, "{value}");
558        // the decumulator mirrors: sell at 105, geared above, KO below 90
559        let mut decu = payoff();
560        decu.side = AccumulatorSide::Decumulator;
561        decu.barrier = 90.0;
562        // day 1: (105-100)=+5; day 2: 0 - 2*(112-105) = -14; day 3: 89 knocks
563        let path = [100.0, 112.0, 89.0, 80.0];
564        let value = decu.path_value(&path, &obs_idx, &dfs, 105.0);
565        assert!((value - (5.0 * 0.99 - 14.0 * 0.98)).abs() < 1e-12, "{value}");
566        // shares_per_day scales linearly
567        let mut sized = payoff();
568        sized.shares_per_day = 100.0;
569        let path = [100.0, 94.0, 112.0, 120.0];
570        let value = sized.path_value(&path, &obs_idx, &dfs, 95.0);
571        assert!((value - 100.0 * (5.0 * 0.99 - 2.0 * 0.98)).abs() < 1e-10);
572    }
573
574    #[test]
575    fn equity_option_route_tracks_the_standalone_reference() {
576        // same contract on both routes: the standalone continuous-KO strip
577        // vs the engine's discretely monitored Monte Carlo (Sobol, 252
578        // observations) — same tolerance shape as the standalone MC test
579        let reference = base().analytic_npv();
580        let mc = option_accumulator(252, 20_000).npv();
581        assert!(
582            (mc - reference).abs() < 0.05 * reference.abs().max(5.0) + 0.5,
583            "engine mc {mc} vs standalone analytic {reference}"
584        );
585        // direction of the monitoring gap: discrete KO survives longer,
586        // and for the GEARED holder living longer is worse (the same
587        // toxic-tail economics as `risk_features_move_the_price_the_right
588        // _way`: a tighter/earlier knock-out helps) — so the discretely
589        // monitored value sits at or below the continuous strip
590        assert!(mc < reference + 1.0, "discrete KO {mc} vs continuous {reference}");
591    }
592
593    #[test]
594    fn accumulator_reprices_in_the_market_context_and_stresses_sensibly() {
595        let option = option_accumulator(12, 8_000);
596        // snapshot / rebind parity is exact: the engine is seeded
597        let market = option.snapshot_market();
598        let direct = option.npv();
599        let rebound = option.npv_in(&market).expect("must reprice");
600        assert!((rebound - direct).abs() < 1e-12, "rebound {rebound} direct {direct}");
601
602        // and the whole point of the migration: the stress runner sees it
603        let mut book = EquityPortfolio::new();
604        book.add(option, 1.0);
605        let config = StressConfig {
606            scenarios: vec![
607                StressScenario {
608                    name: "crash".into(),
609                    shocks: vec![Shock {
610                        factor: RiskFactor::Spot,
611                        mode: BumpMode::Relative,
612                        size: -0.20,
613                        underlying: None,
614                        tenors: None,
615                        shifts: None,
616                    }],
617                },
618                StressScenario {
619                    name: "vols_up".into(),
620                    shocks: vec![Shock {
621                        factor: RiskFactor::Vol,
622                        mode: BumpMode::Absolute,
623                        size: 0.10,
624                        underlying: None,
625                        tenors: None,
626                        shifts: None,
627                    }],
628                },
629            ],
630            arbitrage: ArbitrageCheck::default(),
631        };
632        let results = stress_mtm(&book, &config).expect("stress must run");
633        // spot -20% through the geared strike is the toxic scenario
634        assert!(results[0].stress_pnl < 0.0, "crash pnl {:?}", results[0].stress_pnl);
635        // the geared holder is short vol (short the wings)
636        assert!(results[1].stress_pnl < 0.0, "vol pnl {:?}", results[1].stress_pnl);
637        // labels identify the product in reports
638        assert!(results[0].trades[0].label.contains("Accumulator"), "{}", results[0].trades[0].label);
639    }
640
641    #[test]
642    fn heston_route_degenerates_to_gbm_when_vol_of_vol_vanishes() {
643        // v0 = theta = 0.25^2 and vanishing vol-of-vol: the QE-M paths
644        // are (near) constant-variance, so the Heston route must land on
645        // the GBM route's value up to sampler differences
646        let gbm = option_accumulator(12, 8_000).npv();
647        let heston = EquityOptionBuilder::new()
648            .symbol("ACCU")
649            .spot(100.0)
650            .strike(95.0)
651            .flat_rate(0.03)
652            .dividend_yield(0.01)
653            .years_to_maturity(1.0)
654            .accumulator(110.0, 12, 1.0, 2.0)
655            .heston(crate::equity::heston::HestonParams {
656                v0: 0.0625,
657                kappa: 2.0,
658                theta: 0.0625,
659                vol_of_vol: 1e-4,
660                rho: 0.0,
661            })
662            .engine(Engine::MonteCarlo)
663            .paths(8_000)
664            .seed(42)
665            .build()
666            .expect("heston accumulator must build")
667            .npv();
668        assert!(
669            (heston - gbm).abs() < 0.05 * gbm.abs().max(5.0),
670            "heston {heston} vs gbm {gbm}"
671        );
672    }
673
674    #[test]
675    fn builder_validates_sides_and_engine_support() {
676        let build = |barrier: f64| {
677            EquityOptionBuilder::new()
678                .symbol("ACCU")
679                .spot(100.0)
680                .strike(95.0)
681                .flat_vol(0.25)
682                .flat_rate(0.03)
683                .years_to_maturity(1.0)
684                .accumulator(barrier, 12, 1.0, 2.0)
685                .engine(Engine::MonteCarlo)
686                .build()
687        };
688        // accumulator knock-out below the spot is rejected
689        assert!(build(90.0).is_err());
690        assert!(build(110.0).is_ok());
691        // decumulator mirrored
692        let decu = EquityOptionBuilder::new()
693            .symbol("ACCU")
694            .spot(100.0)
695            .strike(105.0)
696            .flat_vol(0.25)
697            .flat_rate(0.03)
698            .years_to_maturity(1.0)
699            .decumulator(110.0, 12, 1.0, 2.0)
700            .engine(Engine::MonteCarlo)
701            .build();
702        assert!(decu.is_err(), "decumulator KO above spot must be rejected");
703        // non-positive quantity and negative gearing are rejected
704        let bad_shares = EquityOptionBuilder::new()
705            .spot(100.0).strike(95.0).flat_vol(0.25).flat_rate(0.03)
706            .years_to_maturity(1.0)
707            .accumulator(110.0, 12, 0.0, 2.0)
708            .engine(Engine::MonteCarlo)
709            .build();
710        assert!(bad_shares.is_err());
711        // the analytic engine refuses accumulators at build time (build
712        // runs check_engine_support), naming the engine that can price
713        let err = EquityOptionBuilder::new()
714            .spot(100.0).strike(95.0).flat_vol(0.25).flat_rate(0.03)
715            .years_to_maturity(1.0)
716            .accumulator(110.0, 12, 1.0, 2.0)
717            .engine(Engine::BlackScholes)
718            .build()
719            .unwrap_err();
720        assert!(err.to_string().contains("MonteCarlo"), "{err}");
721    }
722
723    #[test]
724    fn json_contract_round_trip() {
725        let json = r#"{
726            "symbol": "ACCU", "side": "accumulator", "underlying_price": 100.0,
727            "strike": 95.0, "barrier": 110.0, "observations": 126,
728            "maturity": "2030-01-01", "shares_per_day": 100.0,
729            "risk_free_rate": 0.03, "dividend": 0.01, "volatility": 0.25,
730            "pricer": "MC", "simulation": 20000
731        }"#;
732        let data: AccumulatorData = serde_json::from_str(json).unwrap();
733        let accu = Accumulator::from_json(&data);
734        assert_eq!(accu.side, AccumulatorSide::Accumulator);
735        assert_eq!(accu.gearing, 2.0); // double-up default
736        let pv = accu.npv();
737        assert!(pv.is_finite() && pv.abs() < 100.0 * 126.0 * 20.0, "{pv}");
738    }
739}