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