A stock-screening strategy reports a 60% win rate from 10 observations. That sounds like a useful starting point—until you discover that six of those ten observations are the same stock appearing on different days. What does the 60% actually tell you?

Whether you compare strategies in a screener or build your own quantitative tools, this is a recurring trap: the sample count may measure how often a signal fired, not how many different stocks it covered—and certainly not how many trades were executed. A single percentage can make repeated observations look like a broad validation sample.

I ran into this denominator problem while reviewing historical maintenance notes for a signal-tracking system. Below, ten deliberately synthetic selection records and runnable Python and SQL produce three different figures: 60%, 25%, and 83.3%. The point is not to pick the most flattering number. It is to ask: What is the denominator, when is the outcome settled, and how much independent evidence do repeated observations add?

Sample deduplication and denominators: ten picks across six dates

Hold market, strategy version, and holding horizon constant, and suppose that every row has already settled at T+5. Stocks A–D are fictional. All forward returns are gross values before commissions and slippage, designed to demonstrate grouping—not sampled from real prices.

Pick dateStocks selected and T+5 returnEqual-weight T+5 mean of that day’s picks
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%

Six of the ten rows are positive, all from A. Counting every settled selection event, the event-level win rate is 6/10 = 60%. If your question is “Of the signals that fired and reached settlement, how many had a positive outcome?”, this is a meaningful denominator. Repeated picks do not invalidate the arithmetic.

But that does not mean the strategy was tested on ten different stocks. There are only four. Calculate each stock’s average T+5 return over the observation window first, then ask how many stocks have a positive average: only A qualifies, so the unique-symbol win rate is 1/4 = 25%. B, C, and D have negative means.

A third view groups by pick date. Average the settled T+5 returns equally within each date, then count dates whose cohort mean is positive. Five of six dates qualify, giving a daily-cohort win rate of 5/6 = 83.3%. The mean of those six date-level T+5 averages is about +1.58%. That is an average forward cohort outcome, not a portfolio earning 1.58% per trading day. It must not be annualized as such.

MetricResultQuestion answered
Event-level win rate60% (6/10)How often was each settled selection record positive?
Unique-symbol win rate25% (1/4)For how many distinct stocks was the average T+5 outcome positive?
Daily-cohort win rate83.3% (5/6)On how many pick dates was the equal-weight cohort mean positive?

Here the date-level rate is the highest. Repeating a stock does not necessarily inflate the event-level win rate; it changes the weight that stock receives. If the repeatedly selected stock is a loser, the event rate can fall below the unique-symbol rate. Before choosing a strategy from a win-rate badge, establish whether it counts selection events, distinct symbols, or pick dates.

Reproduce the three win rates with Python

This is the complete script I executed with Python 3.13.5 and only the standard library. Its input has ten synthetic records—not “a ten-line script.” It prints per-symbol and per-date T+5 averages along with all three win rates. It has seven outcome assertions plus one input-structure assertion. The final ASSERTIONS_PASS=7 label counts the seven outcome assertions, not the separate input check.

#!/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")

Here is the complete standard output (synthetic figures, not a market backtest):

