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