chundev
日期:2026-05-01 標的:台積電 (2330) 資料源:FinMind API(三大法人買賣超 + 日 K,2012-2026)
「外資狂賣,但股價狂漲」是常見的市場敘事,但模糊。把它拆成兩個可量化條件:「滾動 20 日外資累積淨買超 ≤ 歷史 P10」+「同期股價報酬 ≥ 歷史 P75」,再用分位數法決定門檻 — 14 年(3,423 個交易日)只有 6 個事件命中(命中率 1.4%)。結果呈現兩極化:小規模賣超(< 110k 張)外資一週內回補 + 股價持平;大規模賣超(≥ 190k 張)外資從未回補 + 股價後續下跌。
當有人說「最近 X 異常」時,分析者最常踩的坑是直接相信「異常」這個敘事,然後拍腦袋定門檻:「賣超超過 5 萬張就算狂賣」、「漲幅超過 10% 就算狂漲」。這些絕對門檻在不同市場狀態下完全不可比 — 2014 年的台積電 100 元跟 2024 年的 1000 元,賣 5 萬張的意義完全不同。
更好的問法不是「現在是不是異常」,而是:
這篇筆記記錄一個量化篩選的工作流,可套用到任何「定義模糊但需要回測比對歷史」的場景。
兩個設計選擇是這個方法的核心。
單日資料噪音極大。外資某天賣 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 個交易日(約一個月)是常見折衷:夠長能濾掉週級噪音、夠短能反映即時動能。
直接訂「賣超 ≥ 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 提供台股每日三大法人買賣超:
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>
幾個踩過的坑:
name 欄要過濾 — 同一天會有多筆,分別是 Foreign_Investor、Investment_Trust、Dealer_*,不過濾會把投信和自營商也加進來跑出 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 個歷史事件。
| # | 事件期間 | 賣超(張) | 區間漲幅 | 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% 已經回補。
「異常」是個百分位問題,不是絕對值問題。 任何模糊敘事(狂賣、暴漲、過熱)都可以用滾動視窗 + 分位數重定義。沒有「P10 才算狂」的絕對標準,但「比歷史 90% 的時候更極端」這個語意是清楚且跨時可比的。
滾動視窗的長度決定你抓到的故事。 5 天視窗會把每次盤整都標成事件;60 天視窗會把所有單月波動洗掉。20 個交易日是動能類訊號的常見折衷。
「後來怎麼了」是訊號的真正驗證。 找到歷史事件只是第一步 — 對每個事件追蹤後續 30 / 60 / 120 天的目標變數(股價、回補進度、波動率),才能驗證這個訊號有沒有預測力。本例子發現大規模 vs 小規模事件後續走向完全不同,這是事件清單存在的價值。
分位數定位 ≠ 預測。 P98 漲幅不代表會回跌、P10 賣超不代表會反彈。它只告訴你「現在的稀有度」,讓你判斷「這個情境我有沒有歷史 case 可參考」。沒有歷史對照的訊號(命中率 < 1%)通常不該下大注。
API 資料的單位永遠要先確認。 FinMind 三大法人欄位是「股」不是「張」,少除一個 1000 整篇分析會差三個數量級。任何外部資料源第一件事是抓一筆已知答案的 sample 對照。