# 同一檔股票反覆入選，量化策略勝率還可信嗎？

> Canonical: https://vxi.me/zh-tw/posts/quant-winrate-repeated-stock-picks/
> Locale: zh-tw
> Published: 2026-10-17
> Updated: 2026-10-17
> Markdown: https://vxi.me/zh-tw/posts/quant-winrate-repeated-stock-picks.md

一套選股策略顯示「勝率 60%，樣本 10 筆」，看起來有一定參考價值。但若這 10 筆記錄中，有 6 筆只是同一檔股票在不同日期反覆入選，這個 60% 究竟能說明什麼？

無論你用篩選器比較策略，或自己開發量化選股工具，都可能遇到這個盲點：**樣本數也許只是訊號觸發次數，不是不同股票的數量，更不等於真正執行的交易筆數。** 光看單一勝率，很容易將重複觀察誤認為充分驗證。

整理選股訊號追蹤系統的歷史維護筆記時，我遇到了這種分母定義問題。以下以 10 筆**刻意構造的合成入選記錄**，搭配可執行的 Python 與 SQL，算出 **60%、25% 與 83.3%**。重點不是挑最高的數字，而是回答：**分母究竟在算什麼？收益何時結算？重複觀察能增加多少有效證據？**

## 樣本去重與分母選擇：六天入選十次

先固定市場、策略版本與持有期間，假設下表所有記錄都已完成 T+5 結算。股票 A、B、C、D 完全是虛構標的，表內的 T+5 報酬為**未扣除手續費與滑價的毛報酬**，僅用來展示不同分組定義，不是實際行情回測。

| 入選日期 | 入選標的與 T+5 報酬 | 當日訊號的等權 T+5 平均值 |
| --- | --- | ---: |
| d1 | A +5%、B -3% | +1% |
| d2 | A +4%、C -2% | +1% |
| d3 | A +3%、B -1% | +1% |
| d4 | A +6% | +6% |
| d5 | A +2% | +2% |
| d6 | A +1%、D -4% | -1.5% |

10 筆記錄中有 6 筆是正報酬，而且全來自 A。因此，依「每一次策略入選事件」計算的**逐筆樣本勝率為 6/10 = 60%**。如果你問的是「每次訊號觸發並完成結算後，有幾成結果為正」，這個分母合理；重複入選本身不會讓算式出錯。

但這不能解讀成策略在十檔不同股票上驗證過。實際只有四檔。若先在統計期間內對每檔股票的多次 T+5 報酬取平均，再看有幾檔平均報酬為正，只有 A 符合：**唯一標的勝率為 1/4 = 25%**。B、C、D 的平均值都為負。

還可改成按入選日期分組：每天先把所有已結算訊號的 T+5 報酬等權平均，再算其中有幾天的平均值為正。六天中有五天為正，因此**每日組合勝率為 5/6 = 83.3%**。六天的 T+5 事件組合平均值約 +1.58%。這不是實際持倉每天賺 1.58%，更不能直接據此年化。

| 統計指標 | 結果 | 回答的問題 |
| --- | ---: | --- |
| 逐筆樣本勝率 | 60%（6/10） | 每次已結算入選事件有多少比例為正報酬？ |
| 唯一標的勝率 | 25%（1/4） | 不同股票的平均 T+5 報酬，有多少比例為正？ |
| 每日組合勝率 | 83.3%（5/6） | 有多少入選日的等權訊號平均報酬為正？ |

這組數字反而是每日組合勝率最高。重複入選不一定會拉高逐筆勝率；它改變的是各檔股票在樣本裡的**權重**。如果反覆入選的是賠錢股票，逐筆勝率也可能比較低。要據此選策略，先看頁面算的是「入選事件」、「不同標的」，還是「入選日期」。

## 用完整 Python 實驗重算三種勝率

下面就是我用 Python 3.13.5、只靠標準函式庫**實際執行的完整程式**。輸入為 10 筆合成入選事件，不是「只有十行的腳本」。它會印出各標的與各入選日的 T+5 平均報酬及三種勝率。程式包含 **7 項結果斷言與 1 項輸入結構斷言**；最後的 `ASSERTIONS_PASS=7` 只計算七項結果斷言，不包含結構檢查。

