Package installation note

If any package is missing, run this once in the R console (it is not evaluated when knitting):

install.packages(c("httr", "jsonlite", "dplyr", "readr"))

1. Query the FinMind API

The TaiwanStockInfo dataset is the primary source. An API token is optional. To use one, set the environment variable FINMIND_TOKEN (for example in ~/.Renviron: FINMIND_TOKEN=your_token_here) and restart R. Never hard-code a token in this file.

api_url <- "https://api.finmindtrade.com/api/v4/data"
token   <- Sys.getenv("FINMIND_TOKEN", unset = "")

# Token (if any) is sent as a Bearer header so it never appears in the URL.
req_headers <- if (nzchar(token)) {
  add_headers(Authorization = paste("Bearer", token))
} else {
  add_headers()
}

resp <- tryCatch(
  GET(api_url,
      query = list(dataset = "TaiwanStockInfo"),
      req_headers,
      timeout(60)),
  error = function(e) {
    stop("FinMind API request failed (network/connection error): ",
         conditionMessage(e), call. = FALSE)
  }
)

# HTTP-level validation
if (status_code(resp) != 200) {
  stop("FinMind API returned HTTP status ", status_code(resp),
       ". Body: ", substr(content(resp, as = "text", encoding = "UTF-8"), 1, 300),
       call. = FALSE)
}

# Parse JSON
body_text <- content(resp, as = "text", encoding = "UTF-8")
parsed <- tryCatch(
  fromJSON(body_text, simplifyDataFrame = TRUE),
  error = function(e) {
    stop("Could not parse the FinMind response as JSON: ",
         conditionMessage(e), call. = FALSE)
  }
)

# Response-level validation (FinMind reports its own status inside the JSON)
if (is.null(parsed$status) || parsed$status != 200) {
  stop("FinMind response status was not 200. Status: ",
       ifelse(is.null(parsed$status), "missing", parsed$status),
       "; message: ", ifelse(is.null(parsed$msg), "none", parsed$msg),
       call. = FALSE)
}

raw_info <- parsed$data
if (is.null(raw_info) || !is.data.frame(raw_info) || nrow(raw_info) == 0) {
  stop("FinMind returned an empty 'data' payload for TaiwanStockInfo.", call. = FALSE)
}

expected_cols <- c("industry_category", "stock_id", "stock_name", "type", "date")
missing_cols  <- setdiff(expected_cols, names(raw_info))
if (length(missing_cols) > 0) {
  stop("Expected column(s) missing from TaiwanStockInfo: ",
       paste(missing_cols, collapse = ", "),
       ". Columns received: ", paste(names(raw_info), collapse = ", "),
       call. = FALSE)
}

raw_info <- tibble::as_tibble(raw_info)
cat("Rows downloaded:", nrow(raw_info), "\n")
## Rows downloaded: 4340
cat("Columns received:", paste(names(raw_info), collapse = ", "), "\n")
## Columns received: industry_category, stock_id, stock_name, type, date

2. Listing-date investigation

The date column in TaiwanStockInfo is a dataset/update date, not a listing date, so it is not used for listing_date. The chunk below inspects the columns actually returned by the API for anything that looks like a listing/IPO field.

listing_like_cols <- grep("list|ipo|issue", names(raw_info),
                          ignore.case = TRUE, value = TRUE)

if (length(listing_like_cols) == 0) {
  cat("No listing-date-like column found in TaiwanStockInfo.\n")
} else {
  cat("Possible listing-related column(s) found:",
      paste(listing_like_cols, collapse = ", "), "\n")
}
## No listing-date-like column found in TaiwanStockInfo.
# Only an explicitly named `listing_date` column returned by the API itself
# would be used. Otherwise listing_date is set to NA (never guessed).
has_listing_date <- "listing_date" %in% names(raw_info)
cat("Explicit 'listing_date' column present:", has_listing_date, "\n")
## Explicit 'listing_date' column present: FALSE

Other FinMind datasets (for example TaiwanStockPrice) only tell us the first trading day within FinMind’s own coverage window, which is not the true listing date for older companies. They are deliberately not used as a substitute.

3. Clean and filter

Filtering rules:

  1. Keep type == "twse" (case-insensitive, whitespace trimmed).
  2. Trim text fields, convert empty strings to NA, and drop rows with missing stock_id or stock_name.
  3. Remove duplicate stock_id values, keeping the row with the most recent date (ties broken alphabetically by industry_category so the result is deterministic).
  4. Keep only rows that pass the common-stock rules:
    • stock_id must be exactly four digits starting with 1-9 (^[1-9][0-9]{3}$). This removes ETFs (e.g. 0050, 00878), warrants and convertible bonds (5-6 characters), and preferred shares (a letter suffix such as 2882A).
    • industry_category must not match ETF / fund / index / depositary-receipt / bond / warrant keywords.
    • stock_name must not contain ETF, ETN, or end in -DR (case-insensitive). Chinese characters that might appear in ordinary company names (such as 特) are intentionally not used as a filter.
