Skip to content

Commit d5b7c2e

Browse files
committed
address review: handle T=0 + use checked d_add in theta
- Added time_to_expiry helper that mirrors BSM Greeks: Greeks at expiration return discrete values (delta = ±1/0 by intrinsic state, gamma/vega/theta/ρ_d/ρ_f = 0) instead of erroring out from d1/d2. - Replaced raw `+` in theta_gk with checked d_add to keep arithmetic consistent with the rest of the crate (tracked in src/model/decimal.rs). - New tests cover T=0 across all six Greeks, including ITM/OTM and short side. 23 GK Greek tests pass. Addresses Copilot review on PR #403.
1 parent 91efc2c commit d5b7c2e

1 file changed

Lines changed: 99 additions & 9 deletions

File tree

src/greeks/garman_kohlhagen.rs

Lines changed: 99 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,9 @@ use crate::Options;
5656
use crate::error::PricingError;
5757
use crate::error::greeks::GreeksError;
5858
use crate::greeks::utils::{big_n, d1, d2, n};
59-
use crate::model::decimal::{d_div, d_mul, d_sub};
59+
use crate::model::decimal::{d_add, d_div, d_mul, d_sub};
6060
use crate::model::types::{OptionStyle, OptionType, Side};
61+
use positive::Positive;
6162
use rust_decimal::{Decimal, MathematicalOps};
6263
use tracing::{instrument, trace};
6364

@@ -93,6 +94,41 @@ fn cost_of_carry(option: &Options) -> Decimal {
9394
option.risk_free_rate - option.dividend_yield.to_dec()
9495
}
9596

