simulate_clgrp <- function(ff, n_funds = 100, seed = 2026) {
set.seed(seed)
mkt <- ff |> filter(ym >= as.Date("2000-01-01"), ym <= as.Date("2025-12-01"))
n_t <- nrow(mkt)
obj <- c("EDCL", "EDCM", "EDCS", "EDCI", "EDYG", "EDYB", "EDYI")
fam <- c("Alder", "Beacon", "Cedar", "Delta", "Everest", "Falcon", "Granite", "Harbor",
"Juniper", "Kestrel")
word <- c("Growth", "Value", "Opportunities", "Equity Income", "Small Cap", "Mid Cap",
"Select", "Capital Appreciation", "Core Equity", "Discovery")
launched <- runif(n_funds) < 0.35 # launched after 2000
start <- if_else(launched, sample(2:180, n_funds, TRUE), 1L)
exits <- runif(n_funds) < 0.30
end <- if_else(exits & start + 120 < n_t,
map2_int(start, n_t, \(s, n) if (s + 120 < n) sample((s + 120):(n - 1), 1) else n),
n_t)
first_offer <- if_else(launched, mkt$ym[start] - sample(0:25, n_funds, TRUE),
as.Date("2000-01-01") - sample(365:(365 * 35), n_funds, TRUE))
beta <- runif(n_funds, 0.8, 1.2); sd_e <- runif(n_funds, 0.012, 0.03)
tna0 <- if_else(launched, exp(rnorm(n_funds, log(8), 0.8)), exp(rnorm(n_funds, log(200), 1.5)))
merger_t <- rep(NA_integer_, n_funds)
mg <- sample(which(end - start > 60), 3)
merger_t[mg] <- map_int(mg, \(i) sample((start[i] + 24):(end[i] - 12), 1))
R <- matrix(NA_real_, n_funds, n_t); TNA <- R; FL <- R
ncl <- matrix(NA_integer_, n_funds, n_t)
for (t in seq_len(n_t)) {
alive <- start <= t & t <= end
R[alive, t] <- mkt$rf[t] + beta[alive] * mkt$mktrf[t] + rnorm(sum(alive), 0, sd_e[alive]) -
if_else(end[alive] < n_t & end[alive] - t < 36, 0.004, 0) # funds about to exit underperform
born <- alive & start == t
TNA[born, t] <- tna0[born]; ncl[born, t] <- sample(1:4, sum(born), TRUE)
old <- alive & start < t
if (!any(old)) next
has12 <- old & start <= t - 12
r12 <- rep(NA_real_, n_funds)
if (t > 12) r12[has12] <- apply(1 + R[has12, (t - 12):(t - 1), drop = FALSE], 1, prod) - 1
rk <- rep(NA_real_, n_funds); rk[has12] <- percent_rank(r12[has12])
seg <- 0.010 * pmin(rk, 0.2) + 0.012 * pmin(0.6, pmax(rk - 0.2, 0)) + 0.060 * pmax(rk - 0.8, 0)
age <- as.numeric(mkt$ym[t] - first_offer) / 365.25
f <- if_else(has12, -0.010 + if_else(age < 6, 1.8, 1) * seg, 0.015) +
0.25 * coalesce(FL[, t - 1], 0) +
rnorm(n_funds, 0, 0.008 + 0.04 / sqrt(pmax(coalesce(TNA[, t - 1], 1), 1)))
f <- f - if_else(end - t < 24 & end < n_t, 0.03, 0) # funds winding down
launch_class <- old & runif(n_funds) < 0.004 # a new share class brings money
f <- f + if_else(launch_class, runif(n_funds, 0.05, 0.30), 0)
f <- f + if_else(!is.na(merger_t) & merger_t == t, runif(n_funds, 1, 2.5), 0)
FL[old, t] <- f[old]
ncl[old, t] <- ncl[old, t - 1] + launch_class[old]
TNA[old, t] <- pmax(0.1, TNA[old, t - 1] * (1 + R[old, t]) * (1 + f[old]))
}
out <- map(seq_len(n_funds), \(i) {
idx <- start[i]:end[i]
tibble(crsp_cl_grp = 3000000 + i, ym = mkt$ym[idx], n_class = ncl[i, idx],
tna = TNA[i, idx], ret = R[i, idx])
}) |> list_rbind()
chars <- tibble(crsp_cl_grp = 3000000 + seq_len(n_funds),
fund_name = paste(sample(fam, n_funds, TRUE), sample(word, n_funds, TRUE), "Fund"),
first_offer = first_offer,
crsp_obj_cd = sample(obj, n_funds, TRUE, c(.2, .12, .12, .03, .25, .2, .08)),
er0 = pmax(0.002, rnorm(n_funds, 0.0115, 0.003)),
b12 = sample(c(0, 0.0025, 0.005, 0.0075), n_funds, TRUE),
turn0 = exp(rnorm(n_funds, log(0.6), 0.6)))
out |>
left_join(chars, by = "crsp_cl_grp") |>
mutate(# one-month reporting gap: a share class's TNA is missing, so the fund total dips
gap = runif(n()) < 0.003 & ym > min(ym) & ym < max(ym),
tna = round(if_else(gap, tna * (1 - runif(n(), 0.2, 0.6)), tna), 1),
.by = crsp_cl_grp) |>
mutate(ret_vw = if_else(ym == min(ym), NA_real_, ret), # no lagged class TNA in month 1
ret_ew = ret + rnorm(n(), 0, 0.0003),
exp_ratio = round(er0 - 0.00002 * (as.numeric(ym - min(ym)) / 30), 4),
mgmt_fee = round(0.65 * exp_ratio, 4),
actual_12b1 = b12,
turn_ratio = round(turn0 * exp(rnorm(n(), 0, 0.05)), 2),
.by = crsp_cl_grp) |>
mutate(caldt = ceiling_date(ym, "month") - days(1),
age_years = as.numeric(caldt - first_offer) / 365.25) |>
select(crsp_cl_grp, caldt, fund_name, n_class, tna, ret_vw, ret_ew, exp_ratio, mgmt_fee,
actual_12b1, turn_ratio, first_offer, crsp_obj_cd, age_years)
}