Skip to main content

finance_solution/derivatives/
garman_kohlhagen.rs

1//! # Garman–Kohlhagen — European FX options
2//!
3//! Spot FX \(S\) (domestic per unit foreign), continuous **domestic** rate \(r_d\)
4//! and **foreign** rate \(r_f\). Mathematically BSM with dividend yield \(q = r_f\).
5//!
6//! ---
7//!
8//! ## Trading perspective
9//!
10//! | Greek | FX desk habit |
11//! |-------|----------------|
12//! | **Δ** | Hedge in foreign currency units per option |
13//! | **ρ_domestic** | Discount / domestic rate risk |
14//! | **ρ_foreign** | Foreign-rate risk (like −q rho in equity BSM) |
15//! | **IV** | FX vol surfaces (10Δ risk reversals, etc. — build in *your* engine) |
16//!
17//! **Call** here = right to **buy foreign** (pay domestic strike \(K\)).
18//!
19//! ---
20//!
21//! ## Engineering perspective
22//!
23//! Prefer this module when rates are labeled domestic/foreign. Internally maps to
24//! [`crate::derivatives::BsmParams`] with `rate = r_d`, `dividend_yield = r_f` so
25//! prices match BSM exactly.
26//!
27//! ## Word problem
28//!
29//! > Spot 1.20, K=1.20, T=1y, r_d=5%, r_f=3%, σ=10%. GK call?
30//!
31//! ```
32//! use finance_solution::derivatives::{gk_price, GkParams, OptionType};
33//! let p = GkParams {
34//!     spot: 1.20,
35//!     strike: 1.20,
36//!     time_years: 1.0,
37//!     domestic_rate: 0.05,
38//!     foreign_rate: 0.03,
39//!     vol: 0.10,
40//! };
41//! let c = gk_price(p, OptionType::Call).unwrap();
42//! assert!(c > 0.0 && c < 0.10);
43//! ```
44
45use crate::derivatives::black_scholes::{
46    bsm_cross_greeks, bsm_greeks, bsm_price, bsm_terms, BsmCrossGreeks, BsmGreeks, BsmTerms,
47};
48use crate::derivatives::types::{validate_bsm_params, BsmParams, OptionType};
49use crate::util::error::{require_finite, FinanceError, FinanceResult};
50use crate::{columns_with_strings, print_table_locale_opt};
51
52/// Garman–Kohlhagen inputs.
53#[derive(Clone, Copy, Debug, PartialEq)]
54pub struct GkParams {
55    /// Spot FX: domestic currency per one unit foreign.
56    pub spot: f64,
57    pub strike: f64,
58    pub time_years: f64,
59    /// Continuous domestic risk-free rate \(r_d\).
60    pub domestic_rate: f64,
61    /// Continuous foreign risk-free rate \(r_f\).
62    pub foreign_rate: f64,
63    pub vol: f64,
64}
65
66impl GkParams {
67    pub const fn atm_one_year(spot: f64, domestic_rate: f64, foreign_rate: f64, vol: f64) -> Self {
68        Self {
69            spot,
70            strike: spot,
71            time_years: 1.0,
72            domestic_rate,
73            foreign_rate,
74            vol,
75        }
76    }
77
78    pub fn with_days_365_25(
79        spot: f64,
80        strike: f64,
81        days: f64,
82        domestic_rate: f64,
83        foreign_rate: f64,
84        vol: f64,
85    ) -> Self {
86        Self {
87            spot,
88            strike,
89            time_years: days / 365.25,
90            domestic_rate,
91            foreign_rate,
92            vol,
93        }
94    }
95
96    /// Map to BSM: \(r = r_d\), \(q = r_f\).
97    pub fn to_bsm(self) -> BsmParams {
98        BsmParams {
99            spot: self.spot,
100            strike: self.strike,
101            time_years: self.time_years,
102            rate: self.domestic_rate,
103            dividend_yield: self.foreign_rate,
104            vol: self.vol,
105        }
106    }
107}
108
109/// Validated GK snapshot.
110#[derive(Clone, Copy, Debug, PartialEq)]
111pub struct ValidatedGk {
112    params: GkParams,
113}
114
115impl ValidatedGk {
116    pub fn new(params: GkParams) -> FinanceResult<Self> {
117        validate_gk_params(params)?;
118        Ok(Self { params })
119    }
120
121    pub fn params(self) -> GkParams {
122        self.params
123    }
124
125    pub fn price(self, option_type: OptionType) -> FinanceResult<f64> {
126        gk_price(self.params, option_type)
127    }
128
129    pub fn greeks(self, option_type: OptionType) -> FinanceResult<GkGreeks> {
130        gk_greeks(self.params, option_type)
131    }
132}
133
134/// GK Greeks: BSM-style plus dual rate rhos.
135#[derive(Clone, Copy, Debug, PartialEq)]
136pub struct GkGreeks {
137    pub delta: f64,
138    pub gamma: f64,
139    pub vega: f64,
140    pub theta: f64,
141    /// ∂V/∂r_d (domestic).
142    pub rho_domestic: f64,
143    /// ∂V/∂r_f (foreign).
144    pub rho_foreign: f64,
145}
146
147impl GkGreeks {
148    #[inline]
149    pub fn vega_per_vol_point(self) -> f64 {
150        self.vega / 100.0
151    }
152
153    #[inline]
154    pub fn theta_per_calendar_day(self) -> f64 {
155        self.theta / 365.25
156    }
157}
158
159/// Teaching solution for GK.
160#[derive(Clone, Debug)]
161pub struct GkSolution {
162    pub option_type: OptionType,
163    pub params: GkParams,
164    pub price: f64,
165    pub greeks: GkGreeks,
166    pub cross_greeks: BsmCrossGreeks,
167    pub terms: BsmTerms,
168    pub parity_residual: f64,
169    formula: String,
170    symbolic_formula: String,
171}
172
173impl GkSolution {
174    pub fn formula(&self) -> &str {
175        &self.formula
176    }
177    pub fn symbolic_formula(&self) -> &str {
178        &self.symbolic_formula
179    }
180
181    pub fn print_table(&self) {
182        self.print_table_locale_opt(None, None);
183    }
184
185    pub fn print_table_locale(&self, locale: &num_format::Locale, precision: usize) {
186        self.print_table_locale_opt(Some(locale), Some(precision));
187    }
188
189    fn print_table_locale_opt(
190        &self,
191        locale: Option<&num_format::Locale>,
192        precision: Option<usize>,
193    ) {
194        let columns = columns_with_strings(&[
195            ("type", "s", true),
196            ("price", "f", true),
197            ("delta", "f", true),
198            ("gamma", "f", true),
199            ("vega", "f", true),
200            ("theta", "f", true),
201            ("rho_d", "f", true),
202            ("rho_f", "f", true),
203        ]);
204        let data = vec![vec![
205            self.option_type.to_string(),
206            self.price.to_string(),
207            self.greeks.delta.to_string(),
208            self.greeks.gamma.to_string(),
209            self.greeks.vega.to_string(),
210            self.greeks.theta.to_string(),
211            self.greeks.rho_domestic.to_string(),
212            self.greeks.rho_foreign.to_string(),
213        ]];
214        print_table_locale_opt(&columns, data, locale, precision);
215    }
216}
217
218/// Live FX option state.
219#[derive(Clone, Debug, PartialEq)]
220pub struct GkState {
221    params: GkParams,
222    option_type: OptionType,
223}
224
225impl GkState {
226    pub fn new(params: GkParams, option_type: OptionType) -> FinanceResult<Self> {
227        validate_gk_params(params)?;
228        Ok(Self {
229            params,
230            option_type,
231        })
232    }
233
234    pub fn params(&self) -> GkParams {
235        self.params
236    }
237
238    pub fn option_type(&self) -> OptionType {
239        self.option_type
240    }
241
242    pub fn set_spot(&mut self, spot: f64) -> FinanceResult<()> {
243        require_finite("spot", spot)?;
244        let mut p = self.params;
245        p.spot = spot;
246        validate_gk_params(p)?;
247        self.params = p;
248        Ok(())
249    }
250
251    pub fn set_vol(&mut self, vol: f64) -> FinanceResult<()> {
252        require_finite("vol", vol)?;
253        let mut p = self.params;
254        p.vol = vol;
255        validate_gk_params(p)?;
256        self.params = p;
257        Ok(())
258    }
259
260    pub fn set_time_years(&mut self, time_years: f64) -> FinanceResult<()> {
261        require_finite("time_years", time_years)?;
262        let mut p = self.params;
263        p.time_years = time_years;
264        validate_gk_params(p)?;
265        self.params = p;
266        Ok(())
267    }
268
269    pub fn set_vol_from_price(&mut self, market_price: f64) -> FinanceResult<f64> {
270        let iv = gk_implied_vol(self.params, self.option_type, market_price)?;
271        self.set_vol(iv)?;
272        Ok(iv)
273    }
274
275    pub fn price(&self) -> FinanceResult<f64> {
276        gk_price(self.params, self.option_type)
277    }
278
279    pub fn greeks(&self) -> FinanceResult<GkGreeks> {
280        gk_greeks(self.params, self.option_type)
281    }
282}
283
284pub fn gk_price(params: GkParams, option_type: OptionType) -> FinanceResult<f64> {
285    validate_gk_params(params)?;
286    bsm_price(params.to_bsm(), option_type)
287}
288
289pub fn gk_greeks(params: GkParams, option_type: OptionType) -> FinanceResult<GkGreeks> {
290    validate_gk_params(params)?;
291    let bsm = params.to_bsm();
292    let g: BsmGreeks = bsm_greeks(bsm, option_type)?;
293    // Foreign rho: ∂V/∂r_f. Equity BSM rho is ∂V/∂r; foreign is −∂V/∂q style.
294    // For call: rho_f = −S T e^{-r_f T} N(d1)
295    // For put:  rho_f = +S T e^{-r_f T} N(-d1)
296    let terms = bsm_terms(bsm)?;
297    let df_f = terms.dividend_discount;
298    let tt = params.time_years;
299    let rho_foreign = match option_type {
300        OptionType::Call => -params.spot * tt * df_f * crate::derivatives::norm::norm_cdf(terms.d1),
301        OptionType::Put => params.spot * tt * df_f * crate::derivatives::norm::norm_cdf(-terms.d1),
302    };
303    Ok(GkGreeks {
304        delta: g.delta,
305        gamma: g.gamma,
306        vega: g.vega,
307        theta: g.theta,
308        rho_domestic: g.rho,
309        rho_foreign,
310    })
311}
312
313pub fn gk_cross_greeks(params: GkParams, option_type: OptionType) -> FinanceResult<BsmCrossGreeks> {
314    validate_gk_params(params)?;
315    bsm_cross_greeks(params.to_bsm(), option_type)
316}
317
318/// Put–call parity residual: `C − P − (S e^{-r_f T} − K e^{-r_d T})`.
319pub fn gk_parity_residual(params: GkParams) -> FinanceResult<f64> {
320    let c = gk_price(params, OptionType::Call)?;
321    let p = gk_price(params, OptionType::Put)?;
322    let df_d = (-params.domestic_rate * params.time_years).exp();
323    let df_f = (-params.foreign_rate * params.time_years).exp();
324    Ok(c - p - (params.spot * df_f - params.strike * df_d))
325}
326
327pub fn gk_solution(params: GkParams, option_type: OptionType) -> FinanceResult<GkSolution> {
328    let _ = ValidatedGk::new(params)?;
329    let price = gk_price(params, option_type)?;
330    let greeks = gk_greeks(params, option_type)?;
331    let cross_greeks = gk_cross_greeks(params, option_type)?;
332    let terms = bsm_terms(params.to_bsm())?;
333    let parity = gk_parity_residual(params)?;
334    let formula = format!(
335        "{option_type} GK S={} K={} T={} r_d={} r_f={} σ={} → price={:.6}",
336        params.spot,
337        params.strike,
338        params.time_years,
339        params.domestic_rate,
340        params.foreign_rate,
341        params.vol,
342        price
343    );
344    let symbolic = match option_type {
345        OptionType::Call => {
346            "C = S e^{-r_f T} N(d1) - K e^{-r_d T} N(d2); d1=[ln(S/K)+(r_d-r_f+σ²/2)T]/(σ√T)"
347                .to_string()
348        }
349        OptionType::Put => "P = K e^{-r_d T} N(-d2) - S e^{-r_f T} N(-d1)".to_string(),
350    };
351    Ok(GkSolution {
352        option_type,
353        params,
354        price,
355        greeks,
356        cross_greeks,
357        terms,
358        parity_residual: parity,
359        formula,
360        symbolic_formula: symbolic,
361    })
362}
363
364pub fn gk_implied_vol(
365    params: GkParams,
366    option_type: OptionType,
367    market_price: f64,
368) -> FinanceResult<f64> {
369    validate_gk_params(params)?;
370    require_finite("market_price", market_price)?;
371    if market_price < 0.0 {
372        return Err(FinanceError::Unsolvable {
373            message: "market_price must be non-negative",
374        });
375    }
376    if params.time_years == 0.0 {
377        return Err(FinanceError::Unsolvable {
378            message: "implied vol undefined at expiry (T=0)",
379        });
380    }
381    // Reuse BSM IV on the mapped pack.
382    crate::derivatives::implied_vol::bsm_implied_vol(params.to_bsm(), option_type, market_price)
383}
384
385pub(crate) fn validate_gk_params(p: GkParams) -> FinanceResult<()> {
386    // Reuse BSM domain checks via map.
387    validate_bsm_params(p.to_bsm())
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393    use crate::derivatives::black_scholes::bsm_price;
394
395    #[test]
396    fn matches_bsm_mapping() {
397        let g = GkParams::atm_one_year(1.25, 0.04, 0.02, 0.12);
398        let c_gk = gk_price(g, OptionType::Call).unwrap();
399        let c_bsm = bsm_price(g.to_bsm(), OptionType::Call).unwrap();
400        assert!((c_gk - c_bsm).abs() < 1e-12);
401    }
402
403    #[test]
404    fn parity() {
405        let p = GkParams {
406            spot: 1.10,
407            strike: 1.05,
408            time_years: 0.5,
409            domestic_rate: 0.03,
410            foreign_rate: 0.01,
411            vol: 0.15,
412        };
413        assert!(gk_parity_residual(p).unwrap().abs() < 1e-10);
414    }
415
416    #[test]
417    fn iv_round_trip() {
418        let p = GkParams::atm_one_year(1.0, 0.05, 0.03, 0.18);
419        let mkt = gk_price(p, OptionType::Put).unwrap();
420        let iv = gk_implied_vol(p, OptionType::Put, mkt).unwrap();
421        assert!((iv - 0.18).abs() < 1e-6);
422    }
423
424    #[test]
425    fn foreign_rho_sign_call() {
426        let p = GkParams::atm_one_year(1.0, 0.05, 0.02, 0.1);
427        let g = gk_greeks(p, OptionType::Call).unwrap();
428        assert!(g.rho_foreign < 0.0);
429        assert!(g.rho_domestic > 0.0);
430    }
431
432    #[test]
433    fn foreign_rho_sign_put() {
434        let p = GkParams::atm_one_year(1.0, 0.05, 0.02, 0.1);
435        let g = gk_greeks(p, OptionType::Put).unwrap();
436        assert!(g.rho_foreign > 0.0);
437        assert!(g.rho_domestic < 0.0);
438    }
439
440    #[test]
441    fn state_spot_moves_price() {
442        let p = GkParams::atm_one_year(1.10, 0.04, 0.02, 0.12);
443        let mut s = GkState::new(p, OptionType::Call).unwrap();
444        let p0 = s.price().unwrap();
445        s.set_spot(1.15).unwrap();
446        assert!(s.price().unwrap() > p0);
447    }
448
449    #[test]
450    fn cross_greeks_finite() {
451        let p = GkParams::atm_one_year(1.2, 0.03, 0.01, 0.15);
452        let x = gk_cross_greeks(p, OptionType::Call).unwrap();
453        assert!(x.vanna.is_finite() && x.volga.is_finite() && x.charm.is_finite());
454    }
455}