# install.packages(c("tidyverse", "rvest", "httr", "jsonlite")) # run once if missing
library(tidyverse)
library(rvest)
library(httr)
library(jsonlite)
url_isin <- "https://isin.twse.com.tw/isin/C_public.jsp?strMode=2"
res <- GET(url_isin, config(ssl_verifypeer = FALSE))
# convert the raw bytes to UTF-8 ourselves; sub = "?" stops rare characters from causing an error
html_txt <- iconv(rawToChar(content(res, as = "raw")),
from = "BIG5-HKSCS", to = "UTF-8", sub = "?")
page <- read_html(html_txt, encoding = "UTF-8")
tbls <- page |> html_elements("table") |> html_table(header = FALSE)
isin_raw <- tbls[[which.max(map_int(tbls, nrow))]]
names(isin_raw) <- c("code_name", "isin_code", "listing_date",
"market", "industry", "cfi_code", "note")
head(isin_raw)
## # A tibble: 6 × 7
## code_name isin_code listing_date market industry cfi_code note
## <chr> <chr> <chr> <chr> <chr> <chr> <chr>
## 1 有價證券代號及名稱 國際證券辨識號碼(ISIN … 上市日 市場別 產業別 CFICode "備註"…
## 2 股票 股票 股票 股票 股票 股票 "股票"…
## 3 1101 台泥 TW0001101004 1962/02/09 上市 水泥工業 ESVUFR ""
## 4 1102 亞泥 TW0001102002 1962/06/08 上市 水泥工業 ESVUFR ""
## 5 1103 嘉泥 TW0001103000 1969/11/14 上市 水泥工業 ESVUFR ""
## 6 1104 環泥 TW0001104008 1971/02/01 上市 水泥工業 ESVUFR ""
The page mixes several security types (stocks, ETFs, warrants, preferred shares, etc.), each under its own title row. I tag every row with its section first.
isin_tagged <- isin_raw |>
slice(-1) |> # drop the column-title row
mutate(section = if_else(code_name == isin_code, code_name, NA_character_)) |>
fill(section, .direction = "down") |> # copy the title down to its rows
filter(code_name != isin_code) # remove the title rows
count(isin_tagged, section)
## # A tibble: 8 × 2
## section n
## <chr> <int>
## 1 ETF 241
## 2 ETN 15
## 3 上市認購(售)權證 36927
## 4 創新板 31
## 5 受益證券-不動產投資信託 6
## 6 特別股 27
## 7 股票 1054
## 8 臺灣存託憑證(TDR) 10
To keep common stocks only, I keep rows whose CFI code starts with “ES” (equity shares) and whose code is exactly 4 digits. This removes ETFs, ETNs, warrants, TDRs and preferred shares (preferred shares have letters in their code, like 2882A). The 31 Innovation Board stocks are also on the strMode=2 page and are common stocks, so they stay in.
twse_isin <- isin_tagged |>
separate(code_name, into = c("stock_id", "stock_name"),
sep = "\u3000", extra = "merge", fill = "right") |>
filter(str_starts(cfi_code, "ES"),
str_detect(stock_id, "^[0-9]{4}$")) |>
transmute(stock_id,
stock_name = str_squish(stock_name),
listing_date = ymd(listing_date),
industry)
glimpse(twse_isin)
## Rows: 1,085
## Columns: 4
## $ stock_id <chr> "1101", "1102", "1103", "1104", "1108", "1109", "1110", "…
## $ stock_name <chr> "台泥", "亞泥", "嘉泥", "環泥", "幸福", "信大", "東泥", "味全", "味王", "大成…
## $ listing_date <date> 1962-02-09, 1962-06-08, 1969-11-14, 1971-02-01, 1990-06-…
## $ industry <chr> "水泥工業", "水泥工業", "水泥工業", "水泥工業", "水泥工業", "水泥工業", "水泥工業", "…
nrow(twse_isin)
## [1] 1085
write_excel_csv(twse_isin, "twse_isin_listed.csv")
FinMind’s TaiwanStockInfo has no real listing
date. Its date column is just the last update date
of the record, so I call it snapshot_date. Only the ISIN
list has true listing dates.
fm_url <- "https://api.finmindtrade.com/api/v4/data?dataset=TaiwanStockInfo"
# if the API asks for a token: fm_url <- paste0(fm_url, "&token=YOUR_TOKEN")
fm_raw <- fromJSON(fm_url)$data |> as_tibble()
glimpse(fm_raw)
## Rows: 4,333
## Columns: 5
## $ industry_category <chr> "電子零組件業", "電腦及週邊設備業", "光電業", "文化創意業", "其他電子類", "上櫃指數…
## $ stock_id <chr> "5481", "5450", "3629", "3687", "6238", "00835B", "0…
## $ stock_name <chr> "新華", "寶聯通", "地心引力", "歐買尬", "勝麗", "第一金科技債10+", "永豐15…
## $ type <chr> "tpex", "tpex", "tpex", "tpex", "tpex", "tpex", "tpe…
## $ date <chr> "2020-06-03", "2020-06-03", "2020-06-03", "2020-06-0…
count(fm_raw, type)
## # A tibble: 3 × 2
## type n
## <chr> <int>
## 1 emerging 553
## 2 tpex 1378
## 3 twse 2402
fm_stocks <- fm_raw |>
filter(type == "twse",
str_detect(stock_id, "^[0-9]{4}$"),
!str_detect(industry_category,
"ETF|ETN|Index|大盤|存託憑證|受益證券")) |>
arrange(stock_id, industry_category == "電子工業", desc(date)) |>
distinct(stock_id, .keep_all = TRUE) |>
select(stock_id, stock_name, industry = industry_category, snapshot_date = date)
glimpse(fm_stocks)
## Rows: 1,215
## Columns: 4
## $ stock_id <chr> "1101", "1102", "1103", "1104", "1107", "1108", "1109", …
## $ stock_name <chr> "台泥", "亞泥", "嘉泥", "環泥", "建台", "幸福", "信大", "東泥", "味全", "味…
## $ industry <chr> "水泥工業", "水泥工業", "水泥工業", "水泥工業", "其他", "水泥工業", "水泥工業", "水…
## $ snapshot_date <chr> "2026-10-12", "2026-10-12", "2026-10-12", "2026-10-12", …
nrow(fm_stocks)
## [1] 1215
count(fm_stocks, snapshot_date, sort = TRUE) |> head(10) # how old are the records?
## # A tibble: 10 × 2
## snapshot_date n
## <chr> <int>
## 1 2026-10-12 1083
## 2 2024-12-04 65
## 3 2026-06-13 11
## 4 2026-07-06 7
## 5 2024-12-03 6
## 6 2026-06-30 6
## 7 2026-07-03 5
## 8 2026-07-01 4
## 9 2026-07-07 3
## 10 2026-07-08 3
FinMind keeps old records of stocks that are no longer listed (their snapshot dates are old). To remove them without using the ISIN list, I keep only records from the latest snapshot date.
fm_stocks <- fm_stocks |>
filter(snapshot_date == max(snapshot_date))
nrow(fm_stocks)
## [1] 1083
write_excel_csv(fm_stocks, "finmind_listed.csv")
only_isin <- anti_join(twse_isin, fm_stocks, by = "stock_id") # in ISIN but not in FinMind
only_fm <- anti_join(fm_stocks, twse_isin, by = "stock_id") # in FinMind but not in ISIN
nrow(only_isin)
## [1] 2
nrow(only_fm)
## [1] 0
only_isin |> arrange(desc(listing_date))
## # A tibble: 2 × 4
## stock_id stock_name listing_date industry
## <chr> <chr> <date> <chr>
## 1 4155 訊映 2017-12-05 生技醫療業
## 2 1589 永冠-KY 2012-04-27 電機機械
only_fm
## # A tibble: 0 × 4
## # ℹ 4 variables: stock_id <chr>, stock_name <chr>, industry <chr>,
## # snapshot_date <chr>
fm_raw |> filter(stock_id %in% only_isin$stock_id)
## # A tibble: 3 × 5
## industry_category stock_id stock_name type date
## <chr> <chr> <chr> <chr> <chr>
## 1 電機機械 1589 永冠-KY twse 2026-07-04
## 2 化學生技醫療 4155 訊映 twse 2026-10-07
## 3 生技醫療業 4155 訊映 twse 2026-10-07
nrow(twse_isin)
## [1] 1085
nrow(fm_stocks)
## [1] 1083
setequal(twse_isin$stock_id, fm_stocks$stock_id)
## [1] FALSE
The two sources agree almost perfectly: ISIN has 1,085 common stocks and FinMind has 1,083. The 2 stocks that appear only in the ISIN list are 1589 (永冠-KY) and 4155 (訊映). The check above shows that FinMind does contain both, classified as twse, but their records have older snapshot dates (2026-07-04 and 2026-10-07) than the latest snapshot (2026-10-12), so the latest-date filter removed them. Stock 4155 also appears twice in FinMind with two different industry labels. Since ISIN is the official source and still lists both stocks, I treat the ISIN list of 1,085 as the reference, and the 2-stock difference comes from stale FinMind records, not from different stock universes.