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