Share Notes

chundev

View the Project on GitHub latteouka/share-notes

用滾動視窗 + 分位數量化「外資狂賣,但股價狂漲」的歷史鏡像

日期:2026-05-01 標的:台積電 (2330) 資料源:FinMind API(三大法人買賣超 + 日 K,2012-2026)


TL;DR

「外資狂賣,但股價狂漲」是常見的市場敘事,但模糊。把它拆成兩個可量化條件:「滾動 20 日外資累積淨買超 ≤ 歷史 P10」+「同期股價報酬 ≥ 歷史 P75」,再用分位數法決定門檻 — 14 年(3,423 個交易日)只有 6 個事件命中(命中率 1.4%)。結果呈現兩極化:小規模賣超(< 110k 張)外資一週內回補 + 股價持平;大規模賣超(≥ 190k 張)外資從未回補 + 股價後續下跌。


背景

當有人說「最近 X 異常」時,分析者最常踩的坑是直接相信「異常」這個敘事,然後拍腦袋定門檻:「賣超超過 5 萬張就算狂賣」、「漲幅超過 10% 就算狂漲」。這些絕對門檻在不同市場狀態下完全不可比 — 2014 年的台積電 100 元跟 2024 年的 1000 元,賣 5 萬張的意義完全不同。

更好的問法不是「現在是不是異常」,而是:

  1. 用同樣量化定義回看 14 年資料,過去這個情境發生過幾次?
  2. 每次後來怎麼了?
  3. 現在這個時點落在歷史百分位的哪裡?

這篇筆記記錄一個量化篩選的工作流,可套用到任何「定義模糊但需要回測比對歷史」的場景。


方法論:滾動視窗 + 分位數

兩個設計選擇是這個方法的核心。

1. 為什麼用滾動視窗,不用單日

單日資料噪音極大。外資某天賣 2 萬張可能只是季底調倉,不代表趨勢。要抓「持續性」就要用滾動視窗(rolling window)— 把連續 N 天的單日值加總成一個「累積」訊號。

function rollingSum(values: number[], window: number): (number | null)[] {
  const out: (number | null)[] = [];
  let sum = 0;
  for (let i = 0; i < values.length; i++) {
    sum += values[i]!;
    if (i >= window) sum -= values[i - window]!;
    out.push(i >= window - 1 ? sum : null);
  }
  return out;
}

選 20 個交易日(約一個月)是常見折衷:夠長能濾掉週級噪音、夠短能反映即時動能。

2. 為什麼用分位數,不用絕對值

直接訂「賣超 ≥ 5 萬張算狂賣」的問題:

缺點 說明
跨時不可比 股價基期不同,5 萬張在 2014 vs 2024 意義完全不同
跨標的不可比 同一個門檻套到台積電和聯發科會抓出完全不同密度的事件
隱含心理偏誤 5 萬這個數字往往是直覺挑的,沒有統計依據

改用分位數定門檻:「滾動 20 日累積賣超在歷史最深的 10%(P10)」,自動隨時代基期調整、跨標的可比、有清楚的稀有度語意(命中率約 10%)。

function quantile(sorted: number[], q: number): number {
  const idx = (sorted.length - 1) * q;
  const lo = Math.floor(idx);
  const hi = Math.ceil(idx);
  if (lo === hi) return sorted[lo]!;
  return sorted[lo]! + (sorted[hi]! - sorted[lo]!) * (idx - lo);
}

資料源:FinMind

FinMind 提供台股每日三大法人買賣超:

GET https://api.finmindtrade.com/api/v4/data
  ?dataset=TaiwanStockInstitutionalInvestorsBuySell
  &data_id=2330
  &start_date=2010-01-01
  &end_date=2026-04-30
  &token=<API_TOKEN>

幾個踩過的坑:


EDA:先觀察基準分佈

跑出 14 年的滾動 20 日累積分佈,才知道分位數對應的絕對值:

