Skip to main content

autocallable_option/
autocallable_option.rs

1//! Autocallable note with coupon (rebate) and knock-in capital protection.
2//! Priced under GBM, Dupire local volatility and Heston.
3//!
4//! Run with:  cargo run --release --example autocallable_option
5
6mod common;
7
8use chrono::NaiveDate;
9use rustyqlib::core::curves::Tenor;
10use rustyqlib::core::daycount::DayCountConvention;
11use rustyqlib::core::traits::Instrument;
12use rustyqlib::core::vols::VolSurface;
13use rustyqlib::equity::builder::EquityOptionBuilder;
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 NOTIONAL: f64 = 100.0;
23const AUTOCALL: f64 = 100.0;
24const PROTECTION: f64 = 70.0;
25const COUPON: f64 = 6.0;
26const OBSERVATIONS: usize = 4;
27
28fn asof() -> NaiveDate {
29    NaiveDate::from_ymd_opt(2026, 1, 1).unwrap()
30}
31
32fn base() -> EquityOptionBuilder {
33    EquityOptionBuilder::new()
34        .symbol("ATHENA")
35        .spot(SPOT)
36        .flat_vol(VOL)
37        .flat_rate(RATE)
38        .dividend_yield(DIV)
39        .valuation_date(asof())
40        .maturity_date(NaiveDate::from_ymd_opt(2027, 1, 1).unwrap())
41        .engine(Engine::MonteCarlo)
42        .paths(50_000)
43}
44
45fn note(autocall: f64, protection: f64, coupon: f64) -> EquityOptionBuilder {
46    base().autocallable(autocall, protection, coupon, OBSERVATIONS, NOTIONAL)
47}
48
49/// Downward-skewed surface: the shape that actually drives these notes.
50fn skewed_surface() -> VolSurface {
51    VolSurface::from_strike_grid(
52        &[Tenor::YearFraction(0.25), Tenor::YearFraction(0.5), Tenor::YearFraction(1.0)],
53        &[60.0, 70.0, 85.0, 100.0, 115.0, 130.0],
54        &[
55            vec![0.42, 0.38, 0.33, 0.29, 0.27, 0.26],
56            vec![0.41, 0.37, 0.33, 0.30, 0.28, 0.27],
57            vec![0.40, 0.37, 0.33, 0.30, 0.29, 0.28],
58        ],
59        asof(),
60        DayCountConvention::Act365,
61    )
62    .unwrap()
63}
64
65fn main() {
66    common::title(&format!(
67        "AUTOCALLABLE NOTE — N={NOTIONAL} autocall={AUTOCALL} protection={PROTECTION} coupon={COUPON}/period, {OBSERVATIONS} observations, T=1y"
68    ));
69    common::note("pays N + m*coupon if S >= autocall barrier at observation m;");
70    common::note("otherwise N at maturity, or N*S_T/S_0 if the protection barrier was breached.");
71
72    common::section("Model comparison");
73    common::table_header();
74    common::row("GBM (flat 30%)", &note(AUTOCALL, PROTECTION, COUPON).build());
75    common::row(
76        "Local vol (skewed surface)",
77        &note(AUTOCALL, PROTECTION, COUPON)
78            .vol_surface(skewed_surface())
79            .model(McModel::LocalVol)
80            .build(),
81    );
82    common::row(
83        "Heston (vol-of-vol=0.4, rho=-0.7)",
84        &note(AUTOCALL, PROTECTION, COUPON)
85            .heston(HestonParams {
86                v0: VOL * VOL,
87                kappa: 2.0,
88                theta: VOL * VOL,
89                vol_of_vol: 0.4,
90                rho: -0.7,
91            })
92            .build(),
93    );
94    common::row(
95        "Analytical (unsupported)",
96        &note(AUTOCALL, PROTECTION, COUPON).engine(Engine::BlackScholes).build(),
97    );
98    common::note("skew/stoch-vol raise the knock-in probability, lowering the note value");
99
100    common::section("Structure sensitivity (GBM)");
101    common::table_header();
102    for coupon in [0.0, 3.0, 6.0, 9.0] {
103        common::row(&format!("coupon = {coupon}/period"), &note(AUTOCALL, PROTECTION, coupon).build());
104    }
105    for protection in [50.0, 60.0, 70.0, 80.0] {
106        common::row(
107            &format!("protection barrier = {protection}"),
108            &note(AUTOCALL, protection, COUPON).build(),
109        );
110    }
111    for autocall in [95.0, 100.0, 105.0, 110.0] {
112        common::row(
113            &format!("autocall barrier = {autocall}"),
114            &note(autocall, PROTECTION, COUPON).build(),
115        );
116    }
117
118    common::section("Observation frequency (GBM)");
119    common::table_header();
120    for obs in [1usize, 2, 4, 12] {
121        common::row(
122            &format!("{obs} observations"),
123            &base().autocallable(AUTOCALL, PROTECTION, COUPON, obs, NOTIONAL).build(),
124        );
125    }
126
127    common::section("Degenerate cases (exact identities)");
128    let always_calls = base()
129        .autocallable(1e-9, 50.0, COUPON, OBSERVATIONS, NOTIONAL)
130        .build()
131        .npv();
132    common::check(
133        "barrier at 0 -> called at t1 with 1 coupon",
134        always_calls,
135        (NOTIONAL + COUPON) * (-RATE * 0.25_f64).exp(),
136        1e-8,
137    );
138    let never_calls = base()
139        .autocallable(1e12, 1e-9, COUPON, OBSERVATIONS, NOTIONAL)
140        .build()
141        .npv();
142    common::check(
143        "unreachable barriers -> zero-coupon bond",
144        never_calls,
145        NOTIONAL * (-RATE * 1.0_f64).exp(),
146        1e-8,
147    );
148    let full_downside = base()
149        .autocallable(1e12, 1e12, 0.0, OBSERVATIONS, NOTIONAL)
150        .dividend_yield(0.0)
151        .build()
152        .npv();
153    common::check(
154        "always knocked in, no coupon -> discounted forward",
155        full_downside,
156        NOTIONAL,
157        0.3,
158    );
159    println!();
160}