1 Title and Assignment Objective

Objective. Build two independent, cleaned datasets of listed common stocks in Taiwan, one from the Taiwan Stock Exchange (TWSE) ISIN lookup page and one from the FinMind API. Each dataset has four columns: stock_id, name, listing_date, industry. ETFs, funds, preferred stocks and other non-common-stock securities are excluded. The two sources are then compared and exported to CSV.

2 Introduction

Different data providers classify securities differently and update at different times. This report therefore (1) inspects each source before filtering, (2) records why every record is kept, excluded or flagged, and (3) never invents values: if a field cannot be verified (for example FinMind’s listing date), it stays NA.

3 Data Source Descriptions

Source Location What it provides
TWSE ISIN lookup https://isin.twse.com.tw/isin/e_C_public.jsp?strMode=2 HTML table of listed securities: code + name, ISIN, listing date, market, industry, CFI code, remarks. strMode=2 = listed (上市) securities.
FinMind TaiwanStockInfo https://api.finmindtrade.com/api/v4/data JSON: industry_category, stock_id, stock_name, type (twse / tpex / …), date.

Important: FinMind’s date column is the date the record was updated, not the official listing date. It is not used as listing_date.

4 Required R Packages

Run once in the Console (not inside the .Rmd):

install.packages(c("rvest", "httr", "jsonlite", "dplyr", "stringr", "readr", "knitr", "tibble"))

Common error: “package ‘x’ is not available” means a typo or an old R version; update R and retry.

Loaded inside the .Rmd:

library(rvest)
library(httr)
library(jsonlite)
library(dplyr)
library(stringr)
library(readr)
library(knitr)
library(tibble)

5 Reusable Helper Functions

What: functions shared by both sources. Why: identical cleaning and checks make the comparison fair. Where to run: inside the .Rmd. Expected output: none (they only define functions).

final_cols <- c("stock_id", "name", "listing_date", "industry")

# A common stock on TWSE has a 4-digit code that does not start with 0
# (ETFs such as 0050 start with 0; preferred shares carry a letter suffix, e.g. 2881A).
valid_common_id <- function(x) str_detect(x, "^[1-9][0-9]{3}$")

# Standardise to the four required columns, types and order.
standardize_stock_table <- function(df) {
  df |>
    transmute(
      stock_id     = str_squish(as.character(stock_id)),
      name         = str_squish(as.character(name)),
      listing_date = as.Date(listing_date),
      industry     = na_if(str_squish(as.character(industry)), "")
    ) |>
    select(all_of(final_cols))
}

# Validation checks returned as a table.
validate_stock_table <- function(df, source_name) {
  tibble(
    source = source_name,
    check = c("Columns correct and in order",
              "All stock_id are valid 4-digit common codes",
              "No empty names",
              "listing_date is Date class (NA allowed)",
              "Missing listing_date (count)",
              "Missing industry (count)",
              "Duplicate stock_id (count)"),
    result = c(identical(names(df), final_cols),
               all(valid_common_id(df$stock_id)),
               all(!is.na(df$name) & df$name != ""),
               inherits(df$listing_date, "Date"),
               sum(is.na(df$listing_date)),
               sum(is.na(df$industry)),
               sum(duplicated(df$stock_id)))
  )
}

6 TWSE Data Collection

6.1 Download the page

What: sends an HTTP GET request and reads the HTML. Why: we need the raw page before parsing. Expected output: HTTP status 200 and a few lines of readable text. Common errors: timeout (retry, check internet); garbled Chinese (change twse_encoding to "UTF-8" and re-run). Where: inside the .Rmd.

twse_url      <- "https://isin.twse.com.tw/isin/e_C_public.jsp?strMode=2"
twse_encoding <- "Big5"                      # use "UTF-8" if text looks garbled
twse_cache    <- "twse_isin_strMode2.html"   # saved in your project folder

