datasynth_generators/treasury/
hedging_generator.rs1use 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#[derive(Debug, Clone)]
25pub struct FxExposure {
26 pub currency_pair: String,
28 pub foreign_currency: String,
30 pub net_amount: Decimal,
32 pub settlement_date: NaiveDate,
34 pub description: String,
36}
37
38const 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
55pub struct HedgingGenerator {
61 rng: ChaCha8Rng,
62 config: HedgingSchemaConfig,
63 instrument_counter: u64,
64 relationship_counter: u64,
65}
66
67impl HedgingGenerator {
68 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 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 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 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 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 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 if self.rng.gen_bool(0.90) {
195 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 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 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#[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); 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 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 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}