main <- function(chars, features, daily_ret) {
  # CTF Admin Modifications (2026-02-19):
  # --------------------------------------
  # 1. Fixed features column extraction to use "features" column name
  #    Reason: CTF pipeline provides features DataFrame with "features" column
  #
  # 2. Added ctff_test filtering to output
  #    Reason: Without filtering, output exceeds 150MB pipeline limit
  #
  # 3. Added [CTF-DEBUG] progress statements with immediate flush
  #    Reason: Enable HPC job monitoring with consistent log parsing
  #
  # 4. Removed local testing block containing write.csv()
  #    Reason: File writes outside designated output paths are not permitted
  #
  # 5. Updated data.table version in renv.lock (1.18.2 -> 1.18.2.1)
  #    Reason: Version 1.18.2 no longer available on CRAN package repositories

  library(data.table)
  library(glmnet)
  set.seed(42)

  cat("[CTF-DEBUG] Starting main() at", format(Sys.time(), "%Y-%m-%d %H:%M:%S"), "\n")
  start_time <- Sys.time()

  # Identify columns
  dt <- as.data.table(chars)
  dt[, eom := as.Date(eom)]

  # CTF-FIX: Use "features" column (CTF standard) with fallback
  if ("features" %in% names(features)) {
    feat_names <- as.character(features$features)
  } else if ("characteristic" %in% names(features)) {
    feat_names <- as.character(features$characteristic)
  } else {
    feat_names <- as.character(features[[1]])
  }
  feat_names <- intersect(feat_names, names(dt))
  cat("[CTF-DEBUG] Using", length(feat_names), "features\n")
  
  meta_cols <- c("id", "eom", "ctff_test")
  
  other_cols <- setdiff(names(dt), c(meta_cols, feat_names))
  ret_col <- grep("ret", other_cols, value = TRUE)[1]
  if (is.na(ret_col)) {
    num_cols <- other_cols[sapply(other_cols, function(col) is.numeric(dt[[col]]))]
    ret_col <- num_cols[1]
  }
  
  if (is.na(ret_col)) stop("No return column found")
  
  # remove ret col from predictors
  feat_names <- setdiff(feat_names, ret_col)
  
  dt[, (ret_col) := as.numeric(unclass(.SD[[1]])), .SDcols = ret_col]
  
  # lead return by 1 month
  setorder(dt, id, eom)
  dt[, ret_fwd := as.numeric(unclass(shift(.SD[[1]], type = "lead"))),
     by = id, .SDcols = ret_col]
  
  # remove non-consecutive months
  dt[, eom_next := shift(eom, type = "lead"), by = id]
  dt[, days_gap := as.numeric(difftime(eom_next, eom, units = "days"))]
  dt[is.na(days_gap) | days_gap > 45 | days_gap < 20, ret_fwd := NA]
  dt[, c("eom_next", "days_gap") := NULL]
  
  dates <- sort(unique(dt$eom))
  cat("[CTF-DEBUG] Total months:", length(dates), "\n")

  # median impute + rank-transform per month
  
  for (f in feat_names) {
    if (!is.double(dt[[f]])) dt[, (f) := as.double(.SD[[1]]), .SDcols = f]
  }
  
  for (f in feat_names) {
    dt[, (f) := {
      x <- .SD[[1]]
      med_val <- median(x, na.rm = TRUE)
      if (is.na(med_val)) med_val <- 0
      x[is.na(x)] <- med_val
      n <- .N
      s <- sd(x, na.rm = TRUE)
      if (n > 2 && !is.na(s) && s > 1e-12) {
        r <- rank(x, ties.method = "average")
        2 * (r - 0.5) / n - 1
      } else {
        rep(0, n)
      }
    }, by = eom, .SDcols = f]
  }
  
  # ridge regression (expanding window)
  min_train_months <- 120
  retrain_interval <- 12
  n_cv_folds       <- 5
  
  results <- vector("list", length(dates))
  model <- NULL
  months_since_retrain <- retrain_interval
  
  if (length(dates) <= min_train_months) {
    warning("Not enough training data")
    return(data.frame(id = integer(), eom = as.Date(character()), w = numeric()))
  }
  
  for (t_idx in (min_train_months + 1):length(dates)) {
    
    test_dt <- dt[eom == dates[t_idx]]
    if (nrow(test_dt) == 0) next
    
    months_since_retrain <- months_since_retrain + 1
    
    # retrain
    if (is.null(model) || months_since_retrain >= retrain_interval) {
      
      train_dt <- dt[eom %in% dates[1:(t_idx - 1)] & !is.na(ret_fwd)]
      if (nrow(train_dt) < 500) next
      
      X_train <- as.matrix(train_dt[, ..feat_names])
      y_train <- train_dt[["ret_fwd"]]
      X_train[is.na(X_train)] <- 0
      
      # block CV folds by month
      train_months <- sort(unique(train_dt$eom))
      n_months     <- length(train_months)
      fold_assign  <- ceiling(seq_len(n_months) * n_cv_folds / n_months)
      names(fold_assign) <- as.character(train_months)
      foldid <- unname(fold_assign[as.character(train_dt$eom)])
      
      model <- cv.glmnet(
        x           = X_train,
        y           = y_train,
        alpha       = 0,
        foldid      = foldid,
        standardize = FALSE
      )
      
      months_since_retrain <- 0
      cat("[CTF-DEBUG] Retrained at", as.character(dates[t_idx]), "\n")
    }
    
    # predict on current month
    X_test <- as.matrix(test_dt[, ..feat_names])
    X_test[is.na(X_test)] <- 0
    
    pred <- predict(model, newx = X_test, s = "lambda.min")[, 1]
    
    results[[t_idx]] <- data.table(
      id   = test_dt$id,
      eom  = test_dt$eom,
      pred = pred
    )
  }
  
  preds <- rbindlist(results[lengths(results) > 0])
  if (nrow(preds) == 0) {
    warning("No predictions generated.")
    return(data.frame(id = integer(), eom = as.Date(character()), w = numeric()))
  }
  # portfolio weights (dollar-neutral)
  preds[, w := pred - mean(pred, na.rm = TRUE), by = eom]
  preds[, abs_sum := sum(abs(w), na.rm = TRUE), by = eom]
  preds[abs_sum > 1e-12, w := w / abs_sum]
  preds[abs_sum <= 1e-12, w := 0]
  preds[, abs_sum := NULL]
  
  # clean up output
  output <- preds[, .(id, eom, w)]
  output <- output[!is.na(id) & !is.na(eom) & is.finite(w)]

  # CTF-FIX: Filter to test period only (ctff_test == TRUE)
  test_dates <- unique(dt[ctff_test == TRUE, eom])
  output <- output[eom %in% test_dates]

  elapsed <- as.numeric(difftime(Sys.time(), start_time, units = "secs"))
  cat("[CTF-DEBUG] Completed main() in", round(elapsed, 1), "seconds\n")
  cat("[CTF-DEBUG] Output:", nrow(output), "rows,", length(unique(output$eom)), "unique months\n")

  return(as.data.frame(output))
}

# Local testing block removed for CTF pipeline security compliance
# (SEC320: write.csv not allowed outside designated output paths)