Skip to content

Commit be44687

Browse files
committed
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.
1 parent 834594f commit be44687

4 files changed

Lines changed: 157 additions & 77 deletions

File tree

.issue-black76-greeks.md

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
## Overview
2+
3+
Implement Greeks (Delta, Gamma, Vega, Theta, Rho) for the Black-76 closed-form pricing model. Black-76 is used for options on futures, forwards, swaptions, and commodity futures. Greeks are essential for risk management and hedging.
4+
5+
## Context
6+
7+
- Related PRs: #398 (Black-76 pricing kernel)
8+
- Model reference: Black (1976) "The pricing of commodity contracts"
9+
- Existing Greeks framework: `src/greeks/` with Black-Scholes, Monte-Carlo Greeks
10+
- Field mapping: `underlying_price` → forward F, `risk_free_rate` → r (discount), `dividend_yield` → ignored (F is carry-adjusted)
11+
12+
## Formulas
13+
14+
All derivatives are with respect to spot movements in the forward price F.
15+
16+
**Call delta**: `Δ_call = e^(-rT) · N(d1)`
17+
**Put delta**: `Δ_put = -e^(-rT) · N(-d1)`
18+
19+
**Gamma** (same for calls/puts): `Γ = e^(-rT) · n(d1) / (F · σ · √T)` where n = std normal pdf
20+
21+
**Vega** (same for calls/puts): `ν = F · e^(-rT) · n(d1) · √T` (per 1% vol change)
22+
23+
**Theta** (per calendar day): `Θ_call = -F · e^(-rT) · n(d1) · σ / (2√T) - r · K · e^(-rT) · N(d2)`
24+
25+
**Rho** (per 1% rate change): `ρ_call = K · T · e^(-rT) · N(d2)`
26+
27+
## Tasks
28+
29+
- [ ] `src/greeks/black_76.rs` — delta, gamma, vega, theta, rho functions (call/put variants)
30+
- [ ] Helper `calculate_d_values_black_76` if not already co-located (verify deduplication vs pricing module)
31+
- [ ] Trait `Black76Greeks` with default implementations (pattern mirror from `BlackScholesGreeks`)
32+
- [ ] Tests: reference values (Hull), Greek identities (e.g. call-delta - put-delta = e^(-rT)), monotonicity, edge cases
33+
- [ ] Example: `examples/examples_pricing/src/bin/black_76_greeks.rs` — compute Greeks for commodity futures scenario
34+
- [ ] Docs: header in `src/greeks/mod.rs`, inline `///` per function
35+
- [ ] Re-exports: `pub use black_76::{Black76Greeks, ...}` in `src/greeks/mod.rs`
36+
37+
## Acceptance criteria
38+
39+
- [ ] Delta monotonicity: call delta 0..1, put delta -1..0, relation `Δ_call - Δ_put = e^(-rT)`
40+
- [ ] Gamma > 0 always (convexity)
41+
- [ ] Vega > 0 always (price increases with volatility)
42+
- [ ] Hull reference values to 1e-3 tolerance (pick 2-3 examples from Hull tables)
43+
- [ ] Cross-check vs Black-Scholes Greeks with S=F·e^(-rT), q=0 to 1e-9 (Greeks should match exactly post transformation)
44+
- [ ] Zero volatility → error (consistent with pricing)
45+
- [ ] Unsupported option types (American, exotic) → appropriate error
46+
- [ ] `cargo clippy --all-targets --all-features --workspace -- -D warnings` clean
47+
- [ ] `cargo fmt --all --check` clean
48+
- [ ] `cargo test --all-features --workspace` clean
49+
- [ ] `cargo build --release` clean
50+
- [ ] All `pub` items documented
51+
52+
## References
53+
54+
- Black, F. (1976). "The pricing of commodity contracts." Journal of Financial Economics.
55+
- Hull, J. (2017). *Options, Futures, and Other Derivatives* (10th ed.). Prentice Hall.
56+
- Wystup, U. (2006). *FX Options and Structured Products*.

