一个选股策略显示“胜率 60%,样本 10 条”,看起来已有一点参考价值。可如果这 10 条记录中,有 6 条来自同一只股票连续多日入选,这个 60% 还能说明多少?

无论你是用筛选器比较策略,还是自己开发量化选股工具,都容易遇到这个问题:样本数可能是在数信号出现了多少次,而不是多少只不同股票,更不是实际完成了多少笔交易。 只看一个胜率,可能把反复出现的信号误当成充分的验证。

整理选股信号跟踪系统的历史维护记录时,我遇到过这样的分母口径问题。下面用 10 条合成入选记录和可复算的 Python、SQL,得到 60%、25% 和 83.3% 三种结果。目的不是找出最好看的胜率,而是回答选策略前更重要的三个问题:分母统计的是什么、收益按什么时间结算、重复观察能算多少证据?

样本去重与分母选择:六天入选十次

先把市场、策略版本和持有期固定为同一组,假设下表所有记录已经完成 T+5 结算。股票 A、B、C、D 都是虚构标的;表中 T+5 收益均为未扣除佣金、滑点等成本的毛收益,只用于演示统计口径,并非真实行情抽样。

入选日期入选标的与 T+5 收益当日入选信号 T+5 等权均值
d1A +5%,B -3%+1%
d2A +4%,C -2%+1%
d3A +3%,B -1%+1%
d4A +6%+6%
d5A +2%+2%
d6A +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 仅计数七条结果断言,不包含另外一条输入结构断言。

#!/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%),不是每天真实持仓的净收益。脚本本身和输出都已完整给出,复制脚本即可复算;为避免只剩一个无法复核的“内部收据”说法,这里也记录本次原始文件的 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% 没有这些条件,不等于可以实现的日收益率。

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

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

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