datasynth_generators/standards/
lease_generator.rs1use chrono::NaiveDate;
26use datasynth_config::schema::LeaseAccountingConfig;
27use datasynth_core::utils::seeded_rng;
28use datasynth_standards::accounting::leases::{
29 Lease, LeaseAssetClass, LeaseClassification, PaymentFrequency,
30};
31use datasynth_standards::framework::AccountingFramework;
32use rand::prelude::*;
33use rand_chacha::ChaCha8Rng;
34use rand_distr::{LogNormal, Normal};
35use rust_decimal::prelude::*;
36use rust_decimal::Decimal;
37
38const LESSOR_NAMES: &[&str] = &[
40 "Prologis Trust",
41 "Brookfield Property Partners",
42 "Digital Realty Leasing",
43 "CBRE Investments",
44 "JLL Capital Markets",
45 "Realty Income Corp",
46 "Equinix Leasing",
47 "Industrial Innovations Ltd",
48 "Global Fleet Services",
49 "Enterprise Rent-A-Lease",
50 "Dell Financial Services",
51 "HP Capital Leasing",
52 "Cisco Capital Group",
53 "IBM Global Financing",
54 "Mitsubishi HC Capital",
55];
56
57fn description_for(asset_class: LeaseAssetClass, rng: &mut ChaCha8Rng) -> String {
59 let options: &[&str] = match asset_class {
60 LeaseAssetClass::RealEstate => &[
61 "Office space — Class A building",
62 "Warehouse — distribution center",
63 "Retail storefront",
64 "Data center colocation space",
65 "Manufacturing plant lease",
66 ],
67 LeaseAssetClass::Equipment => &[
68 "Industrial CNC machining center",
69 "Production line automation equipment",
70 "Packaging line equipment",
71 "Commercial HVAC system",
72 "Warehouse conveyor system",
73 ],
74 LeaseAssetClass::Vehicles => &[
75 "Delivery truck fleet",
76 "Executive vehicle fleet",
77 "Heavy-duty forklift fleet",
78 "Sales-team vehicle pool",
79 "Cold-chain refrigerated truck",
80 ],
81 LeaseAssetClass::InformationTechnology => &[
82 "Enterprise server cluster",
83 "Networking equipment rack",
84 "Employee laptop fleet",
85 "Point-of-sale terminal fleet",
86 "Video-conferencing equipment",
87 ],
88 LeaseAssetClass::FurnitureAndFixtures => &[
89 "Office furniture suite",
90 "Retail display fixtures",
91 "Modular workstation package",
92 "Conference-room AV equipment",
93 ],
94 LeaseAssetClass::Other => &[
95 "Specialized industrial equipment",
96 "Research laboratory equipment",
97 "Medical imaging equipment",
98 ],
99 };
100 options
101 .choose(rng)
102 .copied()
103 .unwrap_or("Leased asset")
104 .to_string()
105}
106
107pub struct LeaseGenerator {
109 rng: ChaCha8Rng,
110}
111
112impl LeaseGenerator {
113 pub fn new(seed: u64) -> Self {
115 Self {
116 rng: seeded_rng(seed, 0),
117 }
118 }
119
120 pub fn generate(
134 &mut self,
135 company_code: &str,
136 commencement_anchor: NaiveDate,
137 config: &LeaseAccountingConfig,
138 framework: AccountingFramework,
139 ) -> Vec<Lease> {
140 if config.lease_count == 0 {
141 return Vec::new();
142 }
143
144 let term_dist =
145 Normal::new(config.avg_lease_term_months as f64, 12.0).expect("positive sigma");
146 let fair_value_dist = LogNormal::new(11.0, 1.3).expect("valid lognormal");
147 let margin_dist = Normal::new(0.15, 0.08).expect("positive sigma");
148
149 let mut leases = Vec::with_capacity(config.lease_count);
150 for i in 0..config.lease_count {
151 let asset_class = self.pick_asset_class(config);
152 let description = description_for(asset_class, &mut self.rng);
153 let lessor = LESSOR_NAMES
154 .choose(&mut self.rng)
155 .copied()
156 .unwrap_or("Unknown Lessor")
157 .to_string();
158
159 let term_raw: f64 = term_dist.sample(&mut self.rng).round();
160 let lease_term_months = term_raw.clamp(6.0_f64, 240.0_f64) as u32;
161
162 let fair_value_sample: f64 = fair_value_dist.sample(&mut self.rng);
163 let fair_value_raw = fair_value_sample.max(1000.0_f64);
164 let fair_value = Decimal::from_f64(fair_value_raw)
165 .unwrap_or_else(|| Decimal::from(10_000))
166 .round_dp(2);
167
168 let term_months_f = lease_term_months.max(1) as f64;
169 let margin_raw: f64 = margin_dist.sample(&mut self.rng);
170 let margin = margin_raw.clamp(-0.05_f64, 0.40_f64);
171 let fixed_payment_f64 = (fair_value_raw / term_months_f) * (1.0 + margin);
172 let fixed_payment = Decimal::from_f64(fixed_payment_f64.max(10.0))
173 .unwrap_or(Decimal::ONE_HUNDRED)
174 .round_dp(2);
175
176 let discount_rate = {
177 let r: f64 = self.rng.random_range(0.03..0.08);
178 Decimal::from_f64(r)
179 .unwrap_or(Decimal::from_str_exact("0.05").expect("const"))
180 .round_dp(4)
181 };
182
183 let economic_life_months = match asset_class {
188 LeaseAssetClass::RealEstate => lease_term_months.saturating_mul(5).max(360),
189 _ => lease_term_months.saturating_mul(3).clamp(60, 360),
190 };
191
192 let offset_days: i64 = self
195 .rng
196 .random_range(0..(365.max(lease_term_months as i64 / 4)));
197 let commencement = commencement_anchor + chrono::Duration::days(offset_days);
198
199 let mut lease = Lease::new(
200 company_code.to_string(),
201 lessor,
202 description,
203 asset_class,
204 commencement,
205 lease_term_months,
206 fixed_payment,
207 PaymentFrequency::Monthly,
208 discount_rate,
209 fair_value,
210 economic_life_months,
211 framework,
212 );
213
214 if (i as f64 / config.lease_count as f64) < config.finance_lease_percent
218 && lease.classification != LeaseClassification::Finance
219 {
220 lease.transfers_ownership = true;
221 lease.classify();
222 }
223
224 leases.push(lease);
225 }
226 leases
227 }
228
229 fn pick_asset_class(&mut self, config: &LeaseAccountingConfig) -> LeaseAssetClass {
232 let roll: f64 = self.rng.random();
233 if roll < config.real_estate_percent {
234 return LeaseAssetClass::RealEstate;
235 }
236 let remaining = 1.0 - config.real_estate_percent;
238 let per_class = remaining / 5.0;
239 let r = roll - config.real_estate_percent;
240 if r < per_class {
241 LeaseAssetClass::Equipment
242 } else if r < 2.0 * per_class {
243 LeaseAssetClass::Vehicles
244 } else if r < 3.0 * per_class {
245 LeaseAssetClass::InformationTechnology
246 } else if r < 4.0 * per_class {
247 LeaseAssetClass::FurnitureAndFixtures
248 } else {
249 LeaseAssetClass::Other
250 }
251 }
252}
253
254#[cfg(test)]
255#[allow(clippy::unwrap_used)]
256mod tests {
257 use super::*;
258
259 fn fixture_config() -> LeaseAccountingConfig {
260 LeaseAccountingConfig {
261 enabled: true,
262 lease_count: 20,
263 finance_lease_percent: 0.30,
264 avg_lease_term_months: 60,
265 generate_amortization: true,
266 real_estate_percent: 0.40,
267 }
268 }
269
270 #[test]
271 fn generates_requested_count() {
272 let mut gen = LeaseGenerator::new(42);
273 let cfg = fixture_config();
274 let leases = gen.generate(
275 "C001",
276 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
277 &cfg,
278 AccountingFramework::UsGaap,
279 );
280 assert_eq!(leases.len(), cfg.lease_count);
281 }
282
283 #[test]
284 fn lease_measurements_are_positive() {
285 let mut gen = LeaseGenerator::new(123);
286 let leases = gen.generate(
287 "C001",
288 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
289 &fixture_config(),
290 AccountingFramework::Ifrs,
291 );
292 for l in &leases {
293 assert!(l.fair_value_at_commencement > Decimal::ZERO);
294 assert!(l.fixed_payment > Decimal::ZERO);
295 assert!(l.discount_rate > Decimal::ZERO);
296 assert!(l.lease_term_months >= 6);
297 }
298 }
299
300 #[test]
301 fn finance_classification_share_is_close_to_target() {
302 let mut gen = LeaseGenerator::new(7);
303 let mut cfg = fixture_config();
304 cfg.lease_count = 200;
305 let leases = gen.generate(
306 "C001",
307 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
308 &cfg,
309 AccountingFramework::UsGaap,
310 );
311 let finance = leases
312 .iter()
313 .filter(|l| l.classification == LeaseClassification::Finance)
314 .count();
315 let operating = leases
316 .iter()
317 .filter(|l| l.classification == LeaseClassification::Operating)
318 .count();
319 assert!(finance > 0, "expected ≥ 1 Finance lease, got {finance}");
327 assert!(
328 operating > 0 || finance == leases.len(),
329 "expected either some Operating leases or all Finance — got 0 Operating and \
330 only {finance}/{} Finance",
331 leases.len()
332 );
333 }
334
335 #[test]
336 fn zero_count_returns_empty() {
337 let mut gen = LeaseGenerator::new(1);
338 let mut cfg = fixture_config();
339 cfg.lease_count = 0;
340 let leases = gen.generate(
341 "C001",
342 NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(),
343 &cfg,
344 AccountingFramework::UsGaap,
345 );
346 assert!(leases.is_empty());
347 }
348}