Skip to main content

rustyqlib/equity/
montecarlo.rs

1//! Monte Carlo pricing engine.
2//!
3//! - Terminal-value simulation (exact GBM step, 1-D Sobol) for European
4//!   payoffs; path-wise simulation with Exact / Euler / Milstein stepping.
5//! - **Parallel, streamed path generation**: every path derives its own
6//!   deterministic RNG stream from (seed, path index), so paths are
7//!   generated in parallel with rayon, results are independent of thread
8//!   scheduling, and no draw matrix is materialized.
9//! - **Multi-dimensional quasi-Monte Carlo**: with the (default) `Sobol`
10//!   sampler, path-wise routes use a low-discrepancy sequence through a
11//!   **Brownian bridge**, so the best coordinates carry each path's coarse
12//!   structure.
13//! - Dupire local vol dynamics, Brownian-bridge barrier correction,
14//!   geometric control variate for arithmetic Asians.
15//! - American exercise via **two-pass Longstaff-Schwartz** (regression on
16//!   one set of paths, valuation on an independent set — removes foresight
17//!   bias) with a cubic polynomial basis; under Heston the paths and the
18//!   regression basis carry the `(spot, variance)` state, stepping the
19//!   Andersen QE scheme.
20//! - [`npv_with_stats`] reports the standard error alongside the price.
21//! - Greeks by central-difference bump-and-reprice with common random
22//!   numbers (deterministic draws make every reprice use identical paths).
23//!
24//! Path dynamics come from the stochastic-process layer
25//! ([`core::montecarlo::process`](crate::core::montecarlo::process) +
26//! [`equity::processes`](crate::equity::processes)): the SDE's
27//! drift/diffusion live in the process object (GBM / local vol as a
28//! [`BlackScholesProcess`], Heston as the two-factor [`HestonProcess`]),
29//! and Euler / Milstein / exact stepping are generic over it. A new model
30//! plugs in by implementing the process trait; the per-path stream and
31//! stepping structure is factor-agnostic.
32
33use libm::exp;
34use rayon::prelude::*;
35
36use crate::core::utils::ContractStyle;
37use super::asian::{self, AsianStrikeType, AveragingType};
38use super::accumulator::AccumulatorPayoff;
39use super::autocallable::AutocallablePayoff;
40use super::barrier::{BarrierDirection, KnockType};
41use super::heston::HestonParams;
42use super::local_vol::LocalVol;
43use super::processes::{BlackScholesProcess, HestonProcess, HestonScheme, VolDynamics};
44use super::vanilla_option::{AsianPayoff, BarrierPayoff, EquityOption, VanillaPayoff};
45use super::utils::Model;
46use crate::core::montecarlo::process::{StochasticProcess, StochasticProcess1D};
47use crate::core::trade::PutOrCall;
48use crate::core::montecarlo::{path_normals, pseudo_normals, sobol_normals, PathDraws};
49use crate::core::data_models::EquityOptionData;
50use crate::core::errors::RustyQLibError;
51
52/// Re-exported from the asset-agnostic process layer, where the schemes
53/// are defined once against any SDE's drift/diffusion coefficients.
54pub use crate::core::montecarlo::process::DiscretizationScheme;
55
56/// Re-exported from the asset-agnostic path layer. Longstaff-Schwartz
57/// always uses pseudo-random streams.
58pub use crate::core::montecarlo::paths::Sampler;
59
60/// Dynamics used for path generation. `Gbm` diffuses at the option's own
61/// (constant) implied vol; `LocalVol` diffuses at the Dupire local
62/// volatility calibrated from the option's vol surface.
63
64
65#[derive(Debug, Clone, Copy, PartialEq)]
66pub struct MonteCarloConfig {
67    pub paths: usize,
68    /// 1 = terminal simulation (exact); > 1 = path-wise stepping.
69    /// Local vol always steps path-wise (at least [`LOCAL_VOL_MIN_STEPS`]).
70    pub time_steps: usize,
71    pub scheme: DiscretizationScheme,
72    pub sampler: Sampler,
73    pub seed: u64,
74}
75
76pub const LOCAL_VOL_MIN_STEPS: usize = 100;
77/// Step floor for full-truncation Euler, whose O(dt) variance-truncation
78/// bias needs a fine grid.
79pub const HESTON_MIN_STEPS: usize = 250;
80/// Step floor under Andersen QE, which is near bias-free on coarse grids
81/// (that is its point) — the floor only keeps enough resolution for the
82/// vol path itself.
83pub const HESTON_QE_MIN_STEPS: usize = 25;
84/// Minimum monitoring steps for path-dependent payoffs.
85pub const PATH_DEPENDENT_MIN_STEPS: usize = 100;
86
87impl Default for MonteCarloConfig {
88    fn default() -> Self {
89        MonteCarloConfig {
90            paths: 100_000,
91            time_steps: 1,
92            scheme: DiscretizationScheme::Exact,
93            sampler: Sampler::Sobol,
94            seed: 42,
95        }
96    }
97}
98
99impl MonteCarloConfig {
100    /// Read the sampling configuration from contract data, reporting bad
101    /// `mc_scheme` / `mc_sampler` strings as typed errors naming the field.
102    pub fn from_data(data: &EquityOptionData) -> Result<Self, RustyQLibError> {
103        let defaults = MonteCarloConfig::default();
104        let scheme = match data.mc_scheme.as_deref() {
105            Some(s) => s
106                .parse::<DiscretizationScheme>()
107                .map_err(|e| RustyQLibError::invalid_input("mc_scheme", e))?,
108            None => defaults.scheme,
109        };
110        // approximate schemes need real time-stepping to mean anything
111        let default_steps = match scheme {
112            DiscretizationScheme::Exact => 1,
113            _ => 252,
114        };
115        let sampler = match data.mc_sampler.as_deref() {
116            Some(s) => s
117                .parse::<Sampler>()
118                .map_err(|e| RustyQLibError::invalid_input("mc_sampler", e))?,
119            None => defaults.sampler,
120        };
121        Ok(MonteCarloConfig {
122            paths: data.simulation.unwrap_or(defaults.paths as u64) as usize,
123            time_steps: data.mc_time_steps.unwrap_or(default_steps),
124            scheme,
125            sampler,
126            seed: data.mc_seed.unwrap_or(defaults.seed),
127        })
128    }
129
130    /// Domain checks on the sampling parameters; field names match the
131    /// [`EquityOptionBuilder`](crate::equity::builder::EquityOptionBuilder)
132    /// setters.
133    pub fn validate(&self) -> Result<(), RustyQLibError> {
134        if self.paths == 0 {
135            return Err(RustyQLibError::invalid_input(
136                "paths",
137                "Monte Carlo needs at least one path",
138            ));
139        }
140        if self.time_steps == 0 {
141            return Err(RustyQLibError::invalid_input(
142                "mc_time_steps",
143                "Monte Carlo needs at least one time step",
144            ));
145        }
146        Ok(())
147    }
148}
149
150/// Price with sampling diagnostics.
151///
152/// `std_err` is the standard error of the mean over paths. For the
153/// low-discrepancy sampler the points are not independent, so treat it as
154/// an indicative scale rather than a rigorous confidence bound; for the
155/// LSMC it reflects valuation-pass noise only (not regression uncertainty).
156#[derive(Debug, Clone, Copy)]
157pub struct McStats {
158    pub pv: f64,
159    pub std_err: f64,
160    pub paths: usize,
161    pub steps: usize,
162}
163
164fn stats(sum: f64, sum_sq: f64, n: usize, steps: usize, offset: f64) -> McStats {
165    let (mean, std_err) = crate::core::montecarlo::mean_std_err(sum, sum_sq, n);
166    McStats { pv: mean + offset, std_err, paths: n, steps }
167}
168
169/// Market inputs snapshot; Greeks bump these fields and reprice with the
170/// same draws (common random numbers).
171#[derive(Debug, Clone, Copy)]
172struct MarketParams {
173    s0: f64,
174    strike: f64,
175    r: f64,
176    q: f64,
177    sigma: f64,
178    t: f64,
179}
180
181fn market_params(option: &EquityOption) -> MarketParams {
182    MarketParams {
183        s0: option.market.spot.value(),
184        strike: option.base.strike_price,
185        r: option.risk_free_rate(),
186        q: option.carry_yield(),
187        sigma: option.volatility(),
188        t: option.time_to_maturity(),
189    }
190}
191
192/// Cash dividend amounts bucketed per simulation step (None if there are
193/// none): path simulation subtracts them at the ex-date step.
194fn dividends_per_step(option: &EquityOption, t: f64, steps: usize) -> Option<Vec<f64>> {
195    if option.market.cash_dividends.is_empty() {
196        return None;
197    }
198    let dt = t / steps as f64;
199    let mut buckets = vec![0.0; steps];
200    for (date, amount) in &option.market.cash_dividends {
201        let td = (*date - option.market.valuation_date).num_days() as f64 / 365.0;
202        if td > 0.0 && td <= t {
203            let idx = (((td / dt).ceil() as usize).max(1) - 1).min(steps - 1);
204            buckets[idx] += amount;
205        }
206    }
207    Some(buckets)
208}
209
210/// Escrowed-model spot consistent with the bumped market params: rho bumps
211/// shift the dividend discounting, delta bumps move the raw spot.
212///
213/// Cash dividends are discounted at the net carry `r - carry` (here
214/// `p.r - p.q`, `p.q` being the total carry), matching the analytic engine
215/// and the jump-model forward; see
216/// [`EquityOptionBase::pv_cash_dividends`](super::vanilla_option::EquityOptionBase::pv_cash_dividends).
217fn escrowed_spot(option: &EquityOption, p: &MarketParams) -> f64 {
218    let dr = p.r - option.risk_free_rate();
219    let mut pv = 0.0;
220    for (date, amount) in &option.market.cash_dividends {
221        let td = (*date - option.market.valuation_date).num_days() as f64 / 365.0;
222        if td > 0.0 && td <= p.t {
223            // df(td) e^{-dr td} discounts at the bumped rate p.r;
224            // e^{p.q td} moves it to the net carry (p.r - p.q).
225            pv += amount * option.market.discount_curve.df(td) * ((p.q - dr) * td).exp();
226        }
227    }
228    p.s0 - pv
229}
230
231pub fn npv(option: &EquityOption) -> f64 {
232    npv_with_stats(option).pv
233}
234
235/// Price with standard error and simulation diagnostics.
236pub fn npv_with_stats(option: &EquityOption) -> McStats {
237    assert!(option.volatility() >= 0.0);
238    assert!(option.time_to_maturity() >= 0.0);
239    assert!(option.market.spot.mid() >= 0.0);
240    if let Some(barrier) = option.payoff.as_any().downcast_ref::<BarrierPayoff>() {
241        assert!(
242            !(barrier.rebate != 0.0 && barrier.rebate_at_hit),
243            "at-hit rebates need the touch time: price on the Analytical engine              (Monte Carlo supports the at-expiry rebate convention)"
244        );
245    }
246    price(option, &market_params(option))
247}
248
249fn price(option: &EquityOption, p: &MarketParams) -> McStats {
250    match option.payoff.exercise_style() {
251        ContractStyle::American | ContractStyle::Bermudan(_) => american_npv(option, p),
252        _ => european_npv(option, p),
253    }
254}
255
256// Greeks: central-difference bumps with common random numbers, produced
257// by the central sensitivity engine (`crate::equity::greeks`) through
258// [`npv_with`] — every bumped reprice reuses the base price's draws.
259// Where the payoff is smooth enough, the pathwise estimator below
260// replaces the delta/vega bumps.
261
262/// Pathwise delta and vega for the exact terminal-GBM route: differentiate
263/// the discounted payoff along each path instead of bumping,
264///
265/// ```text
266/// dV/dS0    = e^{-rT} E[ payoff'(S_T) * S_T / S0 ]
267/// dV/dsigma = e^{-rT} E[ payoff'(S_T) * S_T * (sqrt(T) Z - sigma T) ]
268/// ```
269///
270/// (`S0` the escrowed spot, `payoff'` = ±indicator for a vanilla). One
271/// simulation instead of four, no finite-difference bias, and the same
272/// draws as the price, so the estimates are exactly reproducible.
273///
274/// Applies to European vanilla payoffs on constant-vol GBM with one-step
275/// terminal simulation; returns `None` outside that scope (path-dependent,
276/// multi-step, local vol, Heston, American), where the central bump
277/// stencils take over. The kink at the strike has measure zero, so the
278/// interchange of derivative and expectation is valid for vanillas.
279pub(crate) fn pathwise_delta_vega(option: &EquityOption) -> Option<(f64, f64)> {
280    let vanilla = option.payoff.as_any().downcast_ref::<VanillaPayoff>()?;
281    if !matches!(vanilla.exercise_style, ContractStyle::European) {
282        return None;
283    }
284    if option.model != Model::Gbm {
285        return None;
286    }
287    let cfg = option.mc_cfg();
288    if effective_steps(cfg, &option.model) > 1 {
289        return None;
290    }
291    let p = market_params(option);
292    let df = exp(-p.r * p.t);
293    let drift = (p.r - p.q - 0.5 * p.sigma * p.sigma) * p.t;
294    let sqrt_t = p.t.sqrt();
295    let vol_sqrt_t = p.sigma * sqrt_t;
296    let s0 = escrowed_spot(option, &p);
297    let z = match cfg.sampler {
298        Sampler::Sobol => sobol_normals(cfg.paths),
299        Sampler::PseudoRandom => pseudo_normals(cfg.paths, cfg.seed),
300    };
301    let sign = match vanilla.put_or_call {
302        PutOrCall::Call => 1.0,
303        PutOrCall::Put => -1.0,
304    };
305    let partials: Vec<(f64, f64)> = z
306        .par_chunks(PATH_CHUNK)
307        .map(|chunk| {
308            let (mut delta_sum, mut vega_sum) = (0.0, 0.0);
309            for z in chunk {
310                let s_t = s0 * exp(drift + vol_sqrt_t * z);
311                let in_the_money = match vanilla.put_or_call {
312                    PutOrCall::Call => s_t > p.strike,
313                    PutOrCall::Put => s_t < p.strike,
314                };
315                if in_the_money {
316                    delta_sum += sign * s_t / s0;
317                    vega_sum += sign * s_t * (sqrt_t * z - p.sigma * p.t);
318                }
319            }
320            (delta_sum, vega_sum)
321        })
322        .collect();
323    let (delta_sum, vega_sum) =
324        partials.into_iter().fold((0.0, 0.0), |a, b| (a.0 + b.0, a.1 + b.1));
325    let n = cfg.paths as f64;
326    Some((df * delta_sum / n, df * vega_sum / n))
327}
328
329/// First-order adjoint Greeks from **one** simulation.
330#[derive(Debug, Clone, Copy)]
331pub(crate) struct AadGreeks {
332    pub delta: f64,
333    /// Sensitivity to a parallel implied-vol shift.
334    pub vega: f64,
335    pub rho: f64,
336}
337
338/// Delta, vega and rho from a backward adjoint sweep per path: the path
339/// stepping and the payoff (via [`Payoff::path_payoff_var`]) are recorded
340/// on an AAD tape with the market inputs as roots, and one reverse pass
341/// per path yields all three sensitivities — the cost does not grow with
342/// the number of Greeks. Holding the draws fixed these are the classical
343/// pathwise estimators: unbiased for continuous payoffs.
344///
345/// Theta is deliberately absent: differentiating with the Brownian path
346/// held fixed while the time grid moves is not well-defined, so theta
347/// stays with the bump stencils.
348///
349/// Scope: European exercise, a payoff that opts into AAD (vanilla,
350/// Asian, lookback, forward-start — the continuous ones), and dynamics
351/// with a tape-safe stepping recursion: GBM under any scheme
352/// ([`gbm_aad_greeks`]), Heston through the full-truncation recursion
353/// ([`heston_aad_greeks`]). Local vol stays with the bump stencils (its
354/// surface interpolation is not on the tape), and discontinuous payoffs
355/// (barrier, binary, autocallable) never opt in because the
356/// almost-everywhere derivative of their indicator is zero.
357pub(crate) fn aad_greeks(option: &EquityOption) -> Option<AadGreeks> {
358    if !matches!(option.payoff.exercise_style(), ContractStyle::European) {
359        return None;
360    }
361    match option.model {
362        Model::Gbm => gbm_aad_greeks(option),
363        Model::Heston(_) => heston_aad_greeks(option),
364        Model::LocalVol => None,
365    }
366}
367
368/// GBM adjoint sweep with `(S0, sigma, r)` as tape roots. All three
369/// schemes are taped — exact log-normal, Euler, Milstein — so switching
370/// the discretization keeps one-simulation Greeks.
371fn gbm_aad_greeks(option: &EquityOption) -> Option<AadGreeks> {
372    use crate::core::aad::{Tape, Var};
373    let cfg = option.mc_cfg();
374    let p = market_params(option);
375    let steps = if option.payoff.is_path_dependent() {
376        effective_steps(cfg, &option.model).max(PATH_DEPENDENT_MIN_STEPS)
377    } else {
378        effective_steps(cfg, &option.model).max(1)
379    };
380    // capability probe before spinning up the parallel loop
381    {
382        let tape = Tape::new();
383        let probe: Vec<Var> = (0..steps.max(2)).map(|_| tape.var(p.s0)).collect();
384        option.payoff.path_payoff_var(&probe, p.strike)?;
385    }
386    let dt = p.t / steps as f64;
387    let draws = PathDraws::new(cfg.sampler, cfg.seed, steps, dt);
388    let divs = dividends_per_step(option, p.t, steps);
389    let chunks = cfg.paths.div_ceil(PATH_CHUNK);
390    let partials: Vec<(f64, f64, f64)> = (0..chunks)
391        .into_par_iter()
392        .map(|chunk| {
393            let mut z = vec![0.0; steps];
394            let mut w = vec![0.0; steps];
395            let mut dw = vec![0.0; steps];
396            let tape = Tape::new();
397            let mut path_vars: Vec<Var> = Vec::with_capacity(steps);
398            let (mut delta_sum, mut vega_sum, mut rho_sum) = (0.0, 0.0, 0.0);
399            for i in chunk * PATH_CHUNK..((chunk + 1) * PATH_CHUNK).min(cfg.paths) {
400                draws.fill(i, &mut z, &mut w, &mut dw);
401                tape.clear();
402                path_vars.clear();
403                let s0 = tape.var(p.s0);
404                let sigma = tape.var(p.sigma);
405                let r = tape.var(p.r);
406                let mut s = s0;
407                for (step_idx, dwi) in dw.iter().enumerate() {
408                    s = match cfg.scheme {
409                        // exact: s * exp((r - q - sigma^2/2) dt + sigma dW)
410                        DiscretizationScheme::Exact => {
411                            let exponent =
412                                (r - p.q) * dt - sigma * sigma * (0.5 * dt) + sigma * *dwi;
413                            s * exponent.exp()
414                        }
415                        DiscretizationScheme::Euler => {
416                            (s * ((r - p.q) * dt + sigma * *dwi + 1.0)).maxf(0.0)
417                        }
418                        DiscretizationScheme::Milstein => {
419                            let correction =
420                                sigma * sigma * (0.5 * (dwi * dwi - dt));
421                            (s * ((r - p.q) * dt + sigma * *dwi + correction + 1.0))
422                                .maxf(0.0)
423                        }
424                    };
425                    if let Some(divs) = &divs {
426                        if divs[step_idx] != 0.0 {
427                            s = (s - divs[step_idx]).maxf(1e-8);
428                        }
429                    }
430                    path_vars.push(s);
431                }
432                let payoff = option
433                    .payoff
434                    .path_payoff_var(&path_vars, p.strike)
435                    .expect("the probe above guaranteed AAD support");
436                let discounted = payoff * (-(r * p.t)).exp();
437                let g = discounted.grad();
438                delta_sum += g.wrt(s0);
439                vega_sum += g.wrt(sigma);
440                rho_sum += g.wrt(r);
441            }
442            (delta_sum, vega_sum, rho_sum)
443        })
444        .collect();
445    let (delta_sum, vega_sum, rho_sum) = partials
446        .into_iter()
447        .fold((0.0, 0.0, 0.0), |a, b| (a.0 + b.0, a.1 + b.1, a.2 + b.2));
448    let n = cfg.paths as f64;
449    Some(AadGreeks { delta: delta_sum / n, vega: vega_sum / n, rho: rho_sum / n })
450}
451
452/// Heston adjoint sweep: the **full-truncation Euler** recursion is
453/// recorded on the tape with `(S0, vol-shift, r)` as roots, where the
454/// vol-shift root enters through the library's Heston vega convention —
455/// `v0 = (sqrt(v0) + shift)^2`, `theta = (sqrt(theta) + shift)^2` — so
456/// `d/d shift` at zero is exactly the vega the bump engine estimates.
457///
458/// Full truncation rather than QE because the tape needs a smooth
459/// recursion: QE's branch switch and its mass at zero have no useful
460/// pathwise derivative, while the FT step is differentiable wherever
461/// `v != 0` (the truncation kink has measure zero away from the origin;
462/// the variance floor is nudged to `1e-12` so `sqrt` stays finite).
463/// The price itself still comes from the QE engine — both estimate the
464/// same Greeks, FT merely needs its fine step floor here.
465fn heston_aad_greeks(option: &EquityOption) -> Option<AadGreeks> {
466    use crate::core::aad::{Tape, Var};
467    let hp = *option.heston_params();
468    let cfg = option.mc_cfg();
469    let p = market_params(option);
470    let steps = cfg.time_steps.max(HESTON_MIN_STEPS);
471    // capability probe before spinning up the parallel loop
472    {
473        let tape = Tape::new();
474        let probe: Vec<Var> = (0..steps.max(2)).map(|_| tape.var(p.s0)).collect();
475        option.payoff.path_payoff_var(&probe, p.strike)?;
476    }
477    let dt = p.t / steps as f64;
478    let sqrt_dt = dt.sqrt();
479    let rho_perp = (1.0 - hp.rho * hp.rho).sqrt();
480    let divs = dividends_per_step(option, p.t, steps);
481    let chunks = cfg.paths.div_ceil(PATH_CHUNK);
482    let partials: Vec<(f64, f64, f64)> = (0..chunks)
483        .into_par_iter()
484        .map(|chunk| {
485            let mut z = vec![0.0; 2 * steps];
486            let tape = Tape::new();
487            let mut path_vars: Vec<Var> = Vec::with_capacity(steps);
488            let (mut delta_sum, mut vega_sum, mut rho_sum) = (0.0, 0.0, 0.0);
489            for i in chunk * PATH_CHUNK..((chunk + 1) * PATH_CHUNK).min(cfg.paths) {
490                path_normals(cfg.seed, (i / 2) as u64, &mut z);
491                let sign = if i % 2 == 0 { 1.0 } else { -1.0 };
492                tape.clear();
493                path_vars.clear();
494                let s0 = tape.var(p.s0);
495                let vol_shift = tape.var(0.0);
496                let r = tape.var(p.r);
497                let sqrt_v0 = vol_shift + hp.v0.sqrt();
498                let sqrt_theta = vol_shift + hp.theta.sqrt();
499                let theta_var = sqrt_theta * sqrt_theta;
500                let mut s = s0;
501                let mut v = sqrt_v0 * sqrt_v0;
502                for j in 0..steps {
503                    let dw_s = sqrt_dt * sign * z[2 * j];
504                    let dw_v =
505                        hp.rho * dw_s + rho_perp * sqrt_dt * sign * z[2 * j + 1];
506                    // floor nudged off zero so sqrt' stays finite on tape
507                    let v_pos = v.maxf(1e-12);
508                    let sqrt_v = v_pos.sqrt();
509                    s = s * ((r - p.q) * dt - v_pos * (0.5 * dt) + sqrt_v * dw_s).exp();
510                    if let Some(divs) = &divs {
511                        if divs[j] != 0.0 {
512                            s = (s - divs[j]).maxf(1e-8);
513                        }
514                    }
515                    v = v + (theta_var - v_pos) * (hp.kappa * dt)
516                        + sqrt_v * (hp.vol_of_vol * dw_v);
517                    path_vars.push(s);
518                }
519                let payoff = option
520                    .payoff
521                    .path_payoff_var(&path_vars, p.strike)
522                    .expect("the probe above guaranteed AAD support");
523                let discounted = payoff * (-(r * p.t)).exp();
524                let g = discounted.grad();
525                delta_sum += g.wrt(s0);
526                vega_sum += g.wrt(vol_shift);
527                rho_sum += g.wrt(r);
528            }
529            (delta_sum, vega_sum, rho_sum)
530        })
531        .collect();
532    let (delta_sum, vega_sum, rho_sum) = partials
533        .into_iter()
534        .fold((0.0, 0.0, 0.0), |a, b| (a.0 + b.0, a.1 + b.1, a.2 + b.2));
535    let n = cfg.paths as f64;
536    Some(AadGreeks { delta: delta_sum / n, vega: vega_sum / n, rho: rho_sum / n })
537}
538
539/// Reprice under a shifted market (spot, parallel vol, rate, calendar time)
540/// with the same random draws as the base price, for PnL attribution.
541pub(crate) fn npv_with(
542    option: &EquityOption,
543    d_spot: f64,
544    d_vol: f64,
545    d_rate: f64,
546    d_time: f64,
547) -> f64 {
548    let p = market_params(option);
549    price(
550        option,
551        &MarketParams {
552            s0: p.s0 + d_spot,
553            sigma: p.sigma + d_vol,
554            r: p.r + d_rate,
555            t: (p.t - d_time).max(1e-6),
556            ..p
557        },
558    )
559    .pv
560}
561
562// ── Model dynamics as a stochastic process ──────────────────────────────
563
564/// The option's dynamics under the (possibly bumped) market `p`, as a
565/// [`BlackScholesProcess`] the generic stepping consumes.
566fn bs_process<'a>(option: &'a EquityOption, p: &MarketParams) -> BlackScholesProcess<'a> {
567    let vol = match option.model {
568        Model::Gbm => VolDynamics::Const(p.sigma),
569        Model::LocalVol => VolDynamics::Local(LocalVol::new(
570            &option.market.vol_surface,
571            &option.market.discount_curve,
572            // the local vol function is frozen at the calibration spot;
573            // spot bumps (delta/gamma) move the path start, not the model
574            option.market.spot.value(),
575            option.carry_yield(),
576            // vega bumps enter as a parallel shift of the implied surface
577            p.sigma - option.volatility(),
578        )),
579        Model::Heston(_) => unreachable!("Heston paths are generated by the dedicated routes"),
580    };
581    BlackScholesProcess::new(p.r - p.q, vol)
582}
583
584fn effective_steps(cfg: &MonteCarloConfig, model: &Model) -> usize {
585    match model {
586        Model::LocalVol => cfg.time_steps.max(LOCAL_VOL_MIN_STEPS),
587        Model::Heston(_) => {
588            // Exact selects the QE scheme (see `run_heston_paths`), which
589            // tolerates far coarser grids than full-truncation Euler
590            let floor = match cfg.scheme {
591                DiscretizationScheme::Exact => HESTON_QE_MIN_STEPS,
592                _ => HESTON_MIN_STEPS,
593            };
594            cfg.time_steps.max(floor)
595        }
596        Model::Gbm => cfg.time_steps,
597    }
598}
599
600/// Paths per parallel work unit. Each chunk is summed serially in index
601/// order and chunk results are folded in order, so totals are bit-exact
602/// reproducible regardless of thread scheduling.
603const PATH_CHUNK: usize = 4096;
604
605/// Parallel map-reduce over paths: `eval(dw, scratch)` values one path from
606/// its Brownian increments; returns (sum, sum of squares) deterministically.
607fn run_paths<F>(paths: usize, steps: usize, draws: &PathDraws, eval: F) -> (f64, f64)
608where
609    F: Fn(&[f64], &mut Vec<f64>) -> f64 + Sync,
610{
611    let chunks = paths.div_ceil(PATH_CHUNK);
612    let partials: Vec<(f64, f64)> = (0..chunks)
613        .into_par_iter()
614        .map(|chunk| {
615            let mut z = vec![0.0; steps];
616            let mut w = vec![0.0; steps];
617            let mut dw = vec![0.0; steps];
618            let mut scratch = Vec::new();
619            let (mut sum, mut sum_sq) = (0.0, 0.0);
620            for i in chunk * PATH_CHUNK..((chunk + 1) * PATH_CHUNK).min(paths) {
621                draws.fill(i, &mut z, &mut w, &mut dw);
622                let v = eval(&dw, &mut scratch);
623                sum += v;
624                sum_sq += v * v;
625            }
626            (sum, sum_sq)
627        })
628        .collect();
629    partials.into_iter().fold((0.0, 0.0), |a, b| (a.0 + b.0, a.1 + b.1))
630}
631
632// ── European ────────────────────────────────────────────────────────────
633
634fn european_npv(option: &EquityOption, p: &MarketParams) -> McStats {
635    if option.model .is_heston() {
636        return heston_european_npv(option, p);
637    }
638    if option.payoff.is_path_dependent() {
639        // barriers get the Brownian-bridge crossing correction; Asians get
640        // the geometric control variate; anything else path-dependent uses
641        // its own path_payoff with discrete monitoring
642        return if let Some(barrier) = option
643            .payoff
644            .as_any()
645            .downcast_ref::<BarrierPayoff>()
646            .filter(|b| b.barrier2.is_none() && b.rebate == 0.0)
647        {
648            barrier_npv(option, barrier, p)
649        } else if let Some(asian) = option.payoff.as_any().downcast_ref::<AsianPayoff>() {
650            asian_npv(option, asian, p)
651        } else if let Some(auto) = option.payoff.as_any().downcast_ref::<AutocallablePayoff>() {
652            autocall_npv(option, auto, p)
653        } else if let Some(accu) = option.payoff.as_any().downcast_ref::<AccumulatorPayoff>() {
654            accumulator_npv(option, accu, p)
655        } else {
656            generic_path_npv(option, p)
657        };
658    }
659    let cfg = option.mc_cfg();
660    let steps = effective_steps(cfg, &option.model);
661    let df = exp(-p.r * p.t);
662    if steps <= 1 {
663        // exact one-step GBM transition (constant vol only)
664        let drift = (p.r - p.q - 0.5 * p.sigma * p.sigma) * p.t;
665        let vol_sqrt_t = p.sigma * p.t.sqrt();
666        let s0 = escrowed_spot(option, p);
667        let z = match cfg.sampler {
668            Sampler::Sobol => sobol_normals(cfg.paths),
669            Sampler::PseudoRandom => pseudo_normals(cfg.paths, cfg.seed),
670        };
671        let partials: Vec<(f64, f64)> = z
672            .par_chunks(PATH_CHUNK)
673            .map(|chunk| {
674                let (mut sum, mut sum_sq) = (0.0, 0.0);
675                for z in chunk {
676                    let v = df
677                        * option.payoff.payoff(s0 * exp(drift + vol_sqrt_t * z), p.strike);
678                    sum += v;
679                    sum_sq += v * v;
680                }
681                (sum, sum_sq)
682            })
683            .collect();
684        let (sum, sum_sq) =
685            partials.into_iter().fold((0.0, 0.0), |a, b| (a.0 + b.0, a.1 + b.1));
686        return stats(sum, sum_sq, cfg.paths, 1, 0.0);
687    }
688    let dt = p.t / steps as f64;
689    let process = bs_process(option, p);
690    let draws = PathDraws::new(cfg.sampler, cfg.seed, steps, dt);
691    let divs = dividends_per_step(option, p.t, steps);
692    let (sum, sum_sq) = run_paths(cfg.paths, steps, &draws, |dw, _| {
693        let mut s = p.s0;
694        for (i, d) in dw.iter().enumerate() {
695            s = process.evolve(cfg.scheme, i as f64 * dt, s, dt, *d);
696            if let Some(divs) = &divs {
697                s = (s - divs[i]).max(1e-8);
698            }
699        }
700        df * option.payoff.payoff(s, p.strike)
701    });
702    stats(sum, sum_sq, cfg.paths, steps, 0.0)
703}
704
705/// Path-dependent pricing through [`Payoff::path_payoff`] on discretely
706/// monitored paths.
707fn generic_path_npv(option: &EquityOption, p: &MarketParams) -> McStats {
708    let cfg = option.mc_cfg();
709    let steps = effective_steps(cfg, &option.model).max(PATH_DEPENDENT_MIN_STEPS);
710    let dt = p.t / steps as f64;
711    let df = exp(-p.r * p.t);
712    let process = bs_process(option, p);
713    let draws = PathDraws::new(cfg.sampler, cfg.seed, steps, dt);
714    let divs = dividends_per_step(option, p.t, steps);
715    let (sum, sum_sq) = run_paths(cfg.paths, steps, &draws, |dw, path| {
716        path.clear();
717        let mut s = p.s0;
718        for (i, d) in dw.iter().enumerate() {
719            s = process.evolve(cfg.scheme, i as f64 * dt, s, dt, *d);
720            if let Some(divs) = &divs {
721                s = (s - divs[i]).max(1e-8);
722            }
723            path.push(s);
724        }
725        df * option.payoff.path_payoff(path, p.strike)
726    });
727    stats(sum, sum_sq, cfg.paths, steps, 0.0)
728}
729
730/// Asian pricing. Arithmetic fixed-strike Asians under plain GBM use the
731/// geometric average as a control variate: the same paths price both
732/// averages, the closed-form discrete geometric value corrects the
733/// difference, and the variance collapses because the two payoffs are
734/// highly correlated. Every other combination (geometric, floating strike,
735/// local vol, approximate schemes) prices through the generic path route.
736fn asian_npv(option: &EquityOption, asian: &AsianPayoff, p: &MarketParams) -> McStats {
737    let cfg = option.mc_cfg();
738    let use_control_variate = asian.averaging == AveragingType::Arithmetic
739        && asian.strike_type == AsianStrikeType::FixedStrike
740        && option.model == Model::Gbm
741        && cfg.scheme == DiscretizationScheme::Exact
742        && option.market.cash_dividends.is_empty();
743    if !use_control_variate {
744        return generic_path_npv(option, p);
745    }
746    let steps = effective_steps(cfg, &option.model).max(PATH_DEPENDENT_MIN_STEPS);
747    let dt = p.t / steps as f64;
748    let drift_dt = (p.r - p.q - 0.5 * p.sigma * p.sigma) * dt;
749    let df = exp(-p.r * p.t);
750    let draws = PathDraws::new(cfg.sampler, cfg.seed, steps, dt);
751    let (sum, sum_sq) = run_paths(cfg.paths, steps, &draws, |dw, _| {
752        let mut s = p.s0;
753        let mut sum_s = 0.0;
754        let mut log_sum = 0.0;
755        for d in dw {
756            s *= exp(drift_dt + p.sigma * d);
757            sum_s += s;
758            log_sum += s.ln();
759        }
760        let arithmetic = sum_s / steps as f64;
761        let geometric = (log_sum / steps as f64).exp();
762        df * (option.payoff.payoff(arithmetic, p.strike)
763            - option.payoff.payoff(geometric, p.strike))
764    });
765    let geo_closed = asian::geometric_asian_price(
766        p.s0,
767        p.strike,
768        p.r,
769        p.q,
770        p.sigma,
771        p.t,
772        Some(steps),
773        *option.payoff.put_or_call(),
774    );
775    stats(sum, sum_sq, cfg.paths, steps, geo_closed)
776}
777
778/// Barrier pricing with a Brownian-bridge crossing correction: each path
779/// carries a survival probability that accounts for the chance of touching
780/// the barrier *between* monitoring points, removing the O(sqrt(dt))
781/// discrete-monitoring bias and reducing variance (conditional Monte Carlo).
782fn barrier_npv(option: &EquityOption, barrier: &BarrierPayoff, p: &MarketParams) -> McStats {
783    let cfg = option.mc_cfg();
784    let steps = effective_steps(cfg, &option.model).max(PATH_DEPENDENT_MIN_STEPS);
785    let dt = p.t / steps as f64;
786    let down = barrier.direction == BarrierDirection::Down;
787    let out = barrier.knock == KnockType::Out;
788    let h = barrier.barrier;
789    let knocked_at_start = if down { p.s0 <= h } else { p.s0 >= h };
790    if knocked_at_start && out {
791        return McStats { pv: 0.0, std_err: 0.0, paths: cfg.paths, steps };
792    }
793    let df = exp(-p.r * p.t);
794    let process = bs_process(option, p);
795    let draws = PathDraws::new(cfg.sampler, cfg.seed, steps, dt);
796    let divs = dividends_per_step(option, p.t, steps);
797    let (sum, sum_sq) = run_paths(cfg.paths, steps, &draws, |dw, _| {
798        let mut s = p.s0;
799        let mut survival = if knocked_at_start { 0.0 } else { 1.0 };
800        for (i, d) in dw.iter().enumerate() {
801            // one vol lookup serves both the step and the bridge
802            // crossing probability below
803            let sigma = process.vol(s, i as f64 * dt);
804            let mut s_next = process.step_with_vol(cfg.scheme, i as f64 * dt, s, dt, *d, sigma);
805            if let Some(divs) = &divs {
806                s_next = (s_next - divs[i]).max(1e-8);
807            }
808            if survival > 0.0 {
809                let crossed = if down { s_next <= h } else { s_next >= h };
810                if crossed {
811                    survival = 0.0;
812                } else {
813                    // probability the bridge touched the barrier inside the step
814                    let (a, b) = if down {
815                        ((s / h).ln(), (s_next / h).ln())
816                    } else {
817                        ((h / s).ln(), (h / s_next).ln())
818                    };
819                    survival *= 1.0 - (-2.0 * a * b / (sigma * sigma * dt)).exp();
820                }
821            }
822            s = s_next;
823        }
824        let vanilla_leg = option.payoff.payoff(s, p.strike);
825        let weight = if out { survival } else { 1.0 - survival };
826        df * weight * vanilla_leg
827    });
828    stats(sum, sum_sq, cfg.paths, steps, 0.0)
829}
830
831/// Observation grid for an autocallable on a path of `steps` steps over
832/// life `t`: per-observation path indices and discount factors. Explicit
833/// `observation_times` (business-day adjusted call dates as year
834/// fractions) map to the nearest grid step and discount at their exact
835/// times; without them observations are equally spaced.
836fn observation_grid(
837    option: &EquityOption,
838    n_obs: usize,
839    observation_times: Option<&Vec<f64>>,
840    t: f64,
841    r: f64,
842    steps: usize,
843) -> (Vec<usize>, Vec<f64>) {
844    let dr = r - option.risk_free_rate();
845    let n_obs = n_obs.max(1);
846    let (obs_idx, obs_times): (Vec<usize>, Vec<f64>) = match observation_times {
847        Some(times) => {
848            let mut idx = Vec::with_capacity(times.len());
849            let mut prev: i64 = 0;
850            for &tm in times {
851                // nearest grid step, strictly increasing so no two
852                // observations collapse onto one step
853                let i = ((tm / t) * steps as f64).round().max(1.0) as i64;
854                let i = i.max(prev + 1).min(steps as i64);
855                idx.push(i as usize - 1);
856                prev = i;
857            }
858            (idx, times.clone())
859        }
860        None => {
861            let dt = t / steps as f64;
862            let idx: Vec<usize> = (1..=n_obs).map(|m| m * steps / n_obs - 1).collect();
863            let times = idx.iter().map(|&i| (i + 1) as f64 * dt).collect();
864            (idx, times)
865        }
866    };
867    let dfs = obs_times
868        .iter()
869        .map(|&tm| option.market.discount_curve.df(tm) * exp(-dr * tm))
870        .collect();
871    (obs_idx, dfs)
872}
873
874/// Autocallable valuation: cash flows land on their own call dates, so
875/// each path value is the redemption amount times the discount factor of
876/// its payment date (curve discount factors, shifted consistently under
877/// rho bumps). Steps are aligned so every observation falls exactly on a
878/// simulation step. Runs under GBM and local vol.
879fn autocall_npv(option: &EquityOption, auto: &AutocallablePayoff, p: &MarketParams) -> McStats {
880    let cfg = option.mc_cfg();
881    let n_obs = auto.observations.max(1);
882    let steps = effective_steps(cfg, &option.model).max(PATH_DEPENDENT_MIN_STEPS).div_ceil(n_obs) * n_obs;
883    let dt = p.t / steps as f64;
884    let (obs_idx, dfs) =
885        observation_grid(option, n_obs, auto.observation_times.as_ref(), p.t, p.r, steps);
886    let divs = dividends_per_step(option, p.t, steps);
887    let process = bs_process(option, p);
888    let draws = PathDraws::new(cfg.sampler, cfg.seed, steps, dt);
889    let (sum, sum_sq) = run_paths(cfg.paths, steps, &draws, |dw, path| {
890        path.clear();
891        let mut s = p.s0;
892        for (i, d) in dw.iter().enumerate() {
893            s = process.evolve(cfg.scheme, i as f64 * dt, s, dt, *d);
894            if let Some(divs) = &divs {
895                s = (s - divs[i]).max(1e-8);
896            }
897            path.push(s);
898        }
899        auto.path_value(path, &obs_idx, &dfs)
900    });
901    stats(sum, sum_sq, cfg.paths, steps, 0.0)
902}
903
904/// Accumulator valuation: daily accruals land on their own observation
905/// dates (each discounted on the option's curve), with the knock-out
906/// checked **discretely** at each observation — the contractual daily-
907/// close convention. Steps are aligned so every observation falls exactly
908/// on a simulation step. Runs under GBM and local vol; the Heston route
909/// lives in `heston_european_npv`.
910fn accumulator_npv(option: &EquityOption, accu: &AccumulatorPayoff, p: &MarketParams) -> McStats {
911    let cfg = option.mc_cfg();
912    let n_obs = accu.observations.max(1);
913    let steps = effective_steps(cfg, &option.model).max(PATH_DEPENDENT_MIN_STEPS).div_ceil(n_obs) * n_obs;
914    let dt = p.t / steps as f64;
915    let (obs_idx, dfs) = observation_grid(option, n_obs, None, p.t, p.r, steps);
916    let divs = dividends_per_step(option, p.t, steps);
917    let process = bs_process(option, p);
918    let draws = PathDraws::new(cfg.sampler, cfg.seed, steps, dt);
919    let (sum, sum_sq) = run_paths(cfg.paths, steps, &draws, |dw, path| {
920        path.clear();
921        let mut s = p.s0;
922        for (i, d) in dw.iter().enumerate() {
923            s = process.evolve(cfg.scheme, i as f64 * dt, s, dt, *d);
924            if let Some(divs) = &divs {
925                s = (s - divs[i]).max(1e-8);
926            }
927            path.push(s);
928        }
929        accu.path_value(path, &obs_idx, &dfs, p.strike)
930    });
931    stats(sum, sum_sq, cfg.paths, steps, 0.0)
932}
933
934// ── Heston stochastic volatility paths ──────────────────────────────────
935
936/// Heston simulation on seeded per-path pseudo-random streams. The
937/// default (`Exact`) scheme selects Andersen QE with martingale
938/// correction; `mc_scheme: euler`/`milstein` select full-truncation
939/// Euler. Vega bumps map to a parallel shift of the instantaneous and
940/// long-run vol.
941fn heston_european_npv(option: &EquityOption, p: &MarketParams) -> McStats {
942    let hp = option.heston_params().with_vol_shift(p.sigma - option.volatility());
943    let cfg = option.mc_cfg();
944    // same monitoring floor as the Black-Scholes path routes — under the
945    // QE floor of 25 steps a path-dependent payoff (fixing dates, barrier
946    // monitoring) would otherwise land on too coarse a grid
947    let steps = if option.payoff.is_path_dependent() {
948        effective_steps(cfg, &option.model).max(PATH_DEPENDENT_MIN_STEPS)
949    } else {
950        effective_steps(cfg, &option.model)
951    };
952    let dt = p.t / steps as f64;
953    let df = exp(-p.r * p.t);
954
955    if let Some(barrier) = option.payoff.as_any().downcast_ref::<BarrierPayoff>() {
956        let down = barrier.direction == BarrierDirection::Down;
957        let out = barrier.knock == KnockType::Out;
958        let h = barrier.barrier;
959        let knocked_at_start = if down { p.s0 <= h } else { p.s0 >= h };
960        if knocked_at_start && out {
961            return McStats { pv: 0.0, std_err: 0.0, paths: cfg.paths, steps };
962        }
963        let (sum, sum_sq) = run_heston_paths(option, p, &hp, steps, dt, |spots, vols| {
964            let mut survival = if knocked_at_start { 0.0 } else { 1.0 };
965            let mut s_prev = p.s0;
966            for (i, &s_next) in spots.iter().enumerate() {
967                if survival > 0.0 {
968                    let crossed = if down { s_next <= h } else { s_next >= h };
969                    if crossed {
970                        survival = 0.0;
971                    } else {
972                        let (a, b) = if down {
973                            ((s_prev / h).ln(), (s_next / h).ln())
974                        } else {
975                            ((h / s_prev).ln(), (h / s_next).ln())
976                        };
977                        let sigma = vols[i].max(1e-8);
978                        survival *= 1.0 - (-2.0 * a * b / (sigma * sigma * dt)).exp();
979                    }
980                }
981                s_prev = s_next;
982            }
983            let weight = if out { survival } else { 1.0 - survival };
984            df * weight * option.payoff.payoff(s_prev, p.strike)
985        });
986        return stats(sum, sum_sq, cfg.paths, steps, 0.0);
987    }
988
989    if let Some(auto) = option.payoff.as_any().downcast_ref::<AutocallablePayoff>() {
990        let n_obs = auto.observations.max(1);
991        let steps = steps.div_ceil(n_obs) * n_obs;
992        let dt = p.t / steps as f64;
993        let (obs_idx, dfs) =
994            observation_grid(option, n_obs, auto.observation_times.as_ref(), p.t, p.r, steps);
995        let (sum, sum_sq) = run_heston_paths(option, p, &hp, steps, dt, |spots, _| {
996            auto.path_value(spots, &obs_idx, &dfs)
997        });
998        return stats(sum, sum_sq, cfg.paths, steps, 0.0);
999    }
1000
1001    if let Some(accu) = option.payoff.as_any().downcast_ref::<AccumulatorPayoff>() {
1002        let n_obs = accu.observations.max(1);
1003        let steps = steps.div_ceil(n_obs) * n_obs;
1004        let dt = p.t / steps as f64;
1005        let (obs_idx, dfs) = observation_grid(option, n_obs, None, p.t, p.r, steps);
1006        let (sum, sum_sq) = run_heston_paths(option, p, &hp, steps, dt, |spots, _| {
1007            accu.path_value(spots, &obs_idx, &dfs, p.strike)
1008        });
1009        return stats(sum, sum_sq, cfg.paths, steps, 0.0);
1010    }
1011
1012    let path_dependent = option.payoff.is_path_dependent();
1013    let (sum, sum_sq) = run_heston_paths(option, p, &hp, steps, dt, |spots, _| {
1014        let v = if path_dependent {
1015            option.payoff.path_payoff(spots, p.strike)
1016        } else {
1017            option.payoff.payoff(*spots.last().unwrap(), p.strike)
1018        };
1019        df * v
1020    });
1021    stats(sum, sum_sq, cfg.paths, steps, 0.0)
1022}
1023
1024/// Parallel Heston path generation through the two-factor
1025/// [`HestonProcess`] (Andersen QE-M under the default `Exact` scheme,
1026/// full-truncation Euler otherwise): `eval(spots, vols)` receives the
1027/// path's spot levels and the per-step vols (`sqrt(v_t)` entering each
1028/// step).
1029fn run_heston_paths<F>(
1030    option: &EquityOption,
1031    p: &MarketParams,
1032    hp: &HestonParams,
1033    steps: usize,
1034    dt: f64,
1035    eval: F,
1036) -> (f64, f64)
1037where
1038    F: Fn(&[f64], &[f64]) -> f64 + Sync,
1039{
1040    let cfg = option.mc_cfg();
1041    // Exact requests the best transition sampling available => Andersen
1042    // QE-M; Euler/Milstein keep the plain full-truncation Euler stepping
1043    let scheme = match cfg.scheme {
1044        DiscretizationScheme::Exact => HestonScheme::QuadraticExponential,
1045        _ => HestonScheme::FullTruncation,
1046    };
1047    let process = HestonProcess { drift_rate: p.r - p.q, params: *hp, scheme };
1048    let sqrt_dt = dt.sqrt();
1049    let divs = dividends_per_step(option, p.t, steps);
1050    let chunks = cfg.paths.div_ceil(PATH_CHUNK);
1051    let partials: Vec<(f64, f64)> = (0..chunks)
1052        .into_par_iter()
1053        .map(|chunk| {
1054            let mut z = vec![0.0; 2 * steps];
1055            let mut spots = vec![0.0; steps];
1056            let mut vols = vec![0.0; steps];
1057            let (mut sum, mut sum_sq) = (0.0, 0.0);
1058            for i in chunk * PATH_CHUNK..((chunk + 1) * PATH_CHUNK).min(cfg.paths) {
1059                // antithetic pairs share a stream with negated draws
1060                path_normals(cfg.seed, (i / 2) as u64, &mut z);
1061                let sign = if i % 2 == 0 { 1.0 } else { -1.0 };
1062                let mut x = [p.s0, hp.v0];
1063                let mut x_next = [0.0; 2];
1064                for j in 0..steps {
1065                    // independent increments; the process applies rho
1066                    let dw = [sign * sqrt_dt * z[2 * j], sign * sqrt_dt * z[2 * j + 1]];
1067                    vols[j] = x[1].max(0.0).sqrt();
1068                    process.evolve(j as f64 * dt, &x, dt, &dw, &mut x_next);
1069                    if let Some(divs) = &divs {
1070                        x_next[0] = (x_next[0] - divs[j]).max(1e-8);
1071                    }
1072                    x = x_next;
1073                    spots[j] = x[0];
1074                }
1075                let value = eval(&spots, &vols);
1076                sum += value;
1077                sum_sq += value * value;
1078            }
1079            (sum, sum_sq)
1080        })
1081        .collect();
1082    partials.into_iter().fold((0.0, 0.0), |a, b| (a.0 + b.0, a.1 + b.1))
1083}
1084
1085// ── American: two-pass Longstaff-Schwartz ───────────────────────────────
1086
1087const LSMC_DEFAULT_STEPS: usize = 50;
1088const LSMC_BASIS: usize = 4;
1089
1090/// Basis functions for the continuation-value regression: cubic in the
1091/// normalized spot. (An "include the payoff" basis is exactly collinear
1092/// with `[1, x]` for vanilla payoffs on in-the-money paths, so the cubic
1093/// term is the safe way to add flexibility.)
1094fn lsmc_basis(x: f64) -> [f64; LSMC_BASIS] {
1095    [1.0, x, x * x, x * x * x]
1096}
1097
1098/// Exercise rights per path index k (spot at time (k+1)dt): every step
1099/// for American, only mapped steps for Bermudan.
1100fn exercise_mask(option: &EquityOption, t: f64, steps: usize) -> Vec<bool> {
1101    match option.payoff.exercise_style() {
1102        ContractStyle::Bermudan(times) => {
1103            let mut mask = vec![false; steps.saturating_sub(1)];
1104            for g in crate::core::utils::times_to_grid_steps(times, t, steps) {
1105                if g < steps {
1106                    mask[g - 1] = true;
1107                }
1108            }
1109            mask
1110        }
1111        _ => vec![true; steps.saturating_sub(1)],
1112    }
1113}
1114
1115/// Two-pass least-squares Monte Carlo (Longstaff-Schwartz):
1116/// pass 1 fits the per-date continuation-value regressions on one set of
1117/// paths; pass 2 applies the fitted exercise rule to an independent set,
1118/// which removes the foresight (in-sample) bias of single-pass LSMC.
1119/// Always uses pseudo-random per-path streams. Heston takes its own
1120/// route ([`heston_american_npv`]): the exercise decision there depends
1121/// on the variance state, so both the paths and the regression basis are
1122/// two-dimensional.
1123fn american_npv(option: &EquityOption, p: &MarketParams) -> McStats {
1124    let cfg = option.mc_cfg();
1125    if option.model.is_heston() {
1126        return heston_american_npv(option, p);
1127    }
1128    let steps = if cfg.time_steps > 1 { cfg.time_steps } else { LSMC_DEFAULT_STEPS }
1129        .max(if option.model == Model::LocalVol { LOCAL_VOL_MIN_STEPS } else { 1 });
1130    let dt = p.t / steps as f64;
1131    let allowed = exercise_mask(option, p.t, steps);
1132    let disc = exp(-p.r * dt);
1133    let process = bs_process(option, p);
1134    let seed_regression = cfg.seed ^ 0xA11C_E5ED;
1135    let seed_valuation = cfg.seed ^ 0xB0B5_1EED;
1136
1137    let simulate = |draws: &PathDraws, index: usize, bufs: &mut (Vec<f64>, Vec<f64>, Vec<f64>), path: &mut Vec<f64>| {
1138        let (z, w, dw) = bufs;
1139        draws.fill(index, z, w, dw);
1140        path.clear();
1141        let mut s = p.s0;
1142        for (i, d) in dw.iter().enumerate() {
1143            s = process.evolve(cfg.scheme, i as f64 * dt, s, dt, *d);
1144            path.push(s);
1145        }
1146    };
1147
1148    // ── pass 1: simulate and fit regressions backwards
1149    let reg_draws = PathDraws::pseudo(seed_regression, dt);
1150    let spots: Vec<Vec<f64>> = (0..cfg.paths)
1151        .into_par_iter()
1152        .map_init(
1153            || (vec![0.0; steps], vec![0.0; steps], vec![0.0; steps]),
1154            |bufs, i| {
1155                let mut path = Vec::with_capacity(steps);
1156                simulate(&reg_draws, i, bufs, &mut path);
1157                path
1158            },
1159        )
1160        .collect();
1161
1162    let mut cashflow: Vec<f64> =
1163        spots.iter().map(|path| option.payoff.payoff(path[steps - 1], p.strike)).collect();
1164    let mut betas: Vec<Option<[f64; LSMC_BASIS]>> = vec![None; steps.saturating_sub(1)];
1165    for step_idx in (0..steps - 1).rev() {
1166        for cf in cashflow.iter_mut() {
1167            *cf *= disc;
1168        }
1169        if !allowed[step_idx] {
1170            // no exercise right at this date: continuation only, no
1171            // regression fitted, so pass 2 cannot exercise here either
1172            continue;
1173        }
1174        let itm: Vec<usize> = (0..spots.len())
1175            .filter(|&i| option.payoff.payoff(spots[i][step_idx], p.strike) > 0.0)
1176            .collect();
1177        if itm.len() < LSMC_BASIS {
1178            continue;
1179        }
1180        let rows: Vec<([f64; LSMC_BASIS], f64)> = itm
1181            .iter()
1182            .map(|&i| {
1183                let s = spots[i][step_idx];
1184                (lsmc_basis(s / p.s0), cashflow[i])
1185            })
1186            .collect();
1187        let Some(beta) = least_squares(&rows) else { continue };
1188        for &i in &itm {
1189            let s = spots[i][step_idx];
1190            let pay = option.payoff.payoff(s, p.strike);
1191            let continuation = dot(&beta, &lsmc_basis(s / p.s0));
1192            if pay > continuation {
1193                cashflow[i] = pay;
1194            }
1195        }
1196        betas[step_idx] = Some(beta);
1197    }
1198    drop(spots);
1199    drop(cashflow);
1200
1201    // ── pass 2: apply the fitted exercise rule to independent paths
1202    let val_draws = PathDraws::pseudo(seed_valuation, dt);
1203    let partials: Vec<(f64, f64)> = (0..cfg.paths.div_ceil(PATH_CHUNK))
1204        .into_par_iter()
1205        .map(|chunk| {
1206            let mut bufs = (vec![0.0; steps], vec![0.0; steps], vec![0.0; steps]);
1207            let mut path = Vec::with_capacity(steps);
1208            let (mut c_sum, mut c_sum_sq) = (0.0, 0.0);
1209            for i in chunk * PATH_CHUNK..((chunk + 1) * PATH_CHUNK).min(cfg.paths) {
1210                simulate(&val_draws, i, &mut bufs, &mut path);
1211                let mut value = 0.0;
1212                let mut exercised = false;
1213                for k in 0..steps - 1 {
1214                    let s = path[k];
1215                    let pay = option.payoff.payoff(s, p.strike);
1216                    if pay > 0.0 {
1217                        if let Some(beta) = &betas[k] {
1218                            let continuation = dot(beta, &lsmc_basis(s / p.s0));
1219                            if pay > continuation {
1220                                value = pay * disc.powi(k as i32 + 1);
1221                                exercised = true;
1222                                break;
1223                            }
1224                        }
1225                    }
1226                }
1227                if !exercised {
1228                    value = option.payoff.payoff(path[steps - 1], p.strike)
1229                        * disc.powi(steps as i32);
1230                }
1231                c_sum += value;
1232                c_sum_sq += value * value;
1233            }
1234            (c_sum, c_sum_sq)
1235        })
1236        .collect();
1237    let (sum, sum_sq) = partials.into_iter().fold((0.0, 0.0), |a, b| (a.0 + b.0, a.1 + b.1));
1238    stats(sum, sum_sq, cfg.paths, steps, 0.0)
1239}
1240
1241// ── American under Heston ───────────────────────────────────────────────
1242
1243const HESTON_LSMC_BASIS: usize = 6;
1244
1245/// Regression basis over the two-dimensional Heston state: cubic in the
1246/// normalized spot plus the variance level and its spot cross term — the
1247/// continuation value of an American option under stochastic vol depends
1248/// on how much volatility is left, not just on where the spot is.
1249fn heston_lsmc_basis(x: f64, v: f64) -> [f64; HESTON_LSMC_BASIS] {
1250    [1.0, x, x * x, x * x * x, v, x * v]
1251}
1252
1253/// Two-pass Longstaff-Schwartz under Heston dynamics, stepping the
1254/// two-factor [`HestonProcess`] (Andersen QE-M under the default `Exact`
1255/// scheme, full-truncation Euler otherwise) and regressing on the
1256/// `(spot, variance)` state. Same structure as [`american_npv`]:
1257/// regression pass on one set of antithetic pseudo-random paths,
1258/// valuation pass on an independent set. QE's coarse-grid accuracy is
1259/// what makes this affordable — the exercise grid (default
1260/// [`LSMC_DEFAULT_STEPS`]) is all the resolution it needs, where
1261/// full-truncation Euler must step at [`HESTON_MIN_STEPS`].
1262fn heston_american_npv(option: &EquityOption, p: &MarketParams) -> McStats {
1263    let hp = option.heston_params().with_vol_shift(p.sigma - option.volatility());
1264    let cfg = option.mc_cfg();
1265    let scheme = match cfg.scheme {
1266        DiscretizationScheme::Exact => HestonScheme::QuadraticExponential,
1267        _ => HestonScheme::FullTruncation,
1268    };
1269    let floor = match scheme {
1270        HestonScheme::QuadraticExponential => HESTON_QE_MIN_STEPS,
1271        HestonScheme::FullTruncation => HESTON_MIN_STEPS,
1272    };
1273    let steps = if cfg.time_steps > 1 { cfg.time_steps } else { LSMC_DEFAULT_STEPS }.max(floor);
1274    let dt = p.t / steps as f64;
1275    let allowed = exercise_mask(option, p.t, steps);
1276    let disc = exp(-p.r * dt);
1277    let process = HestonProcess { drift_rate: p.r - p.q, params: hp, scheme };
1278    let sqrt_dt = dt.sqrt();
1279    let seed_regression = cfg.seed ^ 0xA11C_E5ED;
1280    let seed_valuation = cfg.seed ^ 0xB0B5_1EED;
1281
1282    // fill one path's spot and (truncated) variance levels; antithetic
1283    // pairs (2k, 2k+1) share a stream with negated draws, as everywhere
1284    let simulate = |seed: u64, i: usize, z: &mut [f64], spots: &mut [f64], vars: &mut [f64]| {
1285        path_normals(seed, (i / 2) as u64, z);
1286        let sign = if i % 2 == 0 { 1.0 } else { -1.0 };
1287        let mut x = [p.s0, hp.v0];
1288        let mut x_next = [0.0; 2];
1289        for j in 0..steps {
1290            let dw = [sign * sqrt_dt * z[2 * j], sign * sqrt_dt * z[2 * j + 1]];
1291            process.evolve(j as f64 * dt, &x, dt, &dw, &mut x_next);
1292            x = x_next;
1293            spots[j] = x[0];
1294            vars[j] = x[1].max(0.0);
1295        }
1296    };
1297
1298    // ── pass 1: simulate (S, v) paths and fit regressions backwards
1299    let paths_sv: Vec<(Vec<f64>, Vec<f64>)> = (0..cfg.paths)
1300        .into_par_iter()
1301        .map_init(
1302            || vec![0.0; 2 * steps],
1303            |z, i| {
1304                let mut spots = vec![0.0; steps];
1305                let mut vars = vec![0.0; steps];
1306                simulate(seed_regression, i, z, &mut spots, &mut vars);
1307                (spots, vars)
1308            },
1309        )
1310        .collect();
1311
1312    let mut cashflow: Vec<f64> = paths_sv
1313        .iter()
1314        .map(|(spots, _)| option.payoff.payoff(spots[steps - 1], p.strike))
1315        .collect();
1316    let mut betas: Vec<Option<[f64; HESTON_LSMC_BASIS]>> = vec![None; steps.saturating_sub(1)];
1317    for step_idx in (0..steps - 1).rev() {
1318        for cf in cashflow.iter_mut() {
1319            *cf *= disc;
1320        }
1321        if !allowed[step_idx] {
1322            continue;
1323        }
1324        let itm: Vec<usize> = (0..paths_sv.len())
1325            .filter(|&i| option.payoff.payoff(paths_sv[i].0[step_idx], p.strike) > 0.0)
1326            .collect();
1327        if itm.len() < HESTON_LSMC_BASIS {
1328            continue;
1329        }
1330        let rows: Vec<([f64; HESTON_LSMC_BASIS], f64)> = itm
1331            .iter()
1332            .map(|&i| {
1333                let (spots, vars) = &paths_sv[i];
1334                (heston_lsmc_basis(spots[step_idx] / p.s0, vars[step_idx]), cashflow[i])
1335            })
1336            .collect();
1337        let Some(beta) = least_squares(&rows) else { continue };
1338        for &i in &itm {
1339            let (spots, vars) = &paths_sv[i];
1340            let s = spots[step_idx];
1341            let pay = option.payoff.payoff(s, p.strike);
1342            let continuation = dot(&beta, &heston_lsmc_basis(s / p.s0, vars[step_idx]));
1343            if pay > continuation {
1344                cashflow[i] = pay;
1345            }
1346        }
1347        betas[step_idx] = Some(beta);
1348    }
1349    drop(paths_sv);
1350    drop(cashflow);
1351
1352    // ── pass 2: apply the fitted exercise rule to independent paths
1353    let partials: Vec<(f64, f64)> = (0..cfg.paths.div_ceil(PATH_CHUNK))
1354        .into_par_iter()
1355        .map(|chunk| {
1356            let mut z = vec![0.0; 2 * steps];
1357            let mut spots = vec![0.0; steps];
1358            let mut vars = vec![0.0; steps];
1359            let (mut c_sum, mut c_sum_sq) = (0.0, 0.0);
1360            for i in chunk * PATH_CHUNK..((chunk + 1) * PATH_CHUNK).min(cfg.paths) {
1361                simulate(seed_valuation, i, &mut z, &mut spots, &mut vars);
1362                let mut value = 0.0;
1363                let mut exercised = false;
1364                for k in 0..steps - 1 {
1365                    let pay = option.payoff.payoff(spots[k], p.strike);
1366                    if pay > 0.0 {
1367                        if let Some(beta) = &betas[k] {
1368                            let continuation =
1369                                dot(beta, &heston_lsmc_basis(spots[k] / p.s0, vars[k]));
1370                            if pay > continuation {
1371                                value = pay * disc.powi(k as i32 + 1);
1372                                exercised = true;
1373                                break;
1374                            }
1375                        }
1376                    }
1377                }
1378                if !exercised {
1379                    value = option.payoff.payoff(spots[steps - 1], p.strike)
1380                        * disc.powi(steps as i32);
1381                }
1382                c_sum += value;
1383                c_sum_sq += value * value;
1384            }
1385            (c_sum, c_sum_sq)
1386        })
1387        .collect();
1388    let (sum, sum_sq) = partials.into_iter().fold((0.0, 0.0), |a, b| (a.0 + b.0, a.1 + b.1));
1389    stats(sum, sum_sq, cfg.paths, steps, 0.0)
1390}
1391
1392fn dot<const K: usize>(a: &[f64; K], b: &[f64; K]) -> f64 {
1393    a.iter().zip(b).map(|(x, y)| x * y).sum()
1394}
1395
1396/// Least squares via the normal equations with partial-pivot Gaussian
1397/// elimination; None if (near-)singular. Generic over the basis size so
1398/// the one-dimensional (spot) and Heston (spot, variance) regressions
1399/// share it.
1400fn least_squares<const K: usize>(rows: &[([f64; K], f64)]) -> Option<[f64; K]> {
1401    let mut m = [[0.0; K]; K];
1402    let mut rhs = [0.0; K];
1403    for (basis, y) in rows {
1404        for i in 0..K {
1405            for j in 0..K {
1406                m[i][j] += basis[i] * basis[j];
1407            }
1408            rhs[i] += basis[i] * y;
1409        }
1410    }
1411    for col in 0..K {
1412        let pivot =
1413            (col..K).max_by(|&i, &j| m[i][col].abs().partial_cmp(&m[j][col].abs()).unwrap())?;
1414        if m[pivot][col].abs() < 1e-10 {
1415            return None;
1416        }
1417        m.swap(col, pivot);
1418        rhs.swap(col, pivot);
1419        for row in col + 1..K {
1420            let f = m[row][col] / m[col][col];
1421            for c in col..K {
1422                m[row][c] -= f * m[col][c];
1423            }
1424            rhs[row] -= f * rhs[col];
1425        }
1426    }
1427    let mut beta = [0.0; K];
1428    for row in (0..K).rev() {
1429        let mut acc = rhs[row];
1430        for c in row + 1..K {
1431            acc -= m[row][c] * beta[c];
1432        }
1433        beta[row] = acc / m[row][row];
1434    }
1435    Some(beta)
1436}
1437