if (!file.exists(twse_cache)) {
  twse_resp <- tryCatch(
    RETRY("GET", twse_url,
          user_agent("Mozilla/5.0 (university assignment)"),
          timeout(300),
          write_disk(twse_cache, overwrite = TRUE),
          times = 3, pause_min = 5, terminate_on = c(403, 404)),
    error = function(e) {
      unlink(twse_cache)
      stop("TWSE download failed: ", conditionMessage(e))
    }
  )
  stop_for_status(twse_resp)
  cat("HTTP status:", status_code(twse_resp), "\n")
} else {
  cat("Using cached file:", twse_cache, "\n")
}
## Using cached file: twse_isin_strMode2.html
twse_page <- read_html(twse_cache, encoding = twse_encoding)
cat("Tables found:", length(html_elements(twse_page, "table")), "\n")
## Tables found: 2

6.2 Inspect the structure (do not assume)

What: shows the first rows of every <tr>. Why: the page has section-heading rows (for example “Stock”, “ETF”) mixed with data rows, and html_table() can misalign them. We must see this before parsing.

all_rows <- html_elements(twse_page, "tr")
row_cells <- lapply(all_rows, function(r) str_squish(html_text(html_elements(r, "td, th"))))
cat("Total <tr> rows:", length(row_cells), "\n")
## Total <tr> rows: 38321
cat("Cells per row (distribution):\n")
## Cells per row (distribution):
print(table(lengths(row_cells)))
## 
##     1     7 
##     8 38313
head(Filter(function(x) length(x) > 0, row_cells), 6)
## [[1]]
## [1] "Security Code & Security Name" "ISIN Code"                    
## [3] "Date Listed"                   "Market"                       
## [5] "Industrial Group"              "CFICode"                      
## [7] "Remarks"                      
## 
## [[2]]
## [1] "Stocks"
## 
## [[3]]
## [1] "1101 TCC"     "TW0001101004" "1962/02/09"   "TWSE LISTED"  "Cement"      
## [6] "ESVUFR"       ""            
## 
## [[4]]
## [1] "1102 ACC"     "TW0001102002" "1962/06/08"   "TWSE LISTED"  "Cement"      
## [6] "ESVUFR"       ""            
## 
## [[5]]
## [1] "1103 CHC"     "TW0001103000" "1969/11/14"   "TWSE LISTED"  "Cement"      
## [6] "ESVUFR"       ""            
## 
## [[6]]
## [1] "1104 UCC"     "TW0001104008" "1971/02/01"   "TWSE LISTED"  "Cement"      
## [6] "ESVUFR"       ""

6.3 Parse rows into a table

What: walks through rows. A one-cell row is a section heading (saved as section); the row containing “ISIN” is the header; other full-width rows are data. Why: the section heading is our evidence for the security type. Expected output: a table with the original column names plus section.

current_section <- NA_character_
header <- NULL
data_rows <- list()
data_sections <- character()

for (cells in row_cells) {
  n <- length(cells)
  if (n == 0) next
  if (n == 1) { current_section <- cells[1]; next }
  if (is.null(header)) {
    if (any(str_detect(cells, regex("ISIN", ignore_case = TRUE)))) header <- cells
    next
  }
  if (n == length(header)) {
    data_rows[[length(data_rows) + 1]] <- cells
    data_sections <- c(data_sections, current_section)
  }
}

stopifnot(!is.null(header), length(data_rows) > 0)
twse_raw <- as.data.frame(do.call(rbind, data_rows), stringsAsFactors = FALSE)
names(twse_raw) <- header
twse_raw$section <- data_sections
twse_raw <- as_tibble(twse_raw)

cat("Columns found:\n"); print(names(twse_raw))
## Columns found:
## [1] "Security Code & Security Name" "ISIN Code"                    
## [3] "Date Listed"                   "Market"                       
## [5] "Industrial Group"              "CFICode"                      
## [7] "Remarks"                       "section"
cat("\nRows per section heading:\n"); print(count(twse_raw, section, sort = TRUE))
## 
## Rows per section heading:
## # A tibble: 7 × 2
##   section                                 n
##   <chr>                               <int>
## 1 Listing Warrants                    36927
## 2 Stocks                               1085
## 3 ETF                                   242
## 4 Preferred Stocks                       27
## 5 ETN                                    15
## 6 TDR                                    10
## 7 Real Estate Investment Trust (REIT)     6
head(twse_raw)

