Skip to main content

datasynth_generators/treasury/
hedging_generator.rs

1//! Hedging Instrument Generator.
2//!
3//! Creates FX forward and interest rate swap instruments to hedge FX exposures
4//! from multi-currency AP/AR balances. Designates hedge accounting relationships
5//! under ASC 815 / IFRS 9 and tests effectiveness (80-125% corridor).
6
7use chrono::NaiveDate;
8use rand::prelude::*;
9use rand_chacha::ChaCha8Rng;
10use rust_decimal::Decimal;
11use rust_decimal_macros::dec;
12
13use datasynth_config::schema::HedgingSchemaConfig;
14use datasynth_core::models::{
15    EffectivenessMethod, HedgeInstrumentType, HedgeRelationship, HedgeType, HedgedItemType,
16    HedgingInstrument,
17};
18
19// ---------------------------------------------------------------------------
20// Input abstraction
21// ---------------------------------------------------------------------------
22
23/// An FX exposure from outstanding AP/AR balances.
24#[derive(Debug, Clone)]
25pub struct FxExposure {
26    /// Currency pair (e.g., "EUR/USD")
27    pub currency_pair: String,
28    /// Foreign currency code
29    pub foreign_currency: String,
30    /// Net exposure amount in foreign currency (positive = long, negative = short)
31    pub net_amount: Decimal,
32    /// Expected settlement date
33    pub settlement_date: NaiveDate,
34    /// Description of the exposure source
35    pub description: String,
36}
37
38// ---------------------------------------------------------------------------
39// Counterparty pool
40// ---------------------------------------------------------------------------
41
42const COUNTERPARTIES: &[&str] = &[
43    "JPMorgan Chase",
44    "Deutsche Bank",
45    "Citibank",
46    "HSBC",
47    "Barclays",
48    "BNP Paribas",
49    "Goldman Sachs",
50    "Morgan Stanley",
51    "UBS",
52    "Credit Suisse",
53];
54
55// ---------------------------------------------------------------------------
56// Generator
57// ---------------------------------------------------------------------------
58
59/// Generates hedging instruments and hedge relationship designations.
60pub struct HedgingGenerator {
61    rng: ChaCha8Rng,
62    config: HedgingSchemaConfig,
63    instrument_counter: u64,
64    relationship_counter: u64,
65}
66
67impl HedgingGenerator {
68    /// Creates a new hedging generator.
69    pub fn new(seed: u64, config: HedgingSchemaConfig) -> Self {
70        Self {
71            rng: ChaCha8Rng::seed_from_u64(seed),
72            config,
73            instrument_counter: 0,
74            relationship_counter: 0,
75        }
76    }
77
78    /// Generates hedging instruments and designations from FX exposures.
79    ///
80    /// For each exposure, creates an FX forward covering `hedge_ratio` of the
81    /// net exposure. If hedge accounting is enabled, also designates a
82    /// [`HedgeRelationship`] with effectiveness testing.
83    pub fn generate(
84        &mut self,
85        trade_date: NaiveDate,
86        exposures: &[FxExposure],
87    ) -> (Vec<HedgingInstrument>, Vec<HedgeRelationship>) {
88        let mut instruments = Vec::new();
89        let mut relationships = Vec::new();
90        let hedge_ratio = Decimal::try_from(self.config.hedge_ratio).unwrap_or(dec!(0.75));
91
92        for exposure in exposures {
93            if exposure.net_amount.is_zero() {
94                continue;
95            }
96
97            let notional = (exposure.net_amount.abs() * hedge_ratio).round_dp(2);
98            let counterparty = self.random_counterparty();
99            let forward_rate = self.generate_forward_rate();
100
101            self.instrument_counter += 1;
102            let instr_id = format!("HI-{:06}", self.instrument_counter);
103
104            let instrument = HedgingInstrument::new(
105                &instr_id,
106                HedgeInstrumentType::FxForward,
107                notional,
108                &exposure.foreign_currency,
109                trade_date,
110                exposure.settlement_date,
111                counterparty,
112            )
113            .with_currency_pair(&exposure.currency_pair)
114            .with_fixed_rate(forward_rate)
115            .with_fair_value(self.generate_fair_value(notional));
116
117            instruments.push(instrument);
118
119            // Designate hedge relationship if hedge accounting is enabled
120            if self.config.hedge_accounting {
121                let effectiveness = self.generate_effectiveness_ratio();
122                self.relationship_counter += 1;
123                let rel_id = format!("HR-{:06}", self.relationship_counter);
124
125                let method = self.parse_effectiveness_method();
126
127                let relationship = HedgeRelationship::new(
128                    rel_id,
129                    HedgedItemType::ForecastedTransaction,
130                    &exposure.description,
131                    &instr_id,
132                    HedgeType::CashFlowHedge,
133                    trade_date,
134                    method,
135                    effectiveness,
136                )
137                .with_ineffectiveness_amount(
138                    self.generate_ineffectiveness(notional, effectiveness),
139                );
140
141                relationships.push(relationship);
142            }
143        }
144
145        (instruments, relationships)
146    }
147
148    /// Generates an interest rate swap instrument.
149    pub fn generate_ir_swap(
150        &mut self,
151        entity_currency: &str,
152        notional: Decimal,
153        trade_date: NaiveDate,
154        maturity_date: NaiveDate,
155    ) -> HedgingInstrument {
156        let counterparty = self.random_counterparty();
157        let fixed_rate = dec!(0.03)
158            + Decimal::try_from(self.rng.gen_range(0.0f64..0.025)).unwrap_or(Decimal::ZERO);
159
160        self.instrument_counter += 1;
161        HedgingInstrument::new(
162            format!("HI-{:06}", self.instrument_counter),
163            HedgeInstrumentType::InterestRateSwap,
164            notional,
165            entity_currency,
166            trade_date,
167            maturity_date,
168            counterparty,
169        )
170        .with_fixed_rate(fixed_rate.round_dp(4))
171        .with_floating_index("SOFR")
172        .with_fair_value(self.generate_fair_value(notional))
173    }
174
175    fn random_counterparty(&mut self) -> &'static str {
176        let idx = self.rng.gen_range(0..COUNTERPARTIES.len());
177        COUNTERPARTIES[idx]
178    }
179
180    fn generate_forward_rate(&mut self) -> Decimal {
181        // Typical FX forward rate around 1.0-1.5 range
182        let rate = self.rng.gen_range(0.85f64..1.50f64);
183        Decimal::try_from(rate).unwrap_or(dec!(1.10)).round_dp(4)
184    }
185
186    fn generate_fair_value(&mut self, notional: Decimal) -> Decimal {
187        // Fair value is typically a small fraction of notional (±2%)
188        let pct = self.rng.gen_range(-0.02f64..0.02f64);
189        (notional * Decimal::try_from(pct).unwrap_or(Decimal::ZERO)).round_dp(2)
190    }
191
192    fn generate_effectiveness_ratio(&mut self) -> Decimal {
193        // Most hedges are effective (0.85-1.15), with occasional failures
194        if self.rng.gen_bool(0.90) {
195            // Effective: within 80-125% corridor
196            let ratio = self.rng.gen_range(0.85f64..1.15f64);
197            Decimal::try_from(ratio).unwrap_or(dec!(1.00)).round_dp(4)
198        } else {
199            // Ineffective: outside corridor
200            if self.rng.gen_bool(0.5) {
201                let ratio = self.rng.gen_range(0.60f64..0.79f64);
202                Decimal::try_from(ratio).unwrap_or(dec!(0.75)).round_dp(4)
203            } else {
204                let ratio = self.rng.gen_range(1.26f64..1.50f64);
205                Decimal::try_from(ratio).unwrap_or(dec!(1.30)).round_dp(4)
206            }
207        }
208    }
209
210    fn generate_ineffectiveness(&mut self, notional: Decimal, ratio: Decimal) -> Decimal {
211        // Ineffectiveness = |1.0 - ratio| * notional * small factor
212        let deviation = (dec!(1.00) - ratio).abs();
213        (notional * deviation * dec!(0.1)).round_dp(2)
214    }
215
216    fn parse_effectiveness_method(&self) -> EffectivenessMethod {
217        match self.config.effectiveness_method.as_str() {
218            "dollar_offset" => EffectivenessMethod::DollarOffset,
219            "critical_terms" => EffectivenessMethod::CriticalTerms,
220            _ => EffectivenessMethod::Regression,
221        }
222    }
223}
224
225// ---------------------------------------------------------------------------
226// Tests
227// ---------------------------------------------------------------------------
228
229#[cfg(test)]
230#[allow(clippy::unwrap_used)]
231mod tests {
232    use super::*;
233
234    fn d(s: &str) -> NaiveDate {
235        NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
236    }
237
238    #[test]
239    fn test_generates_fx_forwards_from_exposures() {
240        let mut gen = HedgingGenerator::new(42, HedgingSchemaConfig::default());
241        let exposures = vec![
242            FxExposure {
243                currency_pair: "EUR/USD".to_string(),
244                foreign_currency: "EUR".to_string(),
245                net_amount: dec!(1000000),
246                settlement_date: d("2025-06-30"),
247                description: "EUR receivables Q2".to_string(),
248            },
249            FxExposure {
250                currency_pair: "GBP/USD".to_string(),
251                foreign_currency: "GBP".to_string(),
252                net_amount: dec!(-500000),
253                settlement_date: d("2025-06-30"),
254                description: "GBP payables Q2".to_string(),
255            },
256        ];
257
258        let (instruments, relationships) = gen.generate(d("2025-01-15"), &exposures);
259
260        assert_eq!(instruments.len(), 2);
261        assert_eq!(relationships.len(), 2); // hedge accounting enabled by default
262
263        // Notional should be hedge_ratio * exposure
264        let hedge_ratio = dec!(0.75);
265        assert_eq!(
266            instruments[0].notional_amount,
267            (dec!(1000000) * hedge_ratio).round_dp(2)
268        );
269        assert_eq!(
270            instruments[1].notional_amount,
271            (dec!(500000) * hedge_ratio).round_dp(2)
272        );
273
274        // All should be FX Forwards
275        for instr in &instruments {
276            assert_eq!(instr.instrument_type, HedgeInstrumentType::FxForward);
277            assert!(instr.is_active());
278            assert!(instr.fixed_rate.is_some());
279        }
280    }
281
282    #[test]
283    fn test_hedge_relationships_effectiveness() {
284        let mut gen = HedgingGenerator::new(42, HedgingSchemaConfig::default());
285        let exposures = vec![FxExposure {
286            currency_pair: "EUR/USD".to_string(),
287            foreign_currency: "EUR".to_string(),
288            net_amount: dec!(1000000),
289            settlement_date: d("2025-06-30"),
290            description: "EUR receivables".to_string(),
291        }];
292
293        let (_, relationships) = gen.generate(d("2025-01-15"), &exposures);
294        assert_eq!(relationships.len(), 1);
295        let rel = &relationships[0];
296        assert_eq!(rel.hedge_type, HedgeType::CashFlowHedge);
297        assert_eq!(rel.hedged_item_type, HedgedItemType::ForecastedTransaction);
298        // Effectiveness ratio should be set
299        assert!(rel.effectiveness_ratio > Decimal::ZERO);
300    }
301
302    #[test]
303    fn test_no_hedge_relationships_when_accounting_disabled() {
304        let config = HedgingSchemaConfig {
305            hedge_accounting: false,
306            ..HedgingSchemaConfig::default()
307        };
308        let mut gen = HedgingGenerator::new(42, config);
309        let exposures = vec![FxExposure {
310            currency_pair: "EUR/USD".to_string(),
311            foreign_currency: "EUR".to_string(),
312            net_amount: dec!(1000000),
313            settlement_date: d("2025-06-30"),
314            description: "EUR receivables".to_string(),
315        }];
316
317        let (instruments, relationships) = gen.generate(d("2025-01-15"), &exposures);
318        assert_eq!(instruments.len(), 1);
319        assert_eq!(relationships.len(), 0);
320    }
321
322    #[test]
323    fn test_zero_exposure_skipped() {
324        let mut gen = HedgingGenerator::new(42, HedgingSchemaConfig::default());
325        let exposures = vec![FxExposure {
326            currency_pair: "EUR/USD".to_string(),
327            foreign_currency: "EUR".to_string(),
328            net_amount: dec!(0),
329            settlement_date: d("2025-06-30"),
330            description: "Zero exposure".to_string(),
331        }];
332
333        let (instruments, _) = gen.generate(d("2025-01-15"), &exposures);
334        assert_eq!(instruments.len(), 0);
335    }
336
337    #[test]
338    fn test_ir_swap_generation() {
339        let mut gen = HedgingGenerator::new(42, HedgingSchemaConfig::default());
340        let swap = gen.generate_ir_swap("USD", dec!(5000000), d("2025-01-01"), d("2030-01-01"));
341
342        assert_eq!(swap.instrument_type, HedgeInstrumentType::InterestRateSwap);
343        assert_eq!(swap.notional_amount, dec!(5000000));
344        assert!(swap.fixed_rate.is_some());
345        assert_eq!(swap.floating_index, Some("SOFR".to_string()));
346        assert!(swap.is_active());
347    }
348
349    #[test]
350    fn test_deterministic_generation() {
351        let exposures = vec![FxExposure {
352            currency_pair: "EUR/USD".to_string(),
353            foreign_currency: "EUR".to_string(),
354            net_amount: dec!(1000000),
355            settlement_date: d("2025-06-30"),
356            description: "EUR receivables".to_string(),
357        }];
358
359        let mut gen1 = HedgingGenerator::new(42, HedgingSchemaConfig::default());
360        let (i1, r1) = gen1.generate(d("2025-01-15"), &exposures);
361
362        let mut gen2 = HedgingGenerator::new(42, HedgingSchemaConfig::default());
363        let (i2, r2) = gen2.generate(d("2025-01-15"), &exposures);
364
365        assert_eq!(i1[0].notional_amount, i2[0].notional_amount);
366        assert_eq!(i1[0].fair_value, i2[0].fair_value);
367        assert_eq!(r1[0].effectiveness_ratio, r2[0].effectiveness_ratio);
368    }
369}