1. Introduction

Reliable security master data (stock code, company name, listing date and industry) is the starting point of almost every empirical study of the Taiwanese equity market. This report collects that information for Taiwan Stock Exchange (TWSE) listed common stocks from two independent providers: the exchange’s official ISIN registry and the FinMind data platform. The two datasets are cleaned, validated and then compared using the stock code as the key.

2. Assignment Objectives

The assignment brief contains two tasks:

  • Task 1 (TWSE). Download the official list of listed securities (stock ID, company name, listing date, industry) from the TWSE ISIN lookup page. The strMode value is 2 for listed securities.
  • Task 2 (FinMind). Repeat Task 1 using the data provided by FinMind.
  • Scope rule. The data must exclude ETFs, funds and preferred stocks (and any other security that is not a common stock), so that only listed common stocks remain.

Interpretation notes:

  • The strMode=2 page is the complete listed (TWSE) board. It contains many security types (stocks, preferred shares, ETFs, depositary receipts, beneficiary securities, warrants and so on), so a filtering step is required even though the URL already refers to listed securities.
  • The brief does not ask for prices, returns, ratios or forecasts. None are included.
  • FinMind may not expose every field that TWSE does. A field that is not available is reported as unavailable and is never filled in from TWSE.

3. Data Sources

Source Role in this report Access method
TWSE ISIN registry (https://isin.twse.com.tw/isin/C_public.jsp?strMode=2) Official, authoritative list of listed securities with ISIN, listing date, market, industry and CFI classification code. HTML table read with rvest (the page is Big5-encoded). The cleaned result is stored in data_cache/twse_clean_saved.csv.
FinMind (https://finmindtrade.com/) Third-party financial data platform with a REST API. Used as an independent source of stock information (TaiwanStockInfo dataset). JSON API via httr2. The raw response is stored in data_cache/finmind_raw.csv.

TWSE is the reference record. FinMind is an aggregator, so differences in naming, industry labels or coverage are expected and are analysed rather than treated as errors.

To keep the report reproducible and fast, local files are used whenever they are valid; the network is only used when a required file is missing or when refresh: true is set in the YAML header. The raw and the cleaned versions of the data are kept strictly separate (Sections 5 and 6).

4. Required Packages

Install the packages once (only the first time) by running this line in the R console. httr2, rvest, xml2 and jsonlite are only needed when a download or an HTML parse is actually performed.

install.packages(c("dplyr", "stringr", "stringi", "readr", "tibble", "knitr", "rmarkdown",
                   "httr2", "rvest", "xml2", "jsonlite"))
core_pkgs <- c("dplyr", "stringr", "stringi", "readr", "tibble", "knitr", "rmarkdown")
missing_core <- core_pkgs[!vapply(core_pkgs, requireNamespace, logical(1), quietly = TRUE)]
if (length(missing_core) > 0) {
  stop("Please install the missing package(s) first: ",
       paste(missing_core, collapse = ", "), call. = FALSE)
}
invisible(lapply(core_pkgs, library, character.only = TRUE))

# Packages for the network / HTML steps are loaded only when those steps run.
use_pkgs <- function(pkgs) {
  miss <- pkgs[!vapply(pkgs, requireNamespace, logical(1), quietly = TRUE)]
  if (length(miss) > 0) {
    stop("This step needs the package(s): ", paste(miss, collapse = ", "),
         ". Install them with install.packages().", call. = FALSE)
  }
  invisible(lapply(pkgs, library, character.only = TRUE))
}

# ---- Paths (all relative to the detected project root) ------------------------
proj_path <- function(...) file.path(project_root, ...)
cache_dir <- proj_path("data_cache")
dir.create(cache_dir, showWarnings = FALSE, recursive = TRUE)

path_twse_clean_saved <- file.path(cache_dir, "twse_clean_saved.csv")  # cleaned TWSE common stocks
path_twse_raw         <- file.path(cache_dir, "twse_raw.csv")          # optional: all categories
path_fm_raw           <- file.path(cache_dir, "finmind_raw.csv")       # raw FinMind response
path_fm_clean_saved   <- file.path(cache_dir, "finmind_clean_saved.csv")  # earlier result (cross-check only)
path_manual_html      <- c(proj_path("twse_isin_strMode2.html"),
                           file.path(cache_dir, "twse_isin_strMode2.html"))  # optional manual fallback

out_twse      <- proj_path("TWSE_Common_Stocks.csv")
out_finmind   <- proj_path("FinMind_Common_Stocks.csv")
out_compare   <- proj_path("TWSE_FinMind_Comparison.csv")
out_unmatched <- proj_path("Unmatched_Stocks.csv")

# ---- Settings ------------------------------------------------------------------
twse_urls   <- c("https://isin.twse.com.tw/isin/C_public.jsp?strMode=2",
                 "http://isin.twse.com.tw/isin/C_public.jsp?strMode=2")   # second = fallback
finmind_url <- "https://api.finmindtrade.com/api/v4/data"

# TRUE = ignore local files and download again; FALSE = use valid local files
refresh <- if (exists("params")) isTRUE(params$refresh) else FALSE

# Chinese labels used by the TWSE ISIN page (kept in one place)
CAT_STOCK     <- "股票"       # category title "stocks" (common and preferred shares)
MARKET_LISTED <- "上市"       # market value "listed"
INNOV_PATTERN <- "創新[板版]"  # Taiwan Innovation Board label (two spellings)

# ---- Small helpers ---------------------------------------------------------------
read_chr_csv <- function(path) {
  readr::read_csv(path, col_types = readr::cols(.default = readr::col_character()),
                  na = c("", "NA"), trim_ws = TRUE, show_col_types = FALSE, progress = FALSE)
}

# First candidate name found in a vector of column names (case-insensitive)
pick_col <- function(nms, candidates) {
  i <- match(tolower(candidates), tolower(nms))
  i <- i[!is.na(i)]
  if (length(i) > 0) nms[i[1]] else NA_character_
}

# Dates are accepted as 2003/06/30 or 2003-06-30
parse_date_multi <- function(x) {
  x <- str_squish(x)
  coalesce(as.Date(x, format = "%Y/%m/%d"), as.Date(x, format = "%Y-%m-%d"))
}

# Automatic table numbering
tab_cap <- local({ i <- 0L; function(caption) { i <<- i + 1L; paste0("Table ", i, ". ", caption) } })

# Table, or a short note when there is nothing to show
show_tbl <- function(df, caption, empty_note = "No rows to display.") {
  if (is.null(df) || nrow(df) == 0) {
    knitr::asis_output(paste0("*", empty_note, "*"))
  } else {
    kable(df, caption = tab_cap(caption), format.args = list(big.mark = ","))
  }
}

# One row of the quality-assurance tables
#   rule "zero"     -> PASS when the value is 0, otherwise REVIEW
#   rule "positive" -> PASS when the value is > 0, otherwise REVIEW
#   rule "ge90"     -> PASS when the value is >= 90, otherwise REVIEW
#   rule "info"     -> INFO (descriptive, no pass/fail judgement)
qa <- function(check, value, rule = "info") {
  num <- suppressWarnings(as.numeric(value))
  ok  <- length(num) == 1 && !is.na(num)
  status <- if (!ok) "INFO" else switch(rule,
    zero     = if (num == 0) "PASS" else "REVIEW",
    positive = if (num > 0)  "PASS" else "REVIEW",
    ge90     = if (num >= 90) "PASS" else "REVIEW",
    "INFO")
  tibble(Check  = check,
         Result = if (ok) format(num, big.mark = ",", scientific = FALSE) else as.character(value),
         Status = status)
}

cat("Project root used:", project_root, "\n")
## Project root used: C:/Users/acer/Downloads/HW4
cat("Current working directory:", normalizePath(getwd(), winslash = "/"), "\n")
## Current working directory: C:/Users/acer/Downloads/HW4
cat("Refresh mode:", refresh, "\n")
## Refresh mode: FALSE

5. TWSE Data Collection

The ISIN page is a single HTML table. Category rows (a row that contains only a security-type title) are interleaved with data rows. When the page has to be processed, the code (i) detects the header row by its text, (ii) checks that the columns appear in the expected order, (iii) records the current category for every data row, and (iv) splits the first column into stock ID and company name. Three levels of data are distinguished:

  1. Raw page (all security types): data_cache/twse_raw.csv (optional, produced only if the page is processed).
  2. Cleaned common stocks: data_cache/twse_clean_saved.csv (the file already created in an earlier run).
  3. Final dataset of this report: re-validated in Section 6 and saved as TWSE_Common_Stocks.csv.

If the cleaned file is valid, it is used directly: the page is neither downloaded nor parsed again.

twse_raw_cols <- c("Category", "Stock_ID", "Company_Name", "Listing_Date_Raw", "Industry", "CFI_Code")

# ---- Level 2: the cleaned file written in an earlier run ----------------------------
# Expected columns: stock_id, company_name, listing_date, industry, isin, market, cfi_code
load_twse_clean_saved <- function(path) {
  if (!file.exists(path)) return(NULL)
  d <- tryCatch(read_chr_csv(path), error = function(e) NULL)
  if (is.null(d) || nrow(d) == 0) return(NULL)
  nm <- names(d)
  m <- list(Stock_ID         = pick_col(nm, c("stock_id", "Stock_ID", "stock_code", "code")),
            Company_Name     = pick_col(nm, c("company_name", "Company_Name", "stock_name", "name")),
            Listing_Date_Raw = pick_col(nm, c("listing_date", "Listing_Date", "list_date")),
            Industry         = pick_col(nm, c("industry", "Industry", "industry_category")),
            ISIN             = pick_col(nm, c("isin", "ISIN")),
            Market           = pick_col(nm, c("market", "Market")),
            CFI_Code         = pick_col(nm, c("cfi_code", "CFI_Code", "cfi")))
  if (any(is.na(unlist(m[c("Stock_ID", "Company_Name", "Listing_Date_Raw", "Industry")])))) return(NULL)
  g <- function(k) if (is.na(m[[k]])) rep(NA_character_, nrow(d)) else d[[m[[k]]]]
  out <- tibble(Category         = rep(NA_character_, nrow(d)),
                Stock_ID         = str_squish(g("Stock_ID")),
                Company_Name     = str_squish(g("Company_Name")),
                ISIN             = g("ISIN"),
                Listing_Date_Raw = g("Listing_Date_Raw"),
                Market           = g("Market"),
                Industry         = g("Industry"),
                CFI_Code         = g("CFI_Code"))
  if (mean(str_detect(coalesce(out$Stock_ID, ""), "^[0-9A-Za-z]{4,6}$")) < 0.5) return(NULL)
  out
}

# ---- Level 1: the raw ISIN page (only used when no valid cleaned file exists) -------
parse_twse_table <- function(doc) {
  rows  <- html_elements(doc, "tr")
  cells <- lapply(rows, function(r) str_squish(html_text(html_elements(r, "td, th"))))

  is_header <- vapply(cells, function(x) length(x) >= 6 && any(str_detect(x, "ISIN")), logical(1))
  hdr_idx <- which(is_header)[1]
  if (is.na(hdr_idx)) stop("Header row not found in the TWSE table.", call. = FALSE)
  header <- cells[[hdr_idx]]
  n_col  <- length(header)
  if (!(str_detect(header[2], "ISIN") && str_detect(header[6], "CFI"))) {
    stop("Unexpected TWSE column order. Header seen: ", paste(header, collapse = " | "), call. = FALSE)
  }

  category <- NA_character_
  rows_out <- vector("list", length(cells))
  k <- 0L
  for (i in seq_along(cells)) {
    if (i <= hdr_idx) next
    x <- cells[[i]]
    if (length(x) == 1 && nzchar(x)) {
      category <- x                                   # category title row
    } else if (length(x) == n_col) {
      k <- k + 1L
      rows_out[[k]] <- c(category, x)                 # data row
    }                                                 # any other row is ignored
  }
  if (k == 0L) stop("No data rows were parsed from the TWSE table.", call. = FALSE)

  mat <- do.call(rbind, rows_out[seq_len(k)])
  colnames(mat) <- c("Category", paste0("V", seq_len(n_col)))
  df <- as_tibble(mat)

  # Column 1 holds "stock code <full-width space> company name"
  sp <- str_match(df$V1, "^([^\\s\\x{3000}\\x{00A0}]+)[\\s\\x{3000}\\x{00A0}]+(.*)$")

  tibble(Category         = df$Category,
         Stock_ID         = sp[, 2],
         Company_Name     = str_squish(sp[, 3]),
         ISIN             = df$V2,
         Listing_Date_Raw = df$V3,
         Market           = df$V4,
         Industry         = df$V5,
         CFI_Code         = df$V6) |>
    mutate(across(where(is.character), ~ na_if(str_squish(.x), "")))
}

# Decode raw bytes (the site serves Big5). An encoding is accepted only when the
# "stocks" category becomes readable, which proves that the decoding worked.
decode_and_parse_twse <- function(bytes) {
  for (enc in c("BIG5", "BIG5-HKSCS", "UTF-8")) {
    doc <- tryCatch(read_html(bytes, encoding = enc), error = function(e) NULL)
    if (is.null(doc)) next
    tab <- tryCatch(parse_twse_table(doc), error = function(e) NULL)
    if (!is.null(tab) && any(tab$Category == CAT_STOCK, na.rm = TRUE)) {
      return(list(data = tab, encoding = enc))
    }
  }
  stop("The TWSE page could not be decoded or the 'stocks' category was not found. ",
       "The page structure or encoding may have changed.", call. = FALSE)
}

download_twse_bytes <- function(urls) {
  use_pkgs("httr2")
  last_err <- "no response"
  for (u in urls) {
    bytes <- tryCatch({
      resp <- request(u) |>
        req_user_agent("Mozilla/5.0 (university assignment; R httr2)") |>
        req_timeout(60) |>
        req_retry(max_tries = 3) |>
        req_perform()
      resp_body_raw(resp)
    }, error = function(e) { last_err <<- conditionMessage(e); NULL })
    if (!is.null(bytes) && length(bytes) > 1000) return(bytes)
  }
  stop("Could not download the TWSE ISIN page. Last error: ", last_err, call. = FALSE)
}

read_twse_raw_cache <- function() {
  if (!file.exists(path_twse_raw)) return(NULL)
  d <- tryCatch(read_chr_csv(path_twse_raw), error = function(e) NULL)
  if (!is.null(d) && nrow(d) > 0 && all(twse_raw_cols %in% names(d))) d else NULL
}

# Returns the raw table (all categories), a note on its origin and whether it is new.
get_twse_raw <- function() {
  if (!refresh) {
    d <- read_twse_raw_cache()
    if (!is.null(d)) return(list(data = d, fresh = FALSE,
                                 note = "raw table loaded from data_cache/twse_raw.csv"))
  }
  use_pkgs(c("rvest", "xml2"))
  res <- tryCatch({
    parsed <- decode_and_parse_twse(download_twse_bytes(twse_urls))
    list(data = parsed$data, fresh = TRUE,
         note = paste0("downloaded live from TWSE (decoded as ", parsed$encoding, ")"))
  }, error = function(e) e)

  if (inherits(res, "error")) {
    manual <- path_manual_html[file.exists(path_manual_html)]
    if (length(manual) > 0) {                       # manually saved HTML page
      bytes  <- readBin(manual[1], "raw", n = file.size(manual[1]))
      parsed <- decode_and_parse_twse(bytes)
      res <- list(data = parsed$data, fresh = TRUE,
                  note = paste0("parsed from the manually saved page ", basename(manual[1])))
    } else {
      d <- read_twse_raw_cache()
      if (is.null(d)) {
        stop(conditionMessage(res), "\nSave the page manually as 'twse_isin_strMode2.html' ",
             "in the project folder and knit again.", call. = FALSE)
      }
      res <- list(data = d, fresh = FALSE, note = "download failed; older raw cache used")
    }
  }
  res
}
twse_clean_file <- if (!refresh) load_twse_clean_saved(path_twse_clean_saved) else NULL
twse_all   <- NULL     # raw table with all security types, when available
twse_fresh <- FALSE    # TRUE only if the raw page was processed in this run

if (!is.null(twse_clean_file)) {
  twse_level  <- "clean"
  twse_input  <- twse_clean_file
  twse_source <- paste0("local file data_cache/twse_clean_saved.csv (last modified ",
                        format(file.mtime(path_twse_clean_saved), "%Y-%m-%d %H:%M"),
                        "); no download and no HTML parsing performed")
  twse_all <- read_twse_raw_cache()      # optional, only used for the exclusion statistics
} else {
  twse_res    <- get_twse_raw()
  twse_level  <- "raw"
  twse_all    <- twse_res$data
  twse_input  <- twse_all
  twse_source <- twse_res$note
  twse_fresh  <- twse_res$fresh
}

tibble(Item  = c("Project root", "TWSE input used", "Processing level",
                 "Raw all-category table available", "Rows in the input table"),
       Value = c(project_root, twse_source,
                 if (twse_level == "clean") "cleaned common-stock file (re-validated below)"
                 else "raw page (cleaned below)",
                 if (is.null(twse_all)) "No" else paste0("Yes (", format(nrow(twse_all), big.mark = ","), " rows)"),
                 format(nrow(twse_input), big.mark = ","))) |>
  kable(caption = tab_cap("TWSE data source used in this run"))
Table 1. TWSE data source used in this run
Item Value
Project root C:/Users/acer/Downloads/HW4
TWSE input used local file data_cache/twse_clean_saved.csv (last modified 2026-10-11 11:30); no download and no HTML parsing performed
Processing level cleaned common-stock file (re-validated below)
Raw all-category table available No
Rows in the input table 1,053

6. TWSE Data Cleaning

The filter rests on official classification information rather than on the length of the stock code. Two official markers are used together:

  1. the category title under which the row appears on the ISIN page (it must be the “stocks” category), and
  2. the CFI code (ISO 10962). Equity shares start with ES (common shares), preferred shares start with EP, and funds/ETFs, depositary receipts and warrants use other prefixes.

The same rule is applied to both levels of input. When the already-cleaned file is used, the rule acts as a re-validation and is expected to remove nothing; the number of removed rows is reported below. Markers that are absent from the input (for example the category title in the cleaned file) are skipped and this is stated in the validation table.

twse_input <- twse_input |>
  mutate(Listing_Date = parse_date_multi(Listing_Date_Raw))

cat_available <- any(twse_input$Category == CAT_STOCK, na.rm = TRUE)
cfi_available <- any(!is.na(twse_input$CFI_Code))

twse_clean <- twse_input
if (cat_available) twse_clean <- twse_clean |> filter(Category == CAT_STOCK)
if (cfi_available) twse_clean <- twse_clean |> filter(str_detect(coalesce(CFI_Code, ""), "^ES"))

n_removed_by_filter <- nrow(twse_input) - nrow(twse_clean)
n_dup_removed       <- sum(duplicated(twse_clean$Stock_ID))
twse_clean <- twse_clean |>
  distinct(Stock_ID, .keep_all = TRUE) |>
  arrange(Stock_ID)

if (nrow(twse_clean) == 0) stop("The TWSE common-stock filter returned no rows.", call. = FALSE)
n_twse <- nrow(twse_clean)
cat("Listed common stocks after filtering:", format(n_twse, big.mark = ","), "\n")
## Listed common stocks after filtering: 1,053
# Keep the files in data_cache consistent with what was actually processed.
if (twse_level == "raw") {
  if (twse_fresh) write_excel_csv(twse_all, path_twse_raw)
  write_excel_csv(
    twse_clean |>
      transmute(stock_id = Stock_ID, company_name = Company_Name,
                listing_date = format(Listing_Date, "%Y/%m/%d"),
                industry = Industry, isin = ISIN, market = Market, cfi_code = CFI_Code),
    path_twse_clean_saved)
}

6.1 Validation checks

han_share <- 100 * mean(str_detect(coalesce(twse_clean$Company_Name, ""), "\\p{Han}"))

qa_twse <- bind_rows(
  qa("Securities in the raw all-category table",
     if (is.null(twse_all)) "not available in this run" else nrow(twse_all)),
  qa("Rows in the input table used", nrow(twse_input)),
  qa("Listed common stocks after filtering", nrow(twse_clean), "positive"),
  qa("Rows removed by the common-stock filter (0 expected for an already-cleaned file)",
     n_removed_by_filter, if (twse_level == "clean") "zero" else "info"),
  qa("Category marker available in the input (1 = yes, 0 = no)", as.integer(cat_available)),
  qa("CFI marker available in the input (1 = yes, 0 = no)", as.integer(cfi_available)),
  qa("Duplicate Stock IDs removed", n_dup_removed, "zero"),
  qa("Duplicate Stock IDs in the final table", sum(duplicated(twse_clean$Stock_ID)), "zero"),
  qa("Missing Stock ID", sum(is.na(twse_clean$Stock_ID)), "zero"),
  qa("Missing company name", sum(is.na(twse_clean$Company_Name)), "zero"),
  qa("Missing listing date", sum(is.na(twse_clean$Listing_Date)), "zero"),
  qa("Listing dates that could not be parsed (text present, date invalid)",
     sum(!is.na(twse_clean$Listing_Date_Raw) & is.na(twse_clean$Listing_Date)), "zero"),
  qa("Listing dates in the future", sum(twse_clean$Listing_Date > Sys.Date(), na.rm = TRUE), "zero"),
  qa("Listing dates before 1962-01-01 (before TWSE trading began)",
     sum(twse_clean$Listing_Date < as.Date("1962-01-01"), na.rm = TRUE), "zero"),
  qa("Earliest listing date", format(min(twse_clean$Listing_Date, na.rm = TRUE))),
  qa("Latest listing date", format(max(twse_clean$Listing_Date, na.rm = TRUE))),
  qa("Missing industry", sum(is.na(twse_clean$Industry))),
  qa("Retained rows whose CFI code is not 'ES...'",
     if (cfi_available) sum(!str_detect(coalesce(twse_clean$CFI_Code, ""), "^ES")) else "CFI not available", "zero"),
  qa("Retained rows whose CFI code is preferred 'EP...'",
     if (cfi_available) sum(str_detect(coalesce(twse_clean$CFI_Code, ""), "^EP")) else "CFI not available", "zero"),
  qa("Retained Stock IDs that are not exactly 4 digits",
     sum(!str_detect(coalesce(twse_clean$Stock_ID, ""), "^[0-9]{4}$")), "zero"),
  qa("Retained rows with a market value other than the listed market",
     sum(!is.na(twse_clean$Market) & twse_clean$Market != MARKET_LISTED)),
  qa("Retained rows that refer to the Taiwan Innovation Board (market or industry label)",
     sum(str_detect(coalesce(twse_clean$Market, ""), INNOV_PATTERN) |
         str_detect(coalesce(twse_clean$Industry, ""), INNOV_PATTERN))),
  qa("Company names containing Chinese characters (%; checks the text encoding)", round(han_share, 1), "ge90")
)
kable(qa_twse, caption = tab_cap("TWSE quality checks (PASS / REVIEW / INFO)"))
Table 2. TWSE quality checks (PASS / REVIEW / INFO)
Check Result Status
Securities in the raw all-category table not available in this run INFO
Rows in the input table used 1,053 INFO
Listed common stocks after filtering 1,053 PASS
Rows removed by the common-stock filter (0 expected for an already-cleaned file) 0 PASS
Category marker available in the input (1 = yes, 0 = no) 0 INFO
CFI marker available in the input (1 = yes, 0 = no) 1 INFO
Duplicate Stock IDs removed 0 PASS
Duplicate Stock IDs in the final table 0 PASS
Missing Stock ID 0 PASS
Missing company name 0 PASS
Missing listing date 0 PASS
Listing dates that could not be parsed (text present, date invalid) 0 PASS
Listing dates in the future 0 PASS
Listing dates before 1962-01-01 (before TWSE trading began) 0 PASS
Earliest listing date 1962-02-09 INFO
Latest listing date 2026-09-01 INFO
Missing industry 0 INFO
Retained rows whose CFI code is not ‘ES…’ 0 PASS
Retained rows whose CFI code is preferred ‘EP…’ 0 PASS
Retained Stock IDs that are not exactly 4 digits 0 PASS
Retained rows with a market value other than the listed market 0 INFO
Retained rows that refer to the Taiwan Innovation Board (market or industry label) 0 INFO
Company names containing Chinese characters (%; checks the text encoding) 99.2 PASS

PASS means the check met its expectation, REVIEW means a value that should be zero (or positive) was not, and INFO is descriptive. A REVIEW is not necessarily an error; it marks something to inspect.

6.2 Composition of the data

twse_clean |>
  count(Market, name = "Stocks", sort = TRUE) |>
  show_tbl("Market values of the retained TWSE common stocks")
Table 3. Market values of the retained TWSE common stocks
Market Stocks
上市 1,053
if (!is.null(twse_all)) {
  twse_all |>
    filter(!(Category == CAT_STOCK & str_detect(coalesce(CFI_Code, ""), "^ES"))) |>
    mutate(CFI_Prefix = str_sub(CFI_Code, 1, 2)) |>
    count(Category, CFI_Prefix, name = "Excluded_Rows") |>
    arrange(desc(Excluded_Rows)) |>
    head(15) |>
    show_tbl("Securities excluded from the common-stock list (largest 15 groups; preferred shares have CFI prefix EP)")
} else {
  knitr::asis_output(paste0("*The raw all-category table was not processed in this run (the cleaned file ",
                            "was used), so a breakdown of the excluded security types is not shown.*"))
}

The raw all-category table was not processed in this run (the cleaned file was used), so a breakdown of the excluded security types is not shown.

7. TWSE Results

twse_out <- twse_clean |>
  select(Stock_ID, Company_Name, Listing_Date, Industry, ISIN, Market, CFI_Code)

# The four fields requested in the brief
write_excel_csv(twse_out |> select(Stock_ID, Company_Name, Listing_Date, Industry), out_twse)
n_twse <- nrow(twse_out)

The cleaned dataset contains 1,053 listed common stocks and was saved as TWSE_Common_Stocks.csv (stock ID, company name, listing date, industry). Stock IDs are stored as text.

twse_out |>
  select(Stock_ID, Company_Name, Listing_Date, Industry) |>
  head(10) |>
  show_tbl("First 10 observations of the cleaned TWSE dataset")
Table 4. First 10 observations of the cleaned TWSE dataset
Stock_ID Company_Name Listing_Date Industry
1101 台泥 1962-02-09 水泥工業
1102 亞泥 1962-06-08 水泥工業
1103 嘉泥 1969-11-14 水泥工業
1104 環泥 1971-02-01 水泥工業
1108 幸福 1990-06-06 水泥工業
1109 信大 1991-12-05 水泥工業
1110 東泥 1994-10-22 水泥工業
1201 味全 1962-02-09 食品工業
1203 味王 1964-08-24 食品工業
1210 大成 1978-05-20 食品工業
twse_out |>
  count(Industry, name = "Stocks", sort = TRUE) |>
  head(15) |>
  show_tbl("Largest 15 TWSE industry categories (common stocks)")
Table 5. Largest 15 TWSE industry categories (common stocks)
Industry Stocks
電子零組件業 103
半導體業 91
光電業 67
電腦及週邊設備業 63
建材營造業 55
生技醫療業 54
其他業 52
電機機械 49
通信網路業 46
其他電子業 45
紡織纖維 42
汽車工業 40
金融保險業 31
鋼鐵工業 31
化學工業 28

The complete TWSE dataset can be browsed below.

rmarkdown::paged_table(twse_out |> select(Stock_ID, Company_Name, Listing_Date, Industry))

8. FinMind Data Collection

FinMind exposes a REST API (https://api.finmindtrade.com/api/v4/data). The TaiwanStockInfo dataset returns one row per security with a stock ID, name, industry category, market type and a date field. The raw response is kept in data_cache/finmind_raw.csv and is used directly whenever it is valid; the API is only called when that file is missing or refresh: true is set. The optional API token is never written in this file; it is read from the environment variable FINMIND_TOKEN and sent only in an HTTP header.

To set it safely in RStudio (do not type it into the .Rmd), run once in the Console:

Sys.setenv(FINMIND_TOKEN = "paste-your-token-here")   # Console only; do NOT save this line in the Rmd

or add FINMIND_TOKEN=... to your user .Renviron file (usethis::edit_r_environ()), then restart R.

# Standardise a raw FinMind table (from the local file or from the API).
# Required columns: stock_id, stock_name, industry_category. Returns NULL if they are missing.
standardise_fm <- function(d) {
  nm <- names(d)
  m <- c(stock_id          = pick_col(nm, c("stock_id", "Stock_ID")),
         stock_name        = pick_col(nm, c("stock_name", "Stock_Name", "company_name")),
         industry_category = pick_col(nm, c("industry_category", "Industry_Category", "industry")),
         type              = pick_col(nm, c("type", "Type", "market_type")),
         date              = pick_col(nm, c("date", "Date")),
         listing_date_src  = pick_col(nm, c("listing_date", "list_date", "ipo_date", "listed_date")))
  if (any(is.na(m[c("stock_id", "stock_name", "industry_category")]))) return(NULL)
  g <- function(k) if (is.na(m[[k]])) rep(NA_character_, nrow(d)) else as.character(d[[m[[k]]]])
  out <- tibble(stock_id          = str_squish(g("stock_id")),
                stock_name        = g("stock_name"),
                industry_category = g("industry_category"),
                type              = g("type"),
                date              = g("date"),
                listing_date_src  = g("listing_date_src")) |>
    mutate(across(everything(), ~ na_if(.x, "")))
  attr(out, "orig_names") <- nm
  out
}

load_fm_raw <- function(path) {
  if (!file.exists(path)) return(NULL)
  d <- tryCatch(read_chr_csv(path), error = function(e) NULL)
  if (is.null(d) || nrow(d) == 0) return(NULL)
  standardise_fm(d)
}

fetch_finmind_info <- function(dataset = "TaiwanStockInfo") {
  use_pkgs(c("httr2", "jsonlite"))
  token <- Sys.getenv("FINMIND_TOKEN")
  req <- request(finmind_url) |>
    req_url_query(dataset = dataset) |>
    req_user_agent("university assignment; R httr2") |>
    req_timeout(60) |>
    req_error(is_error = function(resp) FALSE) |>      # status codes are inspected below
    req_retry(max_tries = 3,
              is_transient = function(resp) resp_status(resp) %in% c(429, 500, 502, 503, 504))
  if (nzchar(token)) req <- req_auth_bearer_token(req, token)   # token only in the header

  resp <- tryCatch(req_perform(req),
                   error = function(e) stop("Could not connect to FinMind: ",
                                            conditionMessage(e), call. = FALSE))
  http <- resp_status(resp)
  js   <- tryCatch(jsonlite::fromJSON(resp_body_string(resp, encoding = "UTF-8"), flatten = TRUE),
                   error = function(e) NULL)
  if (is.null(js)) stop("FinMind returned a response that is not valid JSON (HTTP ", http, ").",
                        call. = FALSE)
  api_status <- suppressWarnings(as.integer(js$status))
  if (http != 200 || length(api_status) != 1 || is.na(api_status) || api_status != 200L) {
    stop("FinMind request failed. HTTP ", http, ", API status ",
         paste(js$status, collapse = ""), ", message: ", paste(js$msg, collapse = ""),
         ". If this is a rate-limit or login message, set FINMIND_TOKEN and retry later.",
         call. = FALSE)
  }
  df <- as_tibble(js$data)
  if (nrow(df) == 0) stop("FinMind returned an empty dataset.", call. = FALSE)
  df |> mutate(across(everything(), as.character))
}
fm_raw <- if (!refresh) load_fm_raw(path_fm_raw) else NULL

if (!is.null(fm_raw)) {
  fm_source <- paste0("local file data_cache/finmind_raw.csv (last modified ",
                      format(file.mtime(path_fm_raw), "%Y-%m-%d %H:%M"), "); no download performed")
} else {
  fm_dl <- tryCatch(fetch_finmind_info("TaiwanStockInfo"), error = function(e) e)
  if (inherits(fm_dl, "error")) {
    stop("data_cache/finmind_raw.csv is missing or lacks the required columns, and the API ",
         "download failed: ", conditionMessage(fm_dl), call. = FALSE)
  }
  fm_raw <- standardise_fm(fm_dl)
  if (is.null(fm_raw)) {
    stop("The FinMind response lacks the required columns (stock_id, stock_name, ",
         "industry_category). Columns found: ", paste(names(fm_dl), collapse = ", "), call. = FALSE)
  }
  write_excel_csv(fm_dl, path_fm_raw)
  fm_source <- "downloaded live from the FinMind API and saved to data_cache/finmind_raw.csv"
}

tibble(Item  = c("FinMind input used", "Dataset", "Rows in the raw table",
                 "Columns in the raw file", "Authenticated request"),
       Value = c(fm_source, "TaiwanStockInfo", format(nrow(fm_raw), big.mark = ","),
                 paste(attr(fm_raw, "orig_names"), collapse = ", "),
                 if (grepl("^downloaded", fm_source)) as.character(nzchar(Sys.getenv("FINMIND_TOKEN"))) else "not applicable")) |>
  kable(caption = tab_cap("FinMind data source used in this run"))
Table 6. FinMind data source used in this run
Item Value
FinMind input used local file data_cache/finmind_raw.csv (last modified 2026-10-11 11:42); no download performed
Dataset TaiwanStockInfo
Rows in the raw table 4,333
Columns in the raw file industry_category, stock_id, stock_name, type, date
Authenticated request not applicable

8.1 Selecting comparable listed common stocks

FinMind does not carry the CFI code, so the common-stock universe is identified as reliably as the available fields allow. A row is kept only when

  1. its market type is TWSE (when the type column is present),
  2. its industry label is not an ETF / ETN / index / depositary-receipt / beneficiary-security / fund / warrant style category, and
  3. its stock ID is a plain four-digit code that does not start with 0 (ETFs use IDs starting with 0; preferred shares, warrants and many other instruments use longer or lettered codes).

The four-digit rule is a proxy: it cannot tell a common stock from another security that happens to have a four-digit code (for example a depositary receipt), which is why the label rule is applied as well. The result is cross-checked against the official TWSE list in Section 10, which acts as an independent test of these rules without being used to build them. Securities labelled as Taiwan Innovation Board stocks are not excluded by these rules; they are counted separately in the validation table.

has_type <- any(!is.na(fm_raw$type))

# Industry labels that indicate non-common-stock instruments
excl_pattern <- "ETF|ETN|Index|index|大盤|存託憑證|受益|基金|權證|Warrant|所有證券"

fm_flagged <- fm_raw |>
  mutate(Exclusion_Reason = case_when(
    has_type & str_to_lower(coalesce(type, "")) != "twse"      ~ "Market type is not TWSE",
    str_detect(coalesce(industry_category, ""), excl_pattern)  ~ "ETF / ETN / index / DR / fund / warrant-type industry label",
    !str_detect(coalesce(stock_id, ""), "^[1-9][0-9]{3}$")     ~ "ID is not a plain 4-digit code (preferred, ETF, warrant, etc.)",
    TRUE                                                       ~ "Retained"))

fm_flagged |>
  count(Exclusion_Reason, name = "Rows", sort = TRUE) |>
  show_tbl("FinMind raw rows by common-stock filter outcome (rows, before de-duplication)")
Table 7. FinMind raw rows by common-stock filter outcome (rows, before de-duplication)
Exclusion_Reason Rows
Retained 1,988
Market type is not TWSE 1,931
ETF / ETN / index / DR / fund / warrant-type industry label 374
ID is not a plain 4-digit code (preferred, ETF, warrant, etc.) 40
# A listing-date column exists only if the response really contains one
has_fm_listing <- any(!is.na(fm_raw$listing_date_src))
fm_date_sentence <- if (has_fm_listing) {
  "A column holding a listing date was found in the response and was used."
} else {
  paste0("The TaiwanStockInfo response contains **no field documented as a listing date**. ",
         "Its `date` column is not verified to be a listing date and is therefore not used as one; ",
         "the FinMind Listing_Date column is empty by design and is not filled from TWSE.")
}

# FinMind can list the same stock under several industry labels -> one row per stock
fm_clean <- fm_flagged |>
  filter(Exclusion_Reason == "Retained") |>
  group_by(Stock_ID = stock_id) |>
  summarise(
    Company_Name = first(stock_name),
    Industry     = na_if(paste(sort(unique(industry_category[!is.na(industry_category) &
                                                              industry_category != ""])),
                               collapse = "; "), ""),
    Market_Type  = if (has_type) first(type) else NA_character_,
    Listing_Date = if (has_fm_listing) parse_date_multi(first(listing_date_src)) else as.Date(NA),
    Date_Field   = last(sort(unique(na.omit(date)))),
    Source_Rows  = n(),
    .groups = "drop"
  ) |>
  arrange(Stock_ID)

if (nrow(fm_clean) == 0) stop("The FinMind common-stock filter returned no rows.", call. = FALSE)

# Verification against the official TWSE list (used only for labelling, never for selection)
fm_clean <- fm_clean |> mutate(TWSE_Verified = Stock_ID %in% twse_out$Stock_ID)
n_fm <- nrow(fm_clean)

write_excel_csv(fm_clean |> select(Stock_ID, Company_Name, Listing_Date, Industry, TWSE_Verified),
                out_finmind)

9. FinMind Results

FinMind provides 1,215 comparable TWSE common stocks, of which 1,053 are confirmed by the official TWSE list (saved as FinMind_Common_Stocks.csv, with the verification flag in the last column). The TaiwanStockInfo response contains no field documented as a listing date. Its date column is not verified to be a listing date and is therefore not used as one; the FinMind Listing_Date column is empty by design and is not filled from TWSE.

fm_coverage <- tibble(
  Field = c("Stock ID", "Company name", "Industry", "Market type", "Listing date"),
  Available_In_FinMind = c("Yes", "Yes", "Yes",
                           if (has_type) "Yes" else "No",
                           if (has_fm_listing) "Yes" else "No"),
  Missing_Values = c(sum(is.na(fm_clean$Stock_ID)), sum(is.na(fm_clean$Company_Name)),
                     sum(is.na(fm_clean$Industry)),
                     if (has_type) sum(is.na(fm_clean$Market_Type)) else NA_integer_,
                     if (has_fm_listing) sum(is.na(fm_clean$Listing_Date)) else NA_integer_)
)
kable(fm_coverage, caption = tab_cap("Field coverage of the FinMind dataset (NA = field not provided)"))
Table 8. Field coverage of the FinMind dataset (NA = field not provided)
Field Available_In_FinMind Missing_Values
Stock ID Yes 0
Company name Yes 0
Industry Yes 0
Market type Yes 0
Listing date No NA
fm_clean |>
  select(Stock_ID, Company_Name, Listing_Date, Industry, TWSE_Verified) |>
  head(10) |>
  show_tbl("First 10 observations of the FinMind dataset")
Table 9. First 10 observations of the FinMind dataset
Stock_ID Company_Name Listing_Date Industry TWSE_Verified
1101 台泥 NA 水泥工業 TRUE
1102 亞泥 NA 水泥工業 TRUE
1103 嘉泥 NA 水泥工業 TRUE
1104 環泥 NA 水泥工業 TRUE
1107 建台 NA 其他 FALSE
1108 幸福 NA 水泥工業 TRUE
1109 信大 NA 水泥工業 TRUE
1110 東泥 NA 水泥工業 TRUE
1201 味全 NA 食品工業 TRUE
1203 味王 NA 食品工業 TRUE

Rows with more than one industry label in FinMind: 712 (their labels are joined with a semicolon).

9.1 Validation checks

# Cross-check with the result saved in an earlier run (information only)
fm_saved <- tryCatch({
  if (file.exists(path_fm_clean_saved)) {
    d  <- read_chr_csv(path_fm_clean_saved)
    ic <- pick_col(names(d), c("stock_id", "Stock_ID"))
    if (!is.na(ic)) {
      ids <- unique(d[[ic]])
      list(n = length(ids), only_saved = length(setdiff(ids, fm_clean$Stock_ID)),
           only_new = length(setdiff(fm_clean$Stock_ID, ids)))
    } else NULL
  } else NULL
}, error = function(e) NULL)

qa_fm <- bind_rows(
  qa("Rows in the raw FinMind table", nrow(fm_raw)),
  qa("Rows with market type TWSE", if (has_type) sum(str_to_lower(coalesce(fm_raw$type, "")) == "twse") else "type not available"),
  qa("Comparable common stocks retained (one row per Stock ID)", n_fm, "positive"),
  qa("Duplicate Stock IDs in the retained table", sum(duplicated(fm_clean$Stock_ID)), "zero"),
  qa("Missing company name", sum(is.na(fm_clean$Company_Name)), "zero"),
  qa("Missing industry", sum(is.na(fm_clean$Industry))),
  qa("Retained Stock IDs that are not exactly 4 digits", sum(!str_detect(fm_clean$Stock_ID, "^[0-9]{4}$")), "zero"),
  qa("Retained rows with an ETF / ETN / index / DR / fund / warrant label",
     sum(str_detect(coalesce(fm_clean$Industry, ""), excl_pattern)), "zero"),
  qa("Retained rows labelled as Taiwan Innovation Board stocks",
     sum(str_detect(coalesce(fm_clean$Industry, ""), INNOV_PATTERN))),
  qa("Retained stocks confirmed by the official TWSE list", sum(fm_clean$TWSE_Verified)),
  qa("Retained stocks NOT confirmed by the official TWSE list (see Section 10)", sum(!fm_clean$TWSE_Verified)),
  qa("FinMind listing date available (1 = yes, 0 = no)", as.integer(has_fm_listing))
)
if (!is.null(fm_saved)) {
  qa_fm <- bind_rows(qa_fm,
    qa("IDs in data_cache/finmind_clean_saved.csv (earlier run)", fm_saved$n),
    qa("IDs in the earlier file but not in this run's result", fm_saved$only_saved),
    qa("IDs in this run's result but not in the earlier file", fm_saved$only_new))
}
kable(qa_fm, caption = tab_cap("FinMind quality checks (PASS / REVIEW / INFO)"))
Table 10. FinMind quality checks (PASS / REVIEW / INFO)
Check Result Status
Rows in the raw FinMind table 4,333 INFO
Rows with market type TWSE 2,402 INFO
Comparable common stocks retained (one row per Stock ID) 1,215 PASS
Duplicate Stock IDs in the retained table 0 PASS
Missing company name 0 PASS
Missing industry 0 INFO
Retained Stock IDs that are not exactly 4 digits 0 PASS
Retained rows with an ETF / ETN / index / DR / fund / warrant label 0 PASS
Retained rows labelled as Taiwan Innovation Board stocks 37 INFO
Retained stocks confirmed by the official TWSE list 1,053 INFO
Retained stocks NOT confirmed by the official TWSE list (see Section 10) 162 INFO
FinMind listing date available (1 = yes, 0 = no) 0 INFO
IDs in data_cache/finmind_clean_saved.csv (earlier run) 1,215 INFO
IDs in the earlier file but not in this run’s result 0 INFO
IDs in this run’s result but not in the earlier file 0 INFO

The complete FinMind dataset can be browsed below.

rmarkdown::paged_table(fm_clean |> select(Stock_ID, Company_Name, Listing_Date, Industry, TWSE_Verified))

10. Data Comparison

The two datasets are joined on Stock_ID. Company names are compared after Unicode (NFKC) normalisation and whitespace trimming. Industry labels are reported side by side but not required to be identical, because the two providers use different classification systems. A missing value is labelled Missing; a value present in both sources but different is labelled Mismatch.

norm <- function(x) str_to_lower(str_squish(stringi::stri_trans_nfkc(x)))

t_side <- twse_out |>
  transmute(Stock_ID, In_TWSE = TRUE,
            TWSE_Company_Name = Company_Name, TWSE_Industry = Industry,
            TWSE_Listing_Date = Listing_Date)
f_side <- fm_clean |>
  transmute(Stock_ID, In_FinMind = TRUE,
            FinMind_Company_Name = Company_Name, FinMind_Industry = Industry,
            FinMind_Listing_Date = Listing_Date,
            FinMind_Date_Field_Unverified = Date_Field)

comparison <- full_join(t_side, f_side, by = "Stock_ID") |>
  mutate(In_TWSE = coalesce(In_TWSE, FALSE),
         In_FinMind = coalesce(In_FinMind, FALSE),
         In_Both = In_TWSE & In_FinMind) |>
  mutate(
    Name_Check = case_when(
      !In_Both ~ NA_character_,
      is.na(TWSE_Company_Name) | is.na(FinMind_Company_Name) ~ "Missing",
      norm(TWSE_Company_Name) == norm(FinMind_Company_Name) ~ "Match",
      TRUE ~ "Mismatch"),
    Industry_Label_Check = case_when(
      !In_Both ~ NA_character_,
      is.na(TWSE_Industry) | is.na(FinMind_Industry) ~ "Missing",
      norm(TWSE_Industry) == norm(FinMind_Industry) ~ "Same label",
      TRUE ~ "Different label"),
    Listing_Date_Check = if (has_fm_listing) {
      case_when(
        !In_Both ~ NA_character_,
        is.na(TWSE_Listing_Date) | is.na(FinMind_Listing_Date) ~ "Missing",
        TWSE_Listing_Date == FinMind_Listing_Date ~ "Match",
        TRUE ~ "Mismatch")
    } else {
      if_else(In_Both, "Not available in FinMind", NA_character_)
    },
    Matching_Status = case_when(
      In_TWSE & !In_FinMind ~ "Found only in TWSE",
      !In_TWSE & In_FinMind ~ "Found only in FinMind (comparable universe)",
      Name_Check == "Mismatch" | coalesce(Listing_Date_Check == "Mismatch", FALSE) ~
        "Found in both - information differs",
      Name_Check == "Missing" | coalesce(Listing_Date_Check == "Missing", FALSE) ~
        "Found in both - missing field(s)",
      TRUE ~ "Found in both - consistent")
  ) |>
  select(Stock_ID, In_TWSE, In_FinMind,
         TWSE_Company_Name, FinMind_Company_Name,
         TWSE_Industry, FinMind_Industry,
         TWSE_Listing_Date, FinMind_Listing_Date, FinMind_Date_Field_Unverified,
         Name_Check, Industry_Label_Check, Listing_Date_Check, Matching_Status) |>
  arrange(Stock_ID)

write_excel_csv(comparison, out_compare)

# How FinMind's own filter treated each stock (used to explain the unmatched IDs)
fm_reason_lookup <- fm_flagged |>
  group_by(stock_id) |>
  summarise(FinMind_Filter_Outcome = paste(sort(unique(Exclusion_Reason)), collapse = "; "),
            .groups = "drop")

unmatched_out <- comparison |>
  filter(!(In_TWSE & In_FinMind)) |>
  left_join(fm_reason_lookup, by = c("Stock_ID" = "stock_id")) |>
  mutate(Source = if_else(In_TWSE, "Only in TWSE", "Only in FinMind"),
         Company_Name = coalesce(TWSE_Company_Name, FinMind_Company_Name),
         FinMind_Filter_Outcome = if_else(
           In_TWSE,
           coalesce(FinMind_Filter_Outcome, "Not present in the FinMind TaiwanStockInfo table"),
           "Retained by the FinMind filter")) |>
  select(Source, Stock_ID, Company_Name, TWSE_Industry, FinMind_Industry,
         FinMind_Date_Field_Unverified, FinMind_Filter_Outcome) |>
  arrange(Source, Stock_ID)

write_excel_csv(unmatched_out, out_unmatched)

# Headline numbers used in the text below
n_both       <- sum(str_detect(comparison$Matching_Status, "^Found in both"))
n_only_twse  <- sum(comparison$Matching_Status == "Found only in TWSE")
n_only_fm    <- sum(comparison$Matching_Status == "Found only in FinMind (comparable universe)")
n_consistent <- sum(comparison$Matching_Status == "Found in both - consistent")
n_differs    <- sum(comparison$Matching_Status == "Found in both - information differs")
n_incomplete <- sum(comparison$Matching_Status == "Found in both - missing field(s)")
n_name_diff  <- sum(comparison$Name_Check == "Mismatch", na.rm = TRUE)
n_ind_diff   <- sum(comparison$Industry_Label_Check == "Different label", na.rm = TRUE)
n_unmatched  <- n_only_twse + n_only_fm
tibble(
  Metric = c("Total TWSE listed common stocks",
             "Total comparable FinMind stocks",
             "Stock IDs found in both sources",
             "Unmatched Stock IDs (only in one source)",
             "   - only in TWSE",
             "   - only in FinMind",
             "Both sources, all compared fields consistent",
             "Both sources, information differs",
             "Both sources, a field is missing",
             "Company names that differ (both present)",
             "Industry labels that differ (both present)",
             "TWSE missing industry / FinMind missing industry",
             "FinMind listing date available"),
  Value = as.character(c(n_twse, n_fm, n_both, n_unmatched, n_only_twse, n_only_fm,
                         n_consistent, n_differs, n_incomplete, n_name_diff, n_ind_diff,
                         paste(sum(is.na(twse_out$Industry)), "/", sum(is.na(fm_clean$Industry))),
                         if (has_fm_listing) "Yes" else "No"))
) |>
  kable(caption = tab_cap("Comparison summary (calculated from the data)"))
Table 11. Comparison summary (calculated from the data)
Metric Value
Total TWSE listed common stocks 1053
Total comparable FinMind stocks 1215
Stock IDs found in both sources 1053
Unmatched Stock IDs (only in one source) 162
- only in TWSE 0
- only in FinMind 162
Both sources, all compared fields consistent 1046
Both sources, information differs 7
Both sources, a field is missing 0
Company names that differ (both present) 7
Industry labels that differ (both present) 747
TWSE missing industry / FinMind missing industry 0 / 0
FinMind listing date available No
comparison |>
  count(Matching_Status, name = "Stocks") |>
  show_tbl("Matching status of all Stock IDs")
Table 12. Matching status of all Stock IDs
Matching_Status Stocks
Found in both - consistent 1,046
Found in both - information differs 7
Found only in FinMind (comparable universe) 162

10.1 Differences in company names and industry labels

comparison |>
  filter(Name_Check == "Mismatch") |>
  select(Stock_ID, TWSE_Company_Name, FinMind_Company_Name) |>
  show_tbl("All stocks whose company names differ between TWSE and FinMind",
           "No company-name differences were found.")
Table 13. All stocks whose company names differ between TWSE and FinMind
Stock_ID TWSE_Company_Name FinMind_Company_Name
1443 立益物流 立益
6757 台灣虎航 台灣虎航-創
6794 向榮生技 向榮生技-創
6869 雲豹能源 雲豹能源-創
6873 泓德能源 泓德能源-創
6902 GOGOLOOK GOGOLOOK-創
8422 可寧衛* 可寧衛
comparison |>
  filter(Industry_Label_Check %in% c("Same label", "Different label")) |>
  count(TWSE_Industry, FinMind_Industry, name = "Stocks", sort = TRUE) |>
  head(15) |>
  show_tbl("Industry label crosswalk: most frequent TWSE / FinMind label pairs")
Table 14. Industry label crosswalk: most frequent TWSE / FinMind label pairs
TWSE_Industry FinMind_Industry Stocks
電子零組件業 電子工業; 電子零組件業 103
半導體業 半導體業; 電子工業 89
光電業 光電業; 電子工業 66
電腦及週邊設備業 電子工業; 電腦及週邊設備業 62
生技醫療業 化學生技醫療; 生技醫療業 52
其他業 其他 50
建材營造業 建材營造 50
電機機械 電機機械 49
通信網路業 通信網路業; 電子工業 44
其他電子業 其他電子業; 電子工業 43
紡織纖維 紡織纖維 42
汽車工業 汽車工業 39
金融保險業 金融保險 31
鋼鐵工業 鋼鐵工業 31
化學工業 化學工業; 化學生技醫療 28

10.2 Stocks found in only one source

# FinMind-only IDs: what kind of securities are they?
unm_fm <- comparison |> filter(In_FinMind & !In_TWSE)

unm_fm |>
  count(FinMind_Industry, name = "Stocks", sort = TRUE) |>
  head(15) |>
  show_tbl("FinMind-only Stock IDs by FinMind industry label (largest 15)",
           "Every FinMind stock is also in the TWSE list.")
Table 15. FinMind-only Stock IDs by FinMind industry label (largest 15)
FinMind_Industry Stocks
光電業; 電子工業 22
半導體業; 電子工業 19
金融保險 15
其他 10
電子工業; 電腦及週邊設備業 9
化學生技醫療; 生技醫療業 6
電子工業 6
電子工業; 電子零組件業 6
通信網路業; 電子工業 5
電子工業; 電子通路業 5
其他電子業; 電子工業 4
創新板股票; 創新版股票; 半導體業; 電子工業 4
創新板股票; 化學生技醫療; 生技醫療業 4
化學工業; 化學生技醫療 4
電器電纜 4
unm_fm |>
  count(First_Digit = str_sub(Stock_ID, 1, 1), name = "Stocks") |>
  show_tbl("FinMind-only Stock IDs by first digit of the code",
           "Every FinMind stock is also in the TWSE list.")
Table 16. FinMind-only Stock IDs by first digit of the code
First_Digit Stocks
1 19
2 48
3 33
4 8
5 5
6 29
7 9
8 8
9 3
# The FinMind `date` field is NOT a listing date; it is shown only as a descriptive diagnostic.
date_diag <- comparison |>
  filter(In_FinMind) |>
  mutate(Group = if_else(In_TWSE, "In both sources", "Only in FinMind"),
         D = suppressWarnings(as.Date(FinMind_Date_Field_Unverified)))
if (any(!is.na(date_diag$D))) {
  date_diag |>
    group_by(Group) |>
    summarise(Stocks = n(), With_Date = sum(!is.na(D)),
              Earliest = min(D, na.rm = TRUE), Median = median(D, na.rm = TRUE),
              Latest = max(D, na.rm = TRUE), .groups = "drop") |>
    show_tbl("Descriptive summary of the FinMind `date` field (not a listing date and not used as one)")
} else {
  knitr::asis_output("*The FinMind `date` field is not available in this dataset.*")
}
Table 17. Descriptive summary of the FinMind date field (not a listing date and not used as one)
Group Stocks With_Date Earliest Median Latest
In both sources 1,053 1,053 2026-07-04 2026-10-11 2026-10-11
Only in FinMind 162 162 2024-12-03 2026-06-02 2026-10-11
# TWSE-only IDs: how did FinMind's own data and filter treat them?
comparison |>
  filter(In_TWSE & !In_FinMind) |>
  left_join(fm_reason_lookup, by = c("Stock_ID" = "stock_id")) |>
  mutate(FinMind_Filter_Outcome = coalesce(FinMind_Filter_Outcome,
                                           "Not present in the FinMind TaiwanStockInfo table")) |>
  count(FinMind_Filter_Outcome, name = "Stocks", sort = TRUE) |>
  show_tbl("TWSE-only Stock IDs: treatment in the FinMind data",
           "Every TWSE common stock is also in the FinMind list.")

Every TWSE common stock is also in the FinMind list.

All unmatched stocks are saved in Unmatched_Stocks.csv with the source in which they were found and, where relevant, how the FinMind filter treated them.

10.3 Validation checks

qa_cmp <- bind_rows(
  qa("Duplicate Stock IDs in the comparison table", sum(duplicated(comparison$Stock_ID)), "zero"),
  qa("Stock IDs in both sources", n_both, "positive"),
  qa("Stock IDs found only in TWSE", n_only_twse),
  qa("Stock IDs found only in FinMind", n_only_fm),
  qa("Company names that differ (both present)", n_name_diff),
  qa("Company names missing in one source (both IDs present)", sum(comparison$Name_Check == "Missing", na.rm = TRUE)),
  qa("Industry labels that differ (different taxonomies)", n_ind_diff),
  qa("Industry labels missing in one source (both IDs present)", sum(comparison$Industry_Label_Check == "Missing", na.rm = TRUE)),
  qa("Listing dates comparable between the sources (1 = yes, 0 = no)", as.integer(has_fm_listing))
)
kable(qa_cmp, caption = tab_cap("Comparison quality checks (PASS / REVIEW / INFO)"))
Table 18. Comparison quality checks (PASS / REVIEW / INFO)
Check Result Status
Duplicate Stock IDs in the comparison table 0 PASS
Stock IDs in both sources 1,053 PASS
Stock IDs found only in TWSE 0 INFO
Stock IDs found only in FinMind 162 INFO
Company names that differ (both present) 7 INFO
Company names missing in one source (both IDs present) 0 INFO
Industry labels that differ (different taxonomies) 747 INFO
Industry labels missing in one source (both IDs present) 0 INFO
Listing dates comparable between the sources (1 = yes, 0 = no) 0 INFO

11. Results and Discussion

Coverage. The official TWSE list yields 1,053 listed common stocks and FinMind yields 1,215 comparable stocks. 1,053 Stock IDs appear in both sources; 0 appear only in TWSE and 162 only in FinMind. Of the shared IDs, 1,046 are fully consistent on the compared fields, 7 differ and 0 have a missing field. The unmatched IDs are listed in Section 10.2 and in Unmatched_Stocks.csv. The FinMind-only IDs could not be confirmed as common stocks listed on the TWSE by this comparison, so they are flagged rather than silently accepted or removed. Possible explanations are securities that FinMind classifies differently from TWSE, records that are no longer listed, and listings or delistings that one provider has not yet reflected; these explanations were not verified individually.

Availability of fields. TWSE supplies all four requested fields. FinMind supplies stock ID, name and industry; no field documented as a listing date was returned, so the listing-date comparison is not possible and no value was substituted.

Classification. TWSE assigns industry through its own official classification, whereas FinMind uses its own category labels (and may attach more than one label to a stock). 747 shared stocks have different industry labels (Section 10.1), which reflects differing taxonomies rather than data errors. Company names differ for 7 shared stocks, typically because of naming conventions or the timing of name changes.

Limitations.

  • FinMind has no CFI code, so its common-stock universe is inferred from market type, industry label and ID format. The four-digit rule cannot, on its own, distinguish a common stock from another security with a four-digit code, which is why the TWSE list is used as the reference and why unmatched IDs are reported.
  • FinMind does not provide a documented listing date in this dataset. Its date field was not treated as one.
  • Data reflect the moment of download (the modification times of the local files are shown in Sections 5 and 8); the two providers may update at different times.
  • Industry labels are compared descriptively only; no mapping between taxonomies was attempted.
  • The comparison covers listed (TWSE) common stocks only, not OTC (TPEx) securities.

11.1 Quality assurance summary

All automated checks of this report are collected below. A run without R errors does not by itself show that the data are valid; the statuses show where a result deserves inspection.

qa_all <- bind_rows(qa_twse |> mutate(Block = "TWSE"),
                    qa_fm   |> mutate(Block = "FinMind"),
                    qa_cmp  |> mutate(Block = "Comparison")) |>
  select(Block, Check, Result, Status)
n_pass   <- sum(qa_all$Status == "PASS")
n_review <- sum(qa_all$Status == "REVIEW")
n_info   <- sum(qa_all$Status == "INFO")
kable(qa_all, caption = tab_cap("Consolidated quality-assurance results"))
Table 19. Consolidated quality-assurance results
Block Check Result Status
TWSE Securities in the raw all-category table not available in this run INFO
TWSE Rows in the input table used 1,053 INFO
TWSE Listed common stocks after filtering 1,053 PASS
TWSE Rows removed by the common-stock filter (0 expected for an already-cleaned file) 0 PASS
TWSE Category marker available in the input (1 = yes, 0 = no) 0 INFO
TWSE CFI marker available in the input (1 = yes, 0 = no) 1 INFO
TWSE Duplicate Stock IDs removed 0 PASS
TWSE Duplicate Stock IDs in the final table 0 PASS
TWSE Missing Stock ID 0 PASS
TWSE Missing company name 0 PASS
TWSE Missing listing date 0 PASS
TWSE Listing dates that could not be parsed (text present, date invalid) 0 PASS
TWSE Listing dates in the future 0 PASS
TWSE Listing dates before 1962-01-01 (before TWSE trading began) 0 PASS
TWSE Earliest listing date 1962-02-09 INFO
TWSE Latest listing date 2026-09-01 INFO
TWSE Missing industry 0 INFO
TWSE Retained rows whose CFI code is not ‘ES…’ 0 PASS
TWSE Retained rows whose CFI code is preferred ‘EP…’ 0 PASS
TWSE Retained Stock IDs that are not exactly 4 digits 0 PASS
TWSE Retained rows with a market value other than the listed market 0 INFO
TWSE Retained rows that refer to the Taiwan Innovation Board (market or industry label) 0 INFO
TWSE Company names containing Chinese characters (%; checks the text encoding) 99.2 PASS
FinMind Rows in the raw FinMind table 4,333 INFO
FinMind Rows with market type TWSE 2,402 INFO
FinMind Comparable common stocks retained (one row per Stock ID) 1,215 PASS
FinMind Duplicate Stock IDs in the retained table 0 PASS
FinMind Missing company name 0 PASS
FinMind Missing industry 0 INFO
FinMind Retained Stock IDs that are not exactly 4 digits 0 PASS
FinMind Retained rows with an ETF / ETN / index / DR / fund / warrant label 0 PASS
FinMind Retained rows labelled as Taiwan Innovation Board stocks 37 INFO
FinMind Retained stocks confirmed by the official TWSE list 1,053 INFO
FinMind Retained stocks NOT confirmed by the official TWSE list (see Section 10) 162 INFO
FinMind FinMind listing date available (1 = yes, 0 = no) 0 INFO
FinMind IDs in data_cache/finmind_clean_saved.csv (earlier run) 1,215 INFO
FinMind IDs in the earlier file but not in this run’s result 0 INFO
FinMind IDs in this run’s result but not in the earlier file 0 INFO
Comparison Duplicate Stock IDs in the comparison table 0 PASS
Comparison Stock IDs in both sources 1,053 PASS
Comparison Stock IDs found only in TWSE 0 INFO
Comparison Stock IDs found only in FinMind 162 INFO
Comparison Company names that differ (both present) 7 INFO
Comparison Company names missing in one source (both IDs present) 0 INFO
Comparison Industry labels that differ (different taxonomies) 747 INFO
Comparison Industry labels missing in one source (both IDs present) 0 INFO
Comparison Listing dates comparable between the sources (1 = yes, 0 = no) 0 INFO

Overall, 21 checks passed, 0 are marked for review and 26 are descriptive. No check requires review.

12. Conclusion

Task 1 was completed from the official TWSE ISIN registry (strMode=2): the cleaned list was re-validated against the page category and the CFI code, retaining only common stocks. This gives 1,053 stocks with ID, name, listing date and industry (TWSE_Common_Stocks.csv). Task 2 was completed with the FinMind TaiwanStockInfo dataset, giving 1,215 comparable stocks (FinMind_Common_Stocks.csv), with unavailable fields reported rather than imputed. ETFs, funds and preferred stocks are excluded from both datasets. The comparison (TWSE_FinMind_Comparison.csv) shows 1,053 shared Stock IDs and quantifies the differences in coverage, naming and classification between the two providers; the stocks found in only one source are listed in Unmatched_Stocks.csv.

13. References

## Report generated: 2026-10-11 12:23
## R version: R version 4.6.1 (2026-06-24 ucrt)
## Project root: C:/Users/acer/Downloads/HW4
## Files written: TWSE_Common_Stocks.csv, FinMind_Common_Stocks.csv, TWSE_FinMind_Comparison.csv, Unmatched_Stocks.csv