7 TWSE Data Cleaning and Filtering

7.1 Identify columns by name pattern (not by position)

Common error: “Expected exactly one column matching …”. Look at the printed column names above and adjust the pattern.

cat("Column names found on the page:\n")
## Column names found on the page:
print(names(twse_raw))
## [1] "Security Code & Security Name" "ISIN Code"                    
## [3] "Date Listed"                   "Market"                       
## [5] "Industrial Group"              "CFICode"                      
## [7] "Remarks"                       "section"
share_match <- function(x, pattern) {
  x <- str_squish(x); x <- x[!is.na(x) & x != ""]
  if (length(x) == 0) return(0)
  mean(str_detect(x, pattern))
}
find_col_by_content <- function(df, pattern, label) {
  shares <- sapply(df, share_match, pattern = pattern)
  best <- names(shares)[which.max(shares)]
  if (max(shares) < 0.5) stop("Could not find the ", label, " column by content.")
  best
}
col_codename <- find_col_by_content(twse_raw, "^[0-9A-Z]{4,6}\\s+\\S", "code and name")
col_listing  <- find_col_by_content(twse_raw, "^[0-9]{4}/[0-9]{2}/[0-9]{2}$", "listing date")
col_cfi      <- find_col_by_content(twse_raw, "^[A-Z]{6}$", "CFI code")

find_col_by_name <- function(df, pattern, label) {
  hits <- names(df)[str_detect(names(df), regex(pattern, ignore_case = TRUE))]
  if (length(hits) != 1) stop("Cannot identify the ", label,
                              " column by name. Names are: ", paste(names(df), collapse = " | "))
  hits
}
col_industry <- find_col_by_name(twse_raw, "industr|產業|sector", "industry")
col_market   <- find_col_by_name(twse_raw, "market|市場", "market")

data.frame(role = c("code+name", "listing date", "CFI", "industry", "market"),
           column = c(col_codename, col_listing, col_cfi, col_industry, col_market))

7.2 Split code and name, convert dates

What: code and name sit in one cell separated by a space (often a full-width space). We split at the first space. Dates like 1962/02/09 become real Date values; failed conversions become NA (not guessed).

twse_parsed <- twse_raw |>
  mutate(
    code_name_parts = str_match(.data[[col_codename]], "^(\\S+)\\s+(.*)$"),
    stock_id     = code_name_parts[, 2],
    name         = code_name_parts[, 3],
    listing_date = as.Date(str_replace_all(.data[[col_listing]], "/", "-"), format = "%Y-%m-%d"),
    industry     = .data[[col_industry]],
    cfi_code     = str_squish(.data[[col_cfi]]),
    market       = .data[[col_market]]
  ) |>
  select(stock_id, name, listing_date, industry, cfi_code, market, section)

cat("Rows where code/name could not be split:", sum(is.na(twse_parsed$stock_id)), "\n")
## Rows where code/name could not be split: 0
head(twse_parsed)

7.3 Classify each record with evidence

Three independent pieces of evidence from the TWSE page:

  1. Section heading on the page (stock vs ETF, warrants, TDR, etc.).
  2. CFI code (ISO 10962). ES... = equity, common/ordinary shares; EP... = preferred; other prefixes are funds, depositary receipts, etc.
  3. Code format: 4 digits not starting with 0.

A record is common only if all three agree. If the section says stock but the CFI code is missing, it is ambiguous and is not silently kept: it is exported for manual review.

stock_section_pattern <- regex("^stock|^equit|股票", ignore_case = TRUE)

