1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum AccumulatorSide {
41 Accumulator,
43 Decumulator,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum AccumulatorPricer {
49 Analytical,
50 MonteCarlo,
51}
52
53#[derive(Clone, Debug, Deserialize, Serialize)]
55pub struct AccumulatorData {
56 pub symbol: String,
57 pub side: String,
59 pub underlying_price: f64,
60 pub strike: f64,
62 pub barrier: f64,
64 pub observations: usize,
66 pub maturity: String,
68 pub shares_per_day: Option<f64>,
70 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 pub valuation_date: Option<String>,
80}
81
82#[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 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 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 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 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#[derive(Debug, Clone)]
281pub struct AccumulatorPayoff {
282 pub exercise_style: crate::core::utils::ContractStyle,
283 pub side: AccumulatorSide,
284 pub barrier: f64,
287 pub observations: usize,
289 pub shares_per_day: f64,
290 pub gearing: f64,
292}
293
294impl AccumulatorPayoff {
295 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 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 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 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 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 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 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 let a = base(); 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 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 let mut geared = base();
483 geared.gearing = 3.0;
484 assert!(geared.analytic_npv() < baseline);
485 let mut tight = base();
490 tight.barrier = 103.0;
491 assert!(tight.analytic_npv() > baseline, "tight KO should truncate the toxic tail");
492 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 let mut cheap = base();
501 cheap.strike = 90.0;
502 assert!(cheap.analytic_npv() > baseline);
503 let mut vol = base();
505 vol.sigma = 0.40;
506 assert!(vol.analytic_npv() < baseline, "accumulator holder is short vol");
507 }
508
509 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 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 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 let mut decu = payoff();
560 decu.side = AccumulatorSide::Decumulator;
561 decu.barrier = 90.0;
562 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 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 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 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 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 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 assert!(results[0].stress_pnl < 0.0, "crash pnl {:?}", results[0].stress_pnl);
635 assert!(results[1].stress_pnl < 0.0, "vol pnl {:?}", results[1].stress_pnl);
637 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 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 assert!(build(90.0).is_err());
690 assert!(build(110.0).is_ok());
691 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 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 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); let pv = accu.npv();
737 assert!(pv.is_finite() && pv.abs() < 100.0 * 126.0 * 20.0, "{pv}");
738 }
739}