Skip to content

Commit a0d1aa0

Browse files
authored
Merge pull request #273 from joaquinbejar/feat/issue-237-asian-option
feat: Implement Asian Option Pricing Model
2 parents 62e89ac + 4591edf commit a0d1aa0

3 files changed

Lines changed: 379 additions & 4 deletions

File tree

src/pricing/asian.rs

Lines changed: 374 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,374 @@
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+
}

src/pricing/black_scholes_model.rs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,10 +69,7 @@ pub fn black_scholes(option: &Options) -> Result<Decimal, PricingError> {
6969
"Bermuda",
7070
"Black-Scholes",
7171
)),
72-
OptionType::Asian { .. } => Err(PricingError::unsupported_option_type(
73-
"Asian",
74-
"Black-Scholes",
75-
)),
72+
OptionType::Asian { .. } => crate::pricing::asian::asian_black_scholes(option),
7673
OptionType::Barrier { .. } => crate::pricing::barrier::barrier_black_scholes(option),
7774
OptionType::Binary { .. } => Err(PricingError::unsupported_option_type(
7875
"Binary",

0 commit comments

Comments
 (0)