twse_classified <- twse_parsed |>
  mutate(
    is_stock_section = str_detect(coalesce(section, ""), stock_section_pattern) &
                       !str_detect(coalesce(section, ""), regex("preferred|特別股|warrant|認購|認售|depositary|TDR|ETF|fund|beneficiary|受益", ignore_case = TRUE)),
    cfi_common    = str_detect(coalesce(cfi_code, ""), "^ES"),
    cfi_missing   = is.na(cfi_code) | cfi_code == "",
    id_ok         = valid_common_id(coalesce(stock_id, "")),
    classification = case_when(
      is.na(stock_id)                                   ~ "excluded: unparseable code/name",
      is_stock_section & cfi_common & id_ok             ~ "common",
      is_stock_section & cfi_missing & id_ok            ~ "ambiguous",
      str_detect(coalesce(cfi_code, ""), "^EP")         ~ "excluded: preferred stock (CFI EP)",
      str_detect(stock_id, "^[0-9]{4}[A-Z]$")           ~ "excluded: preferred stock (ID suffix)",
      !is_stock_section                                 ~ "excluded: non-stock section (ETF/fund/warrant/TDR etc.)",
      !id_ok                                            ~ "excluded: not a common-stock code format",
      TRUE                                              ~ "ambiguous"
    )
  )

kable(count(twse_classified, classification, sort = TRUE), caption = "TWSE classification results")
TWSE classification results
classification n
excluded: non-stock section (ETF/fund/warrant/TDR etc.) 37200
common 1085
excluded: preferred stock (CFI EP) 26
excluded: preferred stock (ID suffix) 1

7.4 Build the cleaned TWSE dataset

twse_before <- nrow(twse_parsed)

twse_candidates <- twse_classified |> filter(classification == "common")
twse_ambiguous  <- twse_classified |> filter(classification == "ambiguous")
twse_excluded_n <- sum(str_starts(twse_classified$classification, "excluded"))

twse_dups_n <- sum(duplicated(twse_candidates$stock_id))
twse_clean <- twse_candidates |>
  standardize_stock_table() |>
  distinct(stock_id, .keep_all = TRUE) |>
  arrange(stock_id)

cat("Parsed rows:", twse_before, "| Excluded by type:", twse_excluded_n,
    "| Ambiguous (manual review):", nrow(twse_ambiguous),
    "| Duplicates removed:", twse_dups_n, "| Final:", nrow(twse_clean), "\n")
## Parsed rows: 38312 | Excluded by type: 37227 | Ambiguous (manual review): 0 | Duplicates removed: 0 | Final: 1085
head(twse_clean, 10)

Decision on missing values: records with a missing industry or listing date are kept (flagged in the validation table), because a missing attribute does not change whether the security is a common stock.

8 FinMind API Data Collection

What: requests the TaiwanStockInfo dataset. Why: it is FinMind’s security master list. Optional: a free FinMind token raises the rate limit. Set it once in the Console with Sys.setenv(FINMIND_TOKEN = "your_token"); the code works without it. Common errors: HTTP 402 = rate limit reached (add a token or wait); status not 200 in the JSON = API error message is printed.

fm_token <- Sys.getenv("FINMIND_TOKEN")
fm_query <- list(dataset = "TaiwanStockInfo")
if (nzchar(fm_token)) fm_query$token <- fm_token

fm_resp <- GET("https://api.finmindtrade.com/api/v4/data", query = fm_query, timeout(60))
cat("HTTP status:", status_code(fm_resp), "\n")
## HTTP status: 200
if (http_error(fm_resp)) stop("FinMind request failed: HTTP ", status_code(fm_resp))

fm_json <- fromJSON(content(fm_resp, as = "text", encoding = "UTF-8"), flatten = TRUE)
if (!identical(as.integer(fm_json$status), 200L)) stop("FinMind API error: ", fm_json$msg)

fm_raw <- as_tibble(fm_json$data)
cat("Rows:", nrow(fm_raw), "\nColumns:", paste(names(fm_raw), collapse = ", "), "\n")
## Rows: 4333 
## Columns: industry_category, stock_id, stock_name, type, date
head(fm_raw)

8.1 Inspect the classifications

Why: we decide the filter from the actual values of type and industry_category.

