1use crate::types::{CurrencyExposure, FXHedge, FXHedgingResult, FXRate, HedgeType};
9use rustkernel_core::{domain::Domain, kernel::KernelMetadata, traits::GpuKernel};
10use std::collections::HashMap;
11
12#[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 #[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 pub fn calculate_exposures(
44 positions: &[FXPosition],
45 rates: &[FXRate],
46 base_currency: &str,
47 ) -> Vec<CurrencyExposure> {
48 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 let mut by_currency: HashMap<String, (f64, f64)> = HashMap::new();
56
57 for pos in positions {
58 if pos.currency == base_currency {
59 continue; }
61
62 let entry = by_currency.entry(pos.currency.clone()).or_default();
63 if pos.amount > 0.0 {
64 entry.0 += pos.amount; } else {
66 entry.1 += pos.amount.abs(); }
68 }
69
70 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 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 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 if abs_exposure < config.min_hedge_amount {
118 residual_exposure += abs_exposure;
119 continue;
120 }
121
122 let hedge_amount = abs_exposure * config.target_hedge_ratio;
124
125 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 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 fn calculate_forward_cost(
202 notional: f64,
203 rate: Option<&&FXRate>,
204 config: &HedgingConfig,
205 ) -> f64 {
206 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 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 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 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 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 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 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 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 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 let net_cost = (put_cost - call_premium * 0.9).max(0.0);
308
309 (net_cost, put_strike)
310 }
311
312 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; 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 let unhedged_var: f64 = exposures
335 .iter()
336 .map(|e| e.base_equivalent.abs() * volatility * sqrt_time * confidence_factor)
337 .sum();
338
339 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, HedgeType::Call => {
347 let d1 =
349 (0.02 + volatility * volatility / 2.0) * time / (volatility * time.sqrt());
350 Self::norm_cdf(d1)
351 }
352 HedgeType::Put => {
353 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 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 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 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 let expected_move = confidence_factor * volatility * sqrt_time;
389 1.0 - expected_move * 0.3 } else {
391 1.0
392 };
393
394 let basis_risk_factor = 0.95;
396
397 unhedged_var * hedge_effectiveness * gamma_adjustment * basis_risk_factor
399 }
400
401 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 for exp in exposures {
410 *net.entry(exp.currency.clone()).or_default() += exp.net_position;
411 }
412
413 for hedge in hedges {
415 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#[derive(Debug, Clone)]
437pub struct FXPosition {
438 pub id: String,
440 pub currency: String,
442 pub amount: f64,
444 pub maturity: Option<u64>,
446 pub source: String,
448}
449
450#[derive(Debug, Clone)]
452pub struct HedgingConfig {
453 pub base_currency: String,
455 pub target_hedge_ratio: f64,
457 pub min_hedge_amount: f64,
459 pub hedge_horizon_days: u32,
461 pub preferred_instrument: PreferredInstrument,
463 pub interest_rate_differential: f64,
465 pub implied_volatility: f64,
467 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
488pub enum PreferredInstrument {
489 Forward,
491 Option,
493 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), 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); 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 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 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, 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 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, min_hedge_amount: 0.0,
658 ..Default::default()
659 };
660
661 let result = FXHedging::recommend_hedges(&exposures, &rates, &config);
662
663 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 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 assert!(
722 result
723 .hedges
724 .iter()
725 .all(|h| h.hedge_type == HedgeType::Collar)
726 );
727
728 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}