同一檔股票反覆入選,量化策略勝率還可信嗎?
一套選股策略顯示「勝率 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 只計算七項結果斷言,不包含結構檢查。
#!/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")
以下是完整標準輸出(都是模擬數字,並非真實市場回測):
{
"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 文件說明依時間先後切分以避免用未來資料訓練過去;分組交叉驗證指南則說明相關群組跨資料集會造成洩漏。按標的分組與按時間切分解決的是不同問題。
如果標籤涵蓋未來五個交易日,訓練與測試標籤所用的資訊區間也可能跨界重疊。Hudson & Thames 的 PurgedKFold 實作示範排除與測試標籤資訊區間重疊的訓練樣本。這是第三方原始碼範例,不是本文執行的量化模型。光依日期排序,或任意空出一天,都不足以解決跨期標籤重疊。
這些方法是為了讓研究結論更可靠,不表示每個選股頁面都必須顯示一個看似精準的「有效樣本數」。若要比較策略,先看入選事件數、不同標的數、涵蓋日期、持有期間與待結算比例,往往更有幫助。
SQL 排查:已結算事件、不同標的與入選日期
回到真正的決策問題:這個勝率值得拿來比較策略嗎? 如果手邊有選股資料,先保留每筆入選與結算事件;固定市場、策略版本、進場假設與相同的 T+N,再一起看三種勝率。也要明確區分毛報酬與扣費後淨報酬。
與其相信畫面上的總勝率,不如先限定相同市場、策略版本、T+N 與入選日期區間,只從已結算事件開始。以下 SQL 是 PostgreSQL/SQLite 常見聚合語法的唯讀範例;your_settled_outcomes、example-v2 和欄位名稱都只是佔位設定,不對應任何特定正式環境的資料表。執行前應映射至自己資料庫的欄位,檢查結算條件,並在唯讀副本測試。
-- 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 或正式資料庫。
若懷疑同日同標的事件重複入庫,再對原始入選事件表(而非只看已結算結果)跑一次唯讀查詢。下方的表名與篩選條件都是示例,請替換為實際的市場、策略版本、持有期間與觀察區間。
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 平均值沒有這些條件,不等於可實現的每日投資組合報酬率。
另一個相關但不同的問題可看跨策略選股評分比較:那篇討論不同來源的「兩個 80 分」能不能直接比較。本文先處理更前面的統計單位:一次入選、一檔股票、一個入選日期與一筆可成交交易,並不是同一回事。
所以下次看到「勝率 60%、樣本 10 筆」,我會先問:這 10 筆是訊號事件還是 10 檔不同股票?涵蓋幾個交易日?T+N 有多少筆真正結算? 回答後再用上面的 Python 與 SQL 核查,最後才結合持有期重疊、交易成本和風險分布,判斷哪個策略更可靠。
驗證範圍:Python 3.13.5、SQLite 3.46.1 與合成樣本;工程背景來自歷史維護記錄。未執行真實市場資料稽核或可交易投資組合回測。