Add Garman-Kohlhagen FX pricing model (#397)#399
Conversation
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.
Codecov Report✅ All modified and coverable lines are covered by tests.
... and 1 file with indirect coverage changes 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
Adds a dedicated Garman–Kohlhagen (1983) closed-form entry point for European FX option pricing, integrating it into the unified pricing dispatch and documenting the FX field mapping onto the existing Options schema.
Changes:
- Introduces
pricing::garman_kohlhagenfunction +GarmanKohlhagentrait, delegating to the existing Black–Scholes kernel after option-type validation. - Extends unified dispatch with
PricingEngine::ClosedFormGKand updates pricing module/docs to include the FX model and mapping. - Adds a runnable pricing example binary, bumps crate version to
0.17.1, and updates changelog/docs accordingly.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| src/pricing/unified.rs | Adds ClosedFormGK engine variant and dispatch via garman_kohlhagen; updates error docs. |
| src/pricing/mod.rs | Documents GK model and re-exports garman_kohlhagen + trait; updates model selection/perf notes. |
| src/pricing/garman_kohlhagen.rs | New GK wrapper implementation, trait, and comprehensive unit tests. |
| src/lib.rs | Updates version references and documentation diagram to include GK/FX. |
| examples/examples_pricing/src/bin/garman_kohlhagen.rs | New example showcasing GK pricing and parity checks. |
| examples/examples_pricing/Cargo.toml | Registers new garman_kohlhagen example binary target. |
| Cargo.toml | Bumps crate version from 0.17.0 to 0.17.1. |
| CHANGELOG.md | Adds 0.17.1 entry describing the GK model addition and dispatch integration. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
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.
`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.
|
Thanks for the review. All four points are valid and addressed in be44687, the closed-form branches in |
Summary
Adds the Garman–Kohlhagen (1983) closed-form pricing model for European
FX options. GK is structurally identical to Black–Scholes–Merton with
q = r_funder the FX field interpretation, so until now users had tooverload
dividend_yieldby hand against the BSM kernel. This PRprovides a dedicated, well-named entry point with bit-exact equivalence
to the BSM kernel verified to
1e-9in the tests, and an FX-specificdocumentation section pinning the field mapping.
Changes
garman_kohlhagen(option) -> Result<Decimal, PricingError>:delegates to
black_scholesafter type validation, which guarantees abit-exact equivalence to the BSM kernel for any
Optionsinput. OnlyOptionType::Europeanis priced; American, Bermuda, and every exoticvariant return
PricingError::UnsupportedOptionType { method: "Garman-Kohlhagen" }.GarmanKohlhagentrait mirroringBlackScholes: implementorsprovide
get_option(&self) -> Result<&Options, PricingError>and geta default
calculate_price_garman_kohlhagen.PricingEngine::ClosedFormGKappended at the tail of the enum sothe existing variants (
ClosedFormBS = 0,ClosedFormBlack76 = 1,MonteCarlo = 2) keep their implicit discriminants. No semver-majorbump (
PricingEnginehas been#[non_exhaustive]since 0.17.0).pricing/mod.rs:Options::risk_free_rate->r_d(domestic)Options::dividend_yield->r_f(foreign)Options::underlying_price->S(spot FX)examples/examples_pricing/src/bin/garman_kohlhagen.rswith four demos: Hull canonical USD/GBP (ATM 4-month), ITM EUR/USD
with FX put-call-parity check, dispatch via the unified
PricingEngine, and the symmetric-rate (r_d == r_f) collapse toforward parity. Uses
tracing::info!per project conventions.pricing/mod.rsCore Models / Model SelectionGuidelines / Performance Considerations now include
Garman–Kohlhagen;
lib.rsmermaid gains anFX / Currencysubgraphrouting
garman_kohlhagen -> FX Spot. File inventory updated.Cargo.toml,lib.rs4 refs,CHANGELOG.mdentry).Technical decisions
currency option values. Hull Options, Futures and Other
Derivatives — canonical worked example regressed against
(
S = K = 1.6 USD/GBP,r_d = 0.08,r_f = 0.11,sigma = 0.2,T = 4/12-> call ≈0.0639).BSM(q := r_f). Theimplementation is a thin wrapper that delegates to
black_scholesafter rejecting non-European types. This keeps a single source of
truth for the closed form, eliminates duplicated kernels, and yields
bit-exact equality (
1e-9) by construction. The value of the newmodule is the named entry point + FX documentation +
PricingEnginedispatch, not new math.
Options.dividend_yielddoubles asr_fper the standard textbook reduction, which is documented prominently
in the function header.
ClosedFormGKis appended afterMonteCarloso existing implicit discriminants do not shift; this iswhy a 0.17.1 minor bump is sufficient.
Add Garman-Kohlhagen pricing model (pricing/fx) #397 explicitly allows splitting them off; the matching Greeks
follow-up for Black-76 is also pending).
Public API impact
pricing::garman_kohlhagen,pricing::GarmanKohlhagentrait,pricing::PricingEngine::ClosedFormGK. All re-exported from thepreludevia the existingpub use crate::pricing::*;line.dependencies.
README.tplregeneration is part ofmake pre-push.Testing
src/pricing/garman_kohlhagen.rs):≈ 0.0639, tolerance1e-3).garman_kohlhagenvsblack_scholesforcall/put at ATM/ITM/OTM, tolerance
1e-9.C - P == S·e^(-r_f T) - K·e^(-r_d T)atATM/ITM/OTM, tolerance
1e-6.r_d == r_f->C - P == e^(-r T)·(S - K)),tolerance
1e-6.PricingError.Side::Shortreturns the negation of the long price.GarmanKohlhagentrait default-method consistency.PricingEngine::ClosedFormGKdispatch (long + short — short goesthrough
Positive::new_decimal(price.abs())).PricingEnginedispatch tests above; no separatetests/additions required.
structural BSM equivalence to
1e-9.cargo clippy --all-targets --all-features --workspace -- -D warningsclean.
cargo fmt --all --checkclean.cargo test --lib3805 passed; 0 failed (3784 baseline + 21 newGK tests).
cargo build --releaseclean.cargo doc --no-deps --libclean.Checklist
rules/global_rules.mdandCLAUDE.md.pubitems have///documentation;# Errorson falliblefunctions.
.unwrap()/.expect()/ unchecked indexing in productioncode.
saturating_*/wrapping_*infinancial math.
rust_decimal::Decimalat public monetary boundaries — nof64on prices / premia / P&L.
model/untouched).tracingonly for logging (the new example usestracing::info!).README.tplregenerated viamake readme(run as part ofmake pre-push).Closes #397