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 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
19// ---------------------------------------------------------------------------
20// Lender pool
21// ---------------------------------------------------------------------------
22
23const 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
36// ---------------------------------------------------------------------------
37// Generator
38// ---------------------------------------------------------------------------
39
40/// Generates debt instruments with amortization schedules and covenants.
41pub struct DebtGenerator {
42    rng: ChaCha8Rng,
43    config: DebtSchemaConfig,
44    instrument_counter: u64,
45    covenant_counter: u64,
46}
47
48impl DebtGenerator {
49    /// Creates a new debt generator.
50    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    /// Generates all debt instruments from config definitions.
60    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    /// Generates a single debt instrument from a definition.
79    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        // Generate amortization schedule for term loans
122        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        // Set revolving credit specifics
129        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        // Attach covenants
137        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    /// Generates a level-payment amortization schedule.
147    ///
148    /// Uses quarterly payments. Total principal payments sum to the original principal.
149    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; // quarterly
157        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            // Last payment gets the remainder to ensure exact sum
172            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    /// Generates a covenant from a definition with simulated actual values.
192    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        // Generate actual value: usually compliant (90%), occasionally breached (10%)
203        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    /// Generates an actual value that is compliant with the covenant.
220    fn generate_compliant_value(
221        &mut self,
222        covenant_type: CovenantType,
223        threshold: Decimal,
224    ) -> Decimal {
225        match covenant_type {
226            // Maximum covenants: actual < threshold
227            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            // Minimum covenants: actual > threshold
232            _ => {
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    /// Generates an actual value that breaches the covenant.
240    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
284/// Adds months to a date (clamping to end of month if needed).
285fn 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    // Clamp day to last day of month
290    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// ---------------------------------------------------------------------------
310// Tests
311// ---------------------------------------------------------------------------
312
313#[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        // Total principal payments should equal original principal
344        assert_eq!(debt.total_principal_payments(), dec!(5000000));
345
346        // Last payment should have zero balance
347        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        // No amortization on revolving credit
375        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        // Each covenant should have a threshold and headroom
407        for cov in &debt.covenants {
408            assert!(cov.threshold > Decimal::ZERO);
409            // headroom is positive if compliant, negative if breached
410            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")); // leap year
454    }
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}