1use chrono::{Duration, Local, NaiveDate};
26
27use crate::core::curves::{Compounding, YieldCurve};
28use crate::core::errors::RustyQLibError;
29use crate::core::daycount::DayCountConvention;
30use crate::core::quotes::Quote;
31use crate::core::trade::PutOrCall;
32use crate::core::utils::ContractStyle;
33use crate::core::vols::VolSurface;
34use crate::equity::asian::{AsianStrikeType, AveragingType};
35use crate::equity::accumulator::{AccumulatorPayoff, AccumulatorSide};
36use crate::equity::autocallable::AutocallablePayoff;
37use crate::equity::barrier::{BarrierDirection, KnockType};
38use crate::equity::finite_difference::FdConfig;
39use crate::equity::forward_start_option::ForwardStartPayoff;
40use crate::equity::heston::HestonParams;
41use crate::equity::montecarlo::MonteCarloConfig;
42use crate::equity::utils::PricingEngine;
43use crate::equity::utils::Model;
44use crate::equity::utils::{Engine, LongShort, Payoff};
45use crate::equity::vanilla_option::{
46 AsianPayoff, BarrierPayoff, BinaryPayoff, BinaryType, EquityOption, EquityOptionBase,
47 VanillaPayoff,
48};
49
50enum PayoffSpec {
55 Vanilla {
56 put_or_call: PutOrCall,
57 },
58 Binary {
59 put_or_call: PutOrCall,
60 binary_type: BinaryType,
61 cash: f64,
62 },
63 Barrier {
64 put_or_call: PutOrCall,
65 direction: BarrierDirection,
66 knock: KnockType,
67 barrier: f64,
68 barrier2: Option<f64>,
69 rebate: f64,
70 rebate_at_hit: bool,
71 },
72 Asian {
73 put_or_call: PutOrCall,
74 averaging: AveragingType,
75 strike_type: AsianStrikeType,
76 },
77 Lookback {
78 put_or_call: PutOrCall,
79 lookback_type: crate::equity::vanilla_option::LookbackType,
80 },
81 ForwardStart {
82 put_or_call: PutOrCall,
83 strike_fraction: f64,
84 start_fraction: f64,
85 },
86 Autocallable {
87 autocall_barrier: f64,
88 protection_barrier: f64,
89 coupon: f64,
90 observations: usize,
91 notional: f64,
92 coupon_barrier: Option<f64>,
93 memory: bool,
94 observation_dates: Option<Vec<NaiveDate>>,
95 },
96 Accumulator {
97 side: AccumulatorSide,
98 barrier: f64,
99 observations: usize,
100 shares_per_day: f64,
101 gearing: f64,
102 },
103 Custom(Box<dyn Payoff>),
106}
107
108struct BuildContext {
111 spot: f64,
112 strike: f64,
113 valuation_date: NaiveDate,
114 maturity_date: NaiveDate,
115}
116
117fn date_list_to_times(
120 field: &str,
121 dates: &[NaiveDate],
122 valuation_date: NaiveDate,
123 maturity_date: NaiveDate,
124) -> Result<Vec<f64>, RustyQLibError> {
125 if dates.is_empty() {
126 return Err(RustyQLibError::invalid_input(
127 field,
128 "the date list must not be empty",
129 ));
130 }
131 let mut prev = valuation_date;
132 let mut times = Vec::with_capacity(dates.len());
133 for date in dates {
134 if *date <= prev {
135 return Err(RustyQLibError::invalid_input(
136 field,
137 format!("dates must be strictly increasing after valuation; {date} is not"),
138 ));
139 }
140 if *date > maturity_date {
141 return Err(RustyQLibError::invalid_input(
142 field,
143 format!("date {date} lies after the maturity {maturity_date}"),
144 ));
145 }
146 prev = *date;
147 times.push((*date - valuation_date).num_days() as f64 / 365.0);
148 }
149 Ok(times)
150}
151
152impl PayoffSpec {
153 fn requires_strike(&self) -> bool {
156 matches!(
157 self,
158 PayoffSpec::Vanilla { .. }
159 | PayoffSpec::Binary { .. }
160 | PayoffSpec::Barrier { .. }
161 | PayoffSpec::Asian { .. }
162 | PayoffSpec::Lookback { .. }
163 )
164 }
165
166 fn validate(&self, ctx: &BuildContext) -> Result<(), RustyQLibError> {
170 let invalid = |field: &str, reason: String| {
171 Err(RustyQLibError::InvalidInput { field: field.to_string(), reason })
172 };
173 if self.requires_strike() && !(ctx.strike.is_finite() && ctx.strike > 0.0) {
174 return invalid(
175 "strike",
176 format!("strike must be positive and finite, got {}", ctx.strike),
177 );
178 }
179 match self {
180 PayoffSpec::Vanilla { .. }
181 | PayoffSpec::Asian { .. }
182 | PayoffSpec::Lookback { .. }
183 | PayoffSpec::Custom(_) => Ok(()),
184 PayoffSpec::Binary { cash, .. } => {
185 if !(cash.is_finite() && *cash >= 0.0) {
186 return invalid(
187 "cash",
188 format!("binary cash amount must be non-negative and finite, got {cash}"),
189 );
190 }
191 Ok(())
192 }
193 PayoffSpec::Barrier { barrier, barrier2, rebate, .. } => {
194 if !(barrier.is_finite() && *barrier > 0.0) {
195 return invalid(
196 "barrier",
197 format!("barrier level must be positive and finite, got {barrier}"),
198 );
199 }
200 if let Some(upper) = barrier2 {
201 if !(upper.is_finite() && upper > barrier) {
202 return invalid(
203 "double_barrier",
204 format!(
205 "the upper barrier ({upper}) must exceed the lower ({barrier})"
206 ),
207 );
208 }
209 }
210 if !(rebate.is_finite() && *rebate >= 0.0) {
211 return invalid(
212 "rebate",
213 format!("rebate must be non-negative and finite, got {rebate}"),
214 );
215 }
216 Ok(())
217 }
218 PayoffSpec::ForwardStart { strike_fraction, start_fraction, .. } => {
219 if !(strike_fraction.is_finite() && *strike_fraction > 0.0) {
220 return invalid(
221 "strike_fraction",
222 format!("strike_fraction must be positive and finite, got {strike_fraction}"),
223 );
224 }
225 if !(*start_fraction > 0.0 && *start_fraction < 1.0) {
226 return invalid(
227 "start_fraction",
228 format!("start_fraction must lie in (0, 1), got {start_fraction}"),
229 );
230 }
231 Ok(())
232 }
233 PayoffSpec::Autocallable {
234 autocall_barrier,
235 protection_barrier,
236 coupon,
237 observations,
238 notional,
239 coupon_barrier,
240 observation_dates,
241 ..
242 } => {
243 for (name, x) in [
244 ("autocall_barrier", *autocall_barrier),
245 ("protection_barrier", *protection_barrier),
246 ("notional", *notional),
247 ] {
248 if !(x.is_finite() && x > 0.0) {
249 return invalid(name, format!("{name} must be positive and finite, got {x}"));
250 }
251 }
252 if let Some(cb) = coupon_barrier {
253 if !(cb.is_finite() && *cb > 0.0) {
254 return invalid(
255 "coupon_barrier",
256 format!("coupon_barrier must be positive and finite, got {cb}"),
257 );
258 }
259 }
260 if !(coupon.is_finite() && *coupon >= 0.0) {
261 return invalid(
262 "coupon",
263 format!("coupon must be non-negative and finite, got {coupon}"),
264 );
265 }
266 if *observations < 1 {
267 return invalid("observations", "need at least one observation".to_string());
268 }
269 if let Some(dates) = observation_dates {
270 date_list_to_times(
271 "autocall_observation_dates",
272 dates,
273 ctx.valuation_date,
274 ctx.maturity_date,
275 )?;
276 }
277 Ok(())
278 }
279 PayoffSpec::Accumulator { side, barrier, observations, shares_per_day, gearing } => {
280 if !(barrier.is_finite() && *barrier > 0.0) {
281 return invalid(
282 "barrier",
283 format!("barrier must be positive and finite, got {barrier}"),
284 );
285 }
286 match side {
287 AccumulatorSide::Accumulator if *barrier <= ctx.spot => {
288 return invalid(
289 "barrier",
290 "accumulator knock-out must be above the spot".to_string(),
291 );
292 }
293 AccumulatorSide::Decumulator if *barrier >= ctx.spot => {
294 return invalid(
295 "barrier",
296 "decumulator knock-out must be below the spot".to_string(),
297 );
298 }
299 _ => {}
300 }
301 if *observations < 1 {
302 return invalid("observations", "need at least one observation".to_string());
303 }
304 if !(shares_per_day.is_finite() && *shares_per_day > 0.0) {
305 return invalid(
306 "shares_per_day",
307 format!("shares_per_day must be positive and finite, got {shares_per_day}"),
308 );
309 }
310 if !(gearing.is_finite() && *gearing >= 0.0) {
311 return invalid(
312 "gearing",
313 format!("gearing must be non-negative and finite, got {gearing}"),
314 );
315 }
316 Ok(())
317 }
318 }
319 }
320
321 fn materialize(self, style: ContractStyle, ctx: &BuildContext) -> Box<dyn Payoff> {
324 match self {
325 PayoffSpec::Vanilla { put_or_call } => {
326 Box::new(VanillaPayoff { put_or_call, exercise_style: style })
327 }
328 PayoffSpec::Binary { put_or_call, binary_type, cash } => Box::new(BinaryPayoff {
329 put_or_call,
330 exercise_style: style,
331 binary_type,
332 cash,
333 }),
334 PayoffSpec::Barrier {
335 put_or_call,
336 direction,
337 knock,
338 barrier,
339 barrier2,
340 rebate,
341 rebate_at_hit,
342 } => Box::new(BarrierPayoff {
343 put_or_call,
344 exercise_style: style,
345 direction,
346 knock,
347 barrier,
348 barrier2,
349 rebate,
350 rebate_at_hit,
351 }),
352 PayoffSpec::Asian { put_or_call, averaging, strike_type } => Box::new(AsianPayoff {
353 put_or_call,
354 exercise_style: style,
355 averaging,
356 strike_type,
357 }),
358 PayoffSpec::Lookback { put_or_call, lookback_type } => {
359 Box::new(crate::equity::vanilla_option::LookbackPayoff {
360 put_or_call,
361 exercise_style: style,
362 lookback_type,
363 })
364 }
365 PayoffSpec::ForwardStart { put_or_call, strike_fraction, start_fraction } => {
366 Box::new(ForwardStartPayoff {
367 put_or_call,
368 exercise_style: style,
369 strike_fraction,
370 start_fraction,
371 })
372 }
373 PayoffSpec::Autocallable {
374 autocall_barrier,
375 protection_barrier,
376 coupon,
377 observations,
378 notional,
379 coupon_barrier,
380 memory,
381 observation_dates,
382 } => {
383 let observation_times = observation_dates.as_ref().map(|dates| {
384 dates
385 .iter()
386 .map(|d| (*d - ctx.valuation_date).num_days() as f64 / 365.0)
387 .collect::<Vec<f64>>()
388 });
389 let observations = observation_dates
390 .as_ref()
391 .map_or(observations, |dates| dates.len());
392 Box::new(AutocallablePayoff {
393 exercise_style: style,
394 autocall_barrier,
395 protection_barrier,
396 coupon,
397 observations,
398 notional,
399 initial_fixing: ctx.spot,
400 coupon_barrier,
401 memory,
402 observation_times,
403 })
404 }
405 PayoffSpec::Accumulator { side, barrier, observations, shares_per_day, gearing } => {
406 Box::new(AccumulatorPayoff {
407 exercise_style: style,
408 side,
409 barrier,
410 observations,
411 shares_per_day,
412 gearing,
413 })
414 }
415 PayoffSpec::Custom(p) => p,
416 }
417 }
418}
419
420pub struct EquityOptionBuilder {
421 symbol: String,
422 spot: f64,
423 strike: f64,
424 vol_surface: Option<VolSurface>,
425 flat_vol: f64,
426 discount_curve: Option<YieldCurve>,
427 flat_rate: f64,
428 dividend_yield: f64,
429 borrow_cost: f64,
430 cash_dividends: Vec<(NaiveDate, f64)>,
431 futures_settlement: Option<crate::equity::black76::FuturesSettlement>,
432 valuation_date: NaiveDate,
433 maturity_date: Option<NaiveDate>,
434 exercise_style: ContractStyle,
435 payoff: Option<PayoffSpec>,
436 engine: Engine,
437 mc: MonteCarloConfig,
438 fd: FdConfig,
439 lattice: crate::core::lattice::LatticeConfig,
440 model: Model,
441 autocall_schedule: Option<(u32, crate::core::calendar::Calendar)>,
444 bermudan_dates: Option<Vec<NaiveDate>>,
446 bermudan_schedule: Option<(u32, crate::core::calendar::Calendar)>,
448 setter_error: Option<RustyQLibError>,
451}
452
453impl Default for EquityOptionBuilder {
454 fn default() -> Self {
455 Self::new()
456 }
457}
458
459impl EquityOptionBuilder {
460 pub fn new() -> Self {
461 EquityOptionBuilder {
462 symbol: "TEST".to_string(),
463 spot: 100.0,
464 strike: 100.0,
465 vol_surface: None,
466 flat_vol: 0.2,
467 discount_curve: None,
468 flat_rate: 0.0,
469 dividend_yield: 0.0,
470 borrow_cost: 0.0,
471 cash_dividends: Vec::new(),
472 futures_settlement: None,
473 valuation_date: Local::now().date_naive(),
474 maturity_date: None,
475 exercise_style: ContractStyle::European,
476 payoff: None,
477 engine: Engine::BlackScholes,
478 mc: MonteCarloConfig::default(),
479 fd: FdConfig::default(),
480 lattice: crate::core::lattice::LatticeConfig::default(),
481 model: Model::Gbm,
482 autocall_schedule: None,
483 bermudan_dates: None,
484 bermudan_schedule: None,
485 setter_error: None,
486 }
487 }
488
489 pub fn symbol(mut self, symbol: &str) -> Self {
492 self.symbol = symbol.to_string();
493 self
494 }
495 pub fn spot(mut self, spot: f64) -> Self {
496 self.spot = spot;
497 self
498 }
499 pub fn strike(mut self, strike: f64) -> Self {
500 self.strike = strike;
501 self
502 }
503 pub fn flat_vol(mut self, vol: f64) -> Self {
504 self.flat_vol = vol;
505 self.vol_surface = None;
506 self
507 }
508 pub fn vol_surface(mut self, surface: VolSurface) -> Self {
509 self.vol_surface = Some(surface);
510 self
511 }
512 pub fn flat_rate(mut self, rate: f64) -> Self {
513 self.flat_rate = rate;
514 self.discount_curve = None;
515 self
516 }
517 pub fn discount_curve(mut self, curve: YieldCurve) -> Self {
518 self.discount_curve = Some(curve);
519 self
520 }
521 pub fn dividend_yield(mut self, q: f64) -> Self {
522 self.dividend_yield = q;
523 self
524 }
525 pub fn borrow_cost(mut self, b: f64) -> Self {
527 self.borrow_cost = b;
528 self
529 }
530 pub fn cash_dividend(mut self, date: NaiveDate, amount: f64) -> Self {
531 self.cash_dividends.push((date, amount));
532 self
533 }
534 pub fn on_future(
537 mut self,
538 settlement: crate::equity::black76::FuturesSettlement,
539 ) -> Self {
540 self.futures_settlement = Some(settlement);
541 self
542 }
543
544 pub fn valuation_date(mut self, date: NaiveDate) -> Self {
547 self.valuation_date = date;
548 self
549 }
550 pub fn maturity_date(mut self, date: NaiveDate) -> Self {
551 self.maturity_date = Some(date);
552 self
553 }
554 pub fn years_to_maturity(mut self, years: f64) -> Self {
556 self.maturity_date =
557 Some(self.valuation_date + Duration::days((years * 365.0).round() as i64));
558 self
559 }
560
561 pub fn american(mut self) -> Self {
564 self.exercise_style = ContractStyle::American;
565 self
566 }
567 pub fn bermudan(mut self, dates: Vec<NaiveDate>) -> Self {
571 self.bermudan_dates = Some(dates);
572 self
573 }
574 pub fn bermudan_schedule(mut self, months: u32, calendar: crate::core::calendar::Calendar) -> Self {
578 self.bermudan_schedule = Some((months, calendar));
579 self
580 }
581 pub fn exercise_style(mut self, style: ContractStyle) -> Self {
582 self.exercise_style = style;
583 self
584 }
585 pub fn payoff(mut self, payoff: Box<dyn Payoff>) -> Self {
586 self.payoff = Some(PayoffSpec::Custom(payoff));
587 self
588 }
589 pub fn vanilla(mut self, put_or_call: PutOrCall) -> Self {
590 self.payoff = Some(PayoffSpec::Vanilla { put_or_call });
591 self
592 }
593 pub fn binary(mut self, put_or_call: PutOrCall, binary_type: BinaryType, cash: f64) -> Self {
594 self.payoff = Some(PayoffSpec::Binary { put_or_call, binary_type, cash });
595 self
596 }
597 pub fn barrier(
598 mut self,
599 put_or_call: PutOrCall,
600 direction: BarrierDirection,
601 knock: KnockType,
602 barrier: f64,
603 ) -> Self {
604 self.payoff = Some(PayoffSpec::Barrier {
605 put_or_call,
606 direction,
607 knock,
608 barrier,
609 barrier2: None,
610 rebate: 0.0,
611 rebate_at_hit: false,
612 });
613 self
614 }
615 pub fn double_barrier(
617 mut self,
618 put_or_call: PutOrCall,
619 knock: crate::equity::barrier::KnockType,
620 lower: f64,
621 upper: f64,
622 ) -> Self {
623 self.payoff = Some(PayoffSpec::Barrier {
624 put_or_call,
625 direction: crate::equity::barrier::BarrierDirection::Down,
626 knock,
627 barrier: lower,
628 barrier2: Some(upper),
629 rebate: 0.0,
630 rebate_at_hit: false,
631 });
632 self
633 }
634
635 pub fn barrier_rebate(mut self, rebate: f64, at_hit: bool) -> Self {
639 match &mut self.payoff {
640 Some(PayoffSpec::Barrier { rebate: r, rebate_at_hit: h, .. }) => {
641 *r = rebate;
642 *h = at_hit;
643 }
644 _ => {
645 self.setter_error = Some(RustyQLibError::invalid_input(
646 "barrier_rebate",
647 "barrier_rebate must follow .barrier(...) or .double_barrier(...)",
648 ));
649 }
650 }
651 self
652 }
653
654 pub fn asian(
655 mut self,
656 put_or_call: PutOrCall,
657 averaging: AveragingType,
658 strike_type: AsianStrikeType,
659 ) -> Self {
660 self.payoff = Some(PayoffSpec::Asian { put_or_call, averaging, strike_type });
661 self
662 }
663 pub fn lookback(
667 mut self,
668 put_or_call: PutOrCall,
669 lookback_type: crate::equity::vanilla_option::LookbackType,
670 ) -> Self {
671 self.payoff = Some(PayoffSpec::Lookback { put_or_call, lookback_type });
672 self
673 }
674 pub fn forward_start(
677 mut self,
678 put_or_call: PutOrCall,
679 strike_fraction: f64,
680 start_fraction: f64,
681 ) -> Self {
682 self.payoff = Some(PayoffSpec::ForwardStart {
683 put_or_call,
684 strike_fraction,
685 start_fraction,
686 });
687 self
688 }
689 pub fn autocallable(
690 mut self,
691 autocall_barrier: f64,
692 protection_barrier: f64,
693 coupon: f64,
694 observations: usize,
695 notional: f64,
696 ) -> Self {
697 self.payoff = Some(PayoffSpec::Autocallable {
698 autocall_barrier,
699 protection_barrier,
700 coupon,
701 observations,
702 notional,
703 coupon_barrier: None,
704 memory: false,
705 observation_dates: None,
706 });
707 self
708 }
709
710 pub fn autocall_observation_dates(mut self, dates: Vec<NaiveDate>) -> Self {
715 match &mut self.payoff {
716 Some(PayoffSpec::Autocallable { observation_dates, .. }) => {
717 *observation_dates = Some(dates);
718 }
719 _ => {
720 self.setter_error = Some(RustyQLibError::invalid_input(
721 "autocall_observation_dates",
722 "autocall_observation_dates must follow .autocallable(...) or .phoenix(...)",
723 ));
724 }
725 }
726 self
727 }
728
729 pub fn autocall_schedule(mut self, months: u32, calendar: crate::core::calendar::Calendar) -> Self {
735 if !matches!(self.payoff, Some(PayoffSpec::Autocallable { .. })) {
736 self.setter_error = Some(RustyQLibError::invalid_input(
737 "autocall_schedule",
738 "autocall_schedule must follow .autocallable(...) or .phoenix(...)",
739 ));
740 return self;
741 }
742 self.autocall_schedule = Some((months, calendar));
743 self
744 }
745
746 #[allow(clippy::too_many_arguments)]
750 pub fn phoenix(
751 mut self,
752 autocall_barrier: f64,
753 coupon_barrier: f64,
754 protection_barrier: f64,
755 coupon: f64,
756 observations: usize,
757 notional: f64,
758 memory: bool,
759 ) -> Self {
760 self.payoff = Some(PayoffSpec::Autocallable {
761 autocall_barrier,
762 protection_barrier,
763 coupon,
764 observations,
765 notional,
766 coupon_barrier: Some(coupon_barrier),
767 memory,
768 observation_dates: None,
769 });
770 self
771 }
772
773 pub fn accumulator(
779 mut self,
780 barrier: f64,
781 observations: usize,
782 shares_per_day: f64,
783 gearing: f64,
784 ) -> Self {
785 self.payoff = Some(PayoffSpec::Accumulator {
786 side: AccumulatorSide::Accumulator,
787 barrier,
788 observations,
789 shares_per_day,
790 gearing,
791 });
792 self
793 }
794
795 pub fn decumulator(
799 mut self,
800 barrier: f64,
801 observations: usize,
802 shares_per_day: f64,
803 gearing: f64,
804 ) -> Self {
805 self.payoff = Some(PayoffSpec::Accumulator {
806 side: AccumulatorSide::Decumulator,
807 barrier,
808 observations,
809 shares_per_day,
810 gearing,
811 });
812 self
813 }
814
815 pub fn engine(mut self, engine: Engine) -> Self {
818 self.engine = engine;
819 self
820 }
821 pub fn model(mut self, model: Model) -> Self {
822 self.model = model;
823 self
824 }
825 pub fn heston(mut self, params: HestonParams) -> Self {
826 self.model = Model::Heston(params);
827 self
828 }
829 pub fn mc_config(mut self, cfg: MonteCarloConfig) -> Self {
830 self.mc = cfg;
831 self
832 }
833 pub fn paths(mut self, paths: usize) -> Self {
834 self.mc.paths = paths;
835 self
836 }
837 pub fn mc_time_steps(mut self, steps: usize) -> Self {
838 self.mc.time_steps = steps;
839 self
840 }
841 pub fn seed(mut self, seed: u64) -> Self {
842 self.mc.seed = seed;
843 self
844 }
845 pub fn fd_config(mut self, cfg: FdConfig) -> Self {
846 self.fd = cfg;
847 self
848 }
849 pub fn fd_grid(mut self, spot_steps: usize, time_steps: usize) -> Self {
850 self.fd.spot_steps = spot_steps;
851 self.fd.time_steps = time_steps;
852 self
853 }
854 pub fn lattice_config(mut self, cfg: crate::core::lattice::LatticeConfig) -> Self {
855 self.lattice = cfg;
856 self
857 }
858 pub fn tree_type(mut self, tree_type: crate::core::lattice::BinomialTreeType) -> Self {
860 self.lattice.tree_type = tree_type;
861 self
862 }
863 pub fn tree_steps(mut self, steps: usize) -> Self {
865 self.lattice.steps = steps;
866 self
867 }
868 pub fn tree_term_structure(mut self) -> Self {
871 self.lattice.term_structure = true;
872 self
873 }
874
875 pub fn build(mut self) -> Result<EquityOption, RustyQLibError> {
888 if let Some(e) = self.setter_error.take() {
889 return Err(e);
890 }
891 let invalid = |field: &str, reason: String| {
892 Err(RustyQLibError::InvalidInput { field: field.to_string(), reason })
893 };
894
895 self.validate_market_data()?;
897
898 let maturity_date = match self.maturity_date {
900 Some(d) => d,
901 None => {
902 return invalid(
903 "maturity_date",
904 "set maturity_date() or years_to_maturity() before build()".to_string(),
905 )
906 }
907 };
908 if maturity_date <= self.valuation_date {
909 return invalid(
910 "maturity_date",
911 format!(
912 "maturity {maturity_date} must be after the valuation date {}",
913 self.valuation_date
914 ),
915 );
916 }
917
918 let mut spec = match self.payoff.take() {
920 Some(spec) => spec,
921 None => {
922 return invalid(
923 "payoff",
924 "set a payoff (vanilla(), barrier(), ...) before build()".to_string(),
925 )
926 }
927 };
928 if let Some((months, calendar)) = &self.autocall_schedule {
929 match &mut spec {
930 PayoffSpec::Autocallable { observation_dates, .. } => {
931 let schedule = crate::core::calendar::Schedule::generate(
932 self.valuation_date,
933 maturity_date,
934 *months,
935 calendar,
936 crate::core::calendar::BusinessDayConvention::ModifiedFollowing,
937 crate::core::calendar::DateGeneration::Backward,
938 )?;
939 *observation_dates = Some(schedule.dates);
940 }
941 _ => {
942 return invalid(
943 "autocall_schedule",
944 "autocall_schedule set but the payoff is not an autocallable".to_string(),
945 )
946 }
947 }
948 }
949 let ctx = BuildContext {
950 spot: self.spot,
951 strike: self.strike,
952 valuation_date: self.valuation_date,
953 maturity_date,
954 };
955 spec.validate(&ctx)?;
956
957 let mut bermudan_dates = self.bermudan_dates.take();
959 if let Some((months, calendar)) = &self.bermudan_schedule {
960 let schedule = crate::core::calendar::Schedule::generate(
961 self.valuation_date,
962 maturity_date,
963 *months,
964 calendar,
965 crate::core::calendar::BusinessDayConvention::ModifiedFollowing,
966 crate::core::calendar::DateGeneration::Backward,
967 )?;
968 bermudan_dates = Some(schedule.dates);
969 }
970 let style = match &bermudan_dates {
971 Some(dates) => {
972 if matches!(spec, PayoffSpec::Custom(_)) {
973 return invalid(
974 "bermudan",
975 "bermudan dates apply to built-in payoffs; embed the exercise style \
976 in the custom payoff instead"
977 .to_string(),
978 );
979 }
980 ContractStyle::Bermudan(date_list_to_times(
981 "bermudan",
982 dates,
983 self.valuation_date,
984 maturity_date,
985 )?)
986 }
987 None => self.exercise_style.clone(),
988 };
989 if self.futures_settlement.is_some() {
990 if !matches!(spec, PayoffSpec::Vanilla { .. }) {
991 return invalid(
992 "on_future",
993 "options on futures (Black-76) support the vanilla payoff only".to_string(),
994 );
995 }
996 if matches!(self.exercise_style, ContractStyle::American) {
997 return invalid(
998 "on_future",
999 "Black-76 supports European exercise only".to_string(),
1000 );
1001 }
1002 }
1003
1004 if let Model::Heston(params) = &self.model {
1006 params.validate()?;
1007 }
1008
1009 let discount_curve = match self.discount_curve {
1011 Some(c) => c,
1012 None => YieldCurve::flat(
1013 self.flat_rate,
1014 self.valuation_date,
1015 DayCountConvention::Act365,
1016 Compounding::Continuous,
1017 )?,
1018 };
1019 let vol_surface = match self.vol_surface {
1020 Some(s) => s,
1021 None => {
1022 VolSurface::flat(self.flat_vol, self.valuation_date, DayCountConvention::Act365)?
1023 }
1024 };
1025
1026 let payoff: Box<dyn Payoff> = spec.materialize(style, &ctx);
1028 let base = EquityOptionBase {
1029 symbol: self.symbol,
1030 currency: None,
1031 exchange: None,
1032 name: None,
1033 cusip: None,
1034 isin: None,
1035 settlement_type: None,
1036 strike_price: self.strike,
1037 maturity_date,
1038 futures_settlement: self.futures_settlement,
1039 multiplier: 1.0,
1040 current_price: Quote::new(0.0),
1041 entry_price: 0.0,
1042 long_short: LongShort::LONG,
1043 };
1044 let market = crate::equity::vanilla_option::EquityMarketData {
1045 valuation_date: self.valuation_date,
1046 spot: Quote::new(self.spot),
1047 dividend_yield: self.dividend_yield,
1048 borrow_cost: self.borrow_cost,
1049 cash_dividends: self.cash_dividends,
1050 vol_surface: std::sync::Arc::new(vol_surface),
1051 discount_curve: std::sync::Arc::new(discount_curve),
1052 };
1053 let engine = match self.engine {
1056 Engine::BlackScholes => PricingEngine::BlackScholes,
1057 Engine::MonteCarlo => {
1058 self.mc.validate()?;
1059 PricingEngine::MonteCarlo(self.mc)
1060 }
1061 Engine::Binomial => {
1062 self.lattice.validate()?;
1063 PricingEngine::Binomial(self.lattice)
1064 }
1065 Engine::FiniteDifference => {
1066 self.fd.validate()?;
1067 PricingEngine::FiniteDifference(self.fd)
1068 }
1069 Engine::BaroneAdesiWhaley => PricingEngine::BaroneAdesiWhaley,
1070 Engine::BjerksundStensland => PricingEngine::BjerksundStensland,
1071 };
1072 let option = EquityOption { base, market, payoff, engine, model: self.model };
1073 option.check_engine_support()?;
1076 Ok(option)
1077 }
1078
1079 fn validate_market_data(&self) -> Result<(), RustyQLibError> {
1081 let invalid = |field: &str, reason: String| {
1082 Err(RustyQLibError::InvalidInput { field: field.to_string(), reason })
1083 };
1084 if !(self.spot.is_finite() && self.spot > 0.0) {
1085 return invalid("spot", format!("spot must be positive and finite, got {}", self.spot));
1086 }
1087 if self.vol_surface.is_none() && !(self.flat_vol.is_finite() && self.flat_vol > 0.0) {
1088 return invalid(
1089 "flat_vol",
1090 format!("volatility must be positive and finite, got {}", self.flat_vol),
1091 );
1092 }
1093 for (name, x) in [
1094 ("flat_rate", self.flat_rate),
1095 ("dividend_yield", self.dividend_yield),
1096 ("borrow_cost", self.borrow_cost),
1097 ] {
1098 if !x.is_finite() {
1099 return invalid(name, format!("{name} must be finite, got {x}"));
1100 }
1101 }
1102 for (date, amount) in &self.cash_dividends {
1103 if !(amount.is_finite() && *amount >= 0.0) {
1104 return invalid(
1105 "cash_dividends",
1106 format!("dividend on {date} must be non-negative and finite, got {amount}"),
1107 );
1108 }
1109 }
1110 Ok(())
1111 }
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116 use super::*;
1117 use crate::core::traits::Instrument;
1118
1119 #[test]
1120 fn builder_reproduces_black_scholes_golden() {
1121 let option = EquityOptionBuilder::new()
1122 .spot(100.0)
1123 .strike(100.0)
1124 .flat_vol(0.3)
1125 .flat_rate(0.05)
1126 .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
1127 .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap())
1128 .vanilla(PutOrCall::Call)
1129 .engine(Engine::BlackScholes)
1130 .build().expect("option must build");
1131 assert!((option.npv() - 14.2312547860).abs() < 1e-8);
1132 assert!((option.delta() - 0.6242517279).abs() < 1e-8);
1133 }
1134
1135 #[test]
1136 fn builder_carries_dividends_and_borrow() {
1137 let option = EquityOptionBuilder::new()
1138 .spot(100.0)
1139 .dividend_yield(0.01)
1140 .borrow_cost(0.02)
1141 .years_to_maturity(1.0)
1142 .vanilla(PutOrCall::Call)
1143 .build().expect("option must build");
1144 assert!((option.carry_yield() - 0.03).abs() < 1e-12);
1145 }
1146
1147 #[test]
1148 fn american_flag_applies_to_the_payoff_in_either_order() {
1149 for build_order_reversed in [false, true] {
1152 let b = EquityOptionBuilder::new()
1153 .spot(100.0)
1154 .years_to_maturity(1.0)
1155 .engine(Engine::Binomial);
1156 let b = if build_order_reversed {
1157 b.vanilla(PutOrCall::Put).american()
1158 } else {
1159 b.american().vanilla(PutOrCall::Put)
1160 };
1161 let option = b.build().expect("option must build");
1162 assert!(matches!(option.payoff.exercise_style(), ContractStyle::American));
1163 }
1164 }
1165
1166 #[test]
1167 fn build_rejects_bad_inputs_with_the_offending_field() {
1168 use crate::core::errors::RustyQLibError;
1169 let field = |r: Result<EquityOption, RustyQLibError>| match r {
1170 Err(RustyQLibError::InvalidInput { field, .. }) => field,
1171 other => panic!("expected InvalidInput, got {:?}", other.map(|_| "an option")),
1172 };
1173
1174 let base = || EquityOptionBuilder::new().years_to_maturity(1.0).vanilla(PutOrCall::Call);
1175
1176 assert_eq!(field(base().spot(-1.0).build()), "spot");
1177 assert_eq!(field(base().flat_vol(0.0).build()), "flat_vol");
1178 assert_eq!(field(base().strike(f64::NAN).build()), "strike");
1179 assert_eq!(
1180 field(EquityOptionBuilder::new().vanilla(PutOrCall::Call).build()),
1181 "maturity_date"
1182 );
1183 assert_eq!(field(base().years_to_maturity(-1.0).build()), "maturity_date");
1184 assert_eq!(
1185 field(EquityOptionBuilder::new().years_to_maturity(1.0).build()),
1186 "payoff"
1187 );
1188 assert_eq!(field(base().barrier_rebate(5.0, false).build()), "barrier_rebate");
1189 let bad_heston = crate::equity::heston::HestonParams {
1192 v0: -0.1, kappa: 2.0, theta: 0.09, vol_of_vol: 0.4, rho: -0.7,
1193 };
1194 assert_eq!(
1195 field(base().heston(bad_heston).engine(Engine::MonteCarlo).build()),
1196 "heston params"
1197 );
1198 assert_eq!(
1199 field(
1200 base()
1201 .forward_start(PutOrCall::Call, 1.0, 1.5)
1202 .engine(Engine::MonteCarlo)
1203 .build()
1204 ),
1205 "start_fraction"
1206 );
1207 assert_eq!(
1208 field(
1209 base()
1210 .double_barrier(PutOrCall::Call, KnockType::Out, 120.0, 80.0)
1211 .engine(Engine::MonteCarlo)
1212 .build()
1213 ),
1214 "double_barrier"
1215 );
1216 }
1217
1218 #[test]
1219 fn build_rejects_unsupported_engine_combinations() {
1220 use crate::core::errors::RustyQLibError;
1221 let result = EquityOptionBuilder::new()
1223 .spot(100.0)
1224 .years_to_maturity(1.0)
1225 .vanilla(PutOrCall::Call)
1226 .american()
1227 .engine(Engine::BlackScholes)
1228 .build();
1229 assert!(
1230 matches!(result, Err(RustyQLibError::UnsupportedEngine(_))),
1231 "American exercise on the analytic engine must be refused at build()"
1232 );
1233 }
1234
1235 #[test]
1236 fn autocall_schedule_generates_business_day_observations() {
1237 use crate::core::calendar::Calendar;
1238 let option = EquityOptionBuilder::new()
1239 .spot(100.0)
1240 .flat_vol(0.25)
1241 .flat_rate(0.03)
1242 .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap())
1243 .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 4).unwrap())
1244 .autocallable(105.0, 70.0, 0.02, 4, 100.0)
1245 .autocall_schedule(3, Calendar::UsNyse)
1246 .engine(Engine::MonteCarlo)
1247 .paths(20_000)
1248 .build()
1249 .expect("option must build");
1250 let auto = option
1251 .payoff
1252 .as_any()
1253 .downcast_ref::<AutocallablePayoff>()
1254 .expect("autocallable payoff");
1255 let times = auto.observation_times.as_ref().expect("schedule must set times");
1256 assert_eq!(auto.observations, times.len());
1257 assert!(times.windows(2).all(|w| w[0] < w[1]), "times must increase");
1258 let schedule = crate::core::calendar::Schedule::generate(
1260 NaiveDate::from_ymd_opt(2026, 1, 5).unwrap(),
1261 NaiveDate::from_ymd_opt(2027, 1, 4).unwrap(),
1262 3,
1263 &Calendar::UsNyse,
1264 crate::core::calendar::BusinessDayConvention::ModifiedFollowing,
1265 crate::core::calendar::DateGeneration::Backward,
1266 )
1267 .unwrap();
1268 for d in &schedule.dates {
1269 assert!(Calendar::UsNyse.is_business_day(*d), "{d} not a business day");
1270 }
1271 let baseline = EquityOptionBuilder::new()
1274 .spot(100.0)
1275 .flat_vol(0.25)
1276 .flat_rate(0.03)
1277 .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap())
1278 .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 4).unwrap())
1279 .autocallable(105.0, 70.0, 0.02, 4, 100.0)
1280 .engine(Engine::MonteCarlo)
1281 .paths(20_000)
1282 .build()
1283 .expect("option must build");
1284 let a = option.npv();
1285 let b = baseline.npv();
1286 assert!(a.is_finite() && a > 0.0);
1287 assert!((a - b).abs() < 1.0, "dates ~quarterly: {a} vs equally spaced {b}");
1288 }
1289
1290 #[test]
1291 fn autocall_observation_dates_are_validated() {
1292 use crate::core::errors::RustyQLibError;
1293 let field = |r: Result<EquityOption, RustyQLibError>| match r {
1294 Err(RustyQLibError::InvalidInput { field, .. }) => field,
1295 other => panic!("expected InvalidInput, got {:?}", other.map(|_| "an option")),
1296 };
1297 let base = || {
1298 EquityOptionBuilder::new()
1299 .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap())
1300 .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 4).unwrap())
1301 .autocallable(105.0, 70.0, 0.02, 4, 100.0)
1302 .engine(Engine::MonteCarlo)
1303 };
1304 let unsorted = vec![
1306 NaiveDate::from_ymd_opt(2026, 7, 6).unwrap(),
1307 NaiveDate::from_ymd_opt(2026, 4, 6).unwrap(),
1308 ];
1309 assert_eq!(
1310 field(base().autocall_observation_dates(unsorted).build()),
1311 "autocall_observation_dates"
1312 );
1313 let late = vec![NaiveDate::from_ymd_opt(2027, 6, 1).unwrap()];
1315 assert_eq!(
1316 field(base().autocall_observation_dates(late).build()),
1317 "autocall_observation_dates"
1318 );
1319 assert_eq!(
1321 field(
1322 EquityOptionBuilder::new()
1323 .years_to_maturity(1.0)
1324 .vanilla(PutOrCall::Call)
1325 .autocall_observation_dates(vec![NaiveDate::from_ymd_opt(2026, 9, 1).unwrap()])
1326 .build()
1327 ),
1328 "autocall_observation_dates"
1329 );
1330 }
1331
1332 fn bermudan_put(dates: Vec<NaiveDate>, engine: Engine) -> EquityOption {
1333 EquityOptionBuilder::new()
1334 .spot(100.0)
1335 .strike(100.0)
1336 .flat_vol(0.3)
1337 .flat_rate(0.05)
1338 .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap())
1339 .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 4).unwrap())
1340 .bermudan(dates)
1341 .vanilla(PutOrCall::Put)
1342 .engine(engine)
1343 .build()
1344 .expect("option must build")
1345 }
1346
1347 fn put_with_style(style: fn(EquityOptionBuilder) -> EquityOptionBuilder, engine: Engine) -> EquityOption {
1348 let b = EquityOptionBuilder::new()
1349 .spot(100.0)
1350 .strike(100.0)
1351 .flat_vol(0.3)
1352 .flat_rate(0.05)
1353 .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap())
1354 .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 4).unwrap());
1355 style(b).vanilla(PutOrCall::Put).engine(engine).build().expect("option must build")
1356 }
1357
1358 fn quarterly_dates() -> Vec<NaiveDate> {
1359 vec![
1360 NaiveDate::from_ymd_opt(2026, 4, 6).unwrap(),
1361 NaiveDate::from_ymd_opt(2026, 7, 6).unwrap(),
1362 NaiveDate::from_ymd_opt(2026, 10, 5).unwrap(),
1363 ]
1364 }
1365
1366 #[test]
1367 fn bermudan_with_no_interior_dates_is_european() {
1368 let euro = put_with_style(|b| b, Engine::Binomial).npv();
1371 let berm = bermudan_put(
1372 vec![NaiveDate::from_ymd_opt(2027, 1, 4).unwrap()],
1373 Engine::Binomial,
1374 )
1375 .npv();
1376 assert!((berm - euro).abs() < 1e-10, "berm {berm} vs euro {euro}");
1377 }
1378
1379 #[test]
1380 fn bermudan_value_sits_between_european_and_american() {
1381 let euro = put_with_style(|b| b, Engine::Binomial).npv();
1382 let amer = put_with_style(|b| b.american(), Engine::Binomial).npv();
1383 let quarterly = bermudan_put(quarterly_dates(), Engine::Binomial).npv();
1384 let monthly: Vec<NaiveDate> = (1..12)
1386 .map(|m| NaiveDate::from_ymd_opt(2026, 1, 5).unwrap() + chrono::Months::new(m))
1387 .collect();
1388 let monthly_pv = bermudan_put(monthly, Engine::Binomial).npv();
1389 let eps = 1e-9;
1390 assert!(euro <= quarterly + eps, "euro {euro} quarterly {quarterly}");
1391 assert!(quarterly <= monthly_pv + eps, "quarterly {quarterly} monthly {monthly_pv}");
1392 assert!(monthly_pv <= amer + eps, "monthly {monthly_pv} american {amer}");
1393 assert!(quarterly > euro + 1e-4, "quarterly rights must add value");
1395 }
1396
1397 #[test]
1398 fn dense_bermudan_converges_to_american() {
1399 let amer = put_with_style(|b| b.american(), Engine::Binomial).npv();
1400 let weekly: Vec<NaiveDate> = (1..52)
1402 .map(|w| NaiveDate::from_ymd_opt(2026, 1, 5).unwrap() + chrono::Duration::weeks(w))
1403 .collect();
1404 let dense = bermudan_put(weekly, Engine::Binomial).npv();
1405 assert!(
1406 (amer - dense).abs() < 0.05,
1407 "weekly Bermudan {dense} must approach American {amer}"
1408 );
1409 }
1410
1411 #[test]
1412 fn bermudan_prices_agree_across_engines() {
1413 let tree = bermudan_put(quarterly_dates(), Engine::Binomial).npv();
1414 let fd = bermudan_put(quarterly_dates(), Engine::FiniteDifference).npv();
1415 assert!((tree - fd).abs() < 0.05, "binomial {tree} vs FD {fd}");
1416 let mc = bermudan_put(quarterly_dates(), Engine::MonteCarlo).price().unwrap();
1417 let se = mc.std_err.expect("MC std err");
1418 assert!(
1419 (mc.pv - tree).abs() < (3.0 * se).max(0.15),
1420 "MC {} +- {se} vs tree {tree}",
1421 mc.pv
1422 );
1423 }
1424
1425 #[test]
1426 fn bermudan_schedule_and_validation() {
1427 use crate::core::calendar::Calendar;
1428 use crate::core::errors::RustyQLibError;
1429 let scheduled = EquityOptionBuilder::new()
1431 .spot(100.0)
1432 .strike(100.0)
1433 .flat_vol(0.3)
1434 .flat_rate(0.05)
1435 .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap())
1436 .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 4).unwrap())
1437 .bermudan_schedule(3, Calendar::UsNyse)
1438 .vanilla(PutOrCall::Put)
1439 .engine(Engine::Binomial)
1440 .build()
1441 .expect("option must build");
1442 let explicit = bermudan_put(quarterly_dates(), Engine::Binomial);
1443 assert!((scheduled.npv() - explicit.npv()).abs() < 0.05);
1444
1445 let r = bermudan_put_result(quarterly_dates(), Engine::BaroneAdesiWhaley);
1447 assert!(matches!(r, Err(RustyQLibError::UnsupportedEngine(_))));
1448 let r = bermudan_put_result(
1450 vec![NaiveDate::from_ymd_opt(2028, 1, 1).unwrap()],
1451 Engine::Binomial,
1452 );
1453 assert!(matches!(r, Err(RustyQLibError::InvalidInput { field, .. }) if field == "bermudan"));
1454 }
1455
1456 fn bermudan_put_result(
1457 dates: Vec<NaiveDate>,
1458 engine: Engine,
1459 ) -> Result<EquityOption, crate::core::errors::RustyQLibError> {
1460 EquityOptionBuilder::new()
1461 .spot(100.0)
1462 .strike(100.0)
1463 .flat_vol(0.3)
1464 .flat_rate(0.05)
1465 .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap())
1466 .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 4).unwrap())
1467 .bermudan(dates)
1468 .vanilla(PutOrCall::Put)
1469 .engine(engine)
1470 .build()
1471 }
1472
1473 #[test]
1474 fn tree_type_selects_the_lattice_scheme() {
1475 use crate::core::lattice::BinomialTreeType;
1476 use crate::equity::blackscholes::bs_price;
1477 let build = |tree: BinomialTreeType, steps: usize| {
1478 EquityOptionBuilder::new()
1479 .spot(100.0)
1480 .strike(100.0)
1481 .flat_vol(0.3)
1482 .flat_rate(0.05)
1483 .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap())
1484 .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 5).unwrap())
1485 .vanilla(PutOrCall::Call)
1486 .engine(Engine::Binomial)
1487 .tree_type(tree)
1488 .tree_steps(steps)
1489 .build()
1490 .expect("option must build")
1491 };
1492 let reference = bs_price(
1493 100.0, 100.0, 0.05, 0.0, 0.3, 1.0, PutOrCall::Call,
1494 );
1495 let lr_err = (build(BinomialTreeType::LeisenReimer, 101).npv() - reference).abs();
1498 let crr_err = (build(BinomialTreeType::CoxRossRubinstein, 101).npv() - reference).abs();
1499 assert!(lr_err * 10.0 < crr_err, "LR err {lr_err} vs CRR err {crr_err}");
1500 let option = build(BinomialTreeType::LeisenReimer, 101);
1502 let diag = crate::equity::binomial::npv_with_diagnostics(&option);
1503 assert_eq!(diag.price, option.npv());
1504 assert_eq!(diag.steps, 101);
1505 }
1506
1507 #[test]
1508 fn term_structure_tree_prices_rate_timing_into_early_exercise() {
1509 use crate::core::curves::{Compounding, CurveInput, InterpolationMethod, Tenor};
1510 let curve_input = CurveInput::ZeroRates {
1512 tenors: vec![Tenor::YearFraction(0.25), Tenor::YearFraction(1.0)],
1513 rates: vec![0.01, 0.09],
1514 compounding: Compounding::Continuous,
1515 day_count: DayCountConvention::Act365,
1516 interpolation: InterpolationMethod::LinearZero,
1517 };
1518 let build = |term: bool, american: bool| {
1519 let curve = YieldCurve::from_input(
1520 &curve_input,
1521 NaiveDate::from_ymd_opt(2026, 1, 5).unwrap(),
1522 )
1523 .unwrap();
1524 let mut b = EquityOptionBuilder::new()
1525 .spot(100.0)
1526 .strike(100.0)
1527 .flat_vol(0.3)
1528 .discount_curve(curve)
1529 .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 5).unwrap())
1530 .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 5).unwrap());
1531 if american {
1532 b = b.american();
1533 }
1534 let mut b = b.vanilla(PutOrCall::Put).engine(Engine::Binomial).tree_steps(801);
1535 if term {
1536 b = b.tree_term_structure();
1537 }
1538 b.build().expect("option must build")
1539 };
1540 let (euro_term, euro_uniform) = (build(true, false).npv(), build(false, false).npv());
1542 assert!(
1543 (euro_term - euro_uniform).abs() < 0.05,
1544 "European must agree: term {euro_term} uniform {euro_uniform}"
1545 );
1546 let (amer_term, amer_uniform) = (build(true, true).npv(), build(false, true).npv());
1550 assert!(
1551 (amer_term - amer_uniform).abs() > 0.02,
1552 "rate timing must matter for early exercise: term {amer_term} uniform {amer_uniform}"
1553 );
1554 assert!(amer_term >= euro_term - 1e-9);
1555 }
1556
1557 #[test]
1558 fn autocallable_initial_fixing_uses_the_final_spot() {
1559 let option = EquityOptionBuilder::new()
1561 .years_to_maturity(1.0)
1562 .autocallable(1.0, 0.7, 0.05, 4, 100.0)
1563 .spot(250.0)
1564 .engine(Engine::MonteCarlo)
1565 .build().expect("option must build");
1566 let payoff = option
1567 .payoff
1568 .as_any()
1569 .downcast_ref::<AutocallablePayoff>()
1570 .expect("autocallable payoff");
1571 assert_eq!(payoff.initial_fixing, 250.0);
1572 }
1573}