多策略選股評分如何比較?從同為 80 分看辛普森悖論與機率校準
同一個選股頁面,兩檔股票都得到 80 分。一檔來自技術訊號共振,另一檔來自趨勢與量價條件的篩選器。介面只放了一欄總分,直覺上當然會把兩檔股票直接排名。
但這裡藏著一個問題:這兩個 80 分,真的在量同一件事嗎?
整理多來源選股系統的開發筆記時,我發現當初有一個刻意的取捨:綜合技術訊號與篩選器預設可以共用展示介面,但它們不必具備完全相同的特徵。把某個來源天生沒有的欄位當作零分,是不合理的。來源感知評分(source-aware scoring)可以避開這個問題,卻不代表兩套 0–100 分的數字從此具有相同的統計意義。
規則分數可以先用於來源內排序;跨來源比較要先定義一致的未來事件,再從真正結算的歷史結果驗證。 80 分不能直接解讀成 80% 的上漲機率。證據不足時,保留來源身分比硬湊一份全市場排行榜更可靠。
同樣叫訊號品質,不同來源看的證據本來就不同
一份 2026 年 6 月的專案設計筆記,記錄了綜合訊號的 signal_quality 使用 resonance_score(共振得分)和 technical_rating(技術評級),並以 70% / 30% 作為當時的規則配比範例。
篩選器路徑則可能使用 technical_rating、均線評級、RSI、MFI、距離 52 週高點,以及符合幾項篩選條件;它未必有綜合訊號專屬的 resonance_score。不能因為沒有另一種來源才會生成的欄位,就把篩選器判成低品質。
| 來源 | 內部評分要回答什麼 | 不合理的扣分 |
|---|---|---|
| technical_signal | 多個技術訊號是否形成共振 | 明明有共振輸入,卻忽略它 |
| screener_preset | 趨勢、量價與預設條件是否符合 | 沒有綜合訊號的專屬欄位就扣分 |
| factor_strategy | 策略定義的因子條件是否成立 | 把其他來源的缺失欄位當成失敗 |
這份歷史設計記錄解釋了為什麼要區分來源,卻還沒有回答:不同來源都拿到 80 分後,能否直接比較?
A 的 80 分可能只是高度符合共振規則;B 的 80 分可能表示符合篩選器自己的條件。相同顯示刻度不代表相同的成功機率。scikit-learn 的機率校準文件討論了如何用獨立資料檢驗預測機率與實際事件頻率的對應關係。規則分數和已校準的事件機率,不能直接畫上等號。
那把每個來源「80 分的歷史勝率」算出來就夠了嗎?還是得先確認樣本分布。
辛普森悖論:兩邊都是 80 分,總體排名卻反過來了
以下用**人為設計的 80 筆教學資料(Toy Example,非實盤回測)**隔離市場雜訊,純粹展示跨來源彙總可能踩到的陷阱。每筆 raw_score 都是 80 分,市場狀態分成趨勢(trend)與盤整(range),「成功」是預設的五日二元標籤。
| 來源 | 趨勢狀態 | 盤整狀態 | 直接彙總 |
|---|---|---|---|
| A:technical_signal | 21 / 30 = 70% | 3 / 10 = 30% | 24 / 40 = 60% |
| B:screener_preset | 8 / 10 = 80% | 15 / 30 = 50% | 23 / 40 = 57.5% |
只看最後一欄,A 好像稍微領先。分開看卻相反:趨勢狀態 B 是 80%,A 是 70%;盤整狀態 B 是 50%,A 只有 30%。A 的樣本大多來自比較容易出現正標籤的趨勢環境,B 則有更多盤整樣本。兩邊樣本比例不同,直接混合就可能顛倒比較結果。
若先把兩個來源都調整到同樣的 50% 趨勢/50% 盤整配比:
| 來源 | 原始樣本比例 | 共同 50/50 狀態配比 |
|---|---|---|
| A | 60% | 50% |
| B | 57.5% | 65% |
排序反轉了。這就是**辛普森悖論(Simpson’s paradox)**的一種典型情境。50/50 是教學用的共同權重,正式研究得預先訂出目標市場的狀態分布,而不是看完結果再挑讓自己領先的比例。
與其再用程式重算表格中的除法,我更想把小樣本的風險寫進工具。B 在趨勢狀態有 8 / 10 = 80% 的表面成功率;如果暫時把樣本當作獨立二元事件,95% Wilson 信賴區間下界其實只有約 49.0%。
import math
def wilson_lower_bound(wins: int, n: int, z: float = 1.96) -> float:
if n <= 0 or not 0 <= wins <= n:
raise ValueError("invalid wins or sample size")
p = wins / n
denom = 1 + z*z/n
center = p + z*z/(2*n)
margin = z * math.sqrt(p*(1-p)/n + z*z/(4*n*n))
return (center - margin) / denom
print(f"8/10 = 80%; Wilson 下界 = {wilson_lower_bound(8, 10):.1%}")
# 8/10 = 80%; Wilson 下界 = 49.0%
這裡故意讓 n=0 丟出錯誤:沒有樣本,不等於驗證出零勝率。 Wilson 區間不是保證可交易的保守勝率;同一檔股票重複出現或五日遠期報酬區間重疊時,還得處理相關性,不能直接把每筆當作獨立觀察。
展開 80 筆合成資料的完整重現程式與四項斷言
#!/usr/bin/env python3
"""Deterministic synthetic check of group-conditional scoring; NOT a market backtest.
All labels below are intentionally constructed. There are no real stock prices,
no actual trading outcomes, and no production scoring-engine output.
"""
import json
import math
from collections import defaultdict
# Fixed illustrative counts: (source, hypothetical regime, observations, positive outcomes)
SPEC = [
("technical_signal", "trend", 30, 21),
("technical_signal", "range", 10, 3),
("screener_preset", "trend", 10, 8),
("screener_preset", "range", 30, 15),
]
def wilson_interval(positive, total, z=1.959963984540054):
if total < 1:
raise ValueError("empty group")
p = positive / total
denom = 1 + z*z/total
center = (p + z*z/(2*total)) / denom
half = z * math.sqrt(p*(1-p)/total + z*z/(4*total*total)) / denom
return [round(center-half, 4), round(center+half, 4)]
def make_group(total, positive):
return {"n": total, "positive": positive, "rate": round(positive / total, 4),
"wilson_95": wilson_interval(positive, total)}
def main():
rows = []
for source, regime, total, positive in SPEC:
rows.extend({"source": source, "regime": regime, "raw_score": 80,
"positive_event_5d": int(i < positive)} for i in range(total))
summaries = defaultdict(dict)
pooled = defaultdict(lambda: [0, 0])
for source, regime, n, yes in SPEC:
summaries[source][regime] = make_group(n, yes)
pooled[source][0] += n
pooled[source][1] += yes
result = {"scope": "CONSTRUCTED_SYNTHETIC_EXAMPLE_NOT_MARKET_DATA",
"event_definition": "binary illustrative positive_event_5d, not executed trade PnL",
"raw_score": 80, "total_rows": len(rows), "sources": {}}
for source in sorted(summaries):
trend = summaries[source]["trend"]["rate"]
range_ = summaries[source]["range"]["rate"]
n, yes = pooled[source]
result["sources"][source] = {
"by_regime": summaries[source], "pooled": make_group(n, yes),
"equal_regime_mix_rate": round((trend + range_) / 2, 4)
}
a = result["sources"]["technical_signal"]
b = result["sources"]["screener_preset"]
assert len(rows) == 80
assert a["pooled"]["rate"] == 0.6 and b["pooled"]["rate"] == 0.575
assert a["equal_regime_mix_rate"] == 0.5 and b["equal_regime_mix_rate"] == 0.65
assert all(b["by_regime"][r]["rate"] > a["by_regime"][r]["rate"] for r in ("trend", "range"))
result["test_assertions"] = {
"fixed_80_records": "PASS", "pooled_order_a_above_b": "PASS",
"within_each_regime_b_above_a": "PASS", "fixed_equal_mix_order_b_above_a": "PASS"
}
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
if __name__ == "__main__":
main()
完整的 Python 標準函式庫程式會建立 80 筆示例資料、計算各狀態與整體比例、Wilson 區間,並核對四項斷言。它驗證的是彙總機制,不是任何交易策略真的比較會賺錢。
真實世界的資料通常更麻煩:同一檔股票同一天可能同時觸發兩條策略;t 日與 t+1 日的五日 forward return 大幅重疊;一個來源偏好趨勢,另一個偏好拉回。資料列變多,不代表獨立證據也等比例增加。
要校準分數,先把「成功」定義清楚
第一步不是決定 Logistic Regression 或 Isotonic Regression,而是定義要估計的事件。
「五個交易日後股價為正」、「五日後跑贏大盤」、「扣掉手續費與滑價後確實獲利」,這三件事不一樣。一日與十日結果也不能塞進同一條校準曲線。
如果只做研究,可以先固定市場、收盤決策時點與持有期,以「五個交易日後、依預設成本模型計算的收益是否大於零」作為標籤。只有時間走完才納入已結算樣本。若沒有實際成交價,應稱為價格報酬 proxy,不能寫成實際交易損益。
每次決策至少需要凍結一份 Point-in-time 快照:
decision_id / symbol / trade_date / as_of_time
source_key / raw_score / model_version
market / session / regime
horizon_days / label_status / realized_outcome
source_key 告訴我們 80 分來自哪一套規則;model_version 避免把調權前後的分數混在一起;as_of_time 確保特徵在當下已經可得。如果三個月後才用新版模型和最新資料重算「歷史評分」,檢驗的就不是當時真正看到的訊號。
同一檔股票同一天命中兩個來源,可以留兩條資料研究來源歸屬,但統計信賴區間與組合報酬不能假裝它們是兩筆互不相關的行情。必須明確處理 decision 去重、標的與日期分群。
還有 pending outcome:五日尚未結算的訊號不是失敗,也不是零報酬。只挑 completed 資料,又讓不同來源的成熟比例不一樣,就會引入結算偏差。不同股票池、下市股票、財報發布時間、還原股價與流動性條件也都會改變可比較的樣本群。
這些不是漂亮的演算法,但會決定後面的機率曲線有沒有意義。
排序能力、機率校準與交易報酬要分開驗收
先依 source、時間與市場狀態把原始分數分桶,呈現每桶樣本數、已結算比例、正標籤比例、報酬分布及不確定性,再問資料夠不夠做機率校準。
樣本很少時,保留來源內排名,或考慮有收縮效果的分層估計,比替每個 source × regime × session 擬合一條靈活曲線更合理。小桶的 100% 很可能只是樣本數太少。
資料夠多時可先測試 Sigmoid,再與非參數方法比較;不論哪種方法,都不能拿訓練映射的同一批資料證明它在未來有效。scikit-learn 的機率校準指南介紹 reliability diagram:在真正的機率預測下,比較桶內平均預測機率與實際事件比例。Brier Score 同時受區分度、校準與事件不確定性影響,單一分數下降不等於策略獲利能力提高。
金融時間序列尤其要防未來資料洩漏。t 日的訊號,五日後才會產生 outcome;若訓練期尾端已使用驗證期間才出現的價格變動,即使訊號日期比測試早,模型仍可能偷看到未來。TimeSeriesSplit支援依時間切分及 gap,但不能機械設定 gap=5 就當作萬無一失;還要看標籤完成時間、交易日曆、持有期間和同標的重疊。
| 想回答的問題 | 需要的證據 |
|---|---|
| 高分是否真的把較好的未來標的排在前面? | 時間外排序指標、Top vs Rest 的未來報酬分布 |
| 預測 60% 的事件,未來是否約有 60% 發生? | 時間外 reliability 分桶、樣本數、分組穩定性與機率誤差 |
好的排序分數未必是機率;接近基準發生率的機率模型,也可能缺乏挑選標的的區分能力。Google 的 prediction bias 說明可以作粗略檢查,但平均預測與平均結果相等,不代表每個來源、每個分桶都校準良好。
更不能把機率當成報酬。五日正報酬事件機率即使很準,也沒有回答賺錢時能賺多少、輸時會虧多少、滑價、回撤與部位容量。高勝率可能搭配很差的盈虧比。
在證據成熟前,先保留來源內評分
我不會急著把所有來源重新排成一張全球統一榜單。綜合技術訊號適合用共振資訊,篩選器適合用自己的條件,各自合理的評分框架並沒有錯;真正要補的是跨來源比較的實證。
下一步若能只讀檢查真實的已結算 outcome,應先排除測試與手動驗證批次、固定 source_key 和 model_version,統一市場、session、horizon,再看各 score bin 的正標籤率、報酬中位數、極端虧損、重複標的與狀態構成,最後才在時間外樣本評估機率映射。
這與先前用 Bucket Spread 和 Rank IC 驗證因子以及Regime、Shadow 和權重漂移的文章接得上:前者問因子有沒有資訊;後者問調權能不能上線;本篇問的是中間那一步——兩種策略眼中的「強」,到底能不能互相比較?
要真的決定哪個來源應該排前面,還是得靠相同口徑、完整結算、時間外的真實資料。那之前,讓讀者看得到原始分數及其來源,比提供一個過度精確的「統一勝率」更可靠。