為什麼要分析下市股?
對多數股市參與者來說,「下市」(Delisting)這兩個字幾乎等同於災難——本金歸零、股票變壁紙。但實務上下市的結局差異極大:有些是股價跌到個位數的財務浩劫,有些卻是溢價收購,股東反而賺錢出場。
研究下市股有三大實質意義:
- 風險控管 — 常見的散戶悲劇就是「抱著等反彈」,最終下市歸零。了解型態才能在早期辨識危險訊號。
- 認識市場結構 — 下市集中在特定產業(面板、太陽能、LED),理解這些能避開高風險賽道。
- 區分良性退場 — 近四分之一的下市是溢價收購。學會辨識「被迫退場」vs「主動退場」的差異。
資料來源與數據概覽
本系列的資料來源:
- FinMind — TaiwanStockDelisting(341 筆下市清單)、TaiwanStockPrice(歷史股價)、TaiwanStockFinancialStatements(財報 EPS/權益)、TaiwanStockBalanceSheet(資產負債表)
- TWSE OpenAPI / MOPS — 下市名單與官方資訊
📈 FinMind 撈回股價:190 檔
📋 有財報可供驗證:39 檔
⏳ 資料跨距:1997–2023 年
方法論:五個核心維度
要定義一檔股票是「怎麼死的」,我們從以下五個量化維度切入分析:
四種下市型態
| 型態 | 檔數 | 末日收盤 | 距峰跌幅 | 最後一年報酬 | 量比 | 常見原因 |
|---|---|---|---|---|---|---|
| 財務困境 | 74 | 1.82 | 92% | -60% | 1.02 | 淨值為負/重整/違約 |
| 長期衰退 | 43 | 17.50 | 76% | +1% | 0.67 | 營運長期惡化 |
| 併購/私有化 | 38 | 38.38 | 17% | +30% | 0.58 | 被購併、公開收購 |
| 其他/不明 | 29 | 12.40 | 45% | +4% | 0.77 | 介於中間,含誤分的併購案 |
完整分析程式碼(Python)
以下是可以完整重現本系列分析的 Python 程式碼。它從 FinMind 下載資料、計算特徵、分類型態、畫走勢圖,最後產出 HTML 報告。你只需要一個 FinMind API Token 就能在自己電腦上跑。
執行方式
python delisting_analysis.py analyze — 只用已有資料重跑分析
python delisting_analysis.py all — 從頭下載股價 + 財報 + 分析
python delisting_analysis.py step1 — 只下載股價資料
python delisting_analysis.py step3 — 只重跑特徵計算與分類
程式碼全文
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
台股下市股特徵分析 — 完整流程
==================================
在 ai2 (openclaw90@) 上執行;資料根目錄 ~/Quan_Stock/history/finmind_datasets
流程:
step1 download_prices() 從 FinMind 下載下市股歷史日線 → K線/TaiwanStockPriceDelisted/{code}.csv
step2 download_financials() 從 FinMind 下載下市股財報 → 基本面/FinancialStatements_Delisted/{code}.csv
step3 build_features() 由股價算特徵 + 財報交叉驗證 + 分類 → 元資料/.../delisting_features_enriched.csv
step4 make_charts() 代表個股走勢圖 (base64)
step5 build_report() 產出自包含 HTML 報告
只想重跑分析(已有資料): python delisting_analysis.py analyze
全跑(含下載): python delisting_analysis.py all
"""
import csv, os, io, json, time, base64, glob, sys, urllib.parse, urllib.request, urllib.error
import statistics as stx
from collections import Counter
# ---------- 設定 ----------
ROOT = os.path.expanduser("~/Quan_Stock/history/finmind_datasets")
TOKEN = open(os.path.expanduser("~/Quan_Stock/history/finmind_token.txt")).read().strip()
API = "https://api.finmindtrade.com/api/v4/data"
DD = os.path.join(ROOT, "元資料", "TaiwanStockDelisting") # 下市名單與輸出
PDIR = os.path.join(ROOT, "K線", "TaiwanStockPriceDelisted") # 下市股股價
FDIR = os.path.join(ROOT, "基本面", "FinancialStatements_Delisted") # 下市股財報
LIST = os.path.join(DD, "TaiwanStockDelisting_ALL.csv") # 465 檔下市名單
for d in (PDIR, FDIR):
os.makedirs(d, exist_ok=True)
# ---------- 共用:FinMind 取數(含限流退避) ----------
def finmind(dataset, data_id, start="2000-01-01", end=None):
p = dict(dataset=dataset, data_id=data_id, start_date=start, token=TOKEN)
if end:
p["end_date"] = end
url = API + "?" + urllib.parse.urlencode(p)
for attempt in range(6):
try:
with urllib.request.urlopen(url, timeout=60) as r:
return json.load(r).get("data", [])
except urllib.error.HTTPError as e:
if e.code in (402, 429): # 限流 → 等 60 秒
time.sleep(60); continue
return None
except Exception:
time.sleep(5)
return None
def _f(x):
try: return float(x)
except: return None
# ---------- step1 下載下市股股價 ----------
def download_prices():
rows = list(csv.DictReader(open(LIST, encoding="utf-8-sig")))
ok = empty = 0
for i, d in enumerate(rows, 1):
code = d["stock_id"]; end = (d["delisting_date"] or "2024-12-31")[:10]
fp = os.path.join(PDIR, f"{code}.csv")
if os.path.exists(fp) and os.path.getsize(fp) > 100:
continue
data = finmind("TaiwanStockPrice", code, "1995-01-01", end)
if not data:
empty += 1; continue
with open(fp, "w", newline="", encoding="utf-8-sig") as f:
w = csv.DictWriter(f, fieldnames=list(data[0].keys())); w.writeheader(); w.writerows(data)
ok += 1; time.sleep(0.3)
print(f"[step1] 股價 成功 {ok} / 無資料 {empty}")
# ---------- step2 下載下市股財報 ----------
def download_financials():
have = {os.path.basename(x)[:-4] for x in glob.glob(PDIR + "/*.csv")}
rows = [d for d in csv.DictReader(open(LIST, encoding="utf-8-sig")) if d["stock_id"] in have]
ok = empty = 0
for d in rows:
code = d["stock_id"]; fp = os.path.join(FDIR, f"{code}.csv")
if os.path.exists(fp) and os.path.getsize(fp) > 100:
continue
data = finmind("TaiwanStockFinancialStatements", code)
if not data:
empty += 1; continue
with open(fp, "w", newline="", encoding="utf-8-sig") as f:
w = csv.DictWriter(f, fieldnames=list(data[0].keys())); w.writeheader(); w.writerows(data)
ok += 1; time.sleep(0.25)
print(f"[step2] 財報 成功 {ok} / 無 {empty}")
# ---------- step3 計算特徵 + 財報交叉驗證 + 分類 ----------
def _price_features(fp):
"""由單檔股價算特徵"""
recs = list(csv.DictReader(open(fp, encoding="utf-8-sig")))
cl = []
for r in recs:
c = _f(r["close"]); v = _f(r["Trading_Volume"])
if c and c > 0:
cl.append((c, v or 0))
if len(cl) < 30:
return None
closes = [x[0] for x in cl]; vols = [x[1] for x in cl]
peak, last = max(closes), closes[-1]
dd = 1 - last / peak if peak > 0 else 0 # 距歷史高點跌幅
l250 = closes[-250:]
ret1y = (last / l250[0] - 1) if l250[0] > 0 else 0 # 最後一年報酬
v_last = stx.mean(vols[-60:]) if len(vols) >= 60 else stx.mean(vols)
v_prior = (stx.mean(vols[-310:-60]) if len(vols) >= 310
else (stx.mean(vols[:-60]) if len(vols) > 60 else v_last))
vol_ratio = v_last / v_prior if v_prior > 0 else 0 # 末期/前期量比
return dict(last=round(last, 2), peak=round(peak, 2), drawdown=round(dd, 3),
ret1y=round(ret1y, 3), vol_ratio=round(vol_ratio, 2))
def _fin_features(fp):
"""由單檔財報算困境指標:末期 EPS、近 8 季虧損次數、是否曾淨值為負"""
data = list(csv.DictReader(open(fp, encoding="utf-8-sig")))
eps, eq = {}, {}
for r in data:
v = _f(r["value"])
if v is None:
continue
if r["type"] == "EPS": eps[r["date"]] = v
if r["type"] == "EquityAttributableToOwnersOfParent": eq[r["date"]] = v
eps_s = [eps[k] for k in sorted(eps)]
eq_s = [eq[k] for k in sorted(eq)]
return dict(
last_eps = eps_s[-1] if eps_s else None,
neg_eps_8q = sum(1 for x in eps_s[-8:] if x < 0),
ever_neg_equity = 1 if (eq_s and min(eq_s) < 0) else 0,
)
def classify(dd, last, fin):
"""精修分類:股價型態 + 財報佐證"""
neg_eq = fin and fin["ever_neg_equity"] == 1
leps = fin["last_eps"] if fin else None
neg8 = fin["neg_eps_8q"] if fin else 0
distress = (dd >= 0.85 and last < 15) or neg_eq or (leps is not None and leps < 0 and neg8 >= 4)
buyout = (dd <= 0.5 and last >= 15) and not neg_eq and (leps is None or leps >= 0)
if distress and not buyout: lab = "財務困境"
elif buyout and not distress: lab = "併購/私有化(高價下市)"
elif dd >= 0.6: lab = "長期衰退"
else: lab = "其他/不明"
ev = []
if neg_eq: ev.append("淨值為負")
if leps is not None and leps < 0: ev.append(f"末期EPS={leps}")
if neg8 >= 4: ev.append(f"近8季虧{neg8}")
if last < 2: ev.append("末日<2元")
if last >= 15 and dd <= 0.5: ev.append("高價淺跌")
return lab, ";".join(ev)
def build_features():
meta = {r["stock_id"]: r for r in csv.DictReader(open(LIST, encoding="utf-8-sig"))}
out = []
for fp in glob.glob(PDIR + "/*.csv"):
code = os.path.basename(fp)[:-4]
pf = _price_features(fp)
if not pf:
continue
ffp = os.path.join(FDIR, f"{code}.csv")
fin = _fin_features(ffp) if os.path.exists(ffp) else None
lab, ev = classify(pf["drawdown"], pf["last"], fin)
m = meta.get(code, {})
out.append(dict(code=code, name=m.get("stock_name", "?"), market=m.get("market", "?"),
delist=m.get("delisting_date", ""), **pf,
label_refined=lab, has_fin=1 if fin else 0, evidence=ev))
out.sort(key=lambda x: x["delist"], reverse=True)
path = os.path.join(DD, "delisting_features_enriched.csv")
with open(path, "w", newline="", encoding="utf-8-sig") as f:
w = csv.DictWriter(f, fieldnames=list(out[0].keys())); w.writeheader(); w.writerows(out)
print(f"[step3] 特徵表 {len(out)} 檔 → {path}")
print(" 型態分布:", dict(Counter(x["label_refined"] for x in out)))
print(" 有財報:", sum(x["has_fin"] for x in out),
"|淨值為負:", sum(1 for x in out if "淨值為負" in x["evidence"]))
return out
# ---------- step4 代表個股走勢圖 ----------
def make_charts(reps=None):
import matplotlib; matplotlib.use("Agg")
import matplotlib.pyplot as plt
reps = reps or [("2475", "Hua Ying (CPT)", "Financial Distress"),
("1704", "LCY Chemical", "M&A / Privatization"),
("3682", "APBW", "Long Decline"),
("2456", "Chilisin", "Other (actually M&A)")]
imgs = {}
for code, title, pat in reps:
fp = os.path.join(PDIR, f"{code}.csv")
if not os.path.exists(fp):
continue
recs = list(csv.DictReader(open(fp, encoding="utf-8-sig")))
dates = [r["date"] for r in recs]
close = [_f(r["close"]) for r in recs]
vol = [( _f(r["Trading_Volume"]) or 0) / 1e6 for r in recs]
x = list(range(len(dates)))
fig, (a1, a2) = plt.subplots(2, 1, figsize=(7, 4), height_ratios=[3, 1], sharex=True)
a1.plot(x, close, color="#1f77b4", lw=0.9)
a1.set_title(f"{code} {title} [{pat}] last={close[-1]}", fontsize=9)
a1.set_ylabel("Close (TWD)", fontsize=8); a1.grid(alpha=.3)
a2.bar(x, vol, color="#999", width=1.0)
a2.set_ylabel("Vol (M shares)", fontsize=8); a2.grid(alpha=.3)
step = max(1, len(dates) // 6)
a2.set_xticks(x[::step]); a2.set_xticklabels([dates[i][:7] for i in x[::step]], fontsize=7)
plt.tight_layout()
b = io.BytesIO(); plt.savefig(b, format="png", dpi=110); plt.close(); b.seek(0)
imgs[code] = base64.b64encode(b.read()).decode()
print(f"[step4] 圖表 {list(imgs.keys())}")
return imgs
# ---------- step5 產出 HTML 報告 ----------
def build_report(rows, imgs, out_html="/tmp/delisting_report.html"):
order = ["財務困境", "長期衰退", "併購/私有化(高價下市)", "其他/不明"]
colors = {"財務困境": "#c0392b", "長期衰退": "#e67e22",
"併購/私有化(高價下市)": "#27ae60", "其他/不明": "#7f8c8d"}
reason = {"財務困境": "淨值為負/重整/違約,跌成壁紙後終止",
"長期衰退": "營運長期惡化、基本面轉差後終止",
"併購/私有化(高價下市)": "被購併、公開收購、大股東私有化",
"其他/不明": "介於中間,含部分被門檻誤分的併購案"}
g = {p: [r for r in rows if r["label_refined"] == p] for p in order}
med = lambda gr, k: (stx.median([_f(r[k]) for r in gr if _f(r[k]) is not None]) if gr else 0)
H = ["""<!DOCTYPE html><html lang='zh-Hant'><head><meta charset='utf-8'>
<meta name='viewport' content='width=device-width,initial-scale=1'><title>台股下市股特徵分析</title>
<style>body{font-family:'Noto Sans TC','PingFang TC','Microsoft JhengHei',sans-serif;max-width:1100px;margin:0 auto;padding:24px;color:#222;line-height:1.7}
h1{font-size:24px;border-bottom:3px solid #2c3e50;padding-bottom:8px}h2{font-size:19px;margin-top:34px;border-left:5px solid #2c3e50;padding-left:10px}
.note{background:#fff8e1;border:1px solid #ffe082;border-radius:8px;padding:12px 16px;margin:14px 0}
table{border-collapse:collapse;width:100%;font-size:13px;margin:12px 0}th,td{border:1px solid #ddd;padding:5px 8px;text-align:center}
th{background:#2c3e50;color:#fff}tr:nth-child(even){background:#f7f9fa}.tag{color:#fff;border-radius:4px;padding:2px 7px;font-size:12px;white-space:nowrap}
.wrap{max-height:560px;overflow:auto;border:1px solid #ccc;border-radius:6px}img{max-width:100%;border:1px solid #eee;border-radius:6px;margin:6px 0}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}small{color:#666}</style></head><body>"""]
H.append("<h1>台股下市股特徵分析報告</h1>")
H.append(f"<p><small>樣本 {len(rows)} 檔 | 資料來源 FinMind + TWSE/MOPS</small></p>")
H.append("""<div class='note'><b>前提:</b>「下市原因」是文字事由,不在股價/FinMind 資料中。本報告由下市前股價、成交量、財報行為推斷『型態』,非官方原因。</div>""")
# 型態分布
H.append("<h2>下市型態分布與中位特徵</h2><table><tr><th>型態</th><th>檔數</th><th>末日收盤</th><th>距峰跌幅</th><th>最後一年</th><th>量比</th><th>常見原因</th></tr>")
for p in order:
gp = g[p]
H.append(f"<tr><td><span class='tag' style='background:{colors[p]}'>{p}</span></td><td>{len(gp)}</td>"
f"<td>{med(gp,'last'):.2f}</td><td>{med(gp,'drawdown'):.0%}</td><td>{med(gp,'ret1y'):+.0%}</td>"
f"<td>{med(gp,'vol_ratio'):.2f}</td><td style='text-align:left'>{reason[p]}</td></tr>")
H.append("</table>")
# 圖
caps = {"2475": "華映:跌到 0.17、爆量殺出 → 財務困境清零型。",
"1704": "榮化:高檔量縮 → KKR 私有化。",
"3682": "亞太電:多年緩跌 → 長期衰退。",
"2456": "奇力新:末日 94.6、淺跌 → 實為國巨併購(門檻誤分)。"}
H.append("<h2>代表個股走勢</h2><div class='grid'>")
for c in [k for k in ["2475", "1704", "3682", "2456"] if k in imgs]:
H.append(f"<div><img src='data:image/png;base64,{imgs[c]}'><br><small>{caps.get(c,'')}</small></div>")
H.append("</div>")
# 全表
H.append("<h2>全樣本特徵表</h2><div class='wrap'><table><tr><th>代號</th><th>名稱</th><th>市場</th><th>下市日</th><th>末日</th><th>跌幅</th><th>最後1年</th><th>量比</th><th>型態</th><th>財報</th><th>佐證</th></tr>")
for r in rows:
p = r["label_refined"]
H.append("<tr>" + "".join([
f"<td>{r['code']}</td>", f"<td style='text-align:left'>{r['name']}</td>", f"<td>{r['market']}</td>",
f"<td>{r['delist']}</td>", f"<td>{r['last']}</td>", f"<td>{_f(r['drawdown']):.0%}</td>",
f"<td>{_f(r['ret1y']):+.0%}</td>", f"<td>{r['vol_ratio']}</td>",
f"<td><span class='tag' style='background:{colors[p]}'>{p}</span></td>",
f"<td>{'✓' if str(r['has_fin'])=='1' else ''}</td>",
f"<td style='text-align:left'><small>{r['evidence']}</small></td>"]) + "</tr>")
H.append("</table></div></body></html>")
open(out_html, "w", encoding="utf-8").write("".join(H))
print(f"[step5] HTML → {out_html}")
# ---------- 進入點 ----------
def analyze():
rows = build_features()
imgs = make_charts()
build_report(rows, imgs)
if __name__ == "__main__":
mode = sys.argv[1] if len(sys.argv) > 1 else "analyze"
if mode == "all":
download_prices(); download_financials(); analyze()
elif mode == "analyze":
analyze()
elif mode in ("step1", "prices"): download_prices()
elif mode in ("step2", "fin"): download_financials()
elif mode in ("step3", "features"): build_features()
else:
print("用法: python delisting_analysis.py [all|analyze|step1|step2|step3]")
程式碼解說
1. 資料下載階段(step1 + step2)
download_prices() 從 FinMind API 抓取下市股歷史日線(開高低收 + 成交量),儲存在 K線/TaiwanStockPriceDelisted/ 目錄。API 有限流機制,遇到 402/429 會自動等 60 秒再重試。
download_financials() 抓取同一批股票的財報資料(TaiwanStockFinancialStatements),儲存在 基本面/FinancialStatements_Delisted/。因 FinMind 的財報資料只涵蓋較近期的下市股,早期股票(2000–2005 年下市)通常無資料。
2. 特徵計算(step3)
_price_features() 從股價 CSV 中計算最重要的五個特徵:歷史高點跌幅(drawdown)、末日收盤價(last)、最後一年報酬(ret1y)、末期/前期量比(vol_ratio)。歷史高點取整個期間的最高收盤價,最後一年報酬用最後 250 個交易日計算。
_fin_features() 從財報 CSV 中萃取三個指標:末期 EPS、近 8 季虧損次數、是否曾出現股東權益為負。這些指標用來驗證股價分類是否與基本面一致。
3. 分類邏輯(classify())
分類的核心邏輯是規則式門檻:
– 財務困境:跌幅 ≥85% 且末日 < 15 元,或財報顯示淨值為負、連續虧損
– 併購/私有化:跌幅 ≤50% 且末日 ≥ 15 元,且無財報困境訊號
– 長期衰退:跌幅 ≥60% 但不符合以上兩者
– 其他:其餘案例
這個規則式分類有一個已知問題:少數高價併購案(如泰昇-KY 84.6、奇力新 94.6)因末日收盤價極高且跌幅較小,被誤分到「其他」。這正是後續篇章要探討的分類門檻優化主題。
4. 圖表與報告(step4 + step5)
make_charts() 為代表個股畫出股價走勢疊圖(收盤價 + 成交量),並轉成 base64 嵌入報告中。build_report() 將所有資料整合為一份自包含的 HTML 報告,可直接在瀏覽器中打開。
財報交叉驗證結果
190 檔有股價歷史的股票中,有 39 檔在 FinMind 有財報資料。其中 32 檔曾出現股東權益為負(即淨值為負),這 32 檔「全部」落在財務困境分類中,與股價型態完全一致。
進一步看末期 EPS 與近 8 季虧損次數:財務困境組中,末期 EPS 全數為負,近 8 季虧損次數中位數為 6 季。相對地,併購/私有化組中,末期 EPS 多為正,且無權益為負的案例。這說明財報數據與股價行為的分類具有高度共鳴。
下集預告
下一篇將深入分析最大的一群 —— #2:壁紙股的完整肖像。我們會解剖華映、綠能、誠創、新世紀等經典案例,從股價走勢、成交量變化、財報數據三個面向完整呈現壁紙股的「死法」,並歸納出可供投資者操作的預警訊號。
發佈留言