datasynth_generators/treasury/
hedging_generator.rs1use 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#[derive(Debug, Clone)]
26pub struct FxExposure {
27 pub currency_pair: String,
29 pub foreign_currency: String,
31 pub net_amount: Decimal,
33 pub settlement_date: NaiveDate,
35 pub description: String,
37}
38
39const 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
56pub struct HedgingGenerator {
62 rng: ChaCha8Rng,
63 config: HedgingSchemaConfig,
64 instrument_counter: u64,
65 relationship_counter: u64,
66}
67
68impl HedgingGenerator {
69 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 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 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 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 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 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 if self.rng.gen_bool(0.90) {
196 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 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 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#[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); 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 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 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}