0. Settings

# Files (TEJ exports; tab-delimited, UTF-16)
dl_dir     <- file.path(Sys.getenv("USERPROFILE"), "Downloads", "rstudio")
stock_file <- file.path(dl_dir, "stock_data", "20261113015642.csv")
ff3_file   <- file.path(dl_dir, "ff3", "20261113015903.csv")

# Options
ret_in_percent   <- TRUE      # TEJ returns and factors are in percent
ff3_variant      <- "Y9999 "  # "Y9999 TSEC" = all TSEC stocks (trailing space excludes "Y9999L")
require_complete <- TRUE      # TRUE: replace stocks without 36 months by the next largest

# Window 1
rank_month   <- ymd("2020-12-01")   # month used to pick the 50 largest stocks
window_start <- ymd("2018-01-01")
window_end   <- ymd("2020-12-01")
n_window     <- 36

1. Load data

stopifnot(file.exists(stock_file), file.exists(ff3_file))

# Detect encoding; fall back to tab-delimited if comma-delimited gives one column
read_tej <- function(path) {
  enc <- guess_encoding(path, n_max = 20000)$encoding[1]
  if (is.na(enc) || enc == "ASCII") enc <- "UTF-8"
  loc <- locale(encoding = enc)
  df <- read_csv(path, locale = loc, na = c("", "NA", "-"), show_col_types = FALSE)
  if (ncol(df) == 1) {
    df <- read_tsv(path, locale = loc, na = c("", "NA", "-"), show_col_types = FALSE)
  }
  rename_with(df, str_trim)
}

stock_data <- read_tej(stock_file)
ff3_data   <- read_tej(ff3_file)

# Find a column by regex and stop with a clear message if it is missing
find_col <- function(df, pattern) {
  hit <- str_subset(names(df), regex(pattern, ignore_case = TRUE))
  if (length(hit) == 0) stop("Column not found: ", pattern, " | Available: ",
                             paste(names(df), collapse = " | "))
  hit[1]
}

stock_cols <- c(co_id = find_col(stock_data, "^co_id"),
                date  = find_col(stock_data, "^date"),   # "YYYY/MM" only holds "Current"
                mv    = find_col(stock_data, "^market cap"),
                ret   = find_col(stock_data, "^roi_month"))

ff3_cols <- c(id     = find_col(ff3_data, "^co_id"),
              date   = find_col(ff3_data, "^date"),
              mkt_rf = find_col(ff3_data, "risk premium"),
              smb    = find_col(ff3_data, "^size"),
              hml    = find_col(ff3_data, "^book"))

sort(unique(ff3_data[[ff3_cols["id"]]]))   # the 8 FF3 variants available
## [1] "Y8888 TSEC Non-Banking Subindex"     
## [2] "Y8888L TSEC Non-Banking Subindex"    
## [3] "Y9999 TSEC"                          
## [4] "Y9999L TSEC"                         
## [5] "Z8888 TSEC+GreTai Non-Banking index" 
## [6] "Z8888L TSEC+GreTai Non-Banking index"
## [7] "Z9999 TSEC+GreTai"                   
## [8] "Z9999L TSEC+GreTai"

2. Clean data

# "201801", "2018/01", "2018-01-31", ... -> first day of that month
to_ym <- function(x) {
  x  <- as.character(x)
  m  <- str_match(x, "^\\s*(\\d{4})\\D+(\\d{1,2})")
  d  <- str_remove_all(x, "\\D")
  yr <- coalesce(as.integer(m[, 2]), as.integer(str_sub(d, 1, 4)))
  mo <- coalesce(as.integer(m[, 3]), as.integer(str_sub(d, 5, 6)))
  make_date(yr, mo, 1)
}

pct <- if (ret_in_percent) 100 else 1

stock_clean <- stock_data %>%
  select(all_of(stock_cols)) %>%
  mutate(co_id = as.character(co_id),
         ym    = to_ym(date),
         mv    = parse_number(as.character(mv)),
         ret   = parse_number(as.character(ret)) / pct) %>%
  filter(!is.na(ym), !is.na(mv))

ff3_clean <- ff3_data %>%
  select(all_of(ff3_cols)) %>%
  filter(str_starts(id, fixed(ff3_variant))) %>%
  mutate(ym = to_ym(date),
         across(c(mkt_rf, smb, hml), ~ parse_number(as.character(.x)) / pct)) %>%
  filter(!is.na(ym))

stopifnot("ff3_variant matches no rows"        = nrow(ff3_clean) > 0,
          "more than one FF3 row in some month" = !anyDuplicated(ff3_clean$ym))
