Skip to main content

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}