industry_exclude_regex <- paste(
  c("ETF", "ETN", "Index", "Fund", "Warrant", "Bond", "Depositary",
    "大盤", "受益證券", "存託憑證", "基金", "債券", "權證", "所有證券"),
  collapse = "|"
)
name_exclude_regex <- "ETF|ETN|-DR$"
common_id_regex    <- "^[1-9][0-9]{3}$"

twse_all <- raw_info %>%
  mutate(
    across(c(stock_id, stock_name, industry_category, type),
           ~ trimws(as.character(.x))),
    across(c(stock_id, stock_name, industry_category, type),
           ~ na_if(.x, "")),
    date_parsed = suppressWarnings(as.Date(date))
  ) %>%
  filter(tolower(type) == "twse")

if (nrow(twse_all) == 0) {
  stop("No rows with type == 'twse' were found. Values of 'type' received: ",
       paste(unique(raw_info$type), collapse = ", "), call. = FALSE)
}

twse_dedup <- twse_all %>%
  filter(!is.na(stock_id), !is.na(stock_name)) %>%
  arrange(stock_id, desc(date_parsed), industry_category) %>%
  distinct(stock_id, .keep_all = TRUE)

classified <- twse_dedup %>%
  mutate(
    exclusion_reason = case_when(
      !grepl(common_id_regex, stock_id)                             ~ "stock_id not a 4-digit common-stock code",
      grepl(industry_exclude_regex, industry_category, ignore.case = TRUE) ~ "excluded industry category",
      grepl(name_exclude_regex, stock_name, ignore.case = TRUE)     ~ "excluded name pattern",
      TRUE                                                          ~ "kept"
    )
  )

final_data <- classified %>%
  filter(exclusion_reason == "kept") %>%
  transmute(
    stock_id     = as.character(stock_id),
    name         = stock_name,
    listing_date = if (has_listing_date) {
      suppressWarnings(as.Date(.data$listing_date))
    } else {
      as.Date(NA)
    },
    industry     = industry_category   # NA stays NA; nothing is invented
  ) %>%
  arrange(stock_id)

4. Validation

# Rows removed at each stage
stage_summary <- tibble::tibble(
  stage = c("Rows downloaded",
            "type == 'twse' rows",
            "After removing missing id/name and duplicate stock_id",
            "Retained common stocks"),
  rows  = c(nrow(raw_info), nrow(twse_all), nrow(twse_dedup), nrow(final_data))
)
print(stage_summary)
## # A tibble: 4 × 2
##   stage                                                  rows
##   <chr>                                                 <int>
## 1 Rows downloaded                                        4340
## 2 type == 'twse' rows                                    2404
## 3 After removing missing id/name and duplicate stock_id  1628
## 4 Retained common stocks                                 1216
cat("\nExclusion reasons (after de-duplication):\n")
## 
## Exclusion reasons (after de-duplication):
print(count(classified, exclusion_reason, sort = TRUE))
## # A tibble: 3 × 2
##   exclusion_reason                             n
##   <chr>                                    <int>
## 1 kept                                      1216
## 2 stock_id not a 4-digit common-stock code   401
## 3 excluded industry category                  11
cat("\nIndustry categories among EXCLUDED rows (check these look right):\n")
## 
## Industry categories among EXCLUDED rows (check these look right):
print(classified %>%
        filter(exclusion_reason != "kept") %>%
        count(exclusion_reason, industry_category, sort = TRUE),
      n = 40)
## # A tibble: 14 × 3
##    exclusion_reason                         industry_category     n
##    <chr>                                    <chr>             <int>
##  1 stock_id not a 4-digit common-stock code ETF                 272
##  2 stock_id not a 4-digit common-stock code Index                30
##  3 stock_id not a 4-digit common-stock code ETN                  28
##  4 stock_id not a 4-digit common-stock code 存託憑證             25
##  5 stock_id not a 4-digit common-stock code 金融保險             24
##  6 excluded industry category               存託憑證             11
##  7 stock_id not a 4-digit common-stock code 受益證券              8
##  8 stock_id not a 4-digit common-stock code 其他                  6
##  9 stock_id not a 4-digit common-stock code 電子工業              3
## 10 stock_id not a 4-digit common-stock code 塑膠工業              1
## 11 stock_id not a 4-digit common-stock code 大盤                  1
## 12 stock_id not a 4-digit common-stock code 水泥工業              1
## 13 stock_id not a 4-digit common-stock code 汽車工業              1
## 14 stock_id not a 4-digit common-stock code 鋼鐵工業              1
cat("\nSample of excluded rows whose stock_id looks like 4 digits (possible false exclusions):\n")
## 
## Sample of excluded rows whose stock_id looks like 4 digits (possible false exclusions):
print(classified %>%
        filter(exclusion_reason != "kept", grepl("^[0-9]{4}$", stock_id)) %>%
        select(stock_id, stock_name, industry_category, exclusion_reason),
      n = 30)