97+
/// Returns `Some(time_in_years)` when the option has not expired yet, or
98+
/// `None` at expiration. Mirrors the `T == 0` short-circuit used by the
99+
/// BSM Greeks in `src/greeks/equations.rs`, where the d-values are
100+
/// undefined and the Greeks collapse to discrete intrinsic-state values.
101+
fn time_to_expiry(option: &Options) -> Result<Option<Positive>, GreeksError> {
102+
let years = option.expiration_date.get_years()?;
103+
if years == Positive::ZERO {
104+
Ok(None)
105+
} else {
106+
Ok(Some(years))
107+
}
108+
}
109+
110+
/// Closed-form delta value at expiration. Calls pay 1 ITM / 0 OTM; puts pay
111+
/// −1 ITM / 0 OTM. Matches the `T == 0` branch of `crate::greeks::delta`.
112+
fn delta_at_expiry(option: &Options) -> Decimal {
113+
let sign = side_sign(option);
114+
match option.option_style {
115+
OptionStyle::Call => {
116+
if option.underlying_price > option.strike_price {
117+
sign
118+
} else {
119+
Decimal::ZERO
120+
}
121+
}
122+
OptionStyle::Put => {
123+
if option.underlying_price < option.strike_price {
124+
-sign
125+
} else {
126+
Decimal::ZERO
127+
}
128+
}
129+
}
130+
}
131+
96132
/// Computes (`d1`, `d2`) for Garman–Kohlhagen using `b = r_d − r_f` as the
97133
/// drift term, mirroring the helper used by the GK pricing kernel.
98134
fn calculate_d_values_gk(option: &Options) -> Result<(Decimal, Decimal), GreeksError> {
@@ -144,7 +180,12 @@ fn calculate_d_values_gk(option: &Options) -> Result<(Decimal, Decimal), GreeksE
144180
))]
145181
pub fn delta_gk(option: &Options) -> Result<Decimal, GreeksError> {
146182
ensure_european(option)?;
147-
let t = option.expiration_date.get_years()?.to_dec();
183+
let Some(t_pos) = time_to_expiry(option)? else {
184+
// Mirror BSM: at expiration the option is a binary intrinsic state.
185+
let qty = option.quantity.to_dec();
186+
return Ok(delta_at_expiry(option) * qty);
187+
};
188+
let t = t_pos.to_dec();
148189
let (d1_v, _d2) = calculate_d_values_gk(option)?;
149190

150191
let r_f = option.dividend_yield.to_dec();
@@ -183,7 +224,9 @@ pub fn delta_gk(option: &Options) -> Result<Decimal, GreeksError> {
183224
#[instrument(skip(option), fields(strike = %option.strike_price))]
184225
pub fn gamma_gk(option: &Options) -> Result<Decimal, GreeksError> {
185226
ensure_european(option)?;
186-
let t = option.expiration_date.get_years()?;
227+
let Some(t) = time_to_expiry(option)? else {
228+
return Ok(Decimal::ZERO);
229+
};
187230
let (d1_v, _d2) = calculate_d_values_gk(option)?;
188231

189232
let r_f = option.dividend_yield.to_dec();
@@ -221,7 +264,9 @@ pub fn gamma_gk(option: &Options) -> Result<Decimal, GreeksError> {
221264
#[instrument(skip(option), fields(strike = %option.strike_price))]
222265
pub fn vega_gk(option: &Options) -> Result<Decimal, GreeksError> {
223266
ensure_european(option)?;
224-
let t = option.expiration_date.get_years()?;
267+
let Some(t) = time_to_expiry(option)? else {
268+
return Ok(Decimal::ZERO);
269+
};
225270
let (d1_v, _d2) = calculate_d_values_gk(option)?;
226271

227272
let r_f = option.dividend_yield.to_dec();
@@ -258,7 +303,9 @@ pub fn vega_gk(option: &Options) -> Result<Decimal, GreeksError> {
258303
#[instrument(skip(option), fields(strike = %option.strike_price))]
259304
pub fn theta_gk(option: &Options) -> Result<Decimal, GreeksError> {
260305
ensure_european(option)?;
261-
let t = option.expiration_date.get_years()?;
306+
let Some(t) = time_to_expiry(option)? else {
307+
return Ok(Decimal::ZERO);
308+
};
262309
let (d1_v, d2_v) = calculate_d_values_gk(option)?;
263310

264311
let r_d = option.risk_free_rate;
@@ -290,13 +337,15 @@ pub fn theta_gk(option: &Options) -> Result<Decimal, GreeksError> {
290337
// Θ_call = common − r_d·K·e^(-r_d T)·N(d2) + r_f·S·e^(-r_f T)·N(d1)
291338
let minus = d_mul(r_d_k_df_d, big_n(d2_v)?, "greeks::gk::theta::call::minus")?;
292339
let plus = d_mul(r_f_s_df_f, big_n(d1_v)?, "greeks::gk::theta::call::plus")?;
293-
d_sub(common + plus, minus, "greeks::gk::theta::call::sum")?
340+
let common_plus = d_add(common, plus, "greeks::gk::theta::call::common_plus")?;
341+
d_sub(common_plus, minus, "greeks::gk::theta::call::sum")?
294342
}
295343
OptionStyle::Put => {
296344
// Θ_put = common + r_d·K·e^(-r_d T)·N(-d2) − r_f·S·e^(-r_f T)·N(-d1)
297345
let plus = d_mul(r_d_k_df_d, big_n(-d2_v)?, "greeks::gk::theta::put::plus")?;
298346
let minus = d_mul(r_f_s_df_f, big_n(-d1_v)?, "greeks::gk::theta::put::minus")?;
299-
d_sub(common + plus, minus, "greeks::gk::theta::put::sum")?
347+
let common_plus = d_add(common, plus, "greeks::gk::theta::put::common_plus")?;
348+
d_sub(common_plus, minus, "greeks::gk::theta::put::sum")?
300349
}
301350
};
302351

@@ -329,7 +378,9 @@ pub fn theta_gk(option: &Options) -> Result<Decimal, GreeksError> {
329378
#[instrument(skip(option), fields(strike = %option.strike_price))]
330379
pub fn rho_domestic_gk(option: &Options) -> Result<Decimal, GreeksError> {
331380
ensure_european(option)?;
332-
let t = option.expiration_date.get_years()?;
381+
let Some(t) = time_to_expiry(option)? else {
382+
return Ok(Decimal::ZERO);
383+
};
333384
let (_d1, d2_v) = calculate_d_values_gk(option)?;
334385

335386
let r_d = option.risk_free_rate;
@@ -370,7 +421,9 @@ pub fn rho_domestic_gk(option: &Options) -> Result<Decimal, GreeksError> {
370421
#[instrument(skip(option), fields(strike = %option.strike_price))]
371422
pub fn rho_foreign_gk(option: &Options) -> Result<Decimal, GreeksError> {
372423
ensure_european(option)?;
373-
let t = option.expiration_date.get_years()?;
424+
let Some(t) = time_to_expiry(option)? else {
425+
return Ok(Decimal::ZERO);
426+
};
374427
let (d1_v, _d2) = calculate_d_values_gk(option)?;
375428

376429
let r_f = option.dividend_yield.to_dec();
@@ -766,4 +819,41 @@ mod tests {
766819
assert_eq!(q.rho_domestic_gk().unwrap(), rho_domestic_gk(&opt).unwrap());
767820
assert_eq!(q.rho_foreign_gk().unwrap(), rho_foreign_gk(&opt).unwrap());
768821
}
822+
823+
// ---- T = 0 (expiration) handling, mirrors BSM Greeks --------------
824+
825+
#[test]
826+
fn test_t_zero_delta_call_long_itm() {
827+
let opt = create_fx_option(1.20, 1.10, dec!(0.04), 0.02, 0.0, 0.10, OptionStyle::Call);
828+
assert_eq!(delta_gk(&opt).unwrap(), Decimal::ONE);
829+
}
830+
831+
#[test]
832+
fn test_t_zero_delta_call_long_otm() {
833+
let opt = create_fx_option(1.05, 1.10, dec!(0.04), 0.02, 0.0, 0.10, OptionStyle::Call);
834+
assert_eq!(delta_gk(&opt).unwrap(), Decimal::ZERO);
835+
}
836+
837+
#[test]
838+
fn test_t_zero_delta_put_long_itm() {
839+
let opt = create_fx_option(1.05, 1.10, dec!(0.04), 0.02, 0.0, 0.10, OptionStyle::Put);
840+
assert_eq!(delta_gk(&opt).unwrap(), Decimal::NEGATIVE_ONE);
841+
}
842+
843+
#[test]
844+
fn test_t_zero_delta_short_call_itm() {
845+
let mut opt = create_fx_option(1.20, 1.10, dec!(0.04), 0.02, 0.0, 0.10, OptionStyle::Call);
846+
opt.side = Side::Short;
847+
assert_eq!(delta_gk(&opt).unwrap(), Decimal::NEGATIVE_ONE);
848+
}
849+
850+
#[test]
851+
fn test_t_zero_other_greeks_zero() {
852+
let opt = create_fx_option(1.10, 1.10, dec!(0.04), 0.02, 0.0, 0.10, OptionStyle::Call);
853+
assert_eq!(gamma_gk(&opt).unwrap(), Decimal::ZERO);
854+
assert_eq!(vega_gk(&opt).unwrap(), Decimal::ZERO);
855+
assert_eq!(theta_gk(&opt).unwrap(), Decimal::ZERO);
856+
assert_eq!(rho_domestic_gk(&opt).unwrap(), Decimal::ZERO);
857+
assert_eq!(rho_foreign_gk(&opt).unwrap(), Decimal::ZERO);
858+
}
769859
}

0 commit comments

Comments
 (0)