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)]
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(config, 42);
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(config, 42);
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(config, 42);
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(config, 42);
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(config, 42);
460 let instruments = gen.generate("C001", "USD", d("2025-01-01"));
461 assert!(instruments.is_empty());
462 }
463}