```python
#!/usr/bin/env python3
"""Synthetic settled T+5 selection-outcome metric comparison, no real market data."""
from collections import defaultdict
from statistics import mean

# Entirely fictional stocks and T+5 returns (decimal units).
# Every row is a distinct snapshot-date pick in the SAME market, strategy, and horizon.
ROWS = [
    ("d1", "A", 0.05), ("d1", "B", -0.03),
    ("d2", "A", 0.04), ("d2", "C", -0.02),
    ("d3", "A", 0.03), ("d3", "B", -0.01),
    ("d4", "A", 0.06),
    ("d5", "A", 0.02),
    ("d6", "A", 0.01), ("d6", "D", -0.04),
]

def calc(rows):
    assert rows and all(isinstance(r, tuple) and len(r) == 3 for r in rows)
    symbol_returns = defaultdict(list)
    date_returns = defaultdict(list)
    for day, symbol, settled_return in rows:
        symbol_returns[symbol].append(settled_return)
        date_returns[day].append(settled_return)
    symbol_means = {symbol: mean(xs) for symbol, xs in symbol_returns.items()}
    date_means = {day: mean(xs) for day, xs in date_returns.items()}
    return {
        "settled_rows": len(rows),
        "distinct_symbols": len(symbol_returns),
        "distinct_pick_dates": len(date_returns),
        "sample_win_rate": sum(ret > 0 for _, _, ret in rows) / len(rows),
        "unique_symbol_win_rate": sum(ret > 0 for ret in symbol_means.values()) / len(symbol_means),
        "daily_cohort_win_rate": sum(ret > 0 for ret in date_means.values()) / len(date_means),
        "mean_daily_cohort_return": mean(date_means.values()),
        "symbol_mean_returns": symbol_means,
        "date_cohort_returns": date_means,
    }

if __name__ == "__main__":
    import json
    result = calc(ROWS)
    assert result["settled_rows"] == 10
    assert result["distinct_symbols"] == 4
    assert result["distinct_pick_dates"] == 6
    assert result["sample_win_rate"] == 0.6
    assert result["unique_symbol_win_rate"] == 0.25
    assert abs(result["daily_cohort_win_rate"] - 5 / 6) < 1e-12
    assert abs(result["mean_daily_cohort_return"] - 0.095 / 6) < 1e-12
    print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
    print("ASSERTIONS_PASS=7")
```

以下是完整標準輸出（都是模擬數字，並非真實市場回測）：

```text
{
  "daily_cohort_win_rate": 0.8333333333333334,
  "date_cohort_returns": {
    "d1": 0.010000000000000002,
    "d2": 0.01,
    "d3": 0.009999999999999998,
    "d4": 0.06,
    "d5": 0.02,
    "d6": -0.015
  },
  "distinct_pick_dates": 6,
  "distinct_symbols": 4,
  "mean_daily_cohort_return": 0.015833333333333335,
  "sample_win_rate": 0.6,
  "settled_rows": 10,
  "symbol_mean_returns": {
    "A": 0.035,
    "B": -0.02,
    "C": -0.02,
    "D": -0.04
  },
  "unique_symbol_win_rate": 0.25
}
ASSERTIONS_PASS=7
```

分母分別是逐筆樣本勝率 6/10、唯一標的勝率 1/4、每日組合勝率 5/6。按日分組的 T+5 平均值為 `0.01583333`（約 +1.58%），**不是**每天真實持倉的淨報酬。上方已提供完整可複製程式及輸出，可以自行重算。若要核對原始 UTF-8 檔案位元組，下方是其 SHA-256：

- Python 腳本：`0b554335c960b4a58eab671a693ef64cdba4ab46a6c6f653a93fa2ca220a7b8f`
- 標準輸出：`7b91fcdda0c6bea63d05788f25815af16321c647d5342463601599a81db1b8f2`

雜湊值對應原始檔案的 UTF-8 位元組。從網頁複製時若換行或空白發生變化，雜湊值可能不同，但統計結果仍能獨立驗證。實驗未查詢任何正式環境行情資料庫，也未連接真實交易帳戶。

## 勝率統計的另外兩個陷阱：重複入庫與結算時點