kable(count(fm_raw, type, sort = TRUE), caption = "FinMind: values of `type`")
FinMind: values of type
type n
twse 2402
tpex 1378
emerging 553
kable(count(fm_raw, industry_category, sort = TRUE), caption = "FinMind: values of `industry_category`")
FinMind: values of industry_category
industry_category n
電子工業 545
生技醫療業 303
半導體業 294
ETF 271
電子零組件業 262
其他 194
光電業 178
電腦及週邊設備業 148
電機機械 141
上櫃ETF 125
上櫃指數股票型基金(ETF) 125
通信網路業 119
化學生技醫療 105
建材營造 93
其他電子業 81
觀光餐旅 76
資訊服務業 72
綠能環保 71
金融保險 70
紡織纖維 62
其他電子類 61
化學工業 60
鋼鐵工業 56
電子通路業 51
食品工業 49
汽車工業 47
觀光事業 45
航運業 40
文化創意業 39
貿易百貨 39
數位雲端 38
創新板股票 36
存託憑證 36
所有證券 36
Index 30
塑膠工業 29
ETN 28
數位雲端類 27
居家生活類 25
運動休閒 23
電器電纜 23
創新版股票 21
指數投資證券(ETN) 20
綠能環保類 20
居家生活 18
金融業 16
油電燃氣業 13
橡膠工業 12
運動休閒類 10
電子商務業 10
受益證券 8
水泥工業 8
造紙工業 8
玻璃陶瓷 5
農業科技 5
農業科技業 4
大盤 2

Note on listing_date: TaiwanStockInfo has a date column, but it is a data update date, so it is not used. listing_date is set to NA for FinMind. See the optional supplement in the Comparison section.

9 FinMind Data Cleaning and Filtering

non_common_industry <- regex("ETF|ETN|Index|大盤|基金|存託憑證|受益|Depositary|Beneficiary|Warrant|權證",
                             ignore_case = TRUE)

fm_before <- nrow(fm_raw)

fm_classified <- fm_raw |>
  mutate(
    stock_id = str_squish(stock_id),
    name     = str_squish(stock_name),
    industry = na_if(str_squish(industry_category), ""),
    classification = case_when(
      type != "twse"                                       ~ "excluded: not TWSE-listed (e.g. TPEx/emerging)",
      str_detect(stock_id, "^[0-9]{4}[A-Z]$")              ~ "excluded: preferred stock (ID suffix)",
      str_detect(coalesce(industry, ""), non_common_industry) ~ "excluded: ETF/fund/index/DR/other (industry label)",
      !valid_common_id(stock_id)                           ~ "excluded: not a common-stock code format",
      is.na(industry)                                      ~ "ambiguous",
      TRUE                                                 ~ "common"
    )
  )

kable(count(fm_classified, classification, sort = TRUE), caption = "FinMind classification results")
FinMind classification results
classification n
common 1988
excluded: not TWSE-listed (e.g. TPEx/emerging) 1931
excluded: ETF/fund/index/DR/other (industry label) 374
excluded: preferred stock (ID suffix) 39
excluded: not a common-stock code format 1
fm_ambiguous  <- fm_classified |> filter(classification == "ambiguous")
fm_excluded_n <- sum(str_starts(fm_classified$classification, "excluded"))
fm_candidates <- fm_classified |> filter(classification == "common")
fm_dups_n     <- sum(duplicated(fm_candidates$stock_id))

finmind_clean <- fm_candidates |>
  mutate(listing_date = as.Date(NA)) |>      # not provided by TaiwanStockInfo; left as NA on purpose
  standardize_stock_table() |>
  distinct(stock_id, .keep_all = TRUE) |>
  arrange(stock_id)

cat("Raw rows:", fm_before, "| Excluded by type:", fm_excluded_n,
    "| Ambiguous:", nrow(fm_ambiguous), "| Duplicates removed:", fm_dups_n,
    "| Final:", nrow(finmind_clean), "\n")
## Raw rows: 4333 | Excluded by type: 2345 | Ambiguous: 0 | Duplicates removed: 773 | Final: 1215
head(finmind_clean, 10)