range(stock_clean$ym)
## [1] "2017-11-01" "2025-11-01"
range(ff3_clean$ym)
## [1] "2017-11-01" "2025-11-01"
summary(stock_clean$ret)    # monthly return, decimal
##      Min.   1st Qu.    Median      Mean   3rd Qu.      Max. 
## -0.636090 -0.042856  0.000001  0.013516  0.048781  4.326087
summary(ff3_clean$mkt_rf)   # monthly market premium, decimal
##     Min.  1st Qu.   Median     Mean  3rd Qu.     Max. 
## -0.13896 -0.01865  0.01129  0.01282  0.04244  0.14720
if (max(stock_clean$ym) < ymd("2025-12-01") || max(ff3_clean$ym) < ymd("2025-12-01")) {
  cat("WARNING: data end before 2025/12. Re-download from TEJ through 2025/12.\n")
}
## WARNING: data end before 2025/12. Re-download from TEJ through 2025/12.

3. The 50 largest stocks each month (2017/11 - 2025/12)

top50_monthly <- stock_clean %>%
  filter(ym >= ymd("2017-11-01"), ym <= ymd("2025-12-01")) %>%
  group_by(ym) %>%
  slice_max(mv, n = 50, with_ties = FALSE) %>%
  mutate(rank = row_number()) %>%
  ungroup()

# Monthly returns of the 50 largest stocks
top50_returns <- select(top50_monthly, ym, rank, co_id, mv, ret)
write_csv(top50_returns, file.path(dl_dir, "top50_monthly_returns.csv"))

top50_ids <- top50_monthly %>% filter(ym == rank_month) %>% pull(co_id)
length(top50_ids)   # should be 50
## [1] 50
# List of the 50 largest stocks (row = month, column = rank)
top50_monthly %>%
  select(ym, rank, co_id) %>%
  pivot_wider(names_from = rank, values_from = co_id, names_prefix = "rank_") %>%
  rmarkdown::paged_table()

4. Window 1: 2018/01 - 2020/12

Rows are the 36 months, columns are the 50 largest stocks of 2020/12, then Mkt, SMB, HML.

# All stocks in the ranking month, largest first
ranked <- stock_clean %>%
  filter(ym == rank_month) %>%
  distinct(co_id, .keep_all = TRUE) %>%
  arrange(desc(mv))

# Stocks with returns in all 36 months
complete_ids <- stock_clean %>%
  filter(between(ym, window_start, window_end), !is.na(ret)) %>%
  distinct(co_id, ym) %>%
  count(co_id) %>%
  filter(n == n_window) %>%
  pull(co_id)

selected_ids <- ranked %>%
  filter(!require_complete | co_id %in% complete_ids) %>%
  slice_head(n = 50) %>%
  pull(co_id)

# Differences from the plain top 50
list(dropped = setdiff(top50_ids, selected_ids),
     added   = setdiff(selected_ids, top50_ids))
## $dropped
## [1] "3711 ASEH" "6770 PSMC"
## 
## $added
## [1] "2105 CST" "3481 INX"
final_window1_table <- stock_clean %>%
  filter(co_id %in% selected_ids, between(ym, window_start, window_end)) %>%
  distinct(ym, co_id, .keep_all = TRUE) %>%
  mutate(co_id = factor(co_id, levels = selected_ids)) %>%
  select(ym, co_id, ret) %>%
  pivot_wider(names_from = co_id, values_from = ret, names_sort = TRUE) %>%
  left_join(select(ff3_clean, ym, Mkt = mkt_rf, SMB = smb, HML = hml), by = "ym") %>%
  arrange(ym) %>%
  mutate(Date = paste0(year(ym), "/", month.abb[month(ym)]), .before = 1) %>%
  select(-ym)

dim(final_window1_table)          # 36 x 54
## [1] 36 54
sum(is.na(final_window1_table))   # 0 when require_complete = TRUE
## [1] 0
final_window1_table %>%
  mutate(across(where(is.numeric), ~ round(.x, 4))) %>%
  rmarkdown::paged_table(options = list(rows.print = 36, cols.print = 8))

Stock order in the table (largest market cap in 2020/12 first):

stock_map <- ranked %>%
  filter(co_id %in% selected_ids) %>%
  transmute(stock = paste0("stock", row_number()), co_id, mv)

rmarkdown::paged_table(stock_map)
write_csv(final_window1_table, file.path(dl_dir, "window1_table.csv"))
write_csv(stock_map,           file.path(dl_dir, "window1_stock_map.csv"))