Skip to main content

heston_option/
heston_option.rs

1//! Heston stochastic volatility: semi-analytic characteristic-function
2//! pricing vs Monte Carlo, and the smile the model produces.
3//!
4//! Run with:  cargo run --release --example heston_option
5
6mod common;
7
8use chrono::NaiveDate;
9use rustyqlib::core::trade::PutOrCall;
10use rustyqlib::core::traits::Instrument;
11use rustyqlib::equity::barrier::{BarrierDirection, KnockType};
12use rustyqlib::equity::blackscholes::{bs_price, implied_vol_from_price};
13use rustyqlib::equity::builder::EquityOptionBuilder;
14use rustyqlib::equity::heston::{heston_price, HestonParams};
15use rustyqlib::equity::utils::Engine;
16use rustyqlib::equity::vanila_option::BinaryType;
17
18const SPOT: f64 = 100.0;
19const STRIKE: f64 = 100.0;
20const RATE: f64 = 0.05;
21const DIV: f64 = 0.02;
22
23fn params() -> HestonParams {
24    HestonParams { v0: 0.09, kappa: 2.0, theta: 0.09, vol_of_vol: 0.4, rho: -0.7 }
25}
26
27fn base() -> EquityOptionBuilder {
28    EquityOptionBuilder::new()
29        .symbol("HESTON")
30        .spot(SPOT)
31        .strike(STRIKE)
32        .flat_vol(0.30) // only used as the vega-bump reference
33        .flat_rate(RATE)
34        .dividend_yield(DIV)
35        .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
36        .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap())
37        .heston(params())
38}
39
40fn main() {
41    let p = params();
42    common::title(&format!(
43        "HESTON — v0={} kappa={} theta={} vol-of-vol={} rho={}",
44        p.v0, p.kappa, p.theta, p.vol_of_vol, p.rho
45    ));
46    common::note(&format!(
47        "Feller condition 2*kappa*theta >= vol_of_vol^2: {}",
48        if p.feller_condition_holds() { "holds" } else { "VIOLATED (variance can touch zero)" }
49    ));
50
51    common::section("Vanilla: semi-analytic vs Monte Carlo");
52    common::table_header();
53    for pc in [PutOrCall::Call, PutOrCall::Put] {
54        common::row(
55            &format!("Analytical (char. function), {pc:?}"),
56            &base().vanilla(pc).engine(Engine::BlackScholes).build(),
57        );
58        common::row(
59            &format!("Monte Carlo (full-trunc Euler), {pc:?}"),
60            &base().vanilla(pc).engine(Engine::MonteCarlo).paths(100_000).build(),
61        );
62    }
63    common::row(
64        "Finite difference (unsupported)",
65        &base().vanilla(PutOrCall::Call).engine(Engine::FiniteDifference).build(),
66    );
67    common::note("MC vega/theta bump sqrt(v0) and sqrt(theta) in parallel");
68
69    common::section("Binaries under Heston");
70    common::table_header();
71    common::row(
72        "Cash-or-nothing call (analytic)",
73        &base()
74            .binary(PutOrCall::Call, BinaryType::CashOrNothing, 1.0)
75            .engine(Engine::BlackScholes)
76            .build(),
77    );
78    common::row(
79        "Cash-or-nothing call (MC)",
80        &base()
81            .binary(PutOrCall::Call, BinaryType::CashOrNothing, 1.0)
82            .engine(Engine::MonteCarlo)
83            .paths(100_000)
84            .build(),
85    );
86    common::row(
87        "Asset-or-nothing call (analytic)",
88        &base()
89            .binary(PutOrCall::Call, BinaryType::AssetOrNothing, 0.0)
90            .engine(Engine::BlackScholes)
91            .build(),
92    );
93
94    common::section("Path-dependent payoffs (Monte Carlo only)");
95    common::table_header();
96    common::row(
97        "Down-and-out call H=85",
98        &base()
99            .barrier(PutOrCall::Call, BarrierDirection::Down, KnockType::Out, 85.0)
100            .engine(Engine::MonteCarlo)
101            .paths(50_000)
102            .build(),
103    );
104    common::row(
105        "Down-and-in put H=85",
106        &base()
107            .barrier(PutOrCall::Put, BarrierDirection::Down, KnockType::In, 85.0)
108            .engine(Engine::MonteCarlo)
109            .paths(50_000)
110            .build(),
111    );
112
113    common::section("Identities");
114    let call = base().vanilla(PutOrCall::Call).engine(Engine::BlackScholes).build();
115    let put = base().vanilla(PutOrCall::Put).engine(Engine::BlackScholes).build();
116    let parity = SPOT * (-DIV * 1.0_f64).exp() - STRIKE * (-RATE * 1.0_f64).exp();
117    common::check("put-call parity", call.npv() - put.npv(), parity, 1e-10);
118    let asset = base()
119        .binary(PutOrCall::Call, BinaryType::AssetOrNothing, 0.0)
120        .engine(Engine::BlackScholes)
121        .build();
122    let k_cash = base()
123        .binary(PutOrCall::Call, BinaryType::CashOrNothing, STRIKE)
124        .engine(Engine::BlackScholes)
125        .build();
126    common::check("vanilla = asset digital - K cash digitals", call.npv(), asset.npv() - k_cash.npv(), 1e-10);
127    common::check(
128        "vol-of-vol -> 0 degenerates to Black-Scholes",
129        heston_price(
130            SPOT,
131            STRIKE,
132            RATE,
133            DIV,
134            1.0,
135            &HestonParams { vol_of_vol: 1e-4, ..params() },
136            PutOrCall::Call,
137        ),
138        bs_price(SPOT, STRIKE, RATE, DIV, p.v0.sqrt(), 1.0, PutOrCall::Call),
139        1e-4,
140    );
141
142    common::section("The Heston smile (implied vol backed out of Heston prices)");
143    println!("  {:>8} {:>14} {:>14}", "strike", "heston price", "implied vol");
144    for k in [70.0, 80.0, 90.0, 100.0, 110.0, 120.0, 130.0] {
145        let price = heston_price(SPOT, k, RATE, DIV, 1.0, &p, PutOrCall::Call);
146        let iv = implied_vol_from_price(SPOT, k, RATE, DIV, 1.0, price, PutOrCall::Call)
147            .unwrap_or(f64::NAN);
148        println!("  {k:>8.1} {price:>14.6} {:>13.4}%", iv * 100.0);
149    }
150    common::note("rho < 0 tilts the smile: low strikes carry higher implied vol");
151
152    common::section("Correlation and vol-of-vol control the smile shape");
153    println!("  {:>6} {:>8} {:>12} {:>12} {:>12}", "rho", "vol-of-vol", "iv(80)", "iv(100)", "iv(120)");
154    for (rho, vov) in [(-0.7, 0.4), (0.0, 0.4), (0.7, 0.4), (-0.7, 0.1), (-0.7, 0.8)] {
155        let hp = HestonParams { rho, vol_of_vol: vov, ..params() };
156        let iv = |k: f64| {
157            let price = heston_price(SPOT, k, RATE, DIV, 1.0, &hp, PutOrCall::Call);
158            implied_vol_from_price(SPOT, k, RATE, DIV, 1.0, price, PutOrCall::Call)
159                .unwrap_or(f64::NAN)
160                * 100.0
161        };
162        println!("  {rho:>6.1} {vov:>10.1} {:>11.3}% {:>11.3}% {:>11.3}%", iv(80.0), iv(100.0), iv(120.0));
163    }
164    common::note("rho controls the skew (tilt); vol-of-vol controls the smile (curvature)");
165    println!();
166}