|
| 1 | +/****************************************************************************** |
| 2 | + Author: Joaquín Béjar García |
| 3 | + Email: jb@taunais.com |
| 4 | + Date: 12/01/26 |
| 5 | +******************************************************************************/ |
| 6 | + |
| 7 | +//! Asian option pricing module. |
| 8 | +//! |
| 9 | +//! Asian options are path-dependent options where the payoff depends on the |
| 10 | +//! average price of the underlying asset over a specified period. This module |
| 11 | +//! implements pricing for both geometric and arithmetic averaging. |
| 12 | +//! |
| 13 | +//! # Averaging Types |
| 14 | +//! |
| 15 | +//! - **Geometric Average**: Uses geometric mean of prices. Has a closed-form |
| 16 | +//! Black-Scholes solution with adjusted volatility and drift. |
| 17 | +//! - **Arithmetic Average**: Uses arithmetic mean of prices. No closed-form |
| 18 | +//! solution exists; uses Turnbull-Wakeman approximation. |
| 19 | +//! |
| 20 | +//! # Formula Sources |
| 21 | +//! |
| 22 | +//! - Kemna & Vorst (1990) for geometric average Asian options |
| 23 | +//! - Turnbull & Wakeman (1991) for arithmetic average approximation |
| 24 | +
|
| 25 | +use crate::Options; |
| 26 | +use crate::error::PricingError; |
| 27 | +use crate::greeks::{big_n, d1, d2}; |
| 28 | +use crate::model::types::{AsianAveragingType, OptionStyle, OptionType}; |
| 29 | +use positive::Positive; |
| 30 | +use rust_decimal::Decimal; |
| 31 | +use rust_decimal::prelude::*; |
| 32 | +use rust_decimal_macros::dec; |
| 33 | + |
| 34 | +/// Prices an Asian option using the appropriate method based on averaging type. |
| 35 | +/// |
| 36 | +/// # Arguments |
| 37 | +/// |
| 38 | +/// * `option` - The option to price. Must have `OptionType::Asian`. |
| 39 | +/// |
| 40 | +/// # Returns |
| 41 | +/// |
| 42 | +/// The option price as a `Decimal`, or a `PricingError` if pricing fails. |
| 43 | +/// |
| 44 | +/// # Errors |
| 45 | +/// |
| 46 | +/// Returns `PricingError` if: |
| 47 | +/// - The option type is not Asian |
| 48 | +/// - Required parameters are invalid (zero volatility, etc.) |
| 49 | +pub fn asian_black_scholes(option: &Options) -> Result<Decimal, PricingError> { |
| 50 | + match &option.option_type { |
| 51 | + OptionType::Asian { averaging_type } => match averaging_type { |
| 52 | + AsianAveragingType::Geometric => geometric_asian_price(option), |
| 53 | + AsianAveragingType::Arithmetic => arithmetic_asian_price(option), |
| 54 | + }, |
| 55 | + _ => Err(PricingError::other( |
| 56 | + "asian_black_scholes requires OptionType::Asian", |
| 57 | + )), |
| 58 | + } |
| 59 | +} |
| 60 | + |
| 61 | +/// Prices a geometric average Asian option using closed-form Black-Scholes. |
| 62 | +/// |
| 63 | +/// Uses the Kemna-Vorst (1990) closed-form solution. The geometric average |
| 64 | +/// of a lognormal process is also lognormal, allowing for an analytical solution. |
| 65 | +/// |
| 66 | +/// # Adjustments |
| 67 | +/// |
| 68 | +/// For geometric averaging: |
| 69 | +/// - Adjusted volatility: `σ_adj = σ / √3` |
| 70 | +/// - Adjusted cost-of-carry: `b_adj = (r - q - σ²/6) / 2` |
| 71 | +fn geometric_asian_price(option: &Options) -> Result<Decimal, PricingError> { |
| 72 | + let s = option.underlying_price; |
| 73 | + let k = option.strike_price; |
| 74 | + let r = option.risk_free_rate; |
| 75 | + let q = option.dividend_yield.to_dec(); |
| 76 | + let sigma = option.implied_volatility; |
| 77 | + let t = option |
| 78 | + .expiration_date |
| 79 | + .get_years() |
| 80 | + .map_err(|e| PricingError::other(&e.to_string()))?; |
| 81 | + |
| 82 | + if t == Positive::ZERO { |
| 83 | + return Ok(intrinsic_value(option)); |
| 84 | + } |
| 85 | + |
| 86 | + if sigma == Positive::ZERO { |
| 87 | + // Deterministic case |
| 88 | + let discount = (-r * t).exp(); |
| 89 | + let forward = s * ((r - q) * t).exp(); |
| 90 | + let intrinsic = match option.option_style { |
| 91 | + OptionStyle::Call => (forward - k).max(Positive::ZERO).to_dec(), |
| 92 | + OptionStyle::Put => (k - forward).max(Positive::ZERO).to_dec(), |
| 93 | + }; |
| 94 | + return Ok(apply_side(intrinsic * discount, option)); |
| 95 | + } |
| 96 | + |
| 97 | + // Geometric average adjustments (Kemna-Vorst) |
| 98 | + let sigma_sq = sigma * sigma; |
| 99 | + let sigma_adj = sigma / Positive::new(3.0_f64.sqrt()).unwrap(); |
| 100 | + let b_adj = (r - q - sigma_sq / dec!(6)) / dec!(2); |
| 101 | + |
| 102 | + // Calculate d1 and d2 with adjusted parameters |
| 103 | + let d1_val = d1(s, k, b_adj, t, sigma_adj) |
| 104 | + .map_err(|e: crate::error::GreeksError| PricingError::other(&e.to_string()))?; |
| 105 | + let d2_val = d2(s, k, b_adj, t, sigma_adj) |
| 106 | + .map_err(|e: crate::error::GreeksError| PricingError::other(&e.to_string()))?; |
| 107 | + |
| 108 | + let discount = (-r * t).exp(); |
| 109 | + |
| 110 | + let price = match option.option_style { |
| 111 | + OptionStyle::Call => { |
| 112 | + let n_d1 = big_n(d1_val).unwrap_or(Decimal::ZERO); |
| 113 | + let n_d2 = big_n(d2_val).unwrap_or(Decimal::ZERO); |
| 114 | + s.to_dec() * ((b_adj - r) * t).exp() * n_d1 - k.to_dec() * discount * n_d2 |
| 115 | + } |
| 116 | + OptionStyle::Put => { |
| 117 | + let n_neg_d1 = big_n(-d1_val).unwrap_or(Decimal::ZERO); |
| 118 | + let n_neg_d2 = big_n(-d2_val).unwrap_or(Decimal::ZERO); |
| 119 | + k.to_dec() * discount * n_neg_d2 - s.to_dec() * ((b_adj - r) * t).exp() * n_neg_d1 |
| 120 | + } |
| 121 | + }; |
| 122 | + |
| 123 | + Ok(apply_side(price, option)) |
| 124 | +} |
| 125 | + |
| 126 | +/// Prices an arithmetic average Asian option using Turnbull-Wakeman approximation. |
| 127 | +/// |
| 128 | +/// The arithmetic average of a lognormal process is not lognormal, so no |
| 129 | +/// closed-form solution exists. This implementation uses the Turnbull-Wakeman |
| 130 | +/// (1991) approximation which matches the first two moments of the arithmetic |
| 131 | +/// average to a lognormal distribution. |
| 132 | +fn arithmetic_asian_price(option: &Options) -> Result<Decimal, PricingError> { |
| 133 | + let s = option.underlying_price; |
| 134 | + let k = option.strike_price; |
| 135 | + let r = option.risk_free_rate; |
| 136 | + let q = option.dividend_yield.to_dec(); |
| 137 | + let sigma = option.implied_volatility; |
| 138 | + let t = option |
| 139 | + .expiration_date |
| 140 | + .get_years() |
| 141 | + .map_err(|e| PricingError::other(&e.to_string()))?; |
| 142 | + |
| 143 | + if t == Positive::ZERO { |
| 144 | + return Ok(intrinsic_value(option)); |
| 145 | + } |
| 146 | + |
| 147 | + if sigma == Positive::ZERO { |
| 148 | + let discount = (-r * t).exp(); |
| 149 | + let forward = s * ((r - q) * t).exp(); |
| 150 | + let intrinsic = match option.option_style { |
| 151 | + OptionStyle::Call => (forward - k).max(Positive::ZERO).to_dec(), |
| 152 | + OptionStyle::Put => (k - forward).max(Positive::ZERO).to_dec(), |
| 153 | + }; |
| 154 | + return Ok(apply_side(intrinsic * discount, option)); |
| 155 | + } |
| 156 | + |
| 157 | + // Turnbull-Wakeman approximation |
| 158 | + let b = r - q; // cost of carry |
| 159 | + let sigma_sq = sigma * sigma; |
| 160 | + let t_dec = t.to_dec(); |
| 161 | + |
| 162 | + // First moment of arithmetic average (M1) |
| 163 | + let m1 = if b.abs() < dec!(1e-10) { |
| 164 | + s.to_dec() |
| 165 | + } else { |
| 166 | + s.to_dec() * (((b * t).exp() - dec!(1)) / (b * t_dec)) |
| 167 | + }; |
| 168 | + |
| 169 | + // Second moment of arithmetic average (M2) |
| 170 | + let m2 = if b.abs() < dec!(1e-10) { |
| 171 | + let term = (sigma_sq * t_dec).exp(); |
| 172 | + s.to_dec().powi(2) * term |
| 173 | + } else { |
| 174 | + let term1_exp = ((dec!(2) * b + sigma_sq) * t_dec).exp(); |
| 175 | + let term1 = (dec!(2) * s.to_dec().powi(2) * term1_exp) |
| 176 | + / ((b + sigma_sq) * (dec!(2) * b + sigma_sq) * t_dec.powi(2)); |
| 177 | + |
| 178 | + let term2 = (dec!(2) * s.to_dec().powi(2)) / (b * t_dec.powi(2)) |
| 179 | + * (dec!(1) / (dec!(2) * b + sigma_sq) - (b * t_dec).exp() / (b + sigma_sq)); |
| 180 | + |
| 181 | + term1 + term2 |
| 182 | + }; |
| 183 | + |
| 184 | + // Adjusted volatility from moment matching |
| 185 | + let variance = (m2 / m1.powi(2)).ln() / t_dec; |
| 186 | + let sigma_adj = variance.sqrt().unwrap_or(sigma.to_dec()); |
| 187 | + let sigma_adj_pos = Positive::new_decimal(sigma_adj.max(dec!(0.0001))) |
| 188 | + .unwrap_or(Positive::new(0.0001).unwrap()); |
| 189 | + |
| 190 | + // Forward price of the average |
| 191 | + let f_adj = m1; |
| 192 | + |
| 193 | + // Use Black-Scholes with adjusted parameters |
| 194 | + let d1_val = ((f_adj / k).ln() + sigma_adj * sigma_adj * t_dec / dec!(2)) |
| 195 | + / (sigma_adj * t_dec.sqrt().unwrap()); |
| 196 | + let d2_val = d1_val - sigma_adj * t_dec.sqrt().unwrap(); |
| 197 | + |
| 198 | + let discount = (-r * t).exp(); |
| 199 | + |
| 200 | + let price = match option.option_style { |
| 201 | + OptionStyle::Call => { |
| 202 | + let n_d1 = big_n(d1_val).unwrap_or(Decimal::ZERO); |
| 203 | + let n_d2 = big_n(d2_val).unwrap_or(Decimal::ZERO); |
| 204 | + discount * (f_adj * n_d1 - k.to_dec() * n_d2) |
| 205 | + } |
| 206 | + OptionStyle::Put => { |
| 207 | + let n_neg_d1 = big_n(-d1_val).unwrap_or(Decimal::ZERO); |
| 208 | + let n_neg_d2 = big_n(-d2_val).unwrap_or(Decimal::ZERO); |
| 209 | + discount * (k.to_dec() * n_neg_d2 - f_adj * n_neg_d1) |
| 210 | + } |
| 211 | + }; |
| 212 | + |
| 213 | + // Suppress unused variable warning |
| 214 | + let _ = sigma_adj_pos; |
| 215 | + |
| 216 | + Ok(apply_side(price, option)) |
| 217 | +} |
| 218 | + |
| 219 | +/// Calculates intrinsic value at expiration. |
| 220 | +fn intrinsic_value(option: &Options) -> Decimal { |
| 221 | + let s = option.underlying_price; |
| 222 | + let k = option.strike_price; |
| 223 | + let value = match option.option_style { |
| 224 | + OptionStyle::Call => (s - k).max(Positive::ZERO).to_dec(), |
| 225 | + OptionStyle::Put => (k - s).max(Positive::ZERO).to_dec(), |
| 226 | + }; |
| 227 | + apply_side(value, option) |
| 228 | +} |
| 229 | + |
| 230 | +/// Applies the side (long/short) multiplier to the price. |
| 231 | +fn apply_side(price: Decimal, option: &Options) -> Decimal { |
| 232 | + match option.side { |
| 233 | + crate::model::types::Side::Long => price, |
| 234 | + crate::model::types::Side::Short => -price, |
| 235 | + } |
| 236 | +} |
| 237 | + |
| 238 | +#[cfg(test)] |
| 239 | +mod tests { |
| 240 | + use super::*; |
| 241 | + use crate::ExpirationDate; |
| 242 | + use crate::assert_decimal_eq; |
| 243 | + use crate::model::types::{OptionStyle, OptionType, Side}; |
| 244 | + use positive::pos_or_panic; |
| 245 | + use rust_decimal_macros::dec; |
| 246 | + |
| 247 | + fn create_asian_option(style: OptionStyle, averaging_type: AsianAveragingType) -> Options { |
| 248 | + Options::new( |
| 249 | + OptionType::Asian { averaging_type }, |
| 250 | + Side::Long, |
| 251 | + "TEST".to_string(), |
| 252 | + Positive::HUNDRED, // strike |
| 253 | + ExpirationDate::Days(pos_or_panic!(182.5)), // ~0.5 years |
| 254 | + pos_or_panic!(0.25), // volatility |
| 255 | + Positive::ONE, // quantity |
| 256 | + Positive::HUNDRED, // underlying |
| 257 | + dec!(0.05), // risk-free rate |
| 258 | + style, |
| 259 | + Positive::ZERO, // dividend yield |
| 260 | + None, |
| 261 | + ) |
| 262 | + } |
| 263 | + |
| 264 | + #[test] |
| 265 | + fn test_geometric_asian_call() { |
| 266 | + let option = create_asian_option(OptionStyle::Call, AsianAveragingType::Geometric); |
| 267 | + let price = asian_black_scholes(&option).unwrap(); |
| 268 | + // Price should be positive and less than vanilla BS price |
| 269 | + assert!( |
| 270 | + price > Decimal::ZERO, |
| 271 | + "Geometric Asian call should be positive: {}", |
| 272 | + price |
| 273 | + ); |
| 274 | + assert!( |
| 275 | + price < dec!(15.0), |
| 276 | + "Geometric Asian call should be less than vanilla" |
| 277 | + ); |
| 278 | + } |
| 279 | + |
| 280 | + #[test] |
| 281 | + fn test_geometric_asian_put() { |
| 282 | + let option = create_asian_option(OptionStyle::Put, AsianAveragingType::Geometric); |
| 283 | + let price = asian_black_scholes(&option).unwrap(); |
| 284 | + assert!( |
| 285 | + price > Decimal::ZERO, |
| 286 | + "Geometric Asian put should be positive: {}", |
| 287 | + price |
| 288 | + ); |
| 289 | + } |
| 290 | + |
| 291 | + #[test] |
| 292 | + fn test_arithmetic_asian_call() { |
| 293 | + let option = create_asian_option(OptionStyle::Call, AsianAveragingType::Arithmetic); |
| 294 | + let price = asian_black_scholes(&option).unwrap(); |
| 295 | + assert!( |
| 296 | + price > Decimal::ZERO, |
| 297 | + "Arithmetic Asian call should be positive: {}", |
| 298 | + price |
| 299 | + ); |
| 300 | + } |
| 301 | + |
| 302 | + #[test] |
| 303 | + fn test_arithmetic_asian_put() { |
| 304 | + let option = create_asian_option(OptionStyle::Put, AsianAveragingType::Arithmetic); |
| 305 | + let price = asian_black_scholes(&option).unwrap(); |
| 306 | + assert!( |
| 307 | + price > Decimal::ZERO, |
| 308 | + "Arithmetic Asian put should be positive: {}", |
| 309 | + price |
| 310 | + ); |
| 311 | + } |
| 312 | + |
| 313 | + #[test] |
| 314 | + fn test_geometric_less_than_arithmetic() { |
| 315 | + // For standard cases, geometric average <= arithmetic average |
| 316 | + // So geometric Asian call <= arithmetic Asian call |
| 317 | + let geometric = create_asian_option(OptionStyle::Call, AsianAveragingType::Geometric); |
| 318 | + let arithmetic = create_asian_option(OptionStyle::Call, AsianAveragingType::Arithmetic); |
| 319 | + |
| 320 | + let geo_price = asian_black_scholes(&geometric).unwrap(); |
| 321 | + let arith_price = asian_black_scholes(&arithmetic).unwrap(); |
| 322 | + |
| 323 | + // Allow some tolerance for approximation errors |
| 324 | + assert!( |
| 325 | + geo_price <= arith_price + dec!(0.5), |
| 326 | + "Geometric {} should be <= Arithmetic {}", |
| 327 | + geo_price, |
| 328 | + arith_price |
| 329 | + ); |
| 330 | + } |
| 331 | + |
| 332 | + #[test] |
| 333 | + fn test_short_asian_option() { |
| 334 | + let mut option = create_asian_option(OptionStyle::Call, AsianAveragingType::Geometric); |
| 335 | + let long_price = asian_black_scholes(&option).unwrap(); |
| 336 | + |
| 337 | + option.side = Side::Short; |
| 338 | + let short_price = asian_black_scholes(&option).unwrap(); |
| 339 | + |
| 340 | + assert_decimal_eq!(long_price, -short_price, dec!(1e-10)); |
| 341 | + } |
| 342 | + |
| 343 | + #[test] |
| 344 | + fn test_zero_time_to_expiry() { |
| 345 | + let mut option = create_asian_option(OptionStyle::Call, AsianAveragingType::Geometric); |
| 346 | + option.expiration_date = ExpirationDate::Days(Positive::ZERO); |
| 347 | + let price = asian_black_scholes(&option).unwrap(); |
| 348 | + assert_decimal_eq!(price, Decimal::ZERO, dec!(1e-10)); |
| 349 | + } |
| 350 | + |
| 351 | + #[test] |
| 352 | + fn test_itm_asian_call() { |
| 353 | + let mut option = create_asian_option(OptionStyle::Call, AsianAveragingType::Geometric); |
| 354 | + option.underlying_price = pos_or_panic!(120.0); // ITM |
| 355 | + let price = asian_black_scholes(&option).unwrap(); |
| 356 | + assert!( |
| 357 | + price > dec!(10.0), |
| 358 | + "ITM Asian call should have significant value: {}", |
| 359 | + price |
| 360 | + ); |
| 361 | + } |
| 362 | + |
| 363 | + #[test] |
| 364 | + fn test_otm_asian_call() { |
| 365 | + let mut option = create_asian_option(OptionStyle::Call, AsianAveragingType::Geometric); |
| 366 | + option.underlying_price = pos_or_panic!(80.0); // OTM |
| 367 | + let price = asian_black_scholes(&option).unwrap(); |
| 368 | + assert!( |
| 369 | + price < dec!(5.0), |
| 370 | + "OTM Asian call should have low value: {}", |
| 371 | + price |
| 372 | + ); |
| 373 | + } |
| 374 | +} |
0 commit comments