## # A tibble: 25 × 4
##    stock_id stock_name   industry_category exclusion_reason                     
##    <chr>    <chr>        <chr>             <chr>                                
##  1 0050     元大台灣50   ETF               stock_id not a 4-digit common-stock …
##  2 0051     元大中型100  ETF               stock_id not a 4-digit common-stock …
##  3 0052     富邦科技     ETF               stock_id not a 4-digit common-stock …
##  4 0053     元大科技市值 ETF               stock_id not a 4-digit common-stock …
##  5 0054     元大台商50   ETF               stock_id not a 4-digit common-stock …
##  6 0055     元大MSCI金融 ETF               stock_id not a 4-digit common-stock …
##  7 0056     元大高股息   ETF               stock_id not a 4-digit common-stock …
##  8 0057     富邦摩台     ETF               stock_id not a 4-digit common-stock …
##  9 0058     富邦發達     ETF               stock_id not a 4-digit common-stock …
## 10 0059     富邦金融     ETF               stock_id not a 4-digit common-stock …
## 11 0060     新台灣       ETF               stock_id not a 4-digit common-stock …
## 12 0061     元大寶滬深   ETF               stock_id not a 4-digit common-stock …
## 13 0080     恒中國       ETF               stock_id not a 4-digit common-stock …
## 14 0081     恒香港       ETF               stock_id not a 4-digit common-stock …
## 15 9101     福雷電       存託憑證          excluded industry category           
## 16 9102     東亞科       存託憑證          excluded industry category           
## 17 9103     美德醫療-DR  存託憑證          excluded industry category           
## 18 9104     萬宇科       存託憑證          excluded industry category           
## 19 9105     泰金寶-DR    存託憑證          excluded industry category           
## 20 9106     新焦點-DR    存託憑證          excluded industry category           
## 21 9110     越南控-DR    存託憑證          excluded industry category           
## 22 9136     巨騰-DR      存託憑證          excluded industry category           
## 23 9151     旺旺         存託憑證          excluded industry category           
## 24 9157     陽光能源-DR  存託憑證          excluded industry category           
## 25 9188     精熙-DR      存託憑證          excluded industry category
cat("\nAll distinct industry categories among KEPT rows:\n")
## 
## All distinct industry categories among KEPT rows:
print(sort(unique(classified$industry_category[classified$exclusion_reason == "kept"])))
##  [1] "光電業"       "其他"         "其他電子業"   "創新板股票"   "化學工業"    
##  [6] "化學生技醫療" "半導體業"     "塑膠工業"     "居家生活"     "建材營造"    
## [11] "數位雲端"     "橡膠工業"     "水泥工業"     "汽車工業"     "油電燃氣業"  
## [16] "玻璃陶瓷"     "生技醫療業"   "紡織纖維"     "綠能環保"     "航運業"      
## [21] "觀光餐旅"     "貿易百貨"     "資訊服務業"   "通信網路業"   "造紙工業"    
## [26] "運動休閒"     "金融保險"     "鋼鐵工業"     "電器電纜"     "電子工業"    
## [31] "電子通路業"   "電子零組件業" "電機機械"     "食品工業"
# Stock IDs that appeared under more than one industry in the raw TWSE data
multi_industry_ids <- twse_all %>%
  filter(!is.na(stock_id)) %>%
  group_by(stock_id) %>%
  summarise(n_industries = n_distinct(industry_category, na.rm = TRUE),
            .groups = "drop") %>%
  filter(n_industries > 1)
cat("\nStock IDs with >1 industry category in the raw data:",
    nrow(multi_industry_ids),
    "(most recent 'date' row was kept for each)\n")
