Skip to content

Add Garman-Kohlhagen FX pricing model (#397)#399

Merged
joaquinbejar merged 3 commits into
mainfrom
pricing/garman-kohlhagen
Apr 26, 2026
Merged

Add Garman-Kohlhagen FX pricing model (#397)#399
joaquinbejar merged 3 commits into
mainfrom
pricing/garman-kohlhagen

Conversation

@joaquinbejar
Copy link
Copy Markdown
Owner

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_f under the FX field interpretation, so until now users had to
overload dividend_yield by hand against the BSM kernel. This PR
provides a dedicated, well-named entry point with bit-exact equivalence
to the BSM kernel verified to 1e-9 in the tests, and an FX-specific
documentation section pinning the field mapping.

Changes

  • New pricing kernel garman_kohlhagen(option) -> Result<Decimal, PricingError>:
    delegates to black_scholes after type validation, which guarantees a
    bit-exact equivalence to the BSM kernel for any Options input. Only
    OptionType::European is priced; American, Bermuda, and every exotic
    variant return PricingError::UnsupportedOptionType { method: "Garman-Kohlhagen" }.
  • New GarmanKohlhagen trait mirroring BlackScholes: implementors
    provide get_option(&self) -> Result<&Options, PricingError> and get
    a default calculate_price_garman_kohlhagen.
  • PricingEngine::ClosedFormGK appended at the tail of the enum so
    the existing variants (ClosedFormBS = 0, ClosedFormBlack76 = 1,
    MonteCarlo = 2) keep their implicit discriminants. No semver-major
    bump (PricingEngine has been #[non_exhaustive] since 0.17.0).
  • Field mapping documented in the function header and in
    pricing/mod.rs:
    • Options::risk_free_rate -> r_d (domestic)
    • Options::dividend_yield -> r_f (foreign)
    • Options::underlying_price -> S (spot FX)
  • New workspace member binary examples/examples_pricing/src/bin/garman_kohlhagen.rs
    with 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 to
    forward parity. Uses tracing::info! per project conventions.
  • Doc updates: pricing/mod.rs Core Models / Model Selection
    Guidelines / Performance Considerations now include
    Garman–Kohlhagen; lib.rs mermaid gains an FX / Currency subgraph
    routing garman_kohlhagen -> FX Spot. File inventory updated.
  • Version bump 0.17.0 -> 0.17.1 (Cargo.toml, lib.rs 4 refs,
    CHANGELOG.md entry).

Technical decisions

  • Reference: Garman, M. B., & Kohlhagen, S. W. (1983). Foreign
    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).
  • Wrapper-via-delegation: GK is mathematically BSM(q := r_f). The
    implementation is a thin wrapper that delegates to black_scholes
    after 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 new
    module is the named entry point + FX documentation + PricingEngine
    dispatch, not new math.
  • Schema: no change to Options. dividend_yield doubles as r_f
    per the standard textbook reduction, which is documented prominently
    in the function header.
  • Discriminant ordering: ClosedFormGK is appended after
    MonteCarlo so existing implicit discriminants do not shift; this is
    why a 0.17.1 minor bump is sufficient.
  • Greeks: deferred to a follow-up issue (acceptance criterion in
    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

  • Added: pricing::garman_kohlhagen, pricing::GarmanKohlhagen trait,
    pricing::PricingEngine::ClosedFormGK. All re-exported from the
    prelude via the existing pub use crate::pricing::*; line.
  • No removals, no renames, no feature-flag changes, no new
    dependencies.
  • README.tpl regeneration is part of make pre-push.

Testing

  • Unit tests added (21 co-located tests in
    src/pricing/garman_kohlhagen.rs):
    • Hull canonical FX call (≈ 0.0639, tolerance 1e-3).
    • Hull canonical FX put + ATM FX put-call-parity check.
    • BSM equivalence: garman_kohlhagen vs black_scholes for
      call/put at ATM/ITM/OTM, tolerance 1e-9.
    • FX put-call parity C - P == S·e^(-r_f T) - K·e^(-r_d T) at
      ATM/ITM/OTM, tolerance 1e-6.
    • Symmetric-rate collapse (r_d == r_f -> C - P == e^(-r T)·(S - K)),
      tolerance 1e-6.
    • Zero-vol returns PricingError.
    • Monotonicity in spot for both styles (call up in S, put down in S).
    • Side::Short returns the negation of the long price.
    • Quantity invariance (per-contract pricing).
    • American / Bermuda dispatch errors.
    • GarmanKohlhagen trait default-method consistency.
    • PricingEngine::ClosedFormGK dispatch (long + short — short goes
      through Positive::new_decimal(price.abs())).
  • Integration tests added or updated — covered by the unified
    PricingEngine dispatch tests above; no separate tests/
    additions required.
  • Reference regression test added — Hull canonical FX example +
    structural BSM equivalence to 1e-9.
  • Criterion benchmark added/updated — not a perf PR.
  • cargo clippy --all-targets --all-features --workspace -- -D warnings
    clean.
  • cargo fmt --all --check clean.
  • cargo test --lib 3805 passed; 0 failed (3784 baseline + 21 new
    GK tests).
  • cargo build --release clean.
  • cargo doc --no-deps --lib clean.

Checklist

  • Code follows rules/global_rules.md and CLAUDE.md.
  • All pub items have /// documentation; # Errors on fallible
    functions.
  • No .unwrap() / .expect() / unchecked indexing in production
    code.
  • Checked arithmetic only — no saturating_* / wrapping_* in
    financial math.
  • rust_decimal::Decimal at public monetary boundaries — no f64
    on prices / premia / P&L.
  • Module boundaries respected (model/ untouched).
  • No new dependencies added.
  • tracing only for logging (the new example uses
    tracing::info!).
  • README.tpl regenerated via make readme (run as part of
    make pre-push).

Closes #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.
@joaquinbejar joaquinbejar added enhancement New feature or request pricing Related to options pricing labels Apr 26, 2026
@joaquinbejar joaquinbejar self-assigned this Apr 26, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 26, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

Files with missing lines Coverage Δ
src/lib.rs 100.00% <ø> (ø)
src/pricing/garman_kohlhagen.rs 100.00% <100.00%> (ø)
src/pricing/unified.rs 100.00% <100.00%> (ø)

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_kohlhagen function + GarmanKohlhagen trait, delegating to the existing Black–Scholes kernel after option-type validation.
  • Extends unified dispatch with PricingEngine::ClosedFormGK and 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.

Comment thread src/pricing/unified.rs Outdated
Comment thread src/pricing/unified.rs Outdated
Comment thread src/pricing/garman_kohlhagen.rs
Comment thread src/pricing/garman_kohlhagen.rs Outdated
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.
@joaquinbejar
Copy link
Copy Markdown
Owner Author

Thanks for the review. All four points are valid and addressed in be44687, the closed-form branches in price_option now propagate the original PricingError via ? (BS / Black-76 / GK for consistency), the GK match is collapsed to European => ...; _ => UnsupportedOptionType, and the non-negative r_f limitation imposed by Options::dividend_yield: Positive is documented in both the module-level and function-level docs as a known constraint. Follow-up commit 674ce6c removes two draft issue bodies that git add -A swept up by mistake.

@joaquinbejar joaquinbejar merged commit 0c26f7c into main Apr 26, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request pricing Related to options pricing

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Garman-Kohlhagen pricing model (pricing/fx)

2 participants