Skip to main content

forward_start_option/
forward_start_option.rs

1//! Forward-start options: strike fixed at a future date as k * S(t_f).
2//! The product that exposes the *forward smile*, so Heston and
3//! Black-Scholes disagree by construction.
4//!
5//! Run with:  cargo run --release --example forward_start_option
6
7mod common;
8
9use chrono::NaiveDate;
10use rustyqlib::core::trade::PutOrCall;
11use rustyqlib::core::traits::Instrument;
12use rustyqlib::equity::builder::EquityOptionBuilder;
13use rustyqlib::equity::forward_start_option::forward_start_price;
14use rustyqlib::equity::heston::HestonParams;
15use rustyqlib::equity::montecarlo::McModel;
16use rustyqlib::equity::utils::Engine;
17
18const SPOT: f64 = 100.0;
19const VOL: f64 = 0.30;
20const RATE: f64 = 0.05;
21const DIV: f64 = 0.02;
22const START: f64 = 0.5; // fixing at half the option's life
23
24fn base() -> EquityOptionBuilder {
25    EquityOptionBuilder::new()
26        .symbol("FWDSTART")
27        .spot(SPOT)
28        .flat_vol(VOL)
29        .flat_rate(RATE)
30        .dividend_yield(DIV)
31        .valuation_date(NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
32        .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap())
33}
34
35fn heston_params(vol_of_vol: f64, rho: f64) -> HestonParams {
36    HestonParams { v0: VOL * VOL, kappa: 2.0, theta: VOL * VOL, vol_of_vol, rho }
37}
38
39fn main() {
40    common::title("FORWARD-START OPTION — S=100, strike = 1.0 x S(0.5y), T=1y, sigma=30%");
41
42    common::section("Black-Scholes: analytic vs Monte Carlo");
43    common::table_header();
44    common::row(
45        "Analytical (Rubinstein)",
46        &base()
47            .forward_start(PutOrCall::Call, 1.0, START)
48            .engine(Engine::BlackScholes)
49            .build(),
50    );
51    common::row(
52        "Monte Carlo (GBM)",
53        &base()
54            .forward_start(PutOrCall::Call, 1.0, START)
55            .engine(Engine::MonteCarlo)
56            .paths(100_000)
57            .build(),
58    );
59    common::row(
60        "Finite difference (unsupported)",
61        &base()
62            .forward_start(PutOrCall::Call, 1.0, START)
63            .engine(Engine::FiniteDifference)
64            .build(),
65    );
66
67    common::section("Forward smile: Heston vs Black-Scholes");
68    common::table_header();
69    let bs = base()
70        .forward_start(PutOrCall::Call, 1.0, START)
71        .engine(Engine::BlackScholes)
72        .build()
73        .npv();
74    common::row(
75        "Heston vol-of-vol=0.001 (-> BS)",
76        &base()
77            .forward_start(PutOrCall::Call, 1.0, START)
78            .engine(Engine::MonteCarlo)
79            .heston(heston_params(1e-3, 0.0))
80            .paths(50_000)
81            .build(),
82    );
83    for (vov, rho) in [(0.2, -0.7), (0.4, -0.7), (0.6, -0.7), (0.4, 0.0)] {
84        common::row(
85            &format!("Heston vol-of-vol={vov}, rho={rho}"),
86            &base()
87                .forward_start(PutOrCall::Call, 1.0, START)
88                .engine(Engine::MonteCarlo)
89                .heston(heston_params(vov, rho))
90                .paths(50_000)
91                .build(),
92        );
93    }
94    common::note(&format!("Black-Scholes reference: {bs:.6}"));
95    common::note("the gap is the forward-smile effect — the reason to price these on a stoch-vol model");
96
97    common::section("Strike fraction sweep (analytic)");
98    common::table_header();
99    for k in [0.9, 0.95, 1.0, 1.05, 1.1] {
100        common::row(
101            &format!("strike = {k} x S(t_f), call"),
102            &base().forward_start(PutOrCall::Call, k, START).engine(Engine::BlackScholes).build(),
103        );
104    }
105
106    common::section("Fixing date sweep (analytic, ATM)");
107    common::table_header();
108    for start in [0.1, 0.25, 0.5, 0.75, 0.9] {
109        common::row(
110            &format!("fixing at {:.0}% of life", start * 100.0),
111            &base().forward_start(PutOrCall::Call, 1.0, start).engine(Engine::BlackScholes).build(),
112        );
113    }
114    common::note("later fixing leaves less time to expiry, so the option is worth less");
115
116    common::section("Identities");
117    common::check(
118        "immediate fixing -> vanilla struck at S0",
119        forward_start_price(SPOT, 1.0, RATE, DIV, VOL, 1e-6, 1.0, PutOrCall::Call),
120        base()
121            .strike(SPOT)
122            .vanilla(PutOrCall::Call)
123            .engine(Engine::BlackScholes)
124            .build()
125            .npv(),
126        1e-3,
127    );
128    let p100 = forward_start_price(100.0, 1.0, RATE, DIV, VOL, 0.5, 1.0, PutOrCall::Call);
129    let p200 = forward_start_price(200.0, 1.0, RATE, DIV, VOL, 0.5, 1.0, PutOrCall::Call);
130    common::check("homogeneity: price(2S) = 2 price(S)", p200, 2.0 * p100, 1e-12);
131    let fs = base()
132        .forward_start(PutOrCall::Call, 1.0, START)
133        .engine(Engine::BlackScholes)
134        .build();
135    common::check("delta = price / spot (homogeneity)", fs.delta(), fs.npv() / SPOT, 1e-6);
136    println!();
137}