.issue-gk-greeks.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
## Overview
2+
3+
Implement Greeks (Delta, Gamma, Vega, Theta, and two Rhos) for the Garman-Kohlhagen FX pricing model. Garman-Kohlhagen is the standard for options on foreign exchange. FX Greeks differ from equity Greeks in that they depend on two interest rates (domestic and foreign) and have two separate Rho sensitivities.
4+
5+
## Context
6+
7+
- Related PRs: #399 (Garman-Kohlhagen pricing kernel)
8+
- Model reference: Garman & Kohlhagen (1983) "Foreign currency option values"
9+
- Existing Greeks framework: `src/greeks/` with Black-Scholes, Monte-Carlo Greeks
10+
- Field mapping: `underlying_price` → spot FX S, `risk_free_rate` → r_d (domestic), `dividend_yield` → r_f (foreign, since GK ≡ BSM with q=r_f)
11+
- Structural note: GK Greeks are **mathematically equivalent** to BSM Greeks post-transformation, but FX conventions use spot-delta (not forward-delta) and split rho into `rho_d` (domestic) and `rho_f` (foreign)
12+
13+
## Formulas
14+
15+
All Greeks use spot S and two interest rates.
16+
17+
**Call delta (spot)**: `Δ_call = e^(-r_f·T) · N(d1)`
18+
**Put delta (spot)**: `Δ_put = -e^(-r_f·T) · N(-d1)`
19+
20+
**Gamma** (same for calls/puts): `Γ = e^(-r_f·T) · n(d1) / (S · σ · √T)` where n = std normal pdf
21+
22+
**Vega** (same for calls/puts): `ν = S · e^(-r_f·T) · n(d1) · √T` (per 1% vol change)
23+
24+
**Theta** (per calendar day): Complex formula including both r_d and r_f terms (see Garman & Kohlhagen 1983 or Hull)
25+
26+
**Rho domestic** (per 1% domestic rate change): `ρ_d = K · T · e^(-r_d·T) · N(d2)` for calls, `= -K · T · e^(-r_d·T) · N(-d2)` for puts
27+
28+
**Rho foreign** (per 1% foreign rate change): `ρ_f = -S · T · e^(-r_f·T) · N(d1)` for calls, `= S · T · e^(-r_f·T) · N(-d1)` for puts
29+
30+
## Tasks
31+
32+
- [ ] `src/greeks/garman_kohlhagen.rs` — delta, gamma, vega, theta, rho_domestic, rho_foreign functions (call/put variants)
33+
- [ ] Helper `calculate_d_values_garman_kohlhagen` if needed (verify deduplication vs pricing module)
34+
- [ ] Trait `GarmanKohlhagenGreeks` with default implementations (pattern from `BlackScholesGreeks`)
35+
- [ ] Tests: reference values (Garman & Kohlhagen paper, Hull, Wystup), Greek identities (delta parity), monotonicity in S/r_d/r_f, edge cases
36+
- [ ] Example: `examples/examples_pricing/src/bin/garman_kohlhagen_greeks.rs` — compute Greeks for USD/EUR FX option scenario
37+
- [ ] Docs: header in `src/greeks/mod.rs`, inline `///` per function, explain r_d/r_f split
38+
- [ ] Re-exports: `pub use garman_kohlhagen::{GarmanKohlhagenGreeks, ...}` in `src/greeks/mod.rs`
39+
40+
## Acceptance criteria
41+
42+
- [ ] Delta monotonicity: call delta 0..e^(-r_f·T), put delta between -e^(-r_f·T)..0, delta parity
43+
- [ ] Gamma > 0 always (convexity)
44+
- [ ] Vega > 0 always
45+
- [ ] Rho sensitivities have correct sign (positive for long calls in r_d, negative in r_f; opposite for puts)
46+
- [ ] Hull and Garman & Kohlhagen reference values to 1e-3 tolerance (2-3 FX examples)
47+
- [ ] Cross-check vs Black-Scholes Greeks with S and q=r_f to 1e-9 (delta, gamma, vega post-transformation should match exactly)
48+
- [ ] Zero volatility → error
49+
- [ ] Unsupported option types (American, exotic) → appropriate error
50+
- [ ] Theta formula tested against numerical differentiation (Black-Scholes theta benchmark adapted for GK)
51+
- [ ] `cargo clippy --all-targets --all-features --workspace -- -D warnings` clean
52+
- [ ] `cargo fmt --all --check` clean
53+
- [ ] `cargo test --all-features --workspace` clean
54+
- [ ] `cargo build --release` clean
55+
- [ ] All `pub` items documented
56+
57+
## References
58+
59+
- Garman, M. B., & Kohlhagen, S. W. (1983). "Foreign currency option values." Journal of International Money and Finance, 2(3), 231–237.
60+
- Hull, J. (2017). *Options, Futures, and Other Derivatives* (10th ed.). Prentice Hall. [Chapter 17 on FX options]
61+
- Wystup, U. (2006). *FX Options and Structured Products*. Wiley.

