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.
18//! - [`npv_with_stats`] reports the standard error alongside the price.
19//! - Greeks by central-difference bump-and-reprice with common random
20//!   numbers (deterministic draws make every reprice use identical paths).
21//!
22//! Multi-factor models (stochastic vol) plug in by widening the per-step
23//! draw dimension and adding a second bridge; the per-path stream and
24//! stepping structure is factor-agnostic.
25
26use std::io;
27use std::str::FromStr;
28use chrono::{Local, NaiveDate};
29use libm::exp;
30use rayon::prelude::*;
31
32use crate::core::utils::ContractStyle;
33use super::asian::{self, AsianStrikeType, AveragingType};
34use super::autocallable::AutocallablePayoff;
35use super::barrier::{BarrierDirection, KnockType};
36use super::heston::HestonParams;
37use super::local_vol::LocalVol;
38use super::vanila_option::{AsianPayoff, BarrierPayoff, EquityOption, EquityOptionBase, VanillaPayoff};
39use super::utils::{Engine, LongShort, Payoff};
40use crate::core::trade::PutOrCall;
41use crate::utils::RNG::{
42    path_normals, pseudo_normals, sobol_normals, BrownianBridge, QmcSequence,
43};
44use crate::core::quotes::Quote;
45use crate::core::curves::{Compounding, YieldCurve};
46use crate::core::daycount::DayCountConvention;
47use crate::core::data_models::EquityOptionData;
48use crate::core::vols::VolSurface;
49use crate::core::traits::Instrument;
50
51/// Time-stepping scheme for path-wise simulation.
52/// `Exact` samples the closed-form GBM transition (no discretization bias);
53/// Euler and Milstein are the standard approximate schemes (the basis for
54/// models without closed-form transitions, e.g. local vol / Heston).
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum DiscretizationScheme {
57    Exact,
58    Euler,
59    Milstein,
60}
61
62impl FromStr for DiscretizationScheme {
63    type Err = String;
64    fn from_str(s: &str) -> Result<Self, Self::Err> {
65        match s.trim().to_lowercase().as_str() {
66            "exact" => Ok(DiscretizationScheme::Exact),
67            "euler" => Ok(DiscretizationScheme::Euler),
68            "milstein" => Ok(DiscretizationScheme::Milstein),
69            other => Err(format!("Invalid discretization scheme '{other}'")),
70        }
71    }
72}
73
74/// Draw sampler. `Sobol` selects the low-discrepancy family: true Sobol
75/// (van der Corput) in one dimension, a scrambled multi-dimensional
76/// sequence through a Brownian bridge for path-wise simulation.
77/// `PseudoRandom` uses seeded per-path PCG64 streams with antithetic
78/// pairing. Longstaff-Schwarz always uses pseudo-random streams.
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum Sampler {
81    Sobol,
82    PseudoRandom,
83}
84
85impl FromStr for Sampler {
86    type Err = String;
87    fn from_str(s: &str) -> Result<Self, Self::Err> {
88        match s.trim().to_lowercase().as_str() {
89            "sobol" | "quasi" => Ok(Sampler::Sobol),
90            "pseudo" | "pseudorandom" | "pseudo_random" => Ok(Sampler::PseudoRandom),
91            other => Err(format!("Invalid sampler '{other}'")),
92        }
93    }
94}
95
96/// Dynamics used for path generation. `Gbm` diffuses at the option's own
97/// (constant) implied vol; `LocalVol` diffuses at the Dupire local
98/// volatility calibrated from the option's vol surface.
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum McModel {
101    Gbm,
102    LocalVol,
103    Heston,
104}
105
106impl FromStr for McModel {
107    type Err = String;
108    fn from_str(s: &str) -> Result<Self, Self::Err> {
109        match s.trim().to_lowercase().as_str() {
110            "gbm" | "blackscholes" | "bs" => Ok(McModel::Gbm),
111            "local_vol" | "localvol" | "lv" => Ok(McModel::LocalVol),
112            "heston" => Ok(McModel::Heston),
113            other => Err(format!("Invalid mc_model '{other}'")),
114        }
115    }
116}
117
118#[derive(Debug, Clone, Copy)]
119pub struct MonteCarloConfig {
120    pub paths: usize,
121    /// 1 = terminal simulation (exact); > 1 = path-wise stepping.
122    /// Local vol always steps path-wise (at least [`LOCAL_VOL_MIN_STEPS`]).
123    pub time_steps: usize,
124    pub scheme: DiscretizationScheme,
125    pub sampler: Sampler,
126    pub model: McModel,
127    pub seed: u64,
128}
129
130pub const LOCAL_VOL_MIN_STEPS: usize = 100;
131pub const HESTON_MIN_STEPS: usize = 250;
132/// Minimum monitoring steps for path-dependent payoffs.
133pub const PATH_DEPENDENT_MIN_STEPS: usize = 100;
134
135impl Default for MonteCarloConfig {
136    fn default() -> Self {
137        MonteCarloConfig {
138            paths: 100_000,
139            time_steps: 1,
140            scheme: DiscretizationScheme::Exact,
141            sampler: Sampler::Sobol,
142            model: McModel::Gbm,
143            seed: 42,
144        }
145    }
146}
147
148impl MonteCarloConfig {
149    pub fn from_data(data: &EquityOptionData) -> Self {
150        let defaults = MonteCarloConfig::default();
151        let scheme = data
152            .mc_scheme
153            .as_deref()
154            .map(|s| s.parse::<DiscretizationScheme>().expect("Invalid mc_scheme"))
155            .unwrap_or(defaults.scheme);
156        // approximate schemes need real time-stepping to mean anything
157        let default_steps = match scheme {
158            DiscretizationScheme::Exact => 1,
159            _ => 252,
160        };
161        let model = data
162            .mc_model
163            .as_deref()
164            .map(|s| s.parse::<McModel>().expect("Invalid mc_model"))
165            .unwrap_or(defaults.model);
166        MonteCarloConfig {
167            paths: data.simulation.unwrap_or(defaults.paths as u64) as usize,
168            time_steps: data.mc_time_steps.unwrap_or(default_steps),
169            scheme,
170            sampler: data
171                .mc_sampler
172                .as_deref()
173                .map(|s| s.parse::<Sampler>().expect("Invalid mc_sampler"))
174                .unwrap_or(defaults.sampler),
175            model,
176            seed: data.mc_seed.unwrap_or(defaults.seed),
177        }
178    }
179}
180
181/// Price with sampling diagnostics.
182///
183/// `std_err` is the standard error of the mean over paths. For the
184/// low-discrepancy sampler the points are not independent, so treat it as
185/// an indicative scale rather than a rigorous confidence bound; for the
186/// LSMC it reflects valuation-pass noise only (not regression uncertainty).
187#[derive(Debug, Clone, Copy)]
188pub struct McStats {
189    pub pv: f64,
190    pub std_err: f64,
191    pub paths: usize,
192    pub steps: usize,
193}
194
195fn stats(sum: f64, sum_sq: f64, n: usize, steps: usize, offset: f64) -> McStats {
196    let nf = n as f64;
197    let mean = sum / nf;
198    let var = (sum_sq / nf - mean * mean).max(0.0);
199    McStats { pv: mean + offset, std_err: (var / nf).sqrt(), paths: n, steps }
200}
201
202/// Market inputs snapshot; Greeks bump these fields and reprice with the
203/// same draws (common random numbers).
204#[derive(Debug, Clone, Copy)]
205struct MarketParams {
206    s0: f64,
207    strike: f64,
208    r: f64,
209    q: f64,
210    sigma: f64,
211    t: f64,
212}
213
214fn market_params(option: &EquityOption) -> MarketParams {
215    MarketParams {
216        s0: option.base.underlying_price.value(),
217        strike: option.base.strike_price,
218        r: option.base.risk_free_rate(),
219        q: option.base.carry_yield(),
220        sigma: option.base.volatility(),
221        t: option.time_to_maturity(),
222    }
223}
224
225/// Cash dividend amounts bucketed per simulation step (None if there are
226/// none): path simulation subtracts them at the ex-date step.
227fn dividends_per_step(option: &EquityOption, t: f64, steps: usize) -> Option<Vec<f64>> {
228    if option.base.cash_dividends.is_empty() {
229        return None;
230    }
231    let dt = t / steps as f64;
232    let mut buckets = vec![0.0; steps];
233    for (date, amount) in &option.base.cash_dividends {
234        let td = (*date - option.base.valuation_date).num_days() as f64 / 365.0;
235        if td > 0.0 && td <= t {
236            let idx = (((td / dt).ceil() as usize).max(1) - 1).min(steps - 1);
237            buckets[idx] += amount;
238        }
239    }
240    Some(buckets)
241}
242
243/// Escrowed-model spot consistent with the bumped market params: rho bumps
244/// shift the dividend discounting, delta bumps move the raw spot.
245///
246/// Cash dividends are discounted at the net carry `r - carry` (here
247/// `p.r - p.q`, `p.q` being the total carry), matching the analytic engine
248/// and the jump-model forward; see
249/// [`EquityOptionBase::pv_cash_dividends`](super::vanila_option::EquityOptionBase::pv_cash_dividends).
250fn escrowed_spot(option: &EquityOption, p: &MarketParams) -> f64 {
251    let dr = p.r - option.base.risk_free_rate();
252    let mut pv = 0.0;
253    for (date, amount) in &option.base.cash_dividends {
254        let td = (*date - option.base.valuation_date).num_days() as f64 / 365.0;
255        if td > 0.0 && td <= p.t {
256            // df(td) e^{-dr td} discounts at the bumped rate p.r;
257            // e^{p.q td} moves it to the net carry (p.r - p.q).
258            pv += amount * option.base.discount_curve.df(td) * ((p.q - dr) * td).exp();
259        }
260    }
261    p.s0 - pv
262}
263
264pub fn npv(option: &EquityOption) -> f64 {
265    npv_with_stats(option).pv
266}
267
268/// Price with standard error and simulation diagnostics.
269pub fn npv_with_stats(option: &EquityOption) -> McStats {
270    assert!(option.base.volatility() >= 0.0);
271    assert!(option.base.time_to_maturity() >= 0.0);
272    assert!(option.base.underlying_price.value >= 0.0);
273    price(option, &market_params(option))
274}
275
276fn price(option: &EquityOption, p: &MarketParams) -> McStats {
277    match option.payoff.exercise_style() {
278        ContractStyle::American => american_npv(option, p),
279        _ => european_npv(option, p),
280    }
281}
282
283// ── Greeks: central-difference bumps with common random numbers ─────────
284
285pub fn delta(option: &EquityOption) -> f64 {
286    let p = market_params(option);
287    let h = p.s0 * 0.01;
288    (price(option, &MarketParams { s0: p.s0 + h, ..p }).pv
289        - price(option, &MarketParams { s0: p.s0 - h, ..p }).pv)
290        / (2.0 * h)
291}
292
293pub fn gamma(option: &EquityOption) -> f64 {
294    let p = market_params(option);
295    let h = p.s0 * 0.01;
296    (price(option, &MarketParams { s0: p.s0 + h, ..p }).pv - 2.0 * price(option, &p).pv
297        + price(option, &MarketParams { s0: p.s0 - h, ..p }).pv)
298        / (h * h)
299}
300
301pub fn vega(option: &EquityOption) -> f64 {
302    let p = market_params(option);
303    let h = 0.01;
304    (price(option, &MarketParams { sigma: p.sigma + h, ..p }).pv
305        - price(option, &MarketParams { sigma: p.sigma - h, ..p }).pv)
306        / (2.0 * h)
307}
308
309pub fn theta(option: &EquityOption) -> f64 {
310    // theta = dV/dt (calendar) = -dV/dT
311    let p = market_params(option);
312    let h = (1.0 / 365.0_f64).min(0.5 * p.t);
313    -(price(option, &MarketParams { t: p.t + h, ..p }).pv
314        - price(option, &MarketParams { t: p.t - h, ..p }).pv)
315        / (2.0 * h)
316}
317
318pub fn rho(option: &EquityOption) -> f64 {
319    let p = market_params(option);
320    let h = 1e-4;
321    (price(option, &MarketParams { r: p.r + h, ..p }).pv
322        - price(option, &MarketParams { r: p.r - h, ..p }).pv)
323        / (2.0 * h)
324}
325
326// ── Volatility dynamics along a path ────────────────────────────────────
327
328enum PathVol<'a> {
329    Const(f64),
330    Local(LocalVol<'a>),
331}
332
333impl PathVol<'_> {
334    fn vol(&self, s: f64, t: f64) -> f64 {
335        match self {
336            PathVol::Const(v) => *v,
337            PathVol::Local(lv) => lv.vol(s, t),
338        }
339    }
340}
341
342fn path_vol<'a>(option: &'a EquityOption, p: &MarketParams) -> PathVol<'a> {
343    match option.mc.model {
344        McModel::Gbm => PathVol::Const(p.sigma),
345        McModel::LocalVol => PathVol::Local(LocalVol::new(
346            &option.base.vol_surface,
347            &option.base.discount_curve,
348            // the local vol function is frozen at the calibration spot;
349            // spot bumps (delta/gamma) move the path start, not the model
350            option.base.underlying_price.value(),
351            option.base.carry_yield(),
352            // vega bumps enter as a parallel shift of the implied surface
353            p.sigma - option.base.volatility(),
354        )),
355        McModel::Heston => unreachable!("Heston paths are generated by the dedicated routes"),
356    }
357}
358
359fn effective_steps(cfg: &MonteCarloConfig) -> usize {
360    match cfg.model {
361        McModel::LocalVol => cfg.time_steps.max(LOCAL_VOL_MIN_STEPS),
362        McModel::Heston => cfg.time_steps.max(HESTON_MIN_STEPS),
363        McModel::Gbm => cfg.time_steps,
364    }
365}
366
367// ── Per-path Brownian increments ────────────────────────────────────────
368
369/// Deterministic per-path Brownian increment source. Pseudo-random paths
370/// come in antithetic pairs (2k, 2k+1) from independent per-pair streams;
371/// low-discrepancy paths are sequence points routed through the Brownian
372/// bridge.
373enum PathDraws {
374    Pseudo { seed: u64, sqrt_dt: f64 },
375    Qmc { seq: QmcSequence, bridge: BrownianBridge },
376}
377
378impl PathDraws {
379    fn new(cfg: &MonteCarloConfig, steps: usize, dt: f64) -> Self {
380        match cfg.sampler {
381            Sampler::Sobol => PathDraws::Qmc {
382                seq: QmcSequence::new(steps, cfg.seed),
383                bridge: BrownianBridge::new(steps, dt),
384            },
385            Sampler::PseudoRandom => PathDraws::Pseudo { seed: cfg.seed, sqrt_dt: dt.sqrt() },
386        }
387    }
388
389    fn pseudo(seed: u64, dt: f64) -> Self {
390        PathDraws::Pseudo { seed, sqrt_dt: dt.sqrt() }
391    }
392
393    /// Fill `dw` with the Brownian increments of path `index`.
394    fn fill(&self, index: usize, z: &mut [f64], w: &mut [f64], dw: &mut [f64]) {
395        match self {
396            PathDraws::Pseudo { seed, sqrt_dt } => {
397                path_normals(*seed, (index / 2) as u64, z);
398                let sign = if index % 2 == 0 { 1.0 } else { -1.0 };
399                for (d, zi) in dw.iter_mut().zip(z.iter()) {
400                    *d = sign * sqrt_dt * zi;
401                }
402            }
403            PathDraws::Qmc { seq, bridge } => {
404                seq.normals(index as u64 + 1, z);
405                bridge.increments(z, w, dw);
406            }
407        }
408    }
409}
410
411fn step(scheme: DiscretizationScheme, s: f64, dt: f64, dw: f64, drift: f64, sigma: f64) -> f64 {
412    let next = match scheme {
413        DiscretizationScheme::Exact => s * exp((drift - 0.5 * sigma * sigma) * dt + sigma * dw),
414        DiscretizationScheme::Euler => s * (1.0 + drift * dt + sigma * dw),
415        DiscretizationScheme::Milstein => {
416            s * (1.0 + drift * dt + sigma * dw + 0.5 * sigma * sigma * (dw * dw - dt))
417        }
418    };
419    next.max(0.0)
420}
421
422/// Paths per parallel work unit. Each chunk is summed serially in index
423/// order and chunk results are folded in order, so totals are bit-exact
424/// reproducible regardless of thread scheduling.
425const PATH_CHUNK: usize = 4096;
426
427/// Parallel map-reduce over paths: `eval(dw, scratch)` values one path from
428/// its Brownian increments; returns (sum, sum of squares) deterministically.
429fn run_paths<F>(paths: usize, steps: usize, draws: &PathDraws, eval: F) -> (f64, f64)
430where
431    F: Fn(&[f64], &mut Vec<f64>) -> f64 + Sync,
432{
433    let chunks = paths.div_ceil(PATH_CHUNK);
434    let partials: Vec<(f64, f64)> = (0..chunks)
435        .into_par_iter()
436        .map(|chunk| {
437            let mut z = vec![0.0; steps];
438            let mut w = vec![0.0; steps];
439            let mut dw = vec![0.0; steps];
440            let mut scratch = Vec::new();
441            let (mut sum, mut sum_sq) = (0.0, 0.0);
442            for i in chunk * PATH_CHUNK..((chunk + 1) * PATH_CHUNK).min(paths) {
443                draws.fill(i, &mut z, &mut w, &mut dw);
444                let v = eval(&dw, &mut scratch);
445                sum += v;
446                sum_sq += v * v;
447            }
448            (sum, sum_sq)
449        })
450        .collect();
451    partials.into_iter().fold((0.0, 0.0), |a, b| (a.0 + b.0, a.1 + b.1))
452}
453
454// ── European ────────────────────────────────────────────────────────────
455
456fn european_npv(option: &EquityOption, p: &MarketParams) -> McStats {
457    if option.mc.model == McModel::Heston {
458        return heston_european_npv(option, p);
459    }
460    if option.payoff.is_path_dependent() {
461        // barriers get the Brownian-bridge crossing correction; Asians get
462        // the geometric control variate; anything else path-dependent uses
463        // its own path_payoff with discrete monitoring
464        return if let Some(barrier) = option.payoff.as_any().downcast_ref::<BarrierPayoff>() {
465            barrier_npv(option, barrier, p)
466        } else if let Some(asian) = option.payoff.as_any().downcast_ref::<AsianPayoff>() {
467            asian_npv(option, asian, p)
468        } else if let Some(auto) = option.payoff.as_any().downcast_ref::<AutocallablePayoff>() {
469            autocall_npv(option, auto, p)
470        } else {
471            generic_path_npv(option, p)
472        };
473    }
474    let cfg = &option.mc;
475    let steps = effective_steps(cfg);
476    let df = exp(-p.r * p.t);
477    if steps <= 1 {
478        // exact one-step GBM transition (constant vol only)
479        let drift = (p.r - p.q - 0.5 * p.sigma * p.sigma) * p.t;
480        let vol_sqrt_t = p.sigma * p.t.sqrt();
481        let s0 = escrowed_spot(option, p);
482        let z = match cfg.sampler {
483            Sampler::Sobol => sobol_normals(cfg.paths),
484            Sampler::PseudoRandom => pseudo_normals(cfg.paths, cfg.seed),
485        };
486        let partials: Vec<(f64, f64)> = z
487            .par_chunks(PATH_CHUNK)
488            .map(|chunk| {
489                let (mut sum, mut sum_sq) = (0.0, 0.0);
490                for z in chunk {
491                    let v = df
492                        * option.payoff.payoff(s0 * exp(drift + vol_sqrt_t * z), p.strike);
493                    sum += v;
494                    sum_sq += v * v;
495                }
496                (sum, sum_sq)
497            })
498            .collect();
499        let (sum, sum_sq) =
500            partials.into_iter().fold((0.0, 0.0), |a, b| (a.0 + b.0, a.1 + b.1));
501        return stats(sum, sum_sq, cfg.paths, 1, 0.0);
502    }
503    let dt = p.t / steps as f64;
504    let vol_model = path_vol(option, p);
505    let draws = PathDraws::new(cfg, steps, dt);
506    let divs = dividends_per_step(option, p.t, steps);
507    let drift = p.r - p.q;
508    let (sum, sum_sq) = run_paths(cfg.paths, steps, &draws, |dw, _| {
509        let mut s = p.s0;
510        for (i, d) in dw.iter().enumerate() {
511            let sigma = vol_model.vol(s, i as f64 * dt);
512            s = step(cfg.scheme, s, dt, *d, drift, sigma);
513            if let Some(divs) = &divs {
514                s = (s - divs[i]).max(1e-8);
515            }
516        }
517        df * option.payoff.payoff(s, p.strike)
518    });
519    stats(sum, sum_sq, cfg.paths, steps, 0.0)
520}
521
522/// Path-dependent pricing through [`Payoff::path_payoff`] on discretely
523/// monitored paths.
524fn generic_path_npv(option: &EquityOption, p: &MarketParams) -> McStats {
525    let cfg = &option.mc;
526    let steps = effective_steps(cfg).max(PATH_DEPENDENT_MIN_STEPS);
527    let dt = p.t / steps as f64;
528    let df = exp(-p.r * p.t);
529    let vol_model = path_vol(option, p);
530    let draws = PathDraws::new(cfg, steps, dt);
531    let drift = p.r - p.q;
532    let divs = dividends_per_step(option, p.t, steps);
533    let (sum, sum_sq) = run_paths(cfg.paths, steps, &draws, |dw, path| {
534        path.clear();
535        let mut s = p.s0;
536        for (i, d) in dw.iter().enumerate() {
537            let sigma = vol_model.vol(s, i as f64 * dt);
538            s = step(cfg.scheme, s, dt, *d, drift, sigma);
539            if let Some(divs) = &divs {
540                s = (s - divs[i]).max(1e-8);
541            }
542            path.push(s);
543        }
544        df * option.payoff.path_payoff(path, p.strike)
545    });
546    stats(sum, sum_sq, cfg.paths, steps, 0.0)
547}
548
549/// Asian pricing. Arithmetic fixed-strike Asians under plain GBM use the
550/// geometric average as a control variate: the same paths price both
551/// averages, the closed-form discrete geometric value corrects the
552/// difference, and the variance collapses because the two payoffs are
553/// highly correlated. Every other combination (geometric, floating strike,
554/// local vol, approximate schemes) prices through the generic path route.
555fn asian_npv(option: &EquityOption, asian: &AsianPayoff, p: &MarketParams) -> McStats {
556    let cfg = &option.mc;
557    let use_control_variate = asian.averaging == AveragingType::Arithmetic
558        && asian.strike_type == AsianStrikeType::FixedStrike
559        && cfg.model == McModel::Gbm
560        && cfg.scheme == DiscretizationScheme::Exact
561        && option.base.cash_dividends.is_empty();
562    if !use_control_variate {
563        return generic_path_npv(option, p);
564    }
565    let steps = effective_steps(cfg).max(PATH_DEPENDENT_MIN_STEPS);
566    let dt = p.t / steps as f64;
567    let drift_dt = (p.r - p.q - 0.5 * p.sigma * p.sigma) * dt;
568    let df = exp(-p.r * p.t);
569    let draws = PathDraws::new(cfg, steps, dt);
570    let (sum, sum_sq) = run_paths(cfg.paths, steps, &draws, |dw, _| {
571        let mut s = p.s0;
572        let mut sum_s = 0.0;
573        let mut log_sum = 0.0;
574        for d in dw {
575            s *= exp(drift_dt + p.sigma * d);
576            sum_s += s;
577            log_sum += s.ln();
578        }
579        let arithmetic = sum_s / steps as f64;
580        let geometric = (log_sum / steps as f64).exp();
581        df * (option.payoff.payoff(arithmetic, p.strike)
582            - option.payoff.payoff(geometric, p.strike))
583    });
584    let geo_closed = asian::geometric_asian_price(
585        p.s0,
586        p.strike,
587        p.r,
588        p.q,
589        p.sigma,
590        p.t,
591        Some(steps),
592        *option.payoff.put_or_call(),
593    );
594    stats(sum, sum_sq, cfg.paths, steps, geo_closed)
595}
596
597/// Barrier pricing with a Brownian-bridge crossing correction: each path
598/// carries a survival probability that accounts for the chance of touching
599/// the barrier *between* monitoring points, removing the O(sqrt(dt))
600/// discrete-monitoring bias and reducing variance (conditional Monte Carlo).
601fn barrier_npv(option: &EquityOption, barrier: &BarrierPayoff, p: &MarketParams) -> McStats {
602    let cfg = &option.mc;
603    let steps = effective_steps(cfg).max(PATH_DEPENDENT_MIN_STEPS);
604    let dt = p.t / steps as f64;
605    let down = barrier.direction == BarrierDirection::Down;
606    let out = barrier.knock == KnockType::Out;
607    let h = barrier.barrier;
608    let knocked_at_start = if down { p.s0 <= h } else { p.s0 >= h };
609    if knocked_at_start && out {
610        return McStats { pv: 0.0, std_err: 0.0, paths: cfg.paths, steps };
611    }
612    let df = exp(-p.r * p.t);
613    let vol_model = path_vol(option, p);
614    let draws = PathDraws::new(cfg, steps, dt);
615    let drift = p.r - p.q;
616    let divs = dividends_per_step(option, p.t, steps);
617    let (sum, sum_sq) = run_paths(cfg.paths, steps, &draws, |dw, _| {
618        let mut s = p.s0;
619        let mut survival = if knocked_at_start { 0.0 } else { 1.0 };
620        for (i, d) in dw.iter().enumerate() {
621            let sigma = vol_model.vol(s, i as f64 * dt);
622            let mut s_next = step(cfg.scheme, s, dt, *d, drift, sigma);
623            if let Some(divs) = &divs {
624                s_next = (s_next - divs[i]).max(1e-8);
625            }
626            if survival > 0.0 {
627                let crossed = if down { s_next <= h } else { s_next >= h };
628                if crossed {
629                    survival = 0.0;
630                } else {
631                    // probability the bridge touched the barrier inside the step
632                    let (a, b) = if down {
633                        ((s / h).ln(), (s_next / h).ln())
634                    } else {
635                        ((h / s).ln(), (h / s_next).ln())
636                    };
637                    survival *= 1.0 - (-2.0 * a * b / (sigma * sigma * dt)).exp();
638                }
639            }
640            s = s_next;
641        }
642        let vanilla_leg = option.payoff.payoff(s, p.strike);
643        let weight = if out { survival } else { 1.0 - survival };
644        df * weight * vanilla_leg
645    });
646    stats(sum, sum_sq, cfg.paths, steps, 0.0)
647}
648
649/// Autocallable valuation: cash flows land on their own call dates, so
650/// each path value is the redemption amount times the discount factor of
651/// its payment date (curve discount factors, shifted consistently under
652/// rho bumps). Steps are aligned so every observation falls exactly on a
653/// simulation step. Runs under GBM and local vol.
654fn autocall_npv(option: &EquityOption, auto: &AutocallablePayoff, p: &MarketParams) -> McStats {
655    let cfg = &option.mc;
656    let n_obs = auto.observations.max(1);
657    let steps = effective_steps(cfg).max(PATH_DEPENDENT_MIN_STEPS).div_ceil(n_obs) * n_obs;
658    let dt = p.t / steps as f64;
659    let obs_idx: Vec<usize> = (1..=n_obs).map(|m| m * steps / n_obs - 1).collect();
660    let dr = p.r - option.base.risk_free_rate();
661    let dfs: Vec<f64> = obs_idx
662        .iter()
663        .map(|&i| {
664            let tm = (i + 1) as f64 * dt;
665            option.base.discount_curve.df(tm) * exp(-dr * tm)
666        })
667        .collect();
668    let divs = dividends_per_step(option, p.t, steps);
669    let vol_model = path_vol(option, p);
670    let draws = PathDraws::new(cfg, steps, dt);
671    let drift = p.r - p.q;
672    let (sum, sum_sq) = run_paths(cfg.paths, steps, &draws, |dw, path| {
673        path.clear();
674        let mut s = p.s0;
675        for (i, d) in dw.iter().enumerate() {
676            let sigma = vol_model.vol(s, i as f64 * dt);
677            s = step(cfg.scheme, s, dt, *d, drift, sigma);
678            if let Some(divs) = &divs {
679                s = (s - divs[i]).max(1e-8);
680            }
681            path.push(s);
682        }
683        auto.path_value(path, &obs_idx, &dfs)
684    });
685    stats(sum, sum_sq, cfg.paths, steps, 0.0)
686}
687
688// ── Heston stochastic volatility paths ──────────────────────────────────
689
690/// Full-truncation Euler simulation of the Heston model (two correlated
691/// normals per step, seeded per-path pseudo-random streams; the Andersen QE
692/// scheme is the planned upgrade). Vega bumps map to a parallel shift of
693/// the instantaneous and long-run vol.
694fn heston_european_npv(option: &EquityOption, p: &MarketParams) -> McStats {
695    let hp = option
696        .heston
697        .expect("heston parameters are required when mc_model = heston")
698        .with_vol_shift(p.sigma - option.base.volatility());
699    let cfg = &option.mc;
700    let steps = effective_steps(cfg);
701    let dt = p.t / steps as f64;
702    let df = exp(-p.r * p.t);
703
704    if let Some(barrier) = option.payoff.as_any().downcast_ref::<BarrierPayoff>() {
705        let down = barrier.direction == BarrierDirection::Down;
706        let out = barrier.knock == KnockType::Out;
707        let h = barrier.barrier;
708        let knocked_at_start = if down { p.s0 <= h } else { p.s0 >= h };
709        if knocked_at_start && out {
710            return McStats { pv: 0.0, std_err: 0.0, paths: cfg.paths, steps };
711        }
712        let (sum, sum_sq) = run_heston_paths(option, p, &hp, steps, dt, |spots, vols| {
713            let mut survival = if knocked_at_start { 0.0 } else { 1.0 };
714            let mut s_prev = p.s0;
715            for (i, &s_next) in spots.iter().enumerate() {
716                if survival > 0.0 {
717                    let crossed = if down { s_next <= h } else { s_next >= h };
718                    if crossed {
719                        survival = 0.0;
720                    } else {
721                        let (a, b) = if down {
722                            ((s_prev / h).ln(), (s_next / h).ln())
723                        } else {
724                            ((h / s_prev).ln(), (h / s_next).ln())
725                        };
726                        let sigma = vols[i].max(1e-8);
727                        survival *= 1.0 - (-2.0 * a * b / (sigma * sigma * dt)).exp();
728                    }
729                }
730                s_prev = s_next;
731            }
732            let weight = if out { survival } else { 1.0 - survival };
733            df * weight * option.payoff.payoff(s_prev, p.strike)
734        });
735        return stats(sum, sum_sq, cfg.paths, steps, 0.0);
736    }
737
738    if let Some(auto) = option.payoff.as_any().downcast_ref::<AutocallablePayoff>() {
739        let n_obs = auto.observations.max(1);
740        let steps = steps.div_ceil(n_obs) * n_obs;
741        let dt = p.t / steps as f64;
742        let obs_idx: Vec<usize> = (1..=n_obs).map(|m| m * steps / n_obs - 1).collect();
743        let dr = p.r - option.base.risk_free_rate();
744        let dfs: Vec<f64> = obs_idx
745            .iter()
746            .map(|&i| {
747                let tm = (i + 1) as f64 * dt;
748                option.base.discount_curve.df(tm) * exp(-dr * tm)
749            })
750            .collect();
751        let (sum, sum_sq) = run_heston_paths(option, p, &hp, steps, dt, |spots, _| {
752            auto.path_value(spots, &obs_idx, &dfs)
753        });
754        return stats(sum, sum_sq, cfg.paths, steps, 0.0);
755    }
756
757    let path_dependent = option.payoff.is_path_dependent();
758    let (sum, sum_sq) = run_heston_paths(option, p, &hp, steps, dt, |spots, _| {
759        let v = if path_dependent {
760            option.payoff.path_payoff(spots, p.strike)
761        } else {
762            option.payoff.payoff(*spots.last().unwrap(), p.strike)
763        };
764        df * v
765    });
766    stats(sum, sum_sq, cfg.paths, steps, 0.0)
767}
768
769/// Parallel Heston path generation: `eval(spots, vols)` receives the path's
770/// spot levels and the per-step vols (`sqrt(v)`) actually used to diffuse.
771fn run_heston_paths<F>(
772    option: &EquityOption,
773    p: &MarketParams,
774    hp: &HestonParams,
775    steps: usize,
776    dt: f64,
777    eval: F,
778) -> (f64, f64)
779where
780    F: Fn(&[f64], &[f64]) -> f64 + Sync,
781{
782    let cfg = &option.mc;
783    let drift = p.r - p.q;
784    let sqrt_dt = dt.sqrt();
785    let rho = hp.rho;
786    let rho_perp = (1.0 - rho * rho).sqrt();
787    let divs = dividends_per_step(option, p.t, steps);
788    let chunks = cfg.paths.div_ceil(PATH_CHUNK);
789    let partials: Vec<(f64, f64)> = (0..chunks)
790        .into_par_iter()
791        .map(|chunk| {
792            let mut z = vec![0.0; 2 * steps];
793            let mut spots = vec![0.0; steps];
794            let mut vols = vec![0.0; steps];
795            let (mut sum, mut sum_sq) = (0.0, 0.0);
796            for i in chunk * PATH_CHUNK..((chunk + 1) * PATH_CHUNK).min(cfg.paths) {
797                // antithetic pairs share a stream with negated draws
798                path_normals(cfg.seed, (i / 2) as u64, &mut z);
799                let sign = if i % 2 == 0 { 1.0 } else { -1.0 };
800                let mut s = p.s0;
801                let mut v = hp.v0;
802                for j in 0..steps {
803                    let z_s = sign * z[2 * j];
804                    let z_v = rho * z_s + rho_perp * sign * z[2 * j + 1];
805                    let v_pos = v.max(0.0);
806                    let sqrt_v = v_pos.sqrt();
807                    s *= exp((drift - 0.5 * v_pos) * dt + sqrt_v * sqrt_dt * z_s);
808                    if let Some(divs) = &divs {
809                        s = (s - divs[j]).max(1e-8);
810                    }
811                    v += hp.kappa * (hp.theta - v_pos) * dt
812                        + hp.vol_of_vol * sqrt_v * sqrt_dt * z_v;
813                    spots[j] = s;
814                    vols[j] = sqrt_v;
815                }
816                let value = eval(&spots, &vols);
817                sum += value;
818                sum_sq += value * value;
819            }
820            (sum, sum_sq)
821        })
822        .collect();
823    partials.into_iter().fold((0.0, 0.0), |a, b| (a.0 + b.0, a.1 + b.1))
824}
825
826// ── American: two-pass Longstaff-Schwartz ───────────────────────────────
827
828const LSMC_DEFAULT_STEPS: usize = 50;
829const LSMC_BASIS: usize = 4;
830
831/// Basis functions for the continuation-value regression: cubic in the
832/// normalized spot. (An "include the payoff" basis is exactly collinear
833/// with `[1, x]` for vanilla payoffs on in-the-money paths, so the cubic
834/// term is the safe way to add flexibility.)
835fn lsmc_basis(x: f64) -> [f64; LSMC_BASIS] {
836    [1.0, x, x * x, x * x * x]
837}
838
839/// Two-pass least-squares Monte Carlo (Longstaff-Schwartz):
840/// pass 1 fits the per-date continuation-value regressions on one set of
841/// paths; pass 2 applies the fitted exercise rule to an independent set,
842/// which removes the foresight (in-sample) bias of single-pass LSMC.
843/// Always uses pseudo-random per-path streams.
844fn american_npv(option: &EquityOption, p: &MarketParams) -> McStats {
845    let cfg = &option.mc;
846    if cfg.model == McModel::Heston {
847        panic!("American exercise under the Heston model is not supported yet");
848    }
849    let steps = if cfg.time_steps > 1 { cfg.time_steps } else { LSMC_DEFAULT_STEPS }
850        .max(if cfg.model == McModel::LocalVol { LOCAL_VOL_MIN_STEPS } else { 1 });
851    let dt = p.t / steps as f64;
852    let disc = exp(-p.r * dt);
853    let vol_model = path_vol(option, p);
854    let drift = p.r - p.q;
855    let seed_regression = cfg.seed ^ 0xA11C_E5ED;
856    let seed_valuation = cfg.seed ^ 0xB0B5_1EED;
857
858    let simulate = |draws: &PathDraws, index: usize, bufs: &mut (Vec<f64>, Vec<f64>, Vec<f64>), path: &mut Vec<f64>| {
859        let (z, w, dw) = bufs;
860        draws.fill(index, z, w, dw);
861        path.clear();
862        let mut s = p.s0;
863        for (i, d) in dw.iter().enumerate() {
864            let sigma = vol_model.vol(s, i as f64 * dt);
865            s = step(cfg.scheme, s, dt, *d, drift, sigma);
866            path.push(s);
867        }
868    };
869
870    // ── pass 1: simulate and fit regressions backwards
871    let reg_draws = PathDraws::pseudo(seed_regression, dt);
872    let spots: Vec<Vec<f64>> = (0..cfg.paths)
873        .into_par_iter()
874        .map_init(
875            || (vec![0.0; steps], vec![0.0; steps], vec![0.0; steps]),
876            |bufs, i| {
877                let mut path = Vec::with_capacity(steps);
878                simulate(&reg_draws, i, bufs, &mut path);
879                path
880            },
881        )
882        .collect();
883
884    let mut cashflow: Vec<f64> =
885        spots.iter().map(|path| option.payoff.payoff(path[steps - 1], p.strike)).collect();
886    let mut betas: Vec<Option<[f64; LSMC_BASIS]>> = vec![None; steps.saturating_sub(1)];
887    for step_idx in (0..steps - 1).rev() {
888        for cf in cashflow.iter_mut() {
889            *cf *= disc;
890        }
891        let itm: Vec<usize> = (0..spots.len())
892            .filter(|&i| option.payoff.payoff(spots[i][step_idx], p.strike) > 0.0)
893            .collect();
894        if itm.len() < LSMC_BASIS {
895            continue;
896        }
897        let rows: Vec<([f64; LSMC_BASIS], f64)> = itm
898            .iter()
899            .map(|&i| {
900                let s = spots[i][step_idx];
901                let pay = option.payoff.payoff(s, p.strike);
902                (lsmc_basis(s / p.s0), cashflow[i])
903            })
904            .collect();
905        let Some(beta) = least_squares(&rows) else { continue };
906        for &i in &itm {
907            let s = spots[i][step_idx];
908            let pay = option.payoff.payoff(s, p.strike);
909            let continuation = dot(&beta, &lsmc_basis(s / p.s0));
910            if pay > continuation {
911                cashflow[i] = pay;
912            }
913        }
914        betas[step_idx] = Some(beta);
915    }
916    drop(spots);
917    drop(cashflow);
918
919    // ── pass 2: apply the fitted exercise rule to independent paths
920    let val_draws = PathDraws::pseudo(seed_valuation, dt);
921    let partials: Vec<(f64, f64)> = (0..cfg.paths.div_ceil(PATH_CHUNK))
922        .into_par_iter()
923        .map(|chunk| {
924            let mut bufs = (vec![0.0; steps], vec![0.0; steps], vec![0.0; steps]);
925            let mut path = Vec::with_capacity(steps);
926            let (mut c_sum, mut c_sum_sq) = (0.0, 0.0);
927            for i in chunk * PATH_CHUNK..((chunk + 1) * PATH_CHUNK).min(cfg.paths) {
928                simulate(&val_draws, i, &mut bufs, &mut path);
929                let mut value = 0.0;
930                let mut exercised = false;
931                for k in 0..steps - 1 {
932                    let s = path[k];
933                    let pay = option.payoff.payoff(s, p.strike);
934                    if pay > 0.0 {
935                        if let Some(beta) = &betas[k] {
936                            let continuation = dot(beta, &lsmc_basis(s / p.s0));
937                            if pay > continuation {
938                                value = pay * disc.powi(k as i32 + 1);
939                                exercised = true;
940                                break;
941                            }
942                        }
943                    }
944                }
945                if !exercised {
946                    value = option.payoff.payoff(path[steps - 1], p.strike)
947                        * disc.powi(steps as i32);
948                }
949                c_sum += value;
950                c_sum_sq += value * value;
951            }
952            (c_sum, c_sum_sq)
953        })
954        .collect();
955    let (sum, sum_sq) = partials.into_iter().fold((0.0, 0.0), |a, b| (a.0 + b.0, a.1 + b.1));
956    stats(sum, sum_sq, cfg.paths, steps, 0.0)
957}
958
959fn dot(a: &[f64; LSMC_BASIS], b: &[f64; LSMC_BASIS]) -> f64 {
960    a.iter().zip(b).map(|(x, y)| x * y).sum()
961}
962
963/// Least squares via the normal equations with partial-pivot Gaussian
964/// elimination; None if (near-)singular.
965fn least_squares(rows: &[([f64; LSMC_BASIS], f64)]) -> Option<[f64; LSMC_BASIS]> {
966    let k = LSMC_BASIS;
967    let mut m = [[0.0; LSMC_BASIS + 1]; LSMC_BASIS];
968    for (basis, y) in rows {
969        for i in 0..k {
970            for j in 0..k {
971                m[i][j] += basis[i] * basis[j];
972            }
973            m[i][k] += basis[i] * y;
974        }
975    }
976    for col in 0..k {
977        let pivot =
978            (col..k).max_by(|&i, &j| m[i][col].abs().partial_cmp(&m[j][col].abs()).unwrap())?;
979        if m[pivot][col].abs() < 1e-10 {
980            return None;
981        }
982        m.swap(col, pivot);
983        for row in col + 1..k {
984            let f = m[row][col] / m[col][col];
985            for c in col..=k {
986                m[row][c] -= f * m[col][c];
987            }
988        }
989    }
990    let mut beta = [0.0; LSMC_BASIS];
991    for row in (0..k).rev() {
992        let mut acc = m[row][k];
993        for c in row + 1..k {
994            acc -= m[row][c] * beta[c];
995        }
996        beta[row] = acc / m[row][row];
997    }
998    Some(beta)
999}
1000
1001// ── Interactive CLI helper ──────────────────────────────────────────────
1002
1003pub fn option_pricing() {
1004    println!("Welcome to the Monte Carlo Option pricer.");
1005    println!("(Step 1/7) What is the current price of the underlying asset?");
1006    let mut curr_price = String::new();
1007    io::stdin()
1008        .read_line(&mut curr_price)
1009        .expect("Failed to read line");
1010
1011    println!("(Step 2/7) Do you want a call option ('C') or a put option ('P') ?");
1012    let mut side_input = String::new();
1013    io::stdin()
1014        .read_line(&mut side_input)
1015        .expect("Failed to read line");
1016
1017    let side: PutOrCall;
1018    match side_input.trim() {
1019        "C" | "c" | "Call" | "call" => side = PutOrCall::Call,
1020        "P" | "p" | "Put" | "put" => side = PutOrCall::Put,
1021        _ => panic!("Invalide side argument! Side has to be either 'C' or 'P'."),
1022    }
1023
1024    println!("Stike price:");
1025    let mut strike = String::new();
1026    io::stdin()
1027        .read_line(&mut strike)
1028        .expect("Failed to read line");
1029
1030    println!("Expected annualized volatility in %:");
1031    println!("E.g.: Enter 50% chance as 0.50 ");
1032    let mut vol = String::new();
1033    io::stdin()
1034        .read_line(&mut vol)
1035        .expect("Failed to read line");
1036
1037    println!("Risk-free rate in %:");
1038    let mut rf = String::new();
1039    io::stdin().read_line(&mut rf).expect("Failed to read line");
1040
1041    println!("Maturity date in YYYY-MM-DD format:");
1042    let mut expiry = String::new();
1043    io::stdin()
1044        .read_line(&mut expiry)
1045        .expect("Failed to read line");
1046    let future_date = NaiveDate::parse_from_str(&expiry.trim(), "%Y-%m-%d").expect("Invalid date format");
1047    println!("Dividend yield on this stock:");
1048    let mut div = String::new();
1049    io::stdin()
1050        .read_line(&mut div)
1051        .expect("Failed to read line");
1052
1053    let valuation_date = Local::now().date_naive();
1054    let discount_curve = YieldCurve::flat(
1055        rf.trim().parse::<f64>().unwrap(),
1056        valuation_date,
1057        DayCountConvention::Act365,
1058        Compounding::Continuous,
1059    )
1060    .expect("Invalid risk free rate");
1061    let vol_surface = VolSurface::flat(
1062        vol.trim().parse::<f64>().unwrap(),
1063        valuation_date,
1064        DayCountConvention::Act365,
1065    )
1066    .expect("Invalid volatility");
1067    let curr_quote = Quote::new(curr_price.trim().parse::<f64>().unwrap());
1068    let option = EquityOptionBase {
1069        symbol: "ABC".to_string(),
1070        currency: None,
1071        exchange: None,
1072        name: None,
1073        cusip: None,
1074        isin: None,
1075        settlement_type: Some("ABC".to_string()),
1076        entry_price: 0.0,
1077        long_short: LongShort::LONG,
1078        underlying_price: curr_quote,
1079        current_price: Quote::new(0.0),
1080        strike_price: strike.trim().parse::<f64>().unwrap(),
1081        vol_surface,
1082        maturity_date: future_date,
1083        discount_curve,
1084        dividend_yield: div.trim().parse::<f64>().unwrap(),
1085        borrow_cost: 0.0,
1086        cash_dividends: vec![],
1087        futures_settlement: None,
1088        valuation_date,
1089        multiplier: 1.0,
1090    };
1091    println!("{:?}", option.time_to_maturity());
1092    let payoff = Box::new(VanillaPayoff {
1093        put_or_call: side,
1094        exercise_style: crate::core::utils::ContractStyle::European,
1095    });
1096    let equityoption = EquityOption {
1097        base: option,
1098        payoff: payoff,
1099        engine: Engine::MonteCarlo,
1100        mc: MonteCarloConfig::default(),
1101        fd: crate::equity::finite_difference::FdConfig::default(),
1102        heston: None,
1103    };
1104
1105    let result = npv_with_stats(&equityoption);
1106    println!("Theoretical Price ${} (std err {})", result.pv, result.std_err);
1107    let mut wait = String::new();
1108    io::stdin()
1109        .read_line(&mut wait)
1110        .expect("Failed to read line");
1111}