## 
## Stock IDs with >1 industry category in the raw data: 715 (most recent 'date' row was kept for each)
# Hard checks
stopifnot(
  "Final data must have exactly four columns in the required order" =
    identical(names(final_data), c("stock_id", "name", "listing_date", "industry")),
  "stock_id must be character" = is.character(final_data$stock_id),
  "Duplicate stock_id values found" = !anyDuplicated(final_data$stock_id),
  "Missing/empty stock_id found" = !any(is.na(final_data$stock_id) | final_data$stock_id == ""),
  "Missing/empty name found" = !any(is.na(final_data$name) | final_data$name == ""),
  "Non common-stock IDs remain" = all(grepl(common_id_regex, final_data$stock_id)),
  "Excluded industry categories remain" =
    !any(grepl(industry_exclude_regex, final_data$industry, ignore.case = TRUE)),
  "Excluded name patterns remain" =
    !any(grepl(name_exclude_regex, final_data$name, ignore.case = TRUE))
)

# Market-type check on the source rows that produced the final data
market_check <- twse_dedup %>%
  filter(stock_id %in% final_data$stock_id) %>%
  count(type)
print(market_check)
## # A tibble: 1 × 2
##   type      n
##   <chr> <int>
## 1 twse   1216
stopifnot(all(tolower(market_check$type) == "twse"))

cat("\nMissing values per column:\n")
## 
## Missing values per column:
print(colSums(is.na(final_data)))
##     stock_id         name listing_date     industry 
##            0            0         1216            0
cat("\nAll validation checks passed.\n")
## 
## All validation checks passed.

5. Summary and preview

n_retained        <- nrow(final_data)
n_missing_listing <- sum(is.na(final_data$listing_date))
n_missing_industry <- sum(is.na(final_data$industry))

cat("Total retained common stocks:", n_retained, "\n")
## Total retained common stocks: 1216
cat("Missing listing dates:", n_missing_listing,
    sprintf("(%.1f%%)\n", 100 * n_missing_listing / n_retained))
## Missing listing dates: 1216 (100.0%)
cat("Missing industries:", n_missing_industry, "\n")
## Missing industries: 0

First 10 rows:

head(final_data, 10)

6. Export to CSV (UTF-8)

output_file <- "finmind_twse_common_stocks.csv"

tryCatch(
  readr::write_csv(final_data, output_file, na = "NA"),  # readr always writes UTF-8
  error = function(e) {
    stop("Could not write ", output_file, ": ", conditionMessage(e), call. = FALSE)
  }
)

output_path <- normalizePath(output_file, mustWork = FALSE)
cat(sprintf("**Output file:** `%s`  \n", output_path))

Output file: C:\Users\deegi\Downloads\finmind_twse_common_stocks.csv

cat(sprintf("**Rows written:** %d\n\n", nrow(final_data)))

Rows written: 1216

cat("**Limitations:**\n\n")

Limitations:

cat("- `listing_date` is `NA` unless the API returns an explicit `listing_date` column. ",
    "The `date` field in `TaiwanStockInfo` is a dataset date and was not used.\n", sep = "")
  • listing_date is NA unless the API returns an explicit listing_date column. The date field in TaiwanStockInfo is a dataset date and was not used.
cat("- `TaiwanStockInfo` has no security-type field, so common stocks are identified by a ",
    "stock-code convention (four digits, 1-9 first) plus industry and name keyword exclusions. ",
    "This is a defensible heuristic, not a guaranteed classification.\n", sep = "")
  • TaiwanStockInfo has no security-type field, so common stocks are identified by a stock-code convention (four digits, 1-9 first) plus industry and name keyword exclusions. This is a defensible heuristic, not a guaranteed classification.
cat("- Some stock IDs may appear under several industry categories in the source; ",
    "the most recent row was kept.\n", sep = "")
  • Some stock IDs may appear under several industry categories in the source; the most recent row was kept.
cat("- Results reflect FinMind's data at the time of the run and may change.\n")
  • Results reflect FinMind’s data at the time of the run and may change.

Method notes and limitations

Common-stock filter. FinMind’s TaiwanStockInfo has no field that states whether a security is a common share, preferred share, ETF, etc. The filter therefore uses (1) the Taiwan stock-code convention, where ordinary listed companies have four-digit codes and other instruments use longer codes or letter suffixes, (2) keyword exclusion on industry_category, and (3) a narrow name check (ETF, ETN, -DR). Ambiguous Chinese characters are not used for filtering, so ordinary companies are not removed by accident. Edge cases (unusual codes, newly created instrument classes) could still be misclassified.

Listing dates. No reliable listing-date field was assumed to exist in FinMind. The script checks the returned columns at run time and sets listing_date to NA otherwise. First-trade dates derived from price data were intentionally not substituted because they reflect FinMind’s coverage start, not the real listing date.

Industry. Taken directly from industry_category; missing or empty values stay NA.

Reproducibility. This document does not store any API result. Row counts and the CSV are produced only when you knit it.