Skip to main content

rustyqlib/equity/
builder.rs

1//! Ergonomic construction of [`EquityOption`] from Rust code.
2//!
3//! The JSON path ([`EquityOption::from_json`]) is the primary interface for
4//! the CLI; this builder is the equivalent for library users and for the
5//! runnable examples in `examples/`.
6//!
7//! ```no_run
8//! use rustyqlib::equity::builder::EquityOptionBuilder;
9//! use rustyqlib::equity::utils::Engine;
10//! use rustyqlib::core::trade::PutOrCall;
11//! use rustyqlib::Instrument;
12//!
13//! let option = EquityOptionBuilder::new()
14//!     .spot(100.0)
15//!     .strike(100.0)
16//!     .flat_vol(0.30)
17//!     .flat_rate(0.05)
18//!     .years_to_maturity(1.0)
19//!     .vanilla(PutOrCall::Call)
20//!     .engine(Engine::BlackScholes)
21//!     .build().expect("option must build");
22//! println!("{}", option.npv());
23//! ```
24
25use 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
50/// What to price, recorded as data and materialized into a [`Payoff`] at
51/// [`EquityOptionBuilder::build`] time — so setter order never matters:
52/// `.vanilla(..).american()` and `.american().vanilla(..)` are equivalent,
53/// and an autocallable's initial fixing always uses the final spot.
54enum 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    /// Escape hatch: a caller-supplied payoff is used as given (its own
104    /// exercise style included).
105    Custom(Box<dyn Payoff>),
106}
107
108/// The builder inputs a payoff needs beyond its own parameters, shared by
109/// [`PayoffSpec::validate`] and [`PayoffSpec::materialize`].
110struct BuildContext {
111    spot: f64,
112    strike: f64,
113    valuation_date: NaiveDate,
114    maturity_date: NaiveDate,
115}
116
117/// Check that a date list is non-empty, strictly increasing after the
118/// valuation date and not past maturity; convert to Act/365 year fractions.
119fn 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    /// Payoffs whose value reads the built strike; the rest set their
154    /// strike through the contract mechanics.
155    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    /// Domain checks for this payoff's own parameters — one-dimensional
167    /// checks only; engine/model/payoff combination rules stay centralized
168    /// in `EquityOption::check_engine_support`.
169    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    /// Construct the runtime payoff with the final exercise style; assumes
322    /// [`validate`](Self::validate) has passed.
323    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 request: (months between observations, calendar),
442    /// resolved into dates at `build()` from valuation and maturity.
443    autocall_schedule: Option<(u32, crate::core::calendar::Calendar)>,
444    /// Bermudan exercise dates; converted to year fractions at `build()`.
445    bermudan_dates: Option<Vec<NaiveDate>>,
446    /// Bermudan schedule request, resolved like `autocall_schedule`.
447    bermudan_schedule: Option<(u32, crate::core::calendar::Calendar)>,
448    /// Misuse detected in a setter (e.g. `barrier_rebate` without a
449    /// barrier); reported by `build()` so setters stay chainable.
450    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    // ── Market data ─────────────────────────────────────────────────────
490
491    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    /// Continuous stock borrow (repo) cost; part of the carry.
526    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    /// Price the option on a future with Black-76: `spot` is then the
535    /// futures price `F`. European vanilla, Analytical engine only.
536    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    // ── Dates ───────────────────────────────────────────────────────────
545
546    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    /// Convenience for examples: maturity = valuation + `years * 365` days.
555    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    // ── Payoffs ─────────────────────────────────────────────────────────
562
563    pub fn american(mut self) -> Self {
564        self.exercise_style = ContractStyle::American;
565        self
566    }
567    /// Bermudan exercise on the given dates (expiry is always exercisable
568    /// through the terminal payoff). Overrides `american()` /
569    /// `exercise_style()`; applies to built-in payoffs, not `payoff()`.
570    pub fn bermudan(mut self, dates: Vec<NaiveDate>) -> Self {
571        self.bermudan_dates = Some(dates);
572        self
573    }
574    /// Bermudan exercise every `months` months on business-day adjusted
575    /// dates (modified following) from valuation to maturity, generated at
576    /// `build()` time.
577    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    /// Double-barrier option on the corridor between the two levels.
616    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    /// Rebate on the most recently configured barrier payoff
636    /// (`at_hit = true` pays the knock-out rebate at the touch;
637    /// analytic engine only).
638    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    /// Lookback on the path extremum: floating strike pays against the
664    /// min (call) / max (put); fixed strike pays the max (call) / min
665    /// (put) against the built strike.
666    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    /// `start_fraction` is the strike-fixing time as a fraction of the
675    /// option's life, in (0, 1).
676    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    /// Explicit autocall observation dates (e.g. from a
711    /// [`Schedule`](crate::core::calendar::Schedule)); must follow
712    /// `.autocallable(...)` or `.phoenix(...)`. Overrides the equally
713    /// spaced observation count.
714    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    /// Generate business-day adjusted autocall observation dates every
730    /// `months` months from valuation to maturity on the given calendar
731    /// (modified following); must follow `.autocallable(...)` or
732    /// `.phoenix(...)`. The schedule is built at `build()` time from the
733    /// final valuation and maturity dates.
734    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    /// Phoenix certificate: an autocallable whose coupon is paid at every
747    /// observation with `S >= coupon_barrier` (with optional memory),
748    /// rather than accruing as an at-call rebate.
749    #[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    /// Accumulator: the holder buys `shares_per_day` at the strike (set
774    /// via `.strike(...)`, below spot) on every equally spaced
775    /// observation day, knocked out when the spot reaches `barrier`
776    /// (above spot), with `gearing`x the quantity on days the spot closes
777    /// below the strike. Prices on the MonteCarlo engine.
778    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    /// Decumulator: the mirror of [`accumulator`](Self::accumulator) —
796    /// sell at the strike (above spot), knocked out at `barrier` (below
797    /// spot), geared on days the spot closes above the strike.
798    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    // ── Engine and model ────────────────────────────────────────────────
816
817    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    /// Binomial tree parameterization (default Leisen-Reimer).
859    pub fn tree_type(mut self, tree_type: crate::core::lattice::BinomialTreeType) -> Self {
860        self.lattice.tree_type = tree_type;
861        self
862    }
863    /// Binomial tree steps (default 1000).
864    pub fn tree_steps(mut self, steps: usize) -> Self {
865        self.lattice.steps = steps;
866        self
867    }
868    /// Price the binomial tree with term structures of rates and
869    /// volatility applied per step (`tree_type` is then ignored).
870    pub fn tree_term_structure(mut self) -> Self {
871        self.lattice.term_structure = true;
872        self
873    }
874
875    /// Validate every input and construct the option.
876    ///
877    /// The invariant after a successful `build()` is that the option
878    /// prices: field domains are checked (positive spot, positive vol,
879    /// maturity after valuation, ...), payoff-specific parameters are
880    /// checked, and the engine/model/payoff combination is verified, so
881    /// [`Instrument::price`](crate::core::traits::Instrument::price) on
882    /// the result cannot fail with `InvalidInput` or `UnsupportedEngine`.
883    ///
884    /// Only the configuration of the *selected* engine is validated: an
885    /// out-of-domain Monte Carlo or grid setting is ignored when that
886    /// engine is not the one pricing the option.
887    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        // ── market data domains (shared by every payoff) ────────────────
896        self.validate_market_data()?;
897
898        // ── dates ───────────────────────────────────────────────────────
899        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        // ── payoff spec: resolve schedules, then check its own domains ──
919        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        // ── exercise style (Bermudan dates → year fractions) ────────────
958        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        // ── model configuration ─────────────────────────────────────────
1005        if let Model::Heston(params) = &self.model {
1006            params.validate()?;
1007        }
1008
1009        // ── market objects (curve/surface errors convert via From) ──────
1010        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        // ── materialize the payoff with the final exercise style ────────
1027        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        // only the selected engine's configuration is validated: the
1054        // others never influence the built option
1055        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        // "builds => prices": refuse engine/model/payoff combinations here
1074        // rather than at pricing time
1075        option.check_engine_support()?;
1076        Ok(option)
1077    }
1078
1079    /// Domain checks on the market data fields shared by every payoff.
1080    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        // the payoff is materialized at build() time, so the exercise
1150        // style applies regardless of setter order
1151        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        // Heston params travel inside the model, so "params missing" is
1190        // unrepresentable; invalid params are still rejected at build()
1191        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        // an option that builds must price: engine support is checked here
1222        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        // the same schedule regenerated must be all NYSE business days
1259        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        // calendar-adjusted observations price close to the equally
1272        // spaced approximation (same seed, same paths)
1273        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        // unsorted dates
1305        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        // date past maturity
1314        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        // schedule on a non-autocallable payoff
1320        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        // a single exercise date at expiry adds nothing beyond the
1369        // terminal payoff: the tree must reproduce the European value
1370        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        // monthly rights dominate quarterly rights
1385        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        // quarterly rights must be worth something on an ITM-prone put
1394        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        // weekly exercise rights
1401        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        // schedule-generated quarterly rights price like explicit ones
1430        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        // analytic American approximations refuse Bermudan at build()
1446        let r = bermudan_put_result(quarterly_dates(), Engine::BaroneAdesiWhaley);
1447        assert!(matches!(r, Err(RustyQLibError::UnsupportedEngine(_))));
1448        // a date after maturity is rejected with the offending field
1449        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        // Leisen-Reimer at 101 steps beats CRR at 101 steps by an order
1496        // of magnitude on the same contract
1497        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        // diagnostics agree with the fast engine
1501        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        // steep upward curve: 1% short rate, 9% long rate
1511        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        // European: only df(T) matters, so term and uniform trees agree
1541        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        // American put: early rates are 1%, not the 8.8%-ish zero rate to
1547        // maturity — cheap short-dated carry makes waiting cheaper, so
1548        // rate timing must move the early-exercise value visibly
1549        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        // spot() after autocallable() must still set the initial fixing
1560        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}