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
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}