Skip to main content

datasynth_generators/treasury/
debt_generator.rs

1//! Debt Instrument and Covenant Generator.
2//!
3//! Creates term loans, revolving credit facilities, and bonds with amortization
4//! schedules and financial covenant monitoring. Generates [`AmortizationPayment`]
5//! vectors that sum to principal and computes covenant compliance with headroom.
6
7use 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
20// ---------------------------------------------------------------------------
21// Lender pool
22// ---------------------------------------------------------------------------
23
24const 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
37// ---------------------------------------------------------------------------
38// Generator
39// ---------------------------------------------------------------------------
40
41/// Generates debt instruments with amortization schedules and covenants.
42pub struct DebtGenerator {
43    rng: ChaCha8Rng,
44    config: DebtSchemaConfig,
45    instrument_counter: u64,
46    covenant_counter: u64,
47}
48
49impl DebtGenerator {
50    /// Creates a new debt generator.
51    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    /// Generates all debt instruments from config definitions.
61    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    /// Generates a single debt instrument from a definition.
80    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        // Generate amortization schedule for term loans
123        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        // Set revolving credit specifics
130        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        // Attach covenants
138        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    /// Generates a level-payment amortization schedule.
148    ///
149    /// Uses quarterly payments. Total principal payments sum to the original principal.
150    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; // quarterly
158        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            // Last payment gets the remainder to ensure exact sum
173            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    /// Generates a covenant from a definition with simulated actual values.
193    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        // Generate actual value: usually compliant (90%), occasionally breached (10%)
204        let actual = if self.rng.gen_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    /// Generates an actual value that is compliant with the covenant.
221    fn generate_compliant_value(
222        &mut self,
223        covenant_type: CovenantType,
224        threshold: Decimal,
225    ) -> Decimal {
226        match covenant_type {
227            // Maximum covenants: actual < threshold
228            CovenantType::DebtToEquity | CovenantType::DebtToEbitda => {
229                let factor = self.rng.gen_range(0.50f64..0.90f64);
230                (threshold * Decimal::try_from(factor).unwrap_or(dec!(0.70))).round_dp(2)
231            }
232            // Minimum covenants: actual > threshold
233            _ => {
234                let factor = self.rng.gen_range(1.10f64..2.00f64);
235                (threshold * Decimal::try_from(factor).unwrap_or(dec!(1.50))).round_dp(2)
236            }
237        }
238    }
239
240    /// Generates an actual value that breaches the covenant.
241    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.gen_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.gen_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.gen_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
285/// Adds months to a date (clamping to end of month if needed).
286fn 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    // Clamp day to last day of month
291    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// ---------------------------------------------------------------------------
311// Tests
312// ---------------------------------------------------------------------------
313
314#[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        // Total principal payments should equal original principal
345        assert_eq!(debt.total_principal_payments(), dec!(5000000));
346
347        // Last payment should have zero balance
348        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        // No amortization on revolving credit
376        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        // Each covenant should have a threshold and headroom
408        for cov in &debt.covenants {
409            assert!(cov.threshold > Decimal::ZERO);
410            // headroom is positive if compliant, negative if breached
411            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")); // leap year
455    }
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}