simulate_crsp <- function(n_port = 1500, seed = 2026) {
set.seed(seed)
# --- vendor style codes for broad domestic-equity funds (illustrative) ------
broad <- tribble(
~lipper_obj_cd, ~lipper_class, ~si_obj_cd, ~wbrger_obj_cd, ~crsp_obj_cd,
"G", "LCGE", "GRO", "G", "EDYG",
"GI", "LCCE", "GRI", "GCI", "EDYB",
"EI", "EIEI", "ING", "IEQ", "EDYI",
"MC", "MCGE", "GMC", "MCG", "EDCM",
"SG", "SCGE", "SCG", "SCG", "EDCS",
"CA", "MLGE", "AGG", "LTG", "EDYG",
"MR", "MLCE", "GRO", "IEQ", "EDCM"
) |> mutate(sid = row_number())
sector <- tribble(
~lipper_obj_cd, ~lipper_class, ~si_obj_cd, ~wbrger_obj_cd, ~crsp_obj_cd, ~sector_name,
"TK", "SPTK", "SEC", "S", "EDSF", "Technology",
"H", "SPHE", "SEC", "S", "EDSH", "Health Sciences",
"FS", "SPFS", "SEC", "S", "EDSN", "Financial Services",
"RE", "SPRE", "SEC", "S", "EDSR", "Real Estate",
"UT", "SPUT", "SEC", "S", "EDSU", "Wind Energy"
) |> mutate(sid = row_number()) |> rename_with(~ paste0(.x, "_s"), -sid)
type_lvls <- c("active_broad", "active_sector", "active_special",
"active_foreign_name", "index", "etf_index", "etf_active",
"etn", "enhanced", "target", "intl", "bond")
type_prob <- c(.38, .07, .03, .02, .09, .05, .02, .01, .02, .06, .13, .12)
families <- c("Alder", "Beacon", "Cedar", "Delta", "Everest",
"Falcon", "Granite", "Harbor", "Juniper", "Kestrel")
style_path <- function(n, k, p_switch = 0.04) {
s <- integer(n); s[1] <- sample(k, 1)
for (i in seq_len(n)[-1]) s[i] <- if (runif(1) < p_switch) sample(k, 1) else s[i - 1]
s
}
# --- portfolios -------------------------------------------------------------
port <- tibble(
wficn = 1:n_port,
true_type = sample(type_lvls, n_port, TRUE, type_prob),
family = sample(families, n_port, TRUE)
) |>
mutate(
first_year = map_int(true_type, \(t) {
lo <- switch(t, etf_index = , etf_active = , etn = 2000L, index = 1990L, 1985L)
sample(lo:2022L, 1, prob = (lo:2022L) - lo + 5) # launches grow over time
}),
last_year = pmin(2023L, first_year + 2L + rgeom(n(), 0.07)),
no_codes = runif(n()) < 0.03, # some funds never carry a style code
special_kind = sample(1:3, n(), TRUE),
tdf_eq_code = runif(n()) < 0.40,
idx_flagged = runif(n()) < 0.70,
base_tna = exp(rnorm(n(), 4.5, 1.8)), # $ millions
offer_month = sample(1:12, n(), TRUE),
first_offer = as.Date(sprintf("%d-%02d-15", first_year, offer_month)),
name_pick = sample(1:11, n(), TRUE),
trap_pick = runif(n())
) |>
filter(last_year >= 2000L)
# --- fund names (with a few planted traps) ------------------------------------
act_words <- c("Growth", "Value Opportunities", "Capital Appreciation", "Mid Cap",
"Small Cap", "Equity Income", "Blue Chip", "Dividend Growth",
"Select", "Contrarian", "Core")
port <- port |>
mutate(
base_name = case_when(
true_type == "active_broad" ~ paste(family, act_words[name_pick], "Fund"),
true_type == "active_special" ~ paste(family, c("Market Neutral", "Short Alpha", "Covered Call")[special_kind], "Fund"),
true_type == "active_foreign_name" ~ paste(family, c("China", "India", "Japan", "Asia", "Europe")[name_pick %% 5 + 1], "Opportunities Fund"),
true_type == "index" ~ paste(family, c("500 Index", "Total Market Index", "Small Cap Index", "Value Index", "Growth Index", "Extended Market Index", "Russell 1000 Index", "Mid Cap Index", "Large Cap Idx", "Equity Index", "Total Stock Indx")[name_pick], "Fund"),
true_type == "etf_index" ~ paste(family, c("Core S&P 500 ETF", "Total Market ETF", "Small Cap ETF", "Large Cap ETF", "Dividend ETF")[name_pick %% 5 + 1]),
true_type == "etf_active" ~ paste(family, c("Active Growth ETF", "Active Value ETF", "Dynamic Equity ETF")[name_pick %% 3 + 1]),
true_type == "etn" ~ paste(family, "Leveraged Energy ETN"),
true_type == "enhanced" ~ paste(family, c("Ultra Equity", "2x Large Cap", "Enhanced Equity", "Equity Plus")[name_pick %% 4 + 1], "Fund"),
true_type == "target" ~ paste(family, "Retirement", seq(2010, 2060, 5)[name_pick], "Fund"),
true_type == "intl" ~ paste(family, c("International", "Global", "Emerging Markets", "World")[name_pick %% 4 + 1], "Fund"),
true_type == "bond" ~ paste(family, c("Core Bond", "High Yield", "Municipal Bond")[name_pick %% 3 + 1], "Fund"),
TRUE ~ paste(family, "Fund")
),
# traps: names that fool naive substring rules
base_name = if_else(true_type == "active_broad" & trap_pick < 0.04, paste(family, "Growth Fund RSP"), base_name),
base_name = if_else(true_type == "active_broad" & trap_pick > 0.97, paste(family, "Select 100 Opportunities Fund"), base_name)
)
# --- share classes ----------------------------------------------------------------
cls <- port |>
mutate(n_cls = sample(1:4, n(), TRUE, c(.35, .30, .20, .15))) |>
uncount(n_cls, .id = "k") |>
mutate(crsp_fundno = 10000L + row_number(),
crsp_cl_grp = 500000L + wficn,
crsp_portno = if_else(runif(n()) < .8, 8000L + wficn, NA_integer_),
w_cls = c(.50, .30, .15, .05)[k]) |>
mutate(class_share = w_cls / sum(w_cls), .by = wficn) |>
select(wficn, k, crsp_fundno, crsp_cl_grp, crsp_portno, class_share)
# --- portfolio-year panel with style paths ------------------------------------------
pp <- port |>
mutate(year = map2(pmax(first_year, 2000L), last_year, \(a, b) a:b)) |>
unnest(year) |>
mutate(style_id = style_path(n(), 7L,
if (first(true_type) %in% c("active_broad", "active_special", "active_foreign_name")) 0.04 else 0),
.by = wficn) |>
mutate(sid = style_id, sid_s = ((style_id - 1L) %% 5L) + 1L) |>
left_join(broad, by = "sid") |>
left_join(sector, by = c("sid_s" = "sid"))
pp <- pp |>
mutate(
equity_coded = true_type %in% c("active_broad", "active_special", "active_foreign_name",
"index", "etf_index", "etf_active", "etn", "enhanced") |
(true_type == "target" & tdf_eq_code),
lipper_obj_cd = case_when(true_type == "active_sector" ~ lipper_obj_cd_s,
equity_coded ~ lipper_obj_cd,
true_type == "intl" ~ "IL", true_type == "bond" ~ "A",
TRUE ~ "MTD"),
lipper_class = case_when(true_type == "active_sector" ~ lipper_class_s,
equity_coded ~ lipper_class, TRUE ~ "OTHR"),
si_obj_cd = case_when(true_type == "active_sector" ~ si_obj_cd_s,
true_type == "active_special" & special_kind == 3L ~ "OPI",
equity_coded ~ si_obj_cd, TRUE ~ "OTH"),
wbrger_obj_cd = case_when(true_type == "active_sector" ~ wbrger_obj_cd_s,
equity_coded ~ wbrger_obj_cd, TRUE ~ "OTH"),
crsp_obj_cd = case_when(true_type == "active_sector" ~ crsp_obj_cd_s,
true_type == "active_special" ~ c("EDYH", "EDYS", "EDYI")[special_kind],
equity_coded ~ crsp_obj_cd,
true_type == "intl" ~ "EICE", true_type == "bond" ~ "ICQY",
TRUE ~ "MTAL"),
base_name = if_else(true_type == "active_sector",
paste(family, sector_name_s, "Fund"), base_name),
age_y = year - first_year,
index_fund_flag = case_when(
year < 2008 ~ NA_character_,
true_type == "index" & idx_flagged ~ sample(c("D", "B"), n(), TRUE),
true_type == "etf_index" ~ "D",
true_type == "enhanced" ~ "E",
TRUE ~ NA_character_),
et_flag = case_when(true_type %in% c("etf_index", "etf_active") ~ "F",
true_type == "etn" ~ "N", TRUE ~ NA_character_),
tna_port = base_tna * exp(0.08 * pmin(pmax(age_y, 0), 20) + rnorm(n(), 0, .25))
)
blank <- function(x, p) if_else(runif(length(x)) < p, "", x)
pp |>
inner_join(cls, by = "wficn", relationship = "many-to-many") |>
mutate(
class_suffix = c("A", "C", "I", "Admiral")[k],
fund_name = paste0(base_name, ";", class_suffix),
caldt = as.Date(sprintf("%d-12-31", year)),
first_offer_dt = first_offer + (k - 1L) * 180L,
tna_latest = if_else(runif(n()) < 0.02, NA_real_, tna_port * class_share),
er_mean = case_when(str_detect(true_type, "^active") ~ .0115,
true_type == "index" ~ .0020,
str_detect(true_type, "^etf") ~ .0040, TRUE ~ .0100),
exp_ratio = pmax(.0005, rnorm(n(), er_mean, er_mean / 3) + c(0, .0075, 0, -.003)[k]),
exp_ratio = case_when(runif(n()) < .02 ~ -99,
runif(n()) < .005 ~ .15,
runif(n()) < .01 ~ NA_real_, TRUE ~ exp_ratio),
across(c(lipper_obj_cd, crsp_obj_cd), \(x) blank(x, .06)),
across(c(lipper_class, si_obj_cd, wbrger_obj_cd), \(x) blank(x, .25)),
across(c(lipper_obj_cd, lipper_class, si_obj_cd, wbrger_obj_cd, crsp_obj_cd),
\(x) if_else(no_codes, "", x))
) |>
select(crsp_fundno, crsp_cl_grp, crsp_portno, wficn, fund_name, year, caldt,
first_offer_dt, tna_latest, exp_ratio, lipper_class, lipper_obj_cd,
si_obj_cd, wbrger_obj_cd, crsp_obj_cd, index_fund_flag, et_flag, true_type)
}