1use chrono::{Datelike, NaiveDate};
8use rand::prelude::*;
9use rand_chacha::ChaCha8Rng;
10use rust_decimal::Decimal;
11use rust_decimal_macros::dec;
12
13use datasynth_config::schema::{CovenantDef, DebtInstrumentDef, DebtSchemaConfig};
14use datasynth_core::models::{
15 AmortizationPayment, CovenantType, DebtCovenant, DebtInstrument, DebtType, Frequency,
16 InterestRateType,
17};
18
19const LENDERS: &[&str] = &[
24 "First National Bank",
25 "Wells Fargo",
26 "JPMorgan Chase",
27 "Bank of America",
28 "Citibank",
29 "HSBC",
30 "Deutsche Bank",
31 "Barclays",
32 "BNP Paribas",
33 "Goldman Sachs",
34];
35
36pub struct DebtGenerator {
42 rng: ChaCha8Rng,
43 config: DebtSchemaConfig,
44 instrument_counter: u64,
45 covenant_counter: u64,
46}
47
48impl DebtGenerator {
49 pub fn new(seed: u64, config: DebtSchemaConfig) -> Self {
51 Self {
52 rng: ChaCha8Rng::seed_from_u64(seed),
53 config,
54 instrument_counter: 0,
55 covenant_counter: 0,
56 }
57 }
58
59 pub fn generate(
61 &mut self,
62 entity_id: &str,
63 currency: &str,
64 origination_date: NaiveDate,
65 ) -> Vec<DebtInstrument> {
66 let defs: Vec<DebtInstrumentDef> = self.config.instruments.clone();
67 let covenant_defs: Vec<CovenantDef> = self.config.covenants.clone();
68
69 let mut instruments = Vec::new();
70 for def in &defs {
71 let instrument =
72 self.generate_from_def(entity_id, currency, origination_date, def, &covenant_defs);
73 instruments.push(instrument);
74 }
75 instruments
76 }
77
78 fn generate_from_def(
80 &mut self,
81 entity_id: &str,
82 currency: &str,
83 origination_date: NaiveDate,
84 def: &DebtInstrumentDef,
85 covenant_defs: &[CovenantDef],
86 ) -> DebtInstrument {
87 self.instrument_counter += 1;
88 let id = format!("DEBT-{:06}", self.instrument_counter);
89 let lender = self.random_lender();
90 let debt_type = self.parse_debt_type(&def.instrument_type);
91 let principal =
92 Decimal::try_from(def.principal.unwrap_or(5_000_000.0)).unwrap_or(dec!(5000000));
93 let rate = Decimal::try_from(def.rate.unwrap_or(0.055))
94 .unwrap_or(dec!(0.055))
95 .round_dp(4);
96 let maturity_months = def.maturity_months.unwrap_or(60);
97 let maturity_date = add_months(origination_date, maturity_months);
98
99 let rate_type = if matches!(debt_type, DebtType::RevolvingCredit) {
100 InterestRateType::Variable
101 } else {
102 InterestRateType::Fixed
103 };
104
105 let facility_limit =
106 Decimal::try_from(def.facility.unwrap_or(0.0)).unwrap_or(Decimal::ZERO);
107
108 let mut instrument = DebtInstrument::new(
109 id,
110 entity_id,
111 debt_type,
112 lender,
113 principal,
114 currency,
115 rate,
116 rate_type,
117 origination_date,
118 maturity_date,
119 );
120
121 if matches!(debt_type, DebtType::TermLoan | DebtType::Bond) {
123 let schedule =
124 self.generate_amortization(principal, rate, origination_date, maturity_months);
125 instrument = instrument.with_amortization_schedule(schedule);
126 }
127
128 if matches!(debt_type, DebtType::RevolvingCredit) && facility_limit > Decimal::ZERO {
130 let drawn = (facility_limit * dec!(0.40)).round_dp(2);
131 instrument = instrument
132 .with_facility_limit(facility_limit)
133 .with_drawn_amount(drawn);
134 }
135
136 let measurement_date = origination_date;
138 for cdef in covenant_defs {
139 let covenant = self.generate_covenant(cdef, measurement_date);
140 instrument = instrument.with_covenant(covenant);
141 }
142
143 instrument
144 }
145
146 fn generate_amortization(
150 &mut self,
151 principal: Decimal,
152 annual_rate: Decimal,
153 start_date: NaiveDate,
154 maturity_months: u32,
155 ) -> Vec<AmortizationPayment> {
156 let num_payments = maturity_months / 3; if num_payments == 0 {
158 return Vec::new();
159 }
160
161 let quarterly_rate = (annual_rate / dec!(4)).round_dp(6);
162 let principal_per_period = (principal / Decimal::from(num_payments)).round_dp(2);
163
164 let mut schedule = Vec::new();
165 let mut remaining = principal;
166
167 for i in 0..num_payments {
168 let payment_date = add_months(start_date, (i + 1) * 3);
169 let interest = (remaining * quarterly_rate).round_dp(2);
170
171 let principal_payment = if i == num_payments - 1 {
173 remaining
174 } else {
175 principal_per_period
176 };
177
178 remaining = (remaining - principal_payment).round_dp(2);
179
180 schedule.push(AmortizationPayment {
181 date: payment_date,
182 principal_payment,
183 interest_payment: interest,
184 balance_after: remaining.max(Decimal::ZERO),
185 });
186 }
187
188 schedule
189 }
190
191 fn generate_covenant(
193 &mut self,
194 def: &CovenantDef,
195 measurement_date: NaiveDate,
196 ) -> DebtCovenant {
197 self.covenant_counter += 1;
198 let id = format!("COV-{:06}", self.covenant_counter);
199 let covenant_type = self.parse_covenant_type(&def.covenant_type);
200 let threshold = Decimal::try_from(def.threshold).unwrap_or(dec!(3.0));
201
202 let actual = if self.rng.gen_bool(0.90) {
204 self.generate_compliant_value(covenant_type, threshold)
205 } else {
206 self.generate_breached_value(covenant_type, threshold)
207 };
208
209 DebtCovenant::new(
210 id,
211 covenant_type,
212 threshold,
213 Frequency::Quarterly,
214 actual,
215 measurement_date,
216 )
217 }
218
219 fn generate_compliant_value(
221 &mut self,
222 covenant_type: CovenantType,
223 threshold: Decimal,
224 ) -> Decimal {
225 match covenant_type {
226 CovenantType::DebtToEquity | CovenantType::DebtToEbitda => {
228 let factor = self.rng.gen_range(0.50f64..0.90f64);
229 (threshold * Decimal::try_from(factor).unwrap_or(dec!(0.70))).round_dp(2)
230 }
231 _ => {
233 let factor = self.rng.gen_range(1.10f64..2.00f64);
234 (threshold * Decimal::try_from(factor).unwrap_or(dec!(1.50))).round_dp(2)
235 }
236 }
237 }
238
239 fn generate_breached_value(
241 &mut self,
242 covenant_type: CovenantType,
243 threshold: Decimal,
244 ) -> Decimal {
245 match covenant_type {
246 CovenantType::DebtToEquity | CovenantType::DebtToEbitda => {
247 let factor = self.rng.gen_range(1.05f64..1.30f64);
248 (threshold * Decimal::try_from(factor).unwrap_or(dec!(1.10))).round_dp(2)
249 }
250 _ => {
251 let factor = self.rng.gen_range(0.70f64..0.95f64);
252 (threshold * Decimal::try_from(factor).unwrap_or(dec!(0.85))).round_dp(2)
253 }
254 }
255 }
256
257 fn random_lender(&mut self) -> &'static str {
258 let idx = self.rng.gen_range(0..LENDERS.len());
259 LENDERS[idx]
260 }
261
262 fn parse_debt_type(&self, s: &str) -> DebtType {
263 match s {
264 "revolving_credit" => DebtType::RevolvingCredit,
265 "bond" => DebtType::Bond,
266 "commercial_paper" => DebtType::CommercialPaper,
267 "bridge_loan" => DebtType::BridgeLoan,
268 _ => DebtType::TermLoan,
269 }
270 }
271
272 fn parse_covenant_type(&self, s: &str) -> CovenantType {
273 match s {
274 "debt_to_equity" => CovenantType::DebtToEquity,
275 "interest_coverage" => CovenantType::InterestCoverage,
276 "current_ratio" => CovenantType::CurrentRatio,
277 "net_worth" => CovenantType::NetWorth,
278 "fixed_charge_coverage" => CovenantType::FixedChargeCoverage,
279 _ => CovenantType::DebtToEbitda,
280 }
281 }
282}
283
284fn add_months(date: NaiveDate, months: u32) -> NaiveDate {
286 let total_months = date.month0() + months;
287 let year = date.year() + (total_months / 12) as i32;
288 let month = (total_months % 12) + 1;
289 let day = date.day().min(days_in_month(year, month));
291 NaiveDate::from_ymd_opt(year, month, day).unwrap_or(date)
292}
293
294fn days_in_month(year: i32, month: u32) -> u32 {
295 match month {
296 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
297 4 | 6 | 9 | 11 => 30,
298 2 => {
299 if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
300 29
301 } else {
302 28
303 }
304 }
305 _ => 30,
306 }
307}
308
309#[cfg(test)]
314#[allow(clippy::unwrap_used)]
315mod tests {
316 use super::*;
317
318 fn d(s: &str) -> NaiveDate {
319 NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
320 }
321
322 #[test]
323 fn test_amortization_sums_to_principal() {
324 let config = DebtSchemaConfig {
325 enabled: true,
326 instruments: vec![DebtInstrumentDef {
327 instrument_type: "term_loan".to_string(),
328 principal: Some(5_000_000.0),
329 rate: Some(0.055),
330 maturity_months: Some(60),
331 facility: None,
332 }],
333 covenants: Vec::new(),
334 };
335 let mut gen = DebtGenerator::new(42, config);
336 let instruments = gen.generate("C001", "USD", d("2025-01-01"));
337
338 assert_eq!(instruments.len(), 1);
339 let debt = &instruments[0];
340 assert_eq!(debt.instrument_type, DebtType::TermLoan);
341 assert!(!debt.amortization_schedule.is_empty());
342
343 assert_eq!(debt.total_principal_payments(), dec!(5000000));
345
346 let last = debt.amortization_schedule.last().unwrap();
348 assert_eq!(last.balance_after, Decimal::ZERO);
349 }
350
351 #[test]
352 fn test_revolving_credit_facility() {
353 let config = DebtSchemaConfig {
354 enabled: true,
355 instruments: vec![DebtInstrumentDef {
356 instrument_type: "revolving_credit".to_string(),
357 principal: None,
358 rate: Some(0.045),
359 maturity_months: Some(36),
360 facility: Some(2_000_000.0),
361 }],
362 covenants: Vec::new(),
363 };
364 let mut gen = DebtGenerator::new(42, config);
365 let instruments = gen.generate("C001", "USD", d("2025-01-01"));
366
367 assert_eq!(instruments.len(), 1);
368 let revolver = &instruments[0];
369 assert_eq!(revolver.instrument_type, DebtType::RevolvingCredit);
370 assert_eq!(revolver.rate_type, InterestRateType::Variable);
371 assert_eq!(revolver.facility_limit, dec!(2000000));
372 assert!(revolver.drawn_amount < revolver.facility_limit);
373 assert!(revolver.available_capacity() > Decimal::ZERO);
374 assert!(revolver.amortization_schedule.is_empty());
376 }
377
378 #[test]
379 fn test_covenant_generation() {
380 let config = DebtSchemaConfig {
381 enabled: true,
382 instruments: vec![DebtInstrumentDef {
383 instrument_type: "term_loan".to_string(),
384 principal: Some(3_000_000.0),
385 rate: Some(0.05),
386 maturity_months: Some(48),
387 facility: None,
388 }],
389 covenants: vec![
390 CovenantDef {
391 covenant_type: "debt_to_ebitda".to_string(),
392 threshold: 3.5,
393 },
394 CovenantDef {
395 covenant_type: "interest_coverage".to_string(),
396 threshold: 3.0,
397 },
398 ],
399 };
400 let mut gen = DebtGenerator::new(42, config);
401 let instruments = gen.generate("C001", "USD", d("2025-01-01"));
402
403 let debt = &instruments[0];
404 assert_eq!(debt.covenants.len(), 2);
405
406 for cov in &debt.covenants {
408 assert!(cov.threshold > Decimal::ZERO);
409 if cov.is_compliant {
411 assert!(cov.headroom > Decimal::ZERO);
412 } else {
413 assert!(cov.headroom < Decimal::ZERO);
414 }
415 }
416 }
417
418 #[test]
419 fn test_multiple_instruments() {
420 let config = DebtSchemaConfig {
421 enabled: true,
422 instruments: vec![
423 DebtInstrumentDef {
424 instrument_type: "term_loan".to_string(),
425 principal: Some(5_000_000.0),
426 rate: Some(0.055),
427 maturity_months: Some(60),
428 facility: None,
429 },
430 DebtInstrumentDef {
431 instrument_type: "revolving_credit".to_string(),
432 principal: None,
433 rate: Some(0.045),
434 maturity_months: Some(36),
435 facility: Some(2_000_000.0),
436 },
437 ],
438 covenants: Vec::new(),
439 };
440 let mut gen = DebtGenerator::new(42, config);
441 let instruments = gen.generate("C001", "USD", d("2025-01-01"));
442
443 assert_eq!(instruments.len(), 2);
444 assert_eq!(instruments[0].instrument_type, DebtType::TermLoan);
445 assert_eq!(instruments[1].instrument_type, DebtType::RevolvingCredit);
446 }
447
448 #[test]
449 fn test_add_months() {
450 assert_eq!(add_months(d("2025-01-31"), 1), d("2025-02-28"));
451 assert_eq!(add_months(d("2025-01-15"), 3), d("2025-04-15"));
452 assert_eq!(add_months(d("2025-01-15"), 12), d("2026-01-15"));
453 assert_eq!(add_months(d("2024-01-31"), 1), d("2024-02-29")); }
455
456 #[test]
457 fn test_empty_config_no_instruments() {
458 let config = DebtSchemaConfig::default();
459 let mut gen = DebtGenerator::new(42, config);
460 let instruments = gen.generate("C001", "USD", d("2025-01-01"));
461 assert!(instruments.is_empty());
462 }
463}