Skip to content

Commit 0c26f7c

Browse files
authored
Add Garman-Kohlhagen FX pricing model (#397) (#399)
* feat(pricing): add Garman-Kohlhagen FX pricing model (#397) Adds the Garman–Kohlhagen (1983) closed-form pricing model for European FX options. Structurally identical to Black–Scholes–Merton with `q = r_f` under the FX field interpretation `risk_free_rate -> r_d`, `dividend_yield -> r_f`, `underlying_price -> S`. The new entry point delegates to `black_scholes` after type validation, which guarantees a bit-exact equivalence to the BSM kernel (verified to 1e-9 in the tests) while providing a dedicated, well-named API for FX. What ships: - `src/pricing/garman_kohlhagen.rs`: `garman_kohlhagen()` + `GarmanKohlhagen` trait + 21 co-located unit tests. - `src/pricing/mod.rs`: `pub use` re-exports + Core Models / Model Selection / Performance Considerations sections. - `src/pricing/unified.rs`: appends `PricingEngine::ClosedFormGK` at the tail of the enum so the existing variants keep their implicit discriminants (no semver-major bump). Dispatch + `# Errors` updated. - `src/lib.rs`: pricing-models mermaid gains an `FX / Currency` subgraph routing `garman_kohlhagen -> FX Spot`. File inventory updated. - `examples/examples_pricing/src/bin/garman_kohlhagen.rs`: runnable demo (Hull canonical USD/GBP, ITM EUR/USD with FX parity check, unified-API dispatch, symmetric-rate degenerate case). Uses tracing::info!. - `Cargo.toml` + `src/lib.rs` (4 refs) + `CHANGELOG.md`: bump 0.17.0 -> 0.17.1 (minor; PricingEngine has been #[non_exhaustive] since 0.17.0 and no discriminants shift). Tests cover the Hull canonical reference (S = K = 1.6 USD/GBP, r_d = 0.08, r_f = 0.11, sigma = 0.2, T = 4/12 -> call ~ 0.0639), structural BSM equivalence to 1e-9 (call/put at ATM/ITM/OTM), FX put-call parity to 1e-6, the symmetric-rate (r_d = r_f) collapse to forward parity, zero-vol error propagation, monotonicity in spot, short = -long sign convention, quantity invariance, all unsupported-type dispatches, the trait default method, and PricingEngine::ClosedFormGK dispatch (long + short). Pre-submission status: clippy/fmt/release/doc clean; cargo test --lib 3805 passed; 0 failed (3784 baseline + 21 new GK). Closes #397. * address review: pass-through PricingError + GK doc/match cleanup Addresses the four Copilot review comments on PR #399. - src/pricing/unified.rs: the closed-form branches (BS, Black-76, GK) were wrapping every underlying `PricingError` via `method_error(..)`, which discarded the structured variant and made it impossible for callers to pattern-match on `UnsupportedOptionType` / `ExpirationDate` / `Greeks`. Switch the three branches to `?` pass-through so the original variant survives. Update the `# Errors` docstring to describe the actual propagation behaviour and the variant set callers should expect. - src/pricing/garman_kohlhagen.rs: collapse the 14-arm `OptionType` match into `European => black_scholes(option); _ => Err(...)`. The wildcard arm tags the error as `"Non-European"`, which is what the unit tests already assert via `is_err()` and is more maintainable when new exotic variants land. - src/pricing/garman_kohlhagen.rs: document the non-negative `r_f` limitation imposed by `Options::dividend_yield: Positive` in both the module-level *Limitations* section and the function-level `# Limitations` block. Negative-rate FX regimes (CHF / JPY / EUR 2015–2022) cannot be expressed with the current schema; lifting the constraint requires a dedicated signed `foreign_rate` field, which is deliberately out of scope. `cargo test --lib`: 3805 passed; 0 failed. Clippy / fmt / build clean. * chore: drop accidentally-committed draft issue bodies `be44687f` swept two local draft issue bodies (`.issue-black76-greeks.md`, `.issue-gk-greeks.md`) into the commit via `git add -A`. They were working-tree scratch for the deferred Black-76 / Garman-Kohlhagen Greeks follow-up issues and should not ship. Remove them and add `.issue-*.md` / `.pr-*.md` patterns at the repo root to `.gitignore` so this doesn't recur.
1 parent 18de062 commit 0c26f7c

9 files changed

Lines changed: 800 additions & 27 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,7 @@ src/.DS_Store
3131

3232
# Local development examples
3333
/examples/Local/
34+
35+
# Local draft issue / PR bodies kept at the repo root
36+
/.issue-*.md
37+
/.pr-*.md

CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.17.1] - 2026-04-26
11+
12+
Minor release adding the Garman–Kohlhagen (1983) closed-form pricing
13+
model for European FX options. The new variant
14+
`PricingEngine::ClosedFormGK` is appended at the tail of the enum so
15+
the existing variants keep their implicit discriminants and no major
16+
bump is required (`PricingEngine` has been `#[non_exhaustive]` since
17+
0.17.0).
18+
19+
### Added
20+
21+
- `pricing::garman_kohlhagen`: closed-form
22+
`garman_kohlhagen(option) -> Result<Decimal, PricingError>` for
23+
European options on FX spot rates. Structurally identical to BSM
24+
with `q = r_f`; the implementation delegates to `black_scholes`
25+
after type validation, guaranteeing bit-exact equivalence (verified
26+
to `1e-9` in the tests).
27+
- `pricing::GarmanKohlhagen` trait with default
28+
`calculate_price_garman_kohlhagen` (mirrors the `BlackScholes`
29+
trait pattern).
30+
- `pricing::PricingEngine::ClosedFormGK` variant + dispatch from
31+
`price_option`.
32+
- `examples/examples_pricing/src/bin/garman_kohlhagen.rs`: runnable
33+
demo (Hull canonical USD/GBP, ITM EUR/USD with FX parity check,
34+
unified-API dispatch, symmetric-rate degenerate case).
35+
- `lib.rs` mermaid: new `FX / Currency` subgraph routing
36+
`garman_kohlhagen -> FX Spot`.
37+
38+
### Changed
39+
40+
- `pricing/mod.rs` Core Models / Model Selection Guidelines /
41+
Performance Considerations now include Garman–Kohlhagen with the
42+
explicit field mapping `risk_free_rate -> r_d`,
43+
`dividend_yield -> r_f`, `underlying_price -> S`.
44+
1045
## [0.17.0] - 2026-04-26
1146

1247
Major release adding the Black-76 closed-form pricing model for European

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "optionstratlib"
3-
version = "0.17.0"
3+
version = "0.17.1"
44
edition = "2024"
55
authors = ["Joaquin Bejar <jb@taunais.com>"]
66
description = "OptionStratLib is a comprehensive Rust library for options trading and strategy development across multiple asset classes."

examples/examples_pricing/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,7 @@ tracing-subscriber = { workspace = true, features = ["env-filter"] }
1515
[[bin]]
1616
name = "black_76"
1717
path = "src/bin/black_76.rs"
18+
19+
[[bin]]
20+
name = "garman_kohlhagen"
21+
path = "src/bin/garman_kohlhagen.rs"
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/******************************************************************************
2+
Author: Joaquín Béjar García
3+
Email: jb@taunais.com
4+
Date: 2026-04-26
5+
******************************************************************************/
6+
7+
//! Garman–Kohlhagen pricing example.
8+
//!
9+
//! Demonstrates pricing European FX options using the Garman–Kohlhagen
10+
//! (1983) model: the Hull canonical example (USD/GBP, 4-month ATM), an
11+
//! ITM EUR/USD scenario with FX put-call-parity check, dispatch through
12+
//! the unified `PricingEngine`, and the symmetric-rate degenerate case.
13+
14+
use optionstratlib::model::types::{OptionStyle, OptionType, Side};
15+
use optionstratlib::pricing::{PricingEngine, garman_kohlhagen, price_option};
16+
use optionstratlib::{ExpirationDate, Options};
17+
use positive::pos_or_panic;
18+
use rust_decimal::MathematicalOps;
19+
use rust_decimal_macros::dec;
20+
use tracing::info;
21+
22+
fn main() -> Result<(), Box<dyn std::error::Error>> {
23+
tracing_subscriber::fmt()
24+
.with_env_filter("info")
25+
.without_time()
26+
.init();
27+
28+
info!("=== Garman-Kohlhagen FX Pricing Examples ===");
29+
30+
// ---- Example 1: Hull canonical USD/GBP example ----------------------
31+
// S = K = 1.6 USD/GBP, r_d = 0.08, r_f = 0.11, sigma = 0.2, T = 4/12
32+
// -> call ~ 0.0639 (Hull, "Options, Futures and Other Derivatives").
33+
info!("");
34+
info!("Example 1: Hull canonical USD/GBP (ATM, 4-month)");
35+
let hull_t_days = 365.0 / 3.0;
36+
let option1 = Options::new(
37+
OptionType::European,
38+
Side::Long,
39+
"GBPUSD".to_string(),
40+
pos_or_panic!(1.6),
41+
ExpirationDate::Days(pos_or_panic!(hull_t_days)),
42+
pos_or_panic!(0.2),
43+
pos_or_panic!(1.0),
44+
pos_or_panic!(1.6),
45+
dec!(0.08),
46+
OptionStyle::Call,
47+
pos_or_panic!(0.11),
48+
None,
49+
);
50+
let call_price = garman_kohlhagen(&option1)?;
51+
let mut put1 = option1.clone();
52+
put1.option_style = OptionStyle::Put;
53+
let put_price = garman_kohlhagen(&put1)?;
54+
55+
let years1 = option1.expiration_date.get_years()?.to_dec();
56+
let df_d = (-option1.risk_free_rate * years1).exp();
57+
let df_f = (-option1.dividend_yield.to_dec() * years1).exp();
58+
let parity_lhs = call_price - put_price;
59+
let parity_rhs =
60+
option1.underlying_price.to_dec() * df_f - option1.strike_price.to_dec() * df_d;
61+
62+
info!(" S = 1.6, K = 1.6, r_d = 0.08, r_f = 0.11, T = 1/3 yr, sigma = 0.2");
63+
info!(
64+
" Call price = {} (Hull reference ~ 0.0639)",
65+
call_price
66+
);
67+
info!(" Put price = {}", put_price);
68+
info!(" C - P = {}", parity_lhs);
69+
info!(" S e^(-r_f T) - K e^(-r_d T) = {}", parity_rhs);
70+
71+
// ---- Example 2: ITM EUR/USD call ------------------------------------
72+
info!("");
73+
info!("Example 2: ITM EUR/USD call (FX parity check)");
74+
let option2 = Options::new(
75+
OptionType::European,
76+
Side::Long,
77+
"EURUSD".to_string(),
78+
pos_or_panic!(1.20),
79+
ExpirationDate::Days(pos_or_panic!(180.0)),
80+
pos_or_panic!(0.10),
81+
pos_or_panic!(1.0),
82+
pos_or_panic!(1.25),
83+
dec!(0.045),
84+
OptionStyle::Call,
85+
pos_or_panic!(0.025),
86+
None,
87+
);
88+
let call2 = garman_kohlhagen(&option2)?;
89+
let mut put2 = option2.clone();
90+
put2.option_style = OptionStyle::Put;
91+
let put2_price = garman_kohlhagen(&put2)?;
92+
93+
let years2 = option2.expiration_date.get_years()?.to_dec();
94+
let df_d2 = (-option2.risk_free_rate * years2).exp();
95+
let df_f2 = (-option2.dividend_yield.to_dec() * years2).exp();
96+
let parity2 = option2.underlying_price.to_dec() * df_f2 - option2.strike_price.to_dec() * df_d2;
97+
98+
info!(" S = 1.25, K = 1.20, r_d = 4.5%, r_f = 2.5%, T = 180d, sigma = 0.10");
99+
info!(" Call price = {}", call2);
100+
info!(" Put price = {}", put2_price);
101+
info!(" C - P = {}", call2 - put2_price);
102+
info!(" Expected parity = {}", parity2);
103+
104+
// ---- Example 3: Unified API dispatch --------------------------------
105+
info!("");
106+
info!("Example 3: Unified API via PricingEngine::ClosedFormGK");
107+
let direct = garman_kohlhagen(&option2)?;
108+
let via_engine = price_option(&option2, &PricingEngine::ClosedFormGK)?;
109+
info!(" Direct garman_kohlhagen() = {}", direct);
110+
info!(" Via PricingEngine dispatch = {}", via_engine);
111+
112+
// ---- Example 4: Symmetric rates collapse to forward parity ----------
113+
info!("");
114+
info!("Example 4: Symmetric rates (r_d = r_f) collapse to forward parity");
115+
let option4 = Options::new(
116+
OptionType::European,
117+
Side::Long,
118+
"USDUSD".to_string(),
119+
pos_or_panic!(1.20),
120+
ExpirationDate::Days(pos_or_panic!(180.0)),
121+
pos_or_panic!(0.10),
122+
pos_or_panic!(1.0),
123+
pos_or_panic!(1.25),
124+
dec!(0.04),
125+
OptionStyle::Call,
126+
pos_or_panic!(0.04),
127+
None,
128+
);
129+
let call4 = garman_kohlhagen(&option4)?;
130+
let mut put4 = option4.clone();
131+
put4.option_style = OptionStyle::Put;
132+
let put4_price = garman_kohlhagen(&put4)?;
133+
let years4 = option4.expiration_date.get_years()?.to_dec();
134+
let df = (-option4.risk_free_rate * years4).exp();
135+
let collapsed = df * (option4.underlying_price.to_dec() - option4.strike_price.to_dec());
136+
info!(" C - P = {}", call4 - put4_price);
137+
info!(" e^(-r T) (S - K) = {}", collapsed);
138+
139+
Ok(())
140+
}

src/lib.rs

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
// into, so the lint is silenced in `#[cfg(test)]` only.
1111
#![cfg_attr(test, allow(clippy::indexing_slicing))]
1212

13-
//! # OptionStratLib v0.17.0: Financial Options Library
13+
//! # OptionStratLib v0.17.1: Financial Options Library
1414
//!
1515
//! ## Table of Contents
1616
//! 1. [Introduction](#introduction)
@@ -244,6 +244,7 @@
244244
//! Advanced pricing engines for options valuation:
245245
//! - `black_scholes_model.rs`: European options pricing with Greeks
246246
//! - `black_76.rs`: European options on futures/forwards (Black 1976)
247+
//! - `garman_kohlhagen.rs`: European FX options (Garman-Kohlhagen 1983)
247248
//! - `binomial_model.rs`: American/European options with early exercise
248249
//! - `monte_carlo.rs`: Path-dependent and exotic options pricing
249250
//! - `telegraph.rs`: Jump-diffusion process modeling
@@ -562,11 +563,16 @@
562563
//! FWD[Forward]
563564
//! end
564565
//!
566+
//! subgraph FX["FX / Currency"]
567+
//! FX_S[FX Spot]
568+
//! end
569+
//!
565570
//! BS[black_scholes] --> EU
566571
//! BS --> PathDependent
567572
//! BS --> MultiAsset
568573
//! BS --> Special
569574
//! B76[black_76] --> Forward
575+
//! GK[garman_kohlhagen] --> FX
570576
//! BAW[barone_adesi_whaley] --> AM
571577
//! BIN[binomial_model] --> AM
572578
//! BIN --> BE
@@ -794,7 +800,7 @@
794800
//!
795801
//! ```toml
796802
//! [dependencies]
797-
//! optionstratlib = "0.17.0"
803+
//! optionstratlib = "0.17.1"
798804
//! ```
799805
//!
800806
//! Or use cargo to add it to your project:
@@ -809,7 +815,7 @@
809815
//!
810816
//! ```toml
811817
//! [dependencies]
812-
//! optionstratlib = { version = "0.17.0", features = ["plotly"] }
818+
//! optionstratlib = { version = "0.17.1", features = ["plotly"] }
813819
//! ```
814820
//!
815821
//! - `plotly`: Enables interactive visualization using plotly.rs
@@ -1200,7 +1206,7 @@
12001206
//!
12011207
//! ---
12021208
//!
1203-
//! **OptionStratLib v0.17.0** - Built with ❤️ in Rust for the financial community
1209+
//! **OptionStratLib v0.17.1** - Built with ❤️ in Rust for the financial community
12041210
//!
12051211
12061212
/// # OptionsStratLib: Financial Options Trading Library

0 commit comments

Comments
 (0)