src/pricing/garman_kohlhagen.rs

Lines changed: 24 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,27 @@
1717
//! `Options` carries a single risk-free rate plus a `dividend_yield`. For
1818
//! Garman–Kohlhagen we reuse those fields with the FX interpretation:
1919
//!
20-
//! - `Options::risk_free_rate` — domestic risk-free rate `r_d`.
21-
//! - `Options::dividend_yield` — foreign risk-free rate `r_f`.
20+
//! - `Options::risk_free_rate` — domestic risk-free rate `r_d`
21+
//! (signed `Decimal`, may be negative).
22+
//! - `Options::dividend_yield` — foreign risk-free rate `r_f`
23+
//! (`Positive`, must be ≥ 0 — see *Limitations* below).
2224
//! - `Options::underlying_price` — spot FX rate `S`.
2325
//!
2426
//! No schema change is required. The mapping is intentional: GK is the
2527
//! standard textbook reduction of BSM under the FX interpretation, and
2628
//! delegating to [`crate::pricing::black_scholes_model::black_scholes`]
2729
//! guarantees a bit-exact equivalence to the BSM kernel.
30+
//!
31+
//! ## Limitations
32+
//!
33+
//! Because `Options::dividend_yield` is a [`positive::Positive`],
34+
//! reusing it as `r_f` constrains the foreign rate to be non-negative.
35+
//! Negative-rate FX regimes (e.g. CHF, JPY, EUR for parts of the
36+
//! 2015–2022 cycle) cannot be priced through this entry point with the
37+
//! current `Options` schema. Lifting that limitation requires either a
38+
//! dedicated signed `foreign_rate` field on `Options` (a schema change,
39+
//! deliberately out of scope for this addition) or a follow-up issue
40+
//! tracking a relaxed FX-specific input struct.
2841
2942
use crate::Options;
3043
use crate::error::PricingError;
@@ -84,6 +97,13 @@ use tracing::instrument;
8497
/// expiration cannot be converted to a positive year fraction, and
8598
/// [`PricingError::MethodError`] when the underlying BSM kernel hits a
8699
/// numerical wall (e.g. zero volatility, non-finite intermediate value).
100+
///
101+
/// # Limitations
102+
///
103+
/// `Options::dividend_yield` is a [`positive::Positive`], so the foreign
104+
/// rate `r_f` mapped onto it must be ≥ 0. Negative-rate FX regimes
105+
/// cannot be expressed through this entry point with the current schema;
106+
/// see the module-level *Limitations* section.
87107
#[instrument(skip(option), fields(
88108
strike = %option.strike_price,
89109
style = ?option.option_style,
@@ -94,60 +114,8 @@ use tracing::instrument;
94114
pub fn garman_kohlhagen(option: &Options) -> Result<Decimal, PricingError> {
95115
match option.option_type {
96116
OptionType::European => black_scholes(option),
97-
OptionType::American => Err(PricingError::unsupported_option_type(
98-
"American",
99-
"Garman-Kohlhagen",
100-
)),
101-
OptionType::Bermuda { .. } => Err(PricingError::unsupported_option_type(
102-
"Bermuda",
103-
"Garman-Kohlhagen",
104-
)),
105-
OptionType::Asian { .. } => Err(PricingError::unsupported_option_type(
106-
"Asian",
107-
"Garman-Kohlhagen",
108-
)),
109-
OptionType::Barrier { .. } => Err(PricingError::unsupported_option_type(
110-
"Barrier",
111-
"Garman-Kohlhagen",
112-
)),
113-
OptionType::Binary { .. } => Err(PricingError::unsupported_option_type(
114-
"Binary",
115-
"Garman-Kohlhagen",
116-
)),
117-
OptionType::Lookback { .. } => Err(PricingError::unsupported_option_type(
118-
"Lookback",
119-
"Garman-Kohlhagen",
120-
)),
121-
OptionType::Compound { .. } => Err(PricingError::unsupported_option_type(
122-
"Compound",
123-
"Garman-Kohlhagen",
124-
)),
125-
OptionType::Chooser { .. } => Err(PricingError::unsupported_option_type(
126-
"Chooser",
127-
"Garman-Kohlhagen",
128-
)),
129-
OptionType::Cliquet { .. } => Err(PricingError::unsupported_option_type(
130-
"Cliquet",
131-
"Garman-Kohlhagen",
132-
)),
133-
OptionType::Rainbow { .. } => Err(PricingError::unsupported_option_type(
134-
"Rainbow",
135-
"Garman-Kohlhagen",
136-
)),
137-
OptionType::Spread { .. } => Err(PricingError::unsupported_option_type(
138-
"Spread",
139-
"Garman-Kohlhagen",
140-
)),
141-
OptionType::Quanto { .. } => Err(PricingError::unsupported_option_type(
142-
"Quanto",
143-
"Garman-Kohlhagen",
144-
)),
145-
OptionType::Exchange { .. } => Err(PricingError::unsupported_option_type(
146-
"Exchange",
147-
"Garman-Kohlhagen",
148-
)),
149-
OptionType::Power { .. } => Err(PricingError::unsupported_option_type(
150-
"Power",
117+
_ => Err(PricingError::unsupported_option_type(
118+
"Non-European",
151119
"Garman-Kohlhagen",
152120
)),
153121
}

src/pricing/unified.rs

Lines changed: 16 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -91,39 +91,34 @@ pub enum PricingEngine {
9191
///
9292
/// # Errors
9393
///
94-
/// Propagates any `PricingError` returned by the selected engine:
95-
/// `PricingError::ExpirationDate` or `PricingError::MethodError`
96-
/// from Black–Scholes, Black-76, and Garman–Kohlhagen (all three
97-
/// surface `MethodError` for zero-volatility / non-finite intermediate
98-
/// values, and the latter two also return
99-
/// [`PricingError::UnsupportedOptionType`] for non-European inputs);
100-
/// [`PricingError::BinomialNodeMissing`] or
101-
/// [`PricingError::SqrtFailure`] from the binomial lattice; the
102-
/// equivalent failures from exotic engines (barrier, binary,
103-
/// compound, chooser, cliquet, lookback, telegraph); and
104-
/// `PricingError::SimulationError` from the Monte Carlo engine.
94+
/// Propagates the original `PricingError` returned by the selected engine
95+
/// without wrapping, so callers can pattern-match on the structured
96+
/// variants. From the closed-form engines (Black–Scholes, Black-76,
97+
/// Garman–Kohlhagen) you may receive [`PricingError::ExpirationDate`],
98+
/// [`PricingError::Greeks`] (for example zero-volatility or non-finite
99+
/// intermediate values bubbled up from `d1`/`d2`), and (Black-76 and
100+
/// Garman–Kohlhagen) [`PricingError::UnsupportedOptionType`] for
101+
/// non-European inputs. From the binomial lattice you may receive
102+
/// [`PricingError::BinomialNodeMissing`] or [`PricingError::SqrtFailure`].
103+
/// The Monte Carlo engine surfaces failures as
104+
/// [`PricingError::SimulationError`], and exotic engines surface their
105+
/// own variants (barrier, binary, compound, chooser, cliquet, lookback,
106+
/// telegraph).
105107
pub fn price_option(option: &Options, engine: &PricingEngine) -> PricingResult<Positive> {
106108
match engine {
107109
PricingEngine::ClosedFormBS => {
108-
let price_decimal = black_scholes(option)
109-
.map_err(|e| PricingError::method_error("Black-Scholes", &e.to_string()))?;
110-
111-
// Convert Decimal to Positive using From trait
110+
let price_decimal = black_scholes(option)?;
112111
Ok(Positive::new_decimal(price_decimal.abs())?)
113112
}
114113
PricingEngine::ClosedFormBlack76 => {
115-
let price_decimal = black_76(option)
116-
.map_err(|e| PricingError::method_error("Black-76", &e.to_string()))?;
117-
114+
let price_decimal = black_76(option)?;
118115
Ok(Positive::new_decimal(price_decimal.abs())?)
119116
}
120117
PricingEngine::MonteCarlo { simulator } => simulator
121118
.get_mc_option_price(option)
122119
.map_err(|e| PricingError::simulation_error(&e.to_string())),
123120
PricingEngine::ClosedFormGK => {
124-
let price_decimal = garman_kohlhagen(option)
125-
.map_err(|e| PricingError::method_error("Garman-Kohlhagen", &e.to_string()))?;
126-
121+
let price_decimal = garman_kohlhagen(option)?;
127122
Ok(Positive::new_decimal(price_decimal.abs())?)
128123
}
129124
}

0 commit comments

Comments
 (0)