1mod 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
49fn 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%)", ¬e(AUTOCALL, PROTECTION, COUPON).build());
75 common::row(
76 "Local vol (skewed surface)",
77 ¬e(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 ¬e(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 ¬e(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"), ¬e(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 ¬e(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 ¬e(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}