rustkernel_treasury/
fx.rs

1//! FX hedging kernel.
2//!
3//! This module provides FX hedging optimization for treasury:
4//! - Currency exposure calculation
5//! - Hedge recommendation generation
6//! - Cost-benefit analysis of hedging strategies
7
8use crate::types::{CurrencyExposure, FXHedge, FXHedgingResult, FXRate, HedgeType};
9use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
10use std::collections::HashMap;
11
12// ============================================================================
13// FX Hedging Kernel
14// ============================================================================
15
16/// FX hedging kernel.
17///
18/// Calculates currency exposures and recommends hedging strategies.
19#[derive(Debug, Clone)]
20pub struct FXHedging {
21    metadata: KernelMetadata,
22}
23
24impl Default for FXHedging {
25    fn default() -> Self {
26        Self::new()
27    }
28}
29
30impl FXHedging {
31    /// Create a new FX hedging kernel.
32    #[must_use]
33    pub fn new() -> Self {
34        Self {
35            metadata: KernelMetadata::batch("treasury/fx-hedging", Domain::TreasuryManagement)
36                .with_description("FX exposure and hedging optimization")
37                .with_throughput(10_000)
38                .with_latency_us(500.0),
39        }
40    }
41
42    /// Calculate currency exposures from positions.
43    pub fn calculate_exposures(
44        positions: &[FXPosition],
45        rates: &[FXRate],
46        base_currency: &str,
47    ) -> Vec<CurrencyExposure> {
48        // Build rate lookup
49        let rate_map: HashMap<(String, String), f64> = rates
50            .iter()
51            .map(|r| ((r.base.clone(), r.quote.clone()), r.rate))
52            .collect();
53
54        // Aggregate positions by currency
55        let mut by_currency: HashMap<String, (f64, f64)> = HashMap::new();
56
57        for pos in positions {
58            if pos.currency == base_currency {
59                continue; // Skip base currency
60            }
61
62            let entry = by_currency.entry(pos.currency.clone()).or_default();
63            if pos.amount > 0.0 {
64                entry.0 += pos.amount; // Long
65            } else {
66                entry.1 += pos.amount.abs(); // Short
67            }
68        }
69
70        // Convert to exposures
71        by_currency
72            .into_iter()
73            .map(|(currency, (long, short))| {
74                let rate = rate_map
75                    .get(&(currency.clone(), base_currency.to_string()))
76                    .copied()
77                    .or_else(|| {
78                        rate_map
79                            .get(&(base_currency.to_string(), currency.clone()))
80                            .map(|r| 1.0 / r)
81                    })
82                    .unwrap_or(1.0);
83
84                CurrencyExposure {
85                    currency,
86                    net_position: long - short,
87                    long_positions: long,
88                    short_positions: short,
89                    base_equivalent: (long - short) * rate,
90                }
91            })
92            .collect()
93    }
94
95    /// Generate hedging recommendations.
96    pub fn recommend_hedges(
97        exposures: &[CurrencyExposure],
98        rates: &[FXRate],
99        config: &HedgingConfig,
100    ) -> FXHedgingResult {
101        let mut hedges = Vec::new();
102        let mut total_cost = 0.0;
103        let mut residual_exposure = 0.0;
104        let mut total_exposure = 0.0;
105
106        // Build rate lookup
107        let rate_map: HashMap<String, &FXRate> = rates
108            .iter()
109            .map(|r| (format!("{}{}", r.base, r.quote), r))
110            .collect();
111
112        for exposure in exposures {
113            let abs_exposure = exposure.net_position.abs();
114            total_exposure += abs_exposure;
115
116            // Skip if below threshold
117            if abs_exposure < config.min_hedge_amount {
118                residual_exposure += abs_exposure;
119                continue;
120            }
121
122            // Determine hedge amount based on target ratio
123            let hedge_amount = abs_exposure * config.target_hedge_ratio;
124
125            // Find applicable rate
126            let pair = format!("{}{}", exposure.currency, config.base_currency);
127            let rate = rate_map.get(&pair);
128
129            let hedge = match config.preferred_instrument {
130                PreferredInstrument::Forward => {
131                    let cost = Self::calculate_forward_cost(hedge_amount, rate, config);
132                    FXHedge {
133                        id: hedges.len() as u64 + 1,
134                        currency_pair: pair,
135                        notional: hedge_amount,
136                        hedge_type: HedgeType::Forward,
137                        strike: rate.map(|r| r.rate),
138                        expiry: config.hedge_horizon_days as u64 * 86400,
139                        cost,
140                    }
141                }
142                PreferredInstrument::Option => {
143                    let (cost, strike) = Self::calculate_option_cost(
144                        hedge_amount,
145                        rate,
146                        exposure.net_position < 0.0,
147                        config,
148                    );
149                    FXHedge {
150                        id: hedges.len() as u64 + 1,
151                        currency_pair: pair,
152                        notional: hedge_amount,
153                        hedge_type: if exposure.net_position < 0.0 {
154                            HedgeType::Call
155                        } else {
156                            HedgeType::Put
157                        },
158                        strike: Some(strike),
159                        expiry: config.hedge_horizon_days as u64 * 86400,
160                        cost,
161                    }
162                }
163                PreferredInstrument::Collar => {
164                    let (cost, strike) = Self::calculate_collar_cost(hedge_amount, rate, config);
165                    FXHedge {
166                        id: hedges.len() as u64 + 1,
167                        currency_pair: pair,
168                        notional: hedge_amount,
169                        hedge_type: HedgeType::Collar,
170                        strike: Some(strike),
171                        expiry: config.hedge_horizon_days as u64 * 86400,
172                        cost,
173                    }
174                }
175            };
176
177            total_cost += hedge.cost;
178            residual_exposure += abs_exposure - hedge_amount;
179            hedges.push(hedge);
180        }
181
182        let hedge_ratio = if total_exposure > 0.0 {
183            1.0 - (residual_exposure / total_exposure)
184        } else {
185            0.0
186        };
187
188        // Estimate VaR reduction (simplified model)
189        let var_reduction = Self::estimate_var_reduction(&hedges, exposures, config);
190
191        FXHedgingResult {
192            hedges,
193            residual_exposure,
194            hedge_ratio,
195            total_cost,
196            var_reduction,
197        }
198    }
199
200    /// Calculate forward contract cost.
201    fn calculate_forward_cost(
202        notional: f64,
203        rate: Option<&&FXRate>,
204        config: &HedgingConfig,
205    ) -> f64 {
206        // Forward cost = notional * (bid-ask spread + interest rate differential)
207        let spread = rate.map(|r| r.ask - r.bid).unwrap_or(0.01);
208        let ir_diff = config.interest_rate_differential;
209        let days = config.hedge_horizon_days as f64;
210
211        notional * (spread + ir_diff * days / 365.0)
212    }
213
214    /// Calculate option cost (premium).
215    fn calculate_option_cost(
216        notional: f64,
217        rate: Option<&&FXRate>,
218        is_call: bool,
219        config: &HedgingConfig,
220    ) -> (f64, f64) {
221        let spot = rate.map(|r| r.rate).unwrap_or(1.0);
222        let volatility = config.implied_volatility;
223        let days = config.hedge_horizon_days as f64;
224        let time = days / 365.0;
225
226        // OTM strike based on configuration
227        let strike = if is_call {
228            spot * (1.0 + config.option_otm_offset)
229        } else {
230            spot * (1.0 - config.option_otm_offset)
231        };
232
233        // Full Black-Scholes formula
234        // Assuming risk-free rate of 2% for FX options
235        let r = 0.02;
236        let premium =
237            Self::black_scholes(spot, strike, time, r, volatility, is_call) * notional / spot;
238
239        (premium, strike)
240    }
241
242    /// Black-Scholes option pricing formula.
243    ///
244    /// # Arguments
245    /// * `spot` - Current spot price
246    /// * `strike` - Strike price
247    /// * `time` - Time to expiration in years
248    /// * `rate` - Risk-free interest rate
249    /// * `vol` - Implied volatility
250    /// * `is_call` - True for call, false for put
251    fn black_scholes(spot: f64, strike: f64, time: f64, rate: f64, vol: f64, is_call: bool) -> f64 {
252        if time <= 0.0 || vol <= 0.0 {
253            // Expired or invalid - return intrinsic value
254            return if is_call {
255                (spot - strike).max(0.0)
256            } else {
257                (strike - spot).max(0.0)
258            };
259        }
260
261        let sqrt_t = time.sqrt();
262        let d1 = ((spot / strike).ln() + (rate + vol * vol / 2.0) * time) / (vol * sqrt_t);
263        let d2 = d1 - vol * sqrt_t;
264
265        let discount = (-rate * time).exp();
266
267        if is_call {
268            spot * Self::norm_cdf(d1) - strike * discount * Self::norm_cdf(d2)
269        } else {
270            strike * discount * Self::norm_cdf(-d2) - spot * Self::norm_cdf(-d1)
271        }
272    }
273
274    /// Standard normal CDF approximation (Abramowitz and Stegun).
275    fn norm_cdf(x: f64) -> f64 {
276        let a1 = 0.254829592;
277        let a2 = -0.284496736;
278        let a3 = 1.421413741;
279        let a4 = -1.453152027;
280        let a5 = 1.061405429;
281        let p = 0.3275911;
282
283        let sign = if x < 0.0 { -1.0 } else { 1.0 };
284        let x_abs = x.abs();
285
286        let t = 1.0 / (1.0 + p * x_abs);
287        let y = 1.0
288            - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x_abs * x_abs / 2.0).exp();
289
290        0.5 * (1.0 + sign * y)
291    }
292
293    /// Calculate collar cost.
294    fn calculate_collar_cost(
295        notional: f64,
296        rate: Option<&&FXRate>,
297        config: &HedgingConfig,
298    ) -> (f64, f64) {
299        let _spot = rate.map(|r| r.rate).unwrap_or(1.0);
300
301        // Zero-cost collar approximation
302        // Buy put, sell call at symmetric strikes
303        let (put_cost, put_strike) = Self::calculate_option_cost(notional, rate, false, config);
304        let (call_premium, _) = Self::calculate_option_cost(notional, rate, true, config);
305
306        // Net cost (usually close to zero for symmetric collar)
307        let net_cost = (put_cost - call_premium * 0.9).max(0.0);
308
309        (net_cost, put_strike)
310    }
311
312    /// Estimate VaR reduction from hedging using delta-gamma VaR model.
313    ///
314    /// This calculates VaR reduction considering:
315    /// - Linear hedge delta for forwards
316    /// - Option delta for option hedges
317    /// - Gamma effects for large moves
318    fn estimate_var_reduction(
319        hedges: &[FXHedge],
320        exposures: &[CurrencyExposure],
321        config: &HedgingConfig,
322    ) -> f64 {
323        if exposures.is_empty() {
324            return 0.0;
325        }
326
327        let confidence_factor = 1.645; // 95% confidence (z-score)
328        let days = config.hedge_horizon_days as f64;
329        let time = days / 365.0;
330        let volatility = config.implied_volatility;
331        let sqrt_time = (days / 252.0).sqrt();
332
333        // Calculate unhedged VaR
334        let unhedged_var: f64 = exposures
335            .iter()
336            .map(|e| e.base_equivalent.abs() * volatility * sqrt_time * confidence_factor)
337            .sum();
338
339        // Calculate delta-equivalent hedge coverage
340        let mut total_hedge_delta = 0.0;
341
342        for hedge in hedges {
343            use crate::types::HedgeType;
344            let delta = match hedge.hedge_type {
345                HedgeType::Forward | HedgeType::Swap => 1.0, // Forward/swap has delta = 1
346                HedgeType::Call => {
347                    // Call option delta using N(d1)
348                    let d1 =
349                        (0.02 + volatility * volatility / 2.0) * time / (volatility * time.sqrt());
350                    Self::norm_cdf(d1)
351                }
352                HedgeType::Put => {
353                    // Put option delta = N(d1) - 1
354                    let d1 =
355                        (0.02 + volatility * volatility / 2.0) * time / (volatility * time.sqrt());
356                    Self::norm_cdf(d1) - 1.0
357                }
358                HedgeType::Collar => {
359                    // Collar has partial delta (long put + short call effectively)
360                    // Net delta depends on strikes but typically 0.5-0.8
361                    0.7
362                }
363            };
364            total_hedge_delta += hedge.notional * delta.abs();
365        }
366
367        let total_exposure: f64 = exposures.iter().map(|e| e.net_position.abs()).sum();
368
369        // Calculate hedge effectiveness using delta
370        let hedge_effectiveness = if total_exposure > 0.0 {
371            (total_hedge_delta / total_exposure).min(1.0)
372        } else {
373            0.0
374        };
375
376        // Include gamma adjustment for options (reduces VaR benefit for large moves)
377        let has_options = hedges.iter().any(|h| {
378            use crate::types::HedgeType;
379            matches!(
380                h.hedge_type,
381                HedgeType::Put | HedgeType::Call | HedgeType::Collar
382            )
383        });
384        let gamma_adjustment = if has_options {
385            // Gamma reduces hedge effectiveness for larger moves
386            // At 95% confidence, expected move is ~1.645 std devs
387            // Gamma effect reduces delta linearly with move size
388            let expected_move = confidence_factor * volatility * sqrt_time;
389            1.0 - expected_move * 0.3 // Approximate gamma decay
390        } else {
391            1.0
392        };
393
394        // Basis risk adjustment (hedges may not perfectly track exposure)
395        let basis_risk_factor = 0.95;
396
397        // Final VaR reduction
398        unhedged_var * hedge_effectiveness * gamma_adjustment * basis_risk_factor
399    }
400
401    /// Calculate net exposure after hedges.
402    pub fn net_exposure_after_hedges(
403        exposures: &[CurrencyExposure],
404        hedges: &[FXHedge],
405    ) -> HashMap<String, f64> {
406        let mut net: HashMap<String, f64> = HashMap::new();
407
408        // Add exposures
409        for exp in exposures {
410            *net.entry(exp.currency.clone()).or_default() += exp.net_position;
411        }
412
413        // Subtract hedges
414        for hedge in hedges {
415            // Extract currency from pair (first 3 chars typically)
416            let currency = if hedge.currency_pair.len() >= 3 {
417                &hedge.currency_pair[0..3]
418            } else {
419                &hedge.currency_pair
420            };
421
422            *net.entry(currency.to_string()).or_default() -= hedge.notional;
423        }
424
425        net
426    }
427}
428
429impl GpuKernel for FXHedging {
430    fn metadata(&self) -> &KernelMetadata {
431        &self.metadata
432    }
433}
434
435/// FX position.
436#[derive(Debug, Clone)]
437pub struct FXPosition {
438    /// Position ID.
439    pub id: String,
440    /// Currency.
441    pub currency: String,
442    /// Amount (positive = long, negative = short).
443    pub amount: f64,
444    /// Maturity date.
445    pub maturity: Option<u64>,
446    /// Source (e.g., trade, receivable, payable).
447    pub source: String,
448}
449
450/// Hedging configuration.
451#[derive(Debug, Clone)]
452pub struct HedgingConfig {
453    /// Base currency.
454    pub base_currency: String,
455    /// Target hedge ratio (0-1).
456    pub target_hedge_ratio: f64,
457    /// Minimum hedge amount.
458    pub min_hedge_amount: f64,
459    /// Hedge horizon in days.
460    pub hedge_horizon_days: u32,
461    /// Preferred hedging instrument.
462    pub preferred_instrument: PreferredInstrument,
463    /// Interest rate differential.
464    pub interest_rate_differential: f64,
465    /// Implied volatility.
466    pub implied_volatility: f64,
467    /// OTM offset for options.
468    pub option_otm_offset: f64,
469}
470
471impl Default for HedgingConfig {
472    fn default() -> Self {
473        Self {
474            base_currency: "USD".to_string(),
475            target_hedge_ratio: 0.8,
476            min_hedge_amount: 10_000.0,
477            hedge_horizon_days: 90,
478            preferred_instrument: PreferredInstrument::Forward,
479            interest_rate_differential: 0.02,
480            implied_volatility: 0.10,
481            option_otm_offset: 0.05,
482        }
483    }
484}
485
486/// Preferred hedging instrument.
487#[derive(Debug, Clone, Copy, PartialEq, Eq)]
488pub enum PreferredInstrument {
489    /// Forward contract.
490    Forward,
491    /// Option (put or call).
492    Option,
493    /// Collar (put + call).
494    Collar,
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500
501    fn create_test_positions() -> Vec<FXPosition> {
502        vec![
503            FXPosition {
504                id: "P1".to_string(),
505                currency: "EUR".to_string(),
506                amount: 1_000_000.0,
507                maturity: Some(7776000), // 90 days
508                source: "Receivable".to_string(),
509            },
510            FXPosition {
511                id: "P2".to_string(),
512                currency: "EUR".to_string(),
513                amount: -500_000.0,
514                maturity: Some(7776000),
515                source: "Payable".to_string(),
516            },
517            FXPosition {
518                id: "P3".to_string(),
519                currency: "GBP".to_string(),
520                amount: 300_000.0,
521                maturity: Some(7776000),
522                source: "Receivable".to_string(),
523            },
524        ]
525    }
526
527    fn create_test_rates() -> Vec<FXRate> {
528        vec![
529            FXRate {
530                base: "EUR".to_string(),
531                quote: "USD".to_string(),
532                rate: 1.10,
533                bid: 1.0995,
534                ask: 1.1005,
535                timestamp: 1700000000,
536            },
537            FXRate {
538                base: "GBP".to_string(),
539                quote: "USD".to_string(),
540                rate: 1.25,
541                bid: 1.2490,
542                ask: 1.2510,
543                timestamp: 1700000000,
544            },
545        ]
546    }
547
548    #[test]
549    fn test_fx_metadata() {
550        let kernel = FXHedging::new();
551        assert_eq!(kernel.metadata().id, "treasury/fx-hedging");
552        assert_eq!(kernel.metadata().domain, Domain::TreasuryManagement);
553    }
554
555    #[test]
556    fn test_calculate_exposures() {
557        let positions = create_test_positions();
558        let rates = create_test_rates();
559
560        let exposures = FXHedging::calculate_exposures(&positions, &rates, "USD");
561
562        assert_eq!(exposures.len(), 2); // EUR and GBP
563
564        let eur_exp = exposures.iter().find(|e| e.currency == "EUR").unwrap();
565        assert_eq!(eur_exp.long_positions, 1_000_000.0);
566        assert_eq!(eur_exp.short_positions, 500_000.0);
567        assert_eq!(eur_exp.net_position, 500_000.0);
568
569        let gbp_exp = exposures.iter().find(|e| e.currency == "GBP").unwrap();
570        assert_eq!(gbp_exp.net_position, 300_000.0);
571    }
572
573    #[test]
574    fn test_recommend_hedges_forward() {
575        let positions = create_test_positions();
576        let rates = create_test_rates();
577        let exposures = FXHedging::calculate_exposures(&positions, &rates, "USD");
578
579        let config = HedgingConfig {
580            preferred_instrument: PreferredInstrument::Forward,
581            target_hedge_ratio: 0.8,
582            ..Default::default()
583        };
584
585        let result = FXHedging::recommend_hedges(&exposures, &rates, &config);
586
587        assert!(!result.hedges.is_empty());
588        assert!(result.hedge_ratio > 0.0);
589        assert!(result.total_cost > 0.0);
590
591        // All hedges should be forwards
592        assert!(
593            result
594                .hedges
595                .iter()
596                .all(|h| h.hedge_type == HedgeType::Forward)
597        );
598    }
599
600    #[test]
601    fn test_recommend_hedges_option() {
602        let positions = create_test_positions();
603        let rates = create_test_rates();
604        let exposures = FXHedging::calculate_exposures(&positions, &rates, "USD");
605
606        let config = HedgingConfig {
607            preferred_instrument: PreferredInstrument::Option,
608            target_hedge_ratio: 0.8,
609            ..Default::default()
610        };
611
612        let result = FXHedging::recommend_hedges(&exposures, &rates, &config);
613
614        assert!(!result.hedges.is_empty());
615
616        // Should have put options for long exposures
617        let eur_hedge = result
618            .hedges
619            .iter()
620            .find(|h| h.currency_pair.starts_with("EUR"));
621        assert!(eur_hedge.is_some());
622        assert_eq!(eur_hedge.unwrap().hedge_type, HedgeType::Put);
623    }
624
625    #[test]
626    fn test_min_hedge_threshold() {
627        let positions = vec![FXPosition {
628            id: "P1".to_string(),
629            currency: "EUR".to_string(),
630            amount: 5_000.0, // Below threshold
631            maturity: None,
632            source: "Receivable".to_string(),
633        }];
634        let rates = create_test_rates();
635        let exposures = FXHedging::calculate_exposures(&positions, &rates, "USD");
636
637        let config = HedgingConfig {
638            min_hedge_amount: 10_000.0,
639            ..Default::default()
640        };
641
642        let result = FXHedging::recommend_hedges(&exposures, &rates, &config);
643
644        // Should not recommend hedge for small exposure
645        assert!(result.hedges.is_empty());
646        assert!(result.residual_exposure > 0.0);
647    }
648
649    #[test]
650    fn test_hedge_ratio_calculation() {
651        let positions = create_test_positions();
652        let rates = create_test_rates();
653        let exposures = FXHedging::calculate_exposures(&positions, &rates, "USD");
654
655        let config = HedgingConfig {
656            target_hedge_ratio: 1.0, // Full hedge
657            min_hedge_amount: 0.0,
658            ..Default::default()
659        };
660
661        let result = FXHedging::recommend_hedges(&exposures, &rates, &config);
662
663        // Should be close to 100% hedged
664        assert!(result.hedge_ratio > 0.9);
665    }
666
667    #[test]
668    fn test_var_reduction() {
669        let positions = create_test_positions();
670        let rates = create_test_rates();
671        let exposures = FXHedging::calculate_exposures(&positions, &rates, "USD");
672
673        let config = HedgingConfig::default();
674        let result = FXHedging::recommend_hedges(&exposures, &rates, &config);
675
676        // VaR reduction should be positive if hedges exist
677        if !result.hedges.is_empty() {
678            assert!(result.var_reduction > 0.0);
679        }
680    }
681
682    #[test]
683    fn test_net_exposure_after_hedges() {
684        let exposures = vec![CurrencyExposure {
685            currency: "EUR".to_string(),
686            net_position: 1_000_000.0,
687            long_positions: 1_000_000.0,
688            short_positions: 0.0,
689            base_equivalent: 1_100_000.0,
690        }];
691
692        let hedges = vec![FXHedge {
693            id: 1,
694            currency_pair: "EURUSD".to_string(),
695            notional: 800_000.0,
696            hedge_type: HedgeType::Forward,
697            strike: Some(1.10),
698            expiry: 7776000,
699            cost: 1000.0,
700        }];
701
702        let net = FXHedging::net_exposure_after_hedges(&exposures, &hedges);
703
704        assert_eq!(net.get("EUR"), Some(&200_000.0));
705    }
706
707    #[test]
708    fn test_collar_hedging() {
709        let positions = create_test_positions();
710        let rates = create_test_rates();
711        let exposures = FXHedging::calculate_exposures(&positions, &rates, "USD");
712
713        let config = HedgingConfig {
714            preferred_instrument: PreferredInstrument::Collar,
715            ..Default::default()
716        };
717
718        let result = FXHedging::recommend_hedges(&exposures, &rates, &config);
719
720        // Should have collar hedges
721        assert!(
722            result
723                .hedges
724                .iter()
725                .all(|h| h.hedge_type == HedgeType::Collar)
726        );
727
728        // Collar cost should typically be low (zero-cost structure)
729        assert!(result.total_cost < result.hedges.iter().map(|h| h.notional * 0.05).sum::<f64>());
730    }
731
732    #[test]
733    fn test_empty_positions() {
734        let positions: Vec<FXPosition> = vec![];
735        let rates = create_test_rates();
736
737        let exposures = FXHedging::calculate_exposures(&positions, &rates, "USD");
738        assert!(exposures.is_empty());
739
740        let result = FXHedging::recommend_hedges(&exposures, &rates, &HedgingConfig::default());
741        assert!(result.hedges.is_empty());
742        assert_eq!(result.hedge_ratio, 0.0);
743    }
744}