分位 滾動 20 日外資累積(張) 解讀
P5(重度賣超) -207,299 14 年最深 5%
P10 -158,219 「狂賣」門檻
P25 -79,335 中等賣超
中位數 -6,082 大致平衡(外資總體小幅賣)
P75 +74,368 中等買超
P95 +185,531 重度買超
分位 滾動 20 日股價報酬
P5 -9.23%
P25 -2.07%
中位數 +1.78%
P75 +6.29% ← 「狂漲」門檻
P95 +13.98%

定下兩個條件後,雙條件命中天數 48 天 / 3,423 個交易日 = 1.4% 命中率。把連續/接近的命中日(gap ≤ 5 個交易日)合併成單一「事件」,最終得到 6 個歷史事件。


6 個歷史事件 + 兩極化發現

# 事件期間 賣超(張) 區間漲幅 100% 回補日 最終回補率 +30 / +60 / +120 天股價
1 2013-04-19 109k +7.8% 5/8 563% 107.5 / 108.0 / 106.5
2 2014-02-14 98k +6.4% 2/25 979% 118.5 / 122.0 / 120.5
3 2021-01-25~02-23 220k +23.9% 從未 ❌ 0% 610 / 568 / 580 ↓
4 2025-10-28~11-04 85k +14.0% 從未 ❌ 0% ❌ 1435 / 1805 / —
5 2026-01-09~03-18 193k +19.7% 從未 0%(持續中)
6 2026-04-08 124k +7.7% 從未 25.8%(持續中)

兩極化規律:

規模 外資行為 後續股價(30-120 天)
小規模(< 110k 張) 一週內回補 25%、兩週內 100%(其實是短期調節) 持平
大規模(≥ 190k 張) 從未回補 下跌

唯一已驗證完整週期的大規模事件是 2021/01-02:220k 張賣超 + 23.9% 漲幅,外資 14 年來都沒回補回去,後續 60 天股價跌 11%。


「當下定位」:用百分位反推情境

最有用的不是回看歷史事件,而是定位現在落在歷史百分位的哪裡

const lastRoll = roll20.at(-1);
const lastRet = ret20.at(-1);
const rollPct = (sortedRoll.filter((x) => x <= lastRoll).length / sortedRoll.length) * 100;
const retPct = (sortedRet.filter((x) => x <= lastRet).length / sortedRet.length) * 100;

跑出來:

指標 百分位 翻譯
滾動 20 日外資累積 -24,791 張 P43 不是狂賣,只是比平均賣一點
滾動 20 日股價報酬 +21.31% P98.6 歷史前 1.4% 極端漲幅

這個對照立刻打破直覺敘事:「外資狂賣 + 股價狂漲」這個感覺,只有「股價狂漲」是真的,外資其實已經由賣轉中性。事件 6(4/8 那次小規模賣超)的回補進度也佐證 — 25.8% 已經回補。


學到的事

  1. 「異常」是個百分位問題,不是絕對值問題。 任何模糊敘事(狂賣、暴漲、過熱)都可以用滾動視窗 + 分位數重定義。沒有「P10 才算狂」的絕對標準,但「比歷史 90% 的時候更極端」這個語意是清楚且跨時可比的。

  2. 滾動視窗的長度決定你抓到的故事。 5 天視窗會把每次盤整都標成事件;60 天視窗會把所有單月波動洗掉。20 個交易日是動能類訊號的常見折衷。

  3. 「後來怎麼了」是訊號的真正驗證。 找到歷史事件只是第一步 — 對每個事件追蹤後續 30 / 60 / 120 天的目標變數(股價、回補進度、波動率),才能驗證這個訊號有沒有預測力。本例子發現大規模 vs 小規模事件後續走向完全不同,這是事件清單存在的價值。

  4. 分位數定位 ≠ 預測。 P98 漲幅不代表會回跌、P10 賣超不代表會反彈。它只告訴你「現在的稀有度」,讓你判斷「這個情境我有沒有歷史 case 可參考」。沒有歷史對照的訊號(命中率 < 1%)通常不該下大注。

  5. API 資料的單位永遠要先確認。 FinMind 三大法人欄位是「股」不是「張」,少除一個 1000 整篇分析會差三個數量級。任何外部資料源第一件事是抓一筆已知答案的 sample 對照。


參考資料