Why two filters (type and industry label)? FinMind mixes TWSE, TPEx, emerging-board securities, ETFs and index series in one table. Any record that passes the ID format but has no industry is flagged ambiguous rather than assumed to be a common stock.

10 Data Validation

validation_table <- bind_rows(
  validate_stock_table(twse_clean, "TWSE"),
  validate_stock_table(finmind_clean, "FinMind")
) |> mutate(result = as.character(result))
kable(validation_table)
source check result
TWSE Columns correct and in order 1
TWSE All stock_id are valid 4-digit common codes 1
TWSE No empty names 1
TWSE listing_date is Date class (NA allowed) 1
TWSE Missing listing_date (count) 0
TWSE Missing industry (count) 0
TWSE Duplicate stock_id (count) 0
FinMind Columns correct and in order 1
FinMind All stock_id are valid 4-digit common codes 1
FinMind No empty names 1
FinMind listing_date is Date class (NA allowed) 1
FinMind Missing listing_date (count) 1215
FinMind Missing industry (count) 0
FinMind Duplicate stock_id (count) 0
# Confirm no excluded security types remain
cat("TWSE: any ID starting with 0 or containing letters? ",
    any(!valid_common_id(twse_clean$stock_id)), "\n")
## TWSE: any ID starting with 0 or containing letters?  FALSE
cat("FinMind: any ID starting with 0 or containing letters? ",
    any(!valid_common_id(finmind_clean$stock_id)), "\n")
## FinMind: any ID starting with 0 or containing letters?  FALSE
# Duplicate investigation (should be zero rows)
twse_clean |> count(stock_id) |> filter(n > 1)
finmind_clean |> count(stock_id) |> filter(n > 1)

11 Comparison of the Two Datasets

only_twse    <- anti_join(twse_clean, finmind_clean, by = "stock_id")
only_finmind <- anti_join(finmind_clean, twse_clean, by = "stock_id")
in_both      <- inner_join(twse_clean, finmind_clean, by = "stock_id", suffix = c("_twse", "_finmind"))

comparison <- tibble(
  metric = c("Records before cleaning (parsed/raw)",
             "Records excluded by security type",
             "Ambiguous records withheld for manual review",
             "Duplicate records removed",
             "Records after cleaning",
             "Missing listing_date",
             "Missing industry"),
  TWSE    = c(twse_before, twse_excluded_n, nrow(twse_ambiguous), twse_dups_n,
              nrow(twse_clean), sum(is.na(twse_clean$listing_date)), sum(is.na(twse_clean$industry))),
  FinMind = c(fm_before, fm_excluded_n, nrow(fm_ambiguous), fm_dups_n,
              nrow(finmind_clean), sum(is.na(finmind_clean$listing_date)), sum(is.na(finmind_clean$industry)))
) |> mutate(across(c(TWSE, FinMind), as.character))

overlap <- tibble(
  metric  = c("Stock IDs in both datasets", "Stock IDs only in TWSE", "Stock IDs only in FinMind"),
  TWSE    = as.character(c(nrow(in_both), nrow(only_twse), NA)),
  FinMind = as.character(c(nrow(in_both), NA, nrow(only_finmind)))
)

comparison_all <- bind_rows(comparison, overlap)
kable(comparison_all, caption = "TWSE vs FinMind comparison")
TWSE vs FinMind comparison
metric TWSE FinMind
Records before cleaning (parsed/raw) 38312 4333
Records excluded by security type 37227 2345
Ambiguous records withheld for manual review 0 0
Duplicate records removed 0 773
Records after cleaning 1085 1215
Missing listing_date 0 1215
Missing industry 0 0
Stock IDs in both datasets 1085 1085
Stock IDs only in TWSE 0 NA
Stock IDs only in FinMind NA 130
cat("IDs only in TWSE (first 30):\n");    print(head(only_twse$stock_id, 30))
## IDs only in TWSE (first 30):
## character(0)
cat("IDs only in FinMind (first 30):\n"); print(head(only_finmind$stock_id, 30))
## IDs only in FinMind (first 30):
##  [1] "1107" "1230" "1262" "1311" "1469" "1507" "1520" "1523" "1592" "1601"
## [11] "1606" "1613" "1701" "1704" "1715" "1716" "1724" "1729" "1902" "2311"
## [21] "2315" "2325" "2336" "2341" "2350" "2358" "2361" "2381" "2384" "2391"