檢視一套選股訊號追蹤系統的歷史維護筆記時，我發現比較合理的做法是：**保留每次真實入選事件，並排顯示逐筆樣本勝率、唯一標的勝率與每日組合勝率**。這些口徑來自內部歷史工程記錄，本文沒有再連線正式資料庫核驗；前述 Python 實驗只證明算法，不代表真實策略績效。

首先要分清兩種「重複」。同一檔股票今天與明天都入選，是兩個可以追蹤的訊號；若排程工作重試，卻把**相同日期、市場、策略、標的與持有期間的同一事件**寫入兩次，才需要排查重複入庫。不能粗暴刪掉前者，也不能把後者當成新證據。實際事件唯一鍵仍應依訊號來源與策略定義確認。

更隱蔽的是**結算口徑**。T+5 應依對應市場的五個交易日計算，而不是排程工作執行了五次。退出價格也應使用約定結算日的歷史價格，不是延遲補跑時的最新報價。尚未到期或缺少行情而仍為 `pending` 的記錄，不應算進已結算勝率分母。歷史筆記提到交易日曆、歷史收盤價與策略版本切分，但本文並未獨立驗證現行正式系統。

「沒有候選標的」、「尚未結算」及「已結算但全部虧損」是三種不同狀態。**先確認事件沒有重複入庫、結算價正確、分母只納入已結算記錄，再比較勝率**，遠比挑一個最高百分比有用。

## 重疊持有期間：股票去重不等於統計獨立

即使按股票分組，觀察值也不一定在統計上獨立。假設 A 連續六天入選，每筆都觀察後續五個交易日的報酬，相鄰的未來價格區間就會重疊。按股票取平均，也無法消除不同股票受到共同市場因素影響的相關性。「不同股票數」、「不同入選日數」和「統計有效樣本數」不能畫上等號。