{
  "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

The denominators are 6/10 for event-level wins, 1/4 for unique-symbol wins, and 5/6 for daily-cohort wins. The mean date-level T+5 outcome is 0.01583333, or roughly +1.58%; this is not realized daily net portfolio return. Both the runnable script and its actual output are shown, so you can reproduce the calculation. For byte-level reference, these are the original files’ SHA-256 digests:

  • Python script: 0b554335c960b4a58eab671a693ef64cdba4ab46a6c6f653a93fa2ca220a7b8f
  • Standard output: 7b91fcdda0c6bea63d05788f25815af16321c647d5342463601599a81db1b8f2

The hashes cover the original UTF-8 file bytes. Copying from a webpage may change line endings or whitespace and therefore the file digest, while the numeric results remain independently checkable. No live price database or real brokerage account was accessed.

Two more traps: duplicate database events and incorrect settlement

When reviewing maintenance notes from a stock-signal tracker, I found a useful design choice: retain every genuine selection event but present the event-level, unique-symbol, and daily-cohort win rates together. These definitions came from internal historical engineering notes; I did not independently re-query a live deployment. The Python example validates the formulas, not the track record of a trading strategy.

First distinguish two kinds of repetition. A stock picked on Monday and again on Tuesday represents two traceable signals. A scheduled job retry that writes the same event with the same date, market, strategy, stock, and holding horizon twice may be a duplicate ingestion bug. Do not delete the former wholesale or count the latter as extra evidence. The exact event identity also depends on source and strategy semantics.

A subtler problem is settlement. T+5 should mean five applicable market trading sessions, not five executions of a scheduled job. The exit price must correspond to the agreed settlement date, not the latest quote when a delayed task finally runs. Entries still marked pending—whether immature or missing price data—must not enter the settled win-rate denominator. The historical notes discuss session calendars, historical closes, and strategy-version separation, but they are not an independently verified production audit.

Likewise, “no picks,” “not yet settled,” and “settled but all negative” are three different states. Check event identity, correct settlement prices, and the completed-outcome filter before comparing percentages. That is more useful than grabbing the largest number on the screen.

Overlapping holding periods: deduplicated stocks are not independent samples

Even after grouping by symbol, the remaining observations may still be statistically dependent. Imagine stock A is selected on six consecutive days and every record measures the next five sessions. Adjacent forward-return windows share parts of the same price path. Grouping by stock does not eliminate common market factors affecting different symbols either. The counts of distinct symbols, distinct pick dates, and statistically effective observations are not interchangeable.

That is why ten adjacent T+5 records should not be randomly scattered between training and test sets and treated as independent evidence of generalization. scikit-learn’s TimeSeriesSplit documentation covers chronological splits that avoid training on future observations; its group cross-validation guide addresses related examples leaking across folds. Grouping by symbol and splitting by time address different failure modes.

When labels span five future trading sessions, training and test label intervals can overlap across the boundary. The Hudson & Thames PurgedKFold implementation illustrates excluding training observations whose information intervals overlap test labels. This is a third-party source-code reference, not a model run in this article. Neither chronological ordering alone nor an arbitrary one-day gap fixes all forward-label overlap.

These methods improve inference quality; they do not mean every screener must display a single suspiciously precise “effective sample count.” To a reader comparing strategies, visible event counts, distinct symbols, pick dates, holding horizons, and the pending fraction often tell a more useful story.

Read-only SQL checks: settled events, symbols, and pick dates

Back to the practical decision: is the displayed win rate fit for comparing this strategy? If you have the data, keep the raw selections and settlement records. Compare within the same market, strategy version, entry assumptions, and T+N horizon; then inspect all three denominators. Specify whether returns are gross or net of costs.

Instead of trusting a single total rate, start with settled events and restrict them to one market, one strategy version, one T+N horizon, and one pick-date window. The following read-only query uses ordinary PostgreSQL/SQLite aggregation. your_settled_outcomes, example-v2, and the column names are placeholders—not a claim about any live database schema. Map them to your own data, confirm settlement conditions, and test against a read-only copy.

-- 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;

The SQL follows the same three grouping layers as Python: settled holds completed events, by_symbol groups means by stock, and by_day groups means by pick date. I executed an equivalent query on ten synthetic rows using Python 3.13.5’s sqlite3 module on Linux x86_64 (sqlite3.sqlite_version = 3.46.1). It returned 10 events, 4 stocks, 6 pick dates, and rates 0.6 / 0.25 / 0.833333…. Neither PostgreSQL nor a production database was queried.

To investigate possible duplicate ingestion on the same date and symbol, run a second read-only check against your original selection-event table (not only settled results). The table and filters below are examples; use your own valid market, strategy version, horizon, and date window.

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;

This lists repeated date/symbol combinations within the same market, strategy, and horizon. I tested it with SQLite on the ten fictional events above: the initial set produced no match. After intentionally duplicating the A event dated 2026-07-01, the query returned repeat_rows = 2. This validates the query mechanism; it does not prove a real system has duplicate rows.

A match is not a delete instruction. Different sources or intraday signals can legitimately produce multiple records with these fields. Verify event IDs, generation times, and source metadata before classifying a true duplicate. Picks of the same stock on different dates should not be deleted as ingestion errors. Count pending, missing prices, and older strategy versions separately; real trading performance also needs return distributions and costs.

To claim “buy the recommended basket every day” as an investable result, you still need a position- and cash-aware portfolio backtest with overlapping holdings, fees, liquidity, market impact, and slippage. The +1.58% mean of date-level forward cohorts includes none of that, so it is not a realizable daily portfolio return.

For a related but distinct problem, see comparing stock-screening scores across strategies: that piece asks whether two sources’ “80-point” scores mean the same thing. Here the prerequisite is simpler—one selection, one stock, one pick date, and one executable trade are not the same statistical unit.

So the next time you see “60% win rate, 10 samples,” ask: Do those ten mean signal events or ten different stocks? How many trading dates do they span? How many T+N outcomes have actually settled? Once you can answer these, Python and SQL can help you evaluate what the number says. Only then is it worth looking at overlap, trading costs, and risk before deciding which strategy is more credible.

Validation scope: Python 3.13.5 and SQLite 3.46.1 on fabricated samples, with historical maintenance notes as engineering context. No live market-data audit or executable portfolio backtest was performed.