# 同一只股票反复入选，量化策略胜率还可信吗？

> Canonical: https://vxi.me/posts/quant-winrate-repeated-stock-picks/
> Locale: zh-cn
> Published: 2026-10-17
> Updated: 2026-10-17
> Markdown: https://vxi.me/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 的平均收益都为负。

还可以按日期看。同一天所有已结算入选记录先等权求平均，再统计这些日组合均值为正的日期。上面六天中有五天为正，得到 **5 / 6 = 83.3%**；六个日组合收益的算术平均是约 +1.58%。这里的 +1.58% 是“按入选日期等权的 T+5 样本收益均值”，不是每天持仓赚 1.58%，更不能直接年化。

| 统计指标 | 结果 | 它在回答什么 |
| --- | ---: | --- |
| 逐条样本胜率 | 60%（6/10） | 每次入选记录的正收益比例 |
| 唯一标的胜率 | 25%（1/4） | 窗口内不同股票的平均 T+5 收益为正的比例 |
| 每日组合胜率 | 83.3%（5/6） | 每个入选日的等权样本均值为正的比例 |

这组数据里，每日组合胜率反而最高。重复入选并不必然抬高逐条样本胜率；它改变的是每只股票在统计中的**权重**。如果连续出现的恰好是亏损股票，逐条胜率也可能更低。所以，看胜率决定是否采用一个策略前，先弄清它在数“入选事件”“不同股票”，还是“入选日期”。

## 用完整 Python 实验复算三种胜率

这次实际运行的是下面这份**完整脚本**（Python 3.13.5，仅使用标准库）。输入列表有 10 条合成入选记录，不是“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%），不是每天真实持仓的净收益。**脚本本身和输出都已完整给出**，复制脚本即可复算；为避免只剩一个无法复核的“内部收据”说法，这里也记录本次原始文件的 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% 没有这些条件，**不等于可以实现的日收益率**。

如果还要跨策略比较，可以接着看[多策略选股评分与概率校准](/posts/stock-score-calibration-simpsons-paradox/)：那篇研究“两个 80 分”能否直接比较。这里先解决更基础的分母问题——一次入选、一个标的、一个入选日和一笔可执行交易，不能在统计里混成同一种单位。

所以下次看到“胜率 60%、样本 10 条”，我会先问：**10 条是入选事件，还是 10 只股票？覆盖多少个交易日？T+N 已经结算了几条？** 能回答这三个问题，就能用上面的 Python 和 SQL 检查数字是否有意义；再结合重叠持有期、交易成本和风险分布，才值得讨论哪个策略更可靠。

*验证范围：Python 3.13.5 与 SQLite 3.46.1，使用合成样本复算；工程背景来自历史维护记录，未进行生产数据复核或可交易组合回测。*
