datasynth_generators/hr/stock_comp_generator.rs
1//! Stock-based compensation generator — ASC 718 / IFRS 2.
2//!
3//! Generates equity award grants for executive employees, vesting schedules,
4//! period expense recognition records, and the associated journal entries.
5//!
6//! # Generation logic
7//!
8//! 1. **Grantees** — The top 10% of employees (by list index, acting as a
9//! proxy for seniority / executive status), subject to a configurable
10//! minimum (`min_grantees`) and maximum (`max_grantees`).
11//! 2. **Instrument mix** — 50% RSUs, 30% Options, 20% PSUs (rounded to whole
12//! employees at each threshold).
13//! 3. **Fair value**
14//! - RSUs: `share_price` (default $50).
15//! - Options: `share_price × factor` where factor ∈ [0.30, 0.50]
16//! (simplified Black-Scholes proxy — see note below).
17//! - PSUs: `share_price × factor` where factor ∈ [0.80, 1.20]
18//! (reflecting performance probability weighting).
19//!
20//! ## Option fair-value simplification
21//!
22//! A full Black-Scholes valuation requires five inputs: spot price, exercise
23//! price, risk-free rate, expected volatility, and time-to-expiry. Implementing
24//! the closed-form solution (and the normal CDF it requires) would add
25//! significant complexity without materially improving the utility of the
26//! generated data for audit-simulation purposes. Instead, option fair value is
27//! approximated as a uniform random fraction of the share price in [0.30, 0.50],
28//! which is consistent with at-the-money option premiums under typical
29//! volatility assumptions (σ ≈ 30–50%). If realistic option-pricing outputs
30//! are required, callers should post-process the grants with a proper pricer.
31//! 4. **Vesting** — Graded over 4 years, 25% per year; one `VestingEntry`
32//! per annual anniversary of the grant date.
33//! 5. **Forfeiture rate** — Sampled uniformly in [0.05, 0.15] per grant.
34//! 6. **Expense per period** — Straight-line:
35//! `total_grant_value × (1 − forfeiture_rate) / vesting_periods`
36//! 7. **Journal entry** — DR Compensation Expense (7200) / CR APIC–Stock
37//! Compensation (3150) for the period expense amount.
38
39use chrono::{Datelike, NaiveDate};
40use datasynth_core::models::journal_entry::{JournalEntry, JournalEntryLine, TransactionSource};
41use datasynth_core::models::stock_compensation::{
42 InstrumentType, StockCompExpense, StockGrant, VestingEntry, VestingSchedule, VestingType,
43};
44use datasynth_core::uuid_factory::{DeterministicUuidFactory, GeneratorType};
45use rand::prelude::*;
46use rand_chacha::ChaCha8Rng;
47use rust_decimal::Decimal;
48use rust_decimal_macros::dec;
49use tracing::debug;
50
51// ---------------------------------------------------------------------------
52// GL account codes — canonical constants from `datasynth_core::accounts` so
53// every account referenced here also lives in the generated chart of
54// accounts (see `seed_canonical_accounts`).
55// ---------------------------------------------------------------------------
56
57use datasynth_core::accounts::equity_accounts::APIC_STOCK_COMP;
58use datasynth_core::accounts::expense_accounts::STOCK_COMP_EXPENSE as COMP_EXPENSE;
59
60// ---------------------------------------------------------------------------
61// Configuration
62// ---------------------------------------------------------------------------
63
64/// Configuration for the stock compensation generator.
65#[derive(Debug, Clone)]
66pub struct StockCompConfig {
67 /// Share price used to compute fair value at grant date.
68 pub share_price: Decimal,
69 /// Minimum quantity of shares granted per executive.
70 pub min_grant_quantity: u32,
71 /// Maximum quantity of shares granted per executive.
72 pub max_grant_quantity: u32,
73 /// Number of vesting years (standard: 4).
74 pub vesting_years: u32,
75 /// Forfeiture rate lower bound (e.g. 0.05 = 5%).
76 pub forfeiture_min: Decimal,
77 /// Forfeiture rate upper bound (e.g. 0.15 = 15%).
78 pub forfeiture_max: Decimal,
79}
80
81impl Default for StockCompConfig {
82 fn default() -> Self {
83 Self {
84 share_price: dec!(50.00),
85 min_grant_quantity: 500,
86 max_grant_quantity: 5000,
87 vesting_years: 4,
88 forfeiture_min: dec!(0.05),
89 forfeiture_max: dec!(0.15),
90 }
91 }
92}
93
94// ---------------------------------------------------------------------------
95// Snapshot
96// ---------------------------------------------------------------------------
97
98/// All outputs from one stock compensation generation run.
99#[derive(Debug, Default)]
100pub struct StockCompSnapshot {
101 /// Stock grants (one per grantee).
102 pub grants: Vec<StockGrant>,
103 /// Period expense records (one per grant per active vesting period).
104 pub expenses: Vec<StockCompExpense>,
105 /// Journal entries (DR Comp Expense / CR APIC-Stock Comp).
106 pub journal_entries: Vec<JournalEntry>,
107}
108
109// ---------------------------------------------------------------------------
110// Generator
111// ---------------------------------------------------------------------------
112
113/// Generates stock-based compensation data for a reporting entity.
114pub struct StockCompGenerator {
115 #[allow(dead_code)]
116 uuid_factory: DeterministicUuidFactory,
117 rng: ChaCha8Rng,
118 config: StockCompConfig,
119}
120
121impl StockCompGenerator {
122 /// Create a new generator with a deterministic seed and default config.
123 pub fn new(seed: u64) -> Self {
124 Self {
125 uuid_factory: DeterministicUuidFactory::new(seed, GeneratorType::StockCompensation),
126 rng: ChaCha8Rng::seed_from_u64(seed ^ 0x7182_0018_u64),
127 config: StockCompConfig::default(),
128 }
129 }
130
131 /// Override the default configuration.
132 pub fn with_config(mut self, config: StockCompConfig) -> Self {
133 self.config = config;
134 self
135 }
136
137 /// Generate stock compensation data for one entity and one reporting period.
138 ///
139 /// # Parameters
140 /// - `entity_code` : company / entity identifier
141 /// - `employee_ids` : full list of employees (top 10% become grantees)
142 /// - `grant_date` : date on which grants are made (typically fiscal year start)
143 /// - `period_label` : period string used on expense records (e.g. "FY2024")
144 /// - `reporting_date`: last day of the reporting period (used for JE posting date)
145 /// - `currency` : reporting currency code
146 pub fn generate(
147 &mut self,
148 entity_code: &str,
149 employee_ids: &[String],
150 grant_date: NaiveDate,
151 period_label: &str,
152 reporting_date: NaiveDate,
153 currency: &str,
154 ) -> StockCompSnapshot {
155 let mut snapshot = StockCompSnapshot::default();
156
157 if employee_ids.is_empty() {
158 return snapshot;
159 }
160
161 // Determine executive pool: top 10% (min 1, max 50)
162 let exec_count = ((employee_ids.len() as f64 * 0.10).ceil() as usize).clamp(1, 50);
163 let grantees = &employee_ids[..exec_count];
164
165 debug!(
166 "StockComp: entity={entity_code}, employees={}, grantees={exec_count}",
167 employee_ids.len()
168 );
169
170 // Instrument type distribution across grantees
171 // 50% RSUs, 30% Options, 20% PSUs
172 let rsu_count = ((exec_count as f64 * 0.50).round() as usize).min(exec_count);
173 let opt_count = ((exec_count as f64 * 0.30).round() as usize).min(exec_count - rsu_count);
174 // PSUs get the remainder
175 let _psu_count = exec_count - rsu_count - opt_count;
176
177 for (idx, employee_id) in grantees.iter().enumerate() {
178 let instrument_type = if idx < rsu_count {
179 InstrumentType::RSUs
180 } else if idx < rsu_count + opt_count {
181 InstrumentType::Options
182 } else {
183 InstrumentType::PSUs
184 };
185
186 let grant = self.build_grant(
187 entity_code,
188 employee_id,
189 grant_date,
190 instrument_type,
191 currency,
192 );
193
194 // Generate expense records for each vesting period that falls
195 // within or before the reporting date
196 let expenses = self.build_expenses(&grant, period_label, reporting_date);
197
198 // Generate JEs for this period's expense
199 let period_expense: Decimal = expenses.iter().map(|e| e.expense_amount).sum();
200 if !period_expense.is_zero() {
201 let je = self.build_je(
202 entity_code,
203 reporting_date,
204 &grant.id,
205 period_label,
206 period_expense,
207 );
208 snapshot.journal_entries.push(je);
209 }
210
211 snapshot.expenses.extend(expenses);
212 snapshot.grants.push(grant);
213 }
214
215 debug!(
216 "StockComp generated: entity={entity_code}, grants={}, expenses={}, jes={}",
217 snapshot.grants.len(),
218 snapshot.expenses.len(),
219 snapshot.journal_entries.len()
220 );
221
222 snapshot
223 }
224
225 // -------------------------------------------------------------------------
226 // Grant builder
227 // -------------------------------------------------------------------------
228
229 fn build_grant(
230 &mut self,
231 entity_code: &str,
232 employee_id: &str,
233 grant_date: NaiveDate,
234 instrument_type: InstrumentType,
235 currency: &str,
236 ) -> StockGrant {
237 let grant_id = format!(
238 "GRANT-{}-{}-{}",
239 entity_code,
240 employee_id,
241 grant_date.year()
242 );
243
244 // Quantity
245 let qty_range = (self.config.max_grant_quantity - self.config.min_grant_quantity) as f64;
246 let quantity =
247 self.config.min_grant_quantity + (self.rng.random::<f64>() * qty_range) as u32;
248
249 // Fair value per share
250 let (fair_value_at_grant, exercise_price) = match instrument_type {
251 InstrumentType::RSUs => (self.config.share_price, None),
252 InstrumentType::Options => {
253 // Black-Scholes proxy: 30%–50% of share price.
254 // Intentional simplification — see module-level doc for rationale.
255 let factor = self.rand_rate(dec!(0.30), dec!(0.50));
256 let fv = (self.config.share_price * factor).round_dp(2);
257 // Exercise price = at-the-money (equal to share price)
258 (fv, Some(self.config.share_price))
259 }
260 InstrumentType::PSUs => {
261 // PSUs: 80%–120% of share price reflecting performance probability
262 let factor = self.rand_rate(dec!(0.80), dec!(1.20));
263 ((self.config.share_price * factor).round_dp(2), None)
264 }
265 };
266
267 let total_grant_value = (fair_value_at_grant * Decimal::from(quantity)).round_dp(2);
268
269 // Forfeiture rate
270 let forfeiture_rate =
271 self.rand_rate(self.config.forfeiture_min, self.config.forfeiture_max);
272
273 // Vesting schedule: graded 4-year, 25% per year
274 let vesting_schedule = self.build_vesting_schedule(grant_date, self.config.vesting_years);
275
276 // Options expire 10 years from grant date
277 let expiration_date = if instrument_type == InstrumentType::Options {
278 grant_date.checked_add_signed(chrono::Duration::days(365 * 10))
279 } else {
280 None
281 };
282
283 StockGrant {
284 id: grant_id,
285 entity_code: entity_code.to_string(),
286 employee_id: employee_id.to_string(),
287 grant_date,
288 instrument_type,
289 quantity,
290 exercise_price,
291 fair_value_at_grant,
292 total_grant_value,
293 vesting_schedule,
294 expiration_date,
295 forfeiture_rate,
296 currency: currency.to_string(),
297 }
298 }
299
300 // -------------------------------------------------------------------------
301 // Vesting schedule builder
302 // -------------------------------------------------------------------------
303
304 fn build_vesting_schedule(&self, grant_date: NaiveDate, years: u32) -> VestingSchedule {
305 let pct_per_period = (Decimal::ONE / Decimal::from(years)).round_dp(4);
306 let mut cumulative = Decimal::ZERO;
307 let mut entries = Vec::with_capacity(years as usize);
308
309 for period in 1..=years {
310 // Adjust for rounding: last tranche absorbs any residual
311 let pct = if period == years {
312 (Decimal::ONE - cumulative).round_dp(4)
313 } else {
314 pct_per_period
315 };
316 cumulative = (cumulative + pct).round_dp(4);
317
318 // Vesting date: N-year anniversary of grant date
319 let vesting_date = add_years(grant_date, period);
320
321 entries.push(VestingEntry {
322 period,
323 vesting_date,
324 percentage: pct,
325 cumulative_percentage: cumulative,
326 });
327 }
328
329 VestingSchedule {
330 vesting_type: VestingType::Graded,
331 total_periods: years,
332 cliff_periods: None,
333 vesting_entries: entries,
334 }
335 }
336
337 // -------------------------------------------------------------------------
338 // Expense builder
339 // -------------------------------------------------------------------------
340
341 /// Build period expense records using straight-line expense recognition.
342 ///
343 /// ASC 718 / IFRS 2 requires recognising compensation cost over the
344 /// *requisite service period* (= vesting period), not just at vesting
345 /// dates. For graded vesting with N annual tranches, each tranche
346 /// has a service period of 1 year. We recognise tranche expense
347 /// pro-rata based on the fraction of the service period elapsed by
348 /// `reporting_date`.
349 ///
350 /// A tranche's service period begins on `grant_date` (for tranche 1)
351 /// or on the previous vesting date (for subsequent tranches).
352 /// Any tranche whose service period has started produces an expense record.
353 ///
354 /// One `StockCompExpense` is emitted per grant summarising the
355 /// cumulative expense recognised through `reporting_date`.
356 fn build_expenses(
357 &self,
358 grant: &StockGrant,
359 period_label: &str,
360 reporting_date: NaiveDate,
361 ) -> Vec<StockCompExpense> {
362 // Grant must have started service and reporting_date must be on/after grant_date.
363 if reporting_date < grant.grant_date {
364 return vec![];
365 }
366
367 let total_expense =
368 (grant.total_grant_value * (Decimal::ONE - grant.forfeiture_rate)).round_dp(2);
369 let n = grant.vesting_schedule.total_periods;
370 if n == 0 || total_expense.is_zero() {
371 return vec![];
372 }
373
374 // Per-period expense (straight-line, equal tranche amounts)
375 let per_period_base = (total_expense / Decimal::from(n)).round_dp(2);
376
377 let mut cumulative = Decimal::ZERO;
378
379 for (tranche_idx, entry) in grant.vesting_schedule.vesting_entries.iter().enumerate() {
380 // Service period start for this tranche
381 let service_start = if tranche_idx == 0 {
382 grant.grant_date
383 } else {
384 grant
385 .vesting_schedule
386 .vesting_entries
387 .get(tranche_idx - 1)
388 .map(|prev| prev.vesting_date)
389 .unwrap_or(grant.grant_date)
390 };
391 let service_end = entry.vesting_date;
392
393 // Skip tranches whose service period has not yet begun
394 if service_start > reporting_date {
395 break;
396 }
397
398 // Expense for this tranche: full period if service_end ≤ reporting_date,
399 // otherwise pro-rate by days elapsed / total service days.
400 let expense_amount = if service_end <= reporting_date {
401 // Tranche fully earned (service period complete)
402 if tranche_idx + 1 == n as usize {
403 // Last tranche: absorb any rounding residual
404 (total_expense - cumulative).max(Decimal::ZERO)
405 } else {
406 per_period_base
407 }
408 } else {
409 // Tranche partially earned: pro-rate by days elapsed
410 let total_days = (service_end - service_start).num_days().max(1) as f64;
411 let elapsed_days = (reporting_date - service_start).num_days().max(0) as f64;
412 let tranche_max = if tranche_idx + 1 == n as usize {
413 (total_expense - cumulative).max(Decimal::ZERO)
414 } else {
415 per_period_base
416 };
417 let fraction = elapsed_days / total_days;
418 let frac_dec = Decimal::try_from(fraction).unwrap_or(Decimal::ZERO);
419 (tranche_max * frac_dec).round_dp(2)
420 };
421
422 cumulative = (cumulative + expense_amount).round_dp(2);
423 }
424
425 if cumulative.is_zero() {
426 return vec![];
427 }
428
429 let remaining = (total_expense - cumulative).max(Decimal::ZERO);
430
431 vec![StockCompExpense {
432 grant_id: grant.id.clone(),
433 entity_code: grant.entity_code.clone(),
434 period: period_label.to_string(),
435 expense_amount: cumulative,
436 cumulative_recognized: cumulative,
437 remaining_unrecognized: remaining,
438 forfeiture_rate: grant.forfeiture_rate,
439 }]
440 }
441
442 // -------------------------------------------------------------------------
443 // Journal entry builder
444 // -------------------------------------------------------------------------
445
446 /// DR Compensation Expense (7200) / CR APIC–Stock Compensation (3150).
447 fn build_je(
448 &mut self,
449 entity_code: &str,
450 posting_date: NaiveDate,
451 grant_id: &str,
452 period: &str,
453 amount: Decimal,
454 ) -> JournalEntry {
455 let doc_id = format!("JE-STOCKCOMP-{}-{}", entity_code, period.replace('-', ""));
456
457 let mut je = JournalEntry::new_simple(
458 doc_id,
459 entity_code.to_string(),
460 posting_date,
461 format!("Stock-based compensation expense — {period}"),
462 );
463 je.header.source = TransactionSource::Adjustment;
464
465 je.add_line(JournalEntryLine {
466 line_number: 1,
467 gl_account: COMP_EXPENSE.to_string(),
468 debit_amount: amount,
469 reference: Some(grant_id.to_string()),
470 text: Some(format!("SBC expense {period}")),
471 ..Default::default()
472 });
473 je.add_line(JournalEntryLine {
474 line_number: 2,
475 gl_account: APIC_STOCK_COMP.to_string(),
476 credit_amount: amount,
477 reference: Some(grant_id.to_string()),
478 text: Some(format!("APIC stock comp {period}")),
479 ..Default::default()
480 });
481
482 je
483 }
484
485 // -------------------------------------------------------------------------
486 // Helpers
487 // -------------------------------------------------------------------------
488
489 /// Sample a uniform decimal in [lo, hi].
490 fn rand_rate(&mut self, lo: Decimal, hi: Decimal) -> Decimal {
491 let range_f = (hi - lo).to_string().parse::<f64>().unwrap_or(0.0);
492 let sample: f64 = self.rng.random::<f64>() * range_f;
493 let sample_d = Decimal::try_from(sample).unwrap_or(Decimal::ZERO);
494 (lo + sample_d).round_dp(4)
495 }
496}
497
498// ---------------------------------------------------------------------------
499// Date arithmetic helper
500// ---------------------------------------------------------------------------
501
502/// Add `years` calendar years to `date`, snapping to end-of-month when the
503/// day doesn't exist in the target month (e.g. Feb 29 → Feb 28).
504fn add_years(date: NaiveDate, years: u32) -> NaiveDate {
505 let target_year = date.year() + years as i32;
506 let day = date.day();
507 // Try exact day; fall back to last day of month if it doesn't exist.
508 NaiveDate::from_ymd_opt(target_year, date.month(), day)
509 .or_else(|| NaiveDate::from_ymd_opt(target_year, date.month(), 28))
510 .unwrap_or(date)
511}