use crate::Options;
use crate::error::PricingError;
use crate::model::types::OptionType;
use crate::pricing::black_scholes_model::black_scholes;
use rust_decimal::Decimal;
use tracing::instrument;
#[instrument(skip(option), fields(
strike = %option.strike_price,
style = ?option.option_style,
side = ?option.side,
r_d = %option.risk_free_rate,
r_f = %option.dividend_yield,
))]
pub fn garman_kohlhagen(option: &Options) -> Result<Decimal, PricingError> {
match option.option_type {
OptionType::European => black_scholes(option),
_ => Err(PricingError::unsupported_option_type(
"Non-European",
"Garman-Kohlhagen",
)),
}
}
pub trait GarmanKohlhagen {
fn get_option(&self) -> Result<&Options, PricingError>;
fn calculate_price_garman_kohlhagen(&self) -> Result<Decimal, PricingError> {
garman_kohlhagen(self.get_option()?)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ExpirationDate;
use crate::model::types::{OptionStyle, Side};
use crate::pricing::unified::{PricingEngine, price_option};
use positive::{Positive, pos_or_panic};
use rust_decimal::MathematicalOps;
use rust_decimal_macros::dec;
fn create_fx_option(
s: f64,
k: f64,
r_d: Decimal,
r_f: f64,
t_days: f64,
sigma: f64,
style: OptionStyle,
) -> Options {
Options::new(
OptionType::European,
Side::Long,
"EURUSD".to_string(),
pos_or_panic!(k),
ExpirationDate::Days(pos_or_panic!(t_days)),
pos_or_panic!(sigma),
Positive::ONE,
pos_or_panic!(s),
r_d,
style,
pos_or_panic!(r_f),
None,
)
}
const HULL_T_DAYS: f64 = 365.0 / 3.0;
#[test]
fn test_garman_kohlhagen_call_hull_reference() {
let option = create_fx_option(
1.6,
1.6,
dec!(0.08),
0.11,
HULL_T_DAYS,
0.2,
OptionStyle::Call,
);
let price = garman_kohlhagen(&option).unwrap();
let expected = dec!(0.0639);
let tolerance = dec!(0.001);
assert!(
(price - expected).abs() < tolerance,
"GK call price {} outside tolerance of {} from expected {}",
price,
tolerance,
expected
);
}
#[test]
fn test_garman_kohlhagen_put_hull_reference() {
let call = create_fx_option(
1.6,
1.6,
dec!(0.08),
0.11,
HULL_T_DAYS,
0.2,
OptionStyle::Call,
);
let put = create_fx_option(
1.6,
1.6,
dec!(0.08),
0.11,
HULL_T_DAYS,
0.2,
OptionStyle::Put,
);
let call_price = garman_kohlhagen(&call).unwrap();
let put_price = garman_kohlhagen(&put).unwrap();
let years = call.expiration_date.get_years().unwrap().to_dec();
let discount_d = (-call.risk_free_rate * years).exp();
let discount_f = (-call.dividend_yield.to_dec() * years).exp();
let parity =
call.underlying_price.to_dec() * discount_f - call.strike_price.to_dec() * discount_d;
let actual = call_price - put_price;
assert!(
(actual - parity).abs() < dec!(1e-6),
"FX parity at Hull params: C-P={}, expected={}",
actual,
parity
);
}
fn assert_matches_bsm(
s: f64,
k: f64,
r_d: Decimal,
r_f: f64,
t_days: f64,
sigma: f64,
style: OptionStyle,
) {
let option = create_fx_option(s, k, r_d, r_f, t_days, sigma, style);
let gk_price = garman_kohlhagen(&option).unwrap();
let bs_price = black_scholes(&option).unwrap();
let tolerance = dec!(1e-9);
assert!(
(gk_price - bs_price).abs() < tolerance,
"GK vs BSM mismatch (style={:?}, S={}, K={}, r_d={}, r_f={}, T_days={}, sigma={}): \
GK={}, BSM={}, diff={}",
style,
s,
k,
r_d,
r_f,
t_days,
sigma,
gk_price,
bs_price,
(gk_price - bs_price).abs()
);
}
#[test]
fn test_garman_kohlhagen_matches_bsm_call_atm() {
assert_matches_bsm(1.2, 1.2, dec!(0.05), 0.03, 180.0, 0.15, OptionStyle::Call);
}
#[test]
fn test_garman_kohlhagen_matches_bsm_call_itm() {
assert_matches_bsm(1.3, 1.2, dec!(0.05), 0.03, 180.0, 0.15, OptionStyle::Call);
}
#[test]
fn test_garman_kohlhagen_matches_bsm_call_otm() {
assert_matches_bsm(1.1, 1.2, dec!(0.05), 0.03, 180.0, 0.15, OptionStyle::Call);
}
#[test]
fn test_garman_kohlhagen_matches_bsm_put_atm() {
assert_matches_bsm(1.2, 1.2, dec!(0.05), 0.03, 180.0, 0.15, OptionStyle::Put);
}
#[test]
fn test_garman_kohlhagen_matches_bsm_put_otm() {
assert_matches_bsm(1.3, 1.2, dec!(0.05), 0.03, 180.0, 0.15, OptionStyle::Put);
}
fn assert_fx_parity(
s: f64,
k: f64,
r_d: Decimal,
r_f: f64,
t_days: f64,
sigma: f64,
tolerance: Decimal,
) {
let call = create_fx_option(s, k, r_d, r_f, t_days, sigma, OptionStyle::Call);
let put = create_fx_option(s, k, r_d, r_f, t_days, sigma, OptionStyle::Put);
let call_price = garman_kohlhagen(&call).unwrap();
let put_price = garman_kohlhagen(&put).unwrap();
let years = call.expiration_date.get_years().unwrap().to_dec();
let discount_d = (-call.risk_free_rate * years).exp();
let discount_f = (-call.dividend_yield.to_dec() * years).exp();
let expected =
call.underlying_price.to_dec() * discount_f - call.strike_price.to_dec() * discount_d;
let actual = call_price - put_price;
assert!(
(actual - expected).abs() < tolerance,
"FX parity violation: S={}, K={}, r_d={}, r_f={}: C-P={}, expected={}",
s,
k,
r_d,
r_f,
actual,
expected
);
}
#[test]
fn test_garman_kohlhagen_fx_put_call_parity_atm() {
assert_fx_parity(1.2, 1.2, dec!(0.05), 0.03, 180.0, 0.15, dec!(1e-6));
}
#[test]
fn test_garman_kohlhagen_fx_put_call_parity_itm() {
assert_fx_parity(1.3, 1.2, dec!(0.05), 0.03, 180.0, 0.15, dec!(1e-6));
}
#[test]
fn test_garman_kohlhagen_fx_put_call_parity_otm() {
assert_fx_parity(1.1, 1.2, dec!(0.05), 0.03, 180.0, 0.15, dec!(1e-6));
}
#[test]
fn test_garman_kohlhagen_symmetric_rates_collapse_to_forward_parity() {
let r = dec!(0.05);
let r_f = 0.05;
let s = 1.3;
let k = 1.2;
let t_days = 180.0;
let sigma = 0.15;
let call = create_fx_option(s, k, r, r_f, t_days, sigma, OptionStyle::Call);
let put = create_fx_option(s, k, r, r_f, t_days, sigma, OptionStyle::Put);
let call_price = garman_kohlhagen(&call).unwrap();
let put_price = garman_kohlhagen(&put).unwrap();
let years = call.expiration_date.get_years().unwrap().to_dec();
let discount = (-r * years).exp();
let expected = discount
* (Decimal::from_f64_retain(s).unwrap() - Decimal::from_f64_retain(k).unwrap());
let actual = call_price - put_price;
assert!(
(actual - expected).abs() < dec!(1e-6),
"Symmetric-rate parity: C-P={}, expected={}",
actual,
expected
);
}
#[test]
fn test_garman_kohlhagen_zero_volatility_returns_error() {
let option = create_fx_option(1.2, 1.2, dec!(0.05), 0.03, 180.0, 0.0, OptionStyle::Call);
let result = garman_kohlhagen(&option);
assert!(result.is_err(), "zero vol should propagate BSM error");
}
#[test]
fn test_garman_kohlhagen_monotonicity_call_in_spot() {
let r_d = dec!(0.05);
let r_f = 0.03;
let k = 1.2;
let t_days = 180.0;
let sigma = 0.15;
let p_low = garman_kohlhagen(&create_fx_option(
1.1,
k,
r_d,
r_f,
t_days,
sigma,
OptionStyle::Call,
))
.unwrap();
let p_mid = garman_kohlhagen(&create_fx_option(
1.2,
k,
r_d,
r_f,
t_days,
sigma,
OptionStyle::Call,
))
.unwrap();
let p_high = garman_kohlhagen(&create_fx_option(
1.3,
k,
r_d,
r_f,
t_days,
sigma,
OptionStyle::Call,
))
.unwrap();
assert!(p_low < p_mid, "call must increase with S");
assert!(p_mid < p_high, "call must increase with S");
}
#[test]
fn test_garman_kohlhagen_monotonicity_put_in_spot() {
let r_d = dec!(0.05);
let r_f = 0.03;
let k = 1.2;
let t_days = 180.0;
let sigma = 0.15;
let p_low = garman_kohlhagen(&create_fx_option(
1.1,
k,
r_d,
r_f,
t_days,
sigma,
OptionStyle::Put,
))
.unwrap();
let p_mid = garman_kohlhagen(&create_fx_option(
1.2,
k,
r_d,
r_f,
t_days,
sigma,
OptionStyle::Put,
))
.unwrap();
let p_high = garman_kohlhagen(&create_fx_option(
1.3,
k,
r_d,
r_f,
t_days,
sigma,
OptionStyle::Put,
))
.unwrap();
assert!(p_low > p_mid, "put must decrease with S");
assert!(p_mid > p_high, "put must decrease with S");
}
#[test]
fn test_garman_kohlhagen_short_side_is_negation() {
let long = create_fx_option(1.25, 1.2, dec!(0.05), 0.03, 180.0, 0.15, OptionStyle::Call);
let mut short = long.clone();
short.side = Side::Short;
let p_long = garman_kohlhagen(&long).unwrap();
let p_short = garman_kohlhagen(&short).unwrap();
assert_eq!(p_long, -p_short);
}
#[test]
fn test_garman_kohlhagen_quantity_invariance() {
let mut option =
create_fx_option(1.25, 1.2, dec!(0.05), 0.03, 180.0, 0.15, OptionStyle::Call);
let p1 = garman_kohlhagen(&option).unwrap();
option.quantity = pos_or_panic!(5.0);
let p2 = garman_kohlhagen(&option).unwrap();
assert_eq!(p1, p2, "per-contract price must be quantity-invariant");
}
#[test]
fn test_garman_kohlhagen_unsupported_american() {
let mut option =
create_fx_option(1.2, 1.2, dec!(0.05), 0.03, 180.0, 0.15, OptionStyle::Call);
option.option_type = OptionType::American;
let result = garman_kohlhagen(&option);
assert!(result.is_err());
}
#[test]
fn test_garman_kohlhagen_unsupported_bermuda() {
let mut option =
create_fx_option(1.2, 1.2, dec!(0.05), 0.03, 180.0, 0.15, OptionStyle::Call);
option.option_type = OptionType::Bermuda {
exercise_dates: vec![],
};
let result = garman_kohlhagen(&option);
assert!(result.is_err());
}
#[test]
fn test_garman_kohlhagen_trait_default_method() {
struct FxOption {
option: Options,
}
impl GarmanKohlhagen for FxOption {
fn get_option(&self) -> Result<&Options, PricingError> {
Ok(&self.option)
}
}
let option = create_fx_option(1.25, 1.2, dec!(0.05), 0.03, 180.0, 0.15, OptionStyle::Call);
let wrapped = FxOption { option };
let direct = garman_kohlhagen(wrapped.get_option().unwrap()).unwrap();
let via_trait = wrapped.calculate_price_garman_kohlhagen().unwrap();
assert_eq!(direct, via_trait);
}
#[test]
fn test_pricing_engine_closed_form_gk_dispatch_long() {
let option = create_fx_option(1.25, 1.2, dec!(0.05), 0.03, 180.0, 0.15, OptionStyle::Call);
let direct = garman_kohlhagen(&option).unwrap();
let via_engine = price_option(&option, &PricingEngine::ClosedFormGK).unwrap();
assert_eq!(via_engine.to_dec(), direct);
}
#[test]
fn test_pricing_engine_closed_form_gk_dispatch_short_uses_abs() {
let mut option =
create_fx_option(1.25, 1.2, dec!(0.05), 0.03, 180.0, 0.15, OptionStyle::Call);
option.side = Side::Short;
let direct = garman_kohlhagen(&option).unwrap();
let via_engine = price_option(&option, &PricingEngine::ClosedFormGK).unwrap();
assert_eq!(via_engine.to_dec(), direct.abs());
}
}