🐉 量化龍的量化研究室
2026-06-27

為什麼要分析下市股?

對多數股市參與者來說,「下市」(Delisting)這兩個字幾乎等同於災難——本金歸零、股票變壁紙。但實務上下市的結局差異極大:有些是股價跌到個位數的財務浩劫,有些卻是溢價收購,股東反而賺錢出場。

研究下市股有三大實質意義:

  1. 風險控管 — 常見的散戶悲劇就是「抱著等反彈」,最終下市歸零。了解型態才能在早期辨識危險訊號。
  2. 認識市場結構 — 下市集中在特定產業(面板、太陽能、LED),理解這些能避開高風險賽道。
  3. 區分良性退場 — 近四分之一的下市是溢價收購。學會辨識「被迫退場」vs「主動退場」的差異。

資料來源與數據概覽

本系列的資料來源:

  • FinMind — TaiwanStockDelisting(341 筆下市清單)、TaiwanStockPrice(歷史股價)、TaiwanStockFinancialStatements(財報 EPS/權益)、TaiwanStockBalanceSheet(資產負債表)
  • TWSE OpenAPI / MOPS — 下市名單與官方資訊
📊 資料規模

📂 下市名單總數:465 檔(上市 263 + 上櫃 202)
📈 FinMind 撈回股價:190 檔
📋 有財報可供驗證:39 檔
⏳ 資料跨距:1997–2023 年
⚠️ 重要前提:本系列分析的「下市型態」是從股價行為(跌幅、收盤價、成交量)與財報數據推斷歸類,並非官方公告的下市原因。官方原因須逐檔查 MOPS 重大訊息,無法批量取得。後續我們用財報做了交叉驗證,佐證了分類的可靠性。

方法論:五個核心維度

要定義一檔股票是「怎麼死的」,我們從以下五個量化維度切入分析:

① 距歷史高點跌幅 (drawdown)
1 − 末日收盤 ÷ 歷史最高收盤。壁紙股的中位數達 92%、併購股僅 17%。區別力極強。

② 末日收盤價 (last)
清零型多低於 5 元(中位 1.82),收購型多在 30 元以上(中位 38.38)。最簡單的篩選門檻。

③ 最後一年報酬 (ret1y)
清零型最後一年暴跌 60%、收購型持平或上漲(中位 +30%)。期間的走勢方向截然相反。

④ 末期成交量比 (vol_ratio)
後 60 天 vs 前 250 天的日均量比。清零型常爆量殺出(1.02),收購型量縮(0.58)。

⑤ 財報交叉驗證
EPS(近 8 季虧損次數)、股東權益是否為負。有財報的 39 檔中,32 檔權益為負全落在壁紙群,互相佐證分類可靠。

四種下市型態

型態 檔數 末日收盤 距峰跌幅 最後一年報酬 量比 常見原因
財務困境 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:壁紙股的完整肖像。我們會解剖華映、綠能、誠創、新世紀等經典案例,從股價走勢、成交量變化、財報數據三個面向完整呈現壁紙股的「死法」,並歸納出可供投資者操作的預警訊號。

📚 台股下市股全分析連載
#1 方法論與四大型態總論(本文) 🟢
#2 壁紙股的完整肖像(近期上線) ⏳
全文程式碼:GitHub
本系列文章僅供學術研究參考,不構成任何投資建議。

發佈留言

探索更多來自 量化龍量化分析研究室 的內容

立即訂閱即可持續閱讀,還能取得所有封存文章。

繼續閱讀