11.1 Optional supplement for FinMind listing dates (derived, clearly labelled)

FinMind does not supply listing dates. For stock IDs that appear in both datasets, the TWSE official date can be attached in a separate column so the origin is explicit. The main FinMind file keeps NA.

finmind_supplemented <- finmind_clean |>
  left_join(select(twse_clean, stock_id, listing_date_twse = listing_date), by = "stock_id") |>
  mutate(listing_date_source = if_else(is.na(listing_date_twse), NA_character_, "TWSE ISIN (supplementary)"))
cat("FinMind IDs that can be supplemented from TWSE:", sum(!is.na(finmind_supplemented$listing_date_twse)), "\n")
## FinMind IDs that can be supplemented from TWSE: 1085

12 Results and Discussion

Use the tables above to write your own interpretation. Points to discuss:

  • Coverage. The sources need not match. TWSE ISIN is the exchange’s official register; FinMind is a third-party aggregation that also carries TPEx and other series.
  • Classification. TWSE uses section headings and CFI codes; FinMind uses a free-text industry_category. Industry names may differ in language and granularity, so only stock IDs are compared.
  • Timing. The two sources update on different schedules, so recent listings or delistings may appear in only one of them.
  • Missing values. FinMind has no official listing date, so it is NA by design.

13 Limitations

  • Filtering rules rely on page section headings, CFI codes, ID format and FinMind labels; if a source changes its structure, the code stops with an error or flags records as ambiguous.
  • Ambiguous records are withheld, not guessed, and must be verified manually.
  • The data are a snapshot taken on the knit date.
  • FinMind may require a token if the rate limit is reached.

14 Conclusion

Two independent datasets of listed common stocks were produced with the same four columns, validated with the same checks, compared with dplyr joins, and exported. Every inclusion and exclusion is backed by evidence from the source, and missing information is preserved as NA rather than invented.

15 CSV Export

cat("Working directory:", getwd(), "\n")
## Working directory: /Users/kvn.andretty/Downloads
write_csv(twse_clean,     "twse_listed_common_stocks.csv",     na = "")
write_csv(finmind_clean,  "finmind_listed_common_stocks.csv",  na = "")
write_csv(comparison_all, "twse_finmind_comparison.csv",       na = "")

# Extra files for transparency
write_csv(twse_ambiguous |> select(stock_id, name, listing_date, industry, cfi_code, section),
          "twse_ambiguous_for_review.csv", na = "")
write_csv(select(fm_ambiguous, stock_id, name, industry, type),
          "finmind_ambiguous_for_review.csv", na = "")
write_csv(finmind_supplemented, "finmind_with_twse_listing_date_supplement.csv", na = "")

out_files <- c("twse_listed_common_stocks.csv", "finmind_listed_common_stocks.csv",
               "twse_finmind_comparison.csv", "twse_ambiguous_for_review.csv",
               "finmind_ambiguous_for_review.csv", "finmind_with_twse_listing_date_supplement.csv")
kable(tibble(file = out_files, exists = file.exists(out_files),
             size_kb = round(file.size(out_files) / 1024, 1)))
file exists size_kb
twse_listed_common_stocks.csv TRUE 48.2
finmind_listed_common_stocks.csv TRUE 33.1
twse_finmind_comparison.csv TRUE 0.4
twse_ambiguous_for_review.csv TRUE 0.1
finmind_ambiguous_for_review.csv TRUE 0.0
finmind_with_twse_listing_date_supplement.csv TRUE 72.6

write_csv() always writes UTF-8. If Excel shows garbled Chinese, import the file as UTF-8 (Data > From Text/CSV), or replace write_csv with write_excel_csv, which adds a UTF-8 byte-order mark.