這也是為什麼，不能把相鄰的十筆 T+5 記錄隨機分到訓練集與測試集，再宣稱完成獨立泛化驗證。scikit-learn 的 [TimeSeriesSplit 文件](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.TimeSeriesSplit.html)說明依時間先後切分以避免用未來資料訓練過去；[分組交叉驗證指南](https://scikit-learn.org/stable/modules/cross_validation.html)則說明相關群組跨資料集會造成洩漏。按標的分組與按時間切分解決的是不同問題。

如果標籤涵蓋未來五個交易日，訓練與測試標籤所用的資訊區間也可能跨界重疊。[Hudson & Thames 的 PurgedKFold 實作](https://github.com/hudson-and-thames/mlfinlab/blob/79dcc7120ec84110578f75b025a75850eb72fc73/mlfinlab/cross_validation/cross_validation.py)示範排除與測試標籤資訊區間重疊的訓練樣本。這是第三方**原始碼範例**，不是本文執行的量化模型。光依日期排序，或任意空出一天，都不足以解決跨期標籤重疊。

這些方法是為了讓研究結論更可靠，不表示每個選股頁面都必須顯示一個看似精準的「有效樣本數」。若要比較策略，先看入選事件數、不同標的數、涵蓋日期、持有期間與待結算比例，往往更有幫助。

## SQL 排查：已結算事件、不同標的與入選日期

回到真正的決策問題：**這個勝率值得拿來比較策略嗎？** 如果手邊有選股資料，先保留每筆入選與結算事件；固定市場、策略版本、進場假設與相同的 T+N，再一起看三種勝率。也要明確區分毛報酬與扣費後淨報酬。

與其相信畫面上的總勝率，不如先限定**相同市場、策略版本、T+N 與入選日期區間**，只從已結算事件開始。以下 SQL 是 PostgreSQL/SQLite 常見聚合語法的唯讀範例；`your_settled_outcomes`、`example-v2` 和欄位名稱都只是佔位設定，**不對應任何特定正式環境的資料表**。執行前應映射至自己資料庫的欄位，檢查結算條件，並在唯讀副本測試。

```sql
-- Read-only PostgreSQL/SQLite template. Replace table/columns and filters.
WITH settled AS (
  SELECT snapshot_date, tv_symbol, return_pct
  FROM your_settled_outcomes
  WHERE market = 'US'
    AND strategy_id = 'example-v2'
    AND horizon_days = 5
    AND snapshot_date >= '2026-07-01'
    AND snapshot_date < '2026-10-01'
    AND settled_trade_date IS NOT NULL
    AND return_pct IS NOT NULL
), by_symbol AS (
  SELECT tv_symbol, AVG(return_pct) AS mean_return
  FROM settled GROUP BY tv_symbol
), by_day AS (
  SELECT snapshot_date, AVG(return_pct) AS mean_return
  FROM settled GROUP BY snapshot_date
)
SELECT
  (SELECT COUNT(*) FROM settled) AS settled_rows,
  (SELECT COUNT(DISTINCT tv_symbol) FROM settled) AS unique_symbols,
  (SELECT COUNT(DISTINCT snapshot_date) FROM settled) AS unique_pick_days,
  (SELECT AVG(CASE WHEN return_pct > 0 THEN 1.0 ELSE 0.0 END) FROM settled) AS sample_win_rate,
  (SELECT AVG(CASE WHEN mean_return > 0 THEN 1.0 ELSE 0.0 END) FROM by_symbol) AS unique_symbol_win_rate,
  (SELECT AVG(CASE WHEN mean_return > 0 THEN 1.0 ELSE 0.0 END) FROM by_day) AS daily_cohort_win_rate;
```

這份 SQL 與 Python 用同樣三層分組：`settled` 為已結算事件，`by_symbol` 為每檔股票的平均值，`by_day` 為各入選日的平均值。我在 Linux x86_64 上使用 Python 3.13.5 的 `sqlite3` 模組（`sqlite3.sqlite_version` 為 `3.46.1`），對十筆合成資料執行等價查詢，得到 **10 筆事件、4 檔股票、6 個入選日，以及 0.6 / 0.25 / 0.833333…** 三種勝率。沒有連接 PostgreSQL 或正式資料庫。

若懷疑**同日同標的事件重複入庫**，再對原始入選事件表（而非只看已結算結果）跑一次唯讀查詢。下方的表名與篩選條件都是示例，請替換為實際的市場、策略版本、持有期間與觀察區間。

```sql
SELECT snapshot_date, tv_symbol, COUNT(*) AS repeat_rows
FROM your_signal_events
WHERE market = 'US' AND strategy_id = 'example-v2' AND horizon_days = 5
  AND snapshot_date >= '2026-07-01'
  AND snapshot_date < '2026-10-01'
GROUP BY snapshot_date, tv_symbol
HAVING COUNT(*) > 1;
```

查詢會列出同市場、策略與持有期間內，某日期某股票出現多筆記錄的組合。我用 SQLite 對上述 **10 筆虛構入選事件**測試：原始資料沒有命中；刻意複製一筆日期為 2026-07-01 的 A 事件後，查到 `repeat_rows = 2`。這只驗證查詢邏輯，**不證明正式資料庫存在重複記錄**。

**查到重複組合，不代表應立即刪除。** 同一天不同來源或不同時段的合法訊號，也可能有相同欄位。請核對事件 ID、產生時間與來源資訊；跨日同股再入選更不能直接當錯誤刪除。待結算、行情缺失和舊策略版本應另外計數，評估真實交易還需要報酬分布及成本。

若要宣稱「每天買進推薦股票」的可交易績效，還得建置考慮資金、持倉重疊、手續費、流動性、價格衝擊與滑價的投資組合回測。上述 +1.58% 等權 T+5 平均值沒有這些條件，**不等於可實現的每日投資組合報酬率**。

另一個相關但不同的問題可看[跨策略選股評分比較](/posts/stock-score-calibration-simpsons-paradox/)：那篇討論不同來源的「兩個 80 分」能不能直接比較。本文先處理更前面的統計單位：一次入選、一檔股票、一個入選日期與一筆可成交交易，並不是同一回事。

所以下次看到「勝率 60%、樣本 10 筆」，我會先問：**這 10 筆是訊號事件還是 10 檔不同股票？涵蓋幾個交易日？T+N 有多少筆真正結算？** 回答後再用上面的 Python 與 SQL 核查，最後才結合持有期重疊、交易成本和風險分布，判斷哪個策略更可靠。

*驗證範圍：Python 3.13.5、SQLite 3.46.1 與合成樣本；工程背景來自歷史維護記錄。未執行真實市場資料稽核或可交易投資組合回測。*
