# Lo stesso titolo compare più volte: il win rate è affidabile?

> Canonical: https://vxi.me/it/posts/quant-winrate-repeated-stock-picks/
> Locale: it-it
> Published: 2026-10-17
> Updated: 2026-10-17
> Markdown: https://vxi.me/it/posts/quant-winrate-repeated-stock-picks.md

Uno screener azionario mostra «60% di operazioni positive su 10 osservazioni». A prima vista sembra un dato utile. Ma se sei di quelle dieci osservazioni riguardano lo stesso titolo, selezionato in giorni consecutivi, che cosa ci dice davvero quel 60%?

È un problema comune sia a chi confronta strategie in uno screener sia a chi sviluppa strumenti quantitativi: **il numero di campioni può contare quante volte si è attivato un segnale, non quanti titoli diversi siano stati osservati e tanto meno quante operazioni siano state eseguite**. Un'unica percentuale può far sembrare una ripetizione di osservazioni una validazione ampia.

Esaminando vecchie note di manutenzione di un sistema di tracciamento dei segnali, mi sono imbattuto proprio in questa ambiguità del denominatore. Con **dieci eventi di selezione costruiti artificialmente** e codice riproducibile in Python e SQL otteniamo **60%, 25% e 83,3%**. Lo scopo non è scegliere il numero più attraente, ma chiarire **che cosa conta il denominatore, quando un risultato è maturato e quanta evidenza indipendente aggiungono le osservazioni ripetute**.

## Selezioni ripetute e denominatori: dieci segnali in sei giorni

Manteniamo costanti mercato, versione della strategia e orizzonte di mantenimento. Supponiamo che tutti i risultati della tabella siano già maturati a T+5. A, B, C e D sono titoli fittizi. I rendimenti T+5 sono **lordi, prima di commissioni e slippage**, costruiti solo per mostrare i raggruppamenti e non ricavati da quotazioni reali.

| Data di selezione | Titoli selezionati e rendimento T+5 | Media T+5 equiponderata dei segnali del giorno |
| --- | --- | ---: |
| 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% |

Sei righe su dieci hanno un risultato positivo, tutte riferite ad A. Contando **ogni evento di selezione con esito già maturato**, il **win rate per evento è 6/10 = 60%**. Se vogliamo sapere «quante volte un segnale attivato e arrivato a scadenza ha avuto rendimento positivo?», questo denominatore è sensato. Le selezioni ripetute non rendono sbagliata l'aritmetica.

Ma non significa che la strategia sia stata verificata su dieci titoli diversi. I titoli sono soltanto quattro. Se calcoliamo prima la media dei rendimenti T+5 per ogni titolo nella finestra osservata e poi contiamo quanti titoli hanno media positiva, solo A supera lo zero: **win rate per titolo distinto, 1/4 = 25%**. Le medie di B, C e D sono negative.

Possiamo anche raggruppare per data di selezione. Calcoliamo la media equiponderata dei T+5 maturati di ciascun giorno e contiamo i giorni con media positiva: cinque su sei, dunque **win rate per coorte giornaliera 5/6 = 83,3%**. La media aritmetica di questi sei rendimenti T+5 di coorte è circa +1,58%. **Non** vuol dire che un portafoglio abbia guadagnato l'1,58% ogni giorno, né si può annualizzare il dato in questo modo.

| Metrica | Risultato | Quale domanda risponde? |
| --- | ---: | --- |
| Win rate per evento | 60% (6/10) | Quale quota dei segnali maturati è stata positiva? |
| Win rate per titolo distinto | 25% (1/4) | Quanti titoli hanno una media T+5 positiva? |
| Win rate per coorte giornaliera | 83,3% (5/6) | Quante date hanno una media equiponderata positiva? |

In questo esempio il tasso per data è addirittura il più alto. Ripetere un titolo **non gonfia necessariamente** la quota di eventi positivi: cambia il **peso** attribuito a quel titolo nella statistica. Se il titolo selezionato più spesso perde, il win rate per evento potrebbe perfino essere più basso. Prima di scegliere una strategia da una percentuale, verifichiamo se sta contando *eventi*, *titoli distinti* o *date di selezione*.

## Riprodurre i tre win rate con Python

Quello che segue è lo **script completo realmente eseguito** con Python 3.13.5 e soltanto la libreria standard. L'input contiene dieci eventi sintetici, non uno «script di dieci righe». Stampa le medie T+5 per titolo e per data, oltre alle tre percentuali. Comprende **sette assert sui risultati e uno sulla struttura dell'input**. La riga finale `ASSERTIONS_PASS=7` conta solo i sette controlli sui risultati.

```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")
```

Ecco l'output standard integrale (valori sintetici, non risultati di un backtest di mercato):

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

I denominatori sono 6/10 per evento, 1/4 per titolo distinto e 5/6 per coorte giornaliera. La media T+5 tra le date è `0.01583333` (circa +1,58%), **non un rendimento netto giornaliero realizzato dal portafoglio**. Script e output sono riportati per intero e permettono di ripetere il calcolo. Per un confronto dei byte originali dei file UTF-8, ecco gli hash SHA-256:

- Script Python: `0b554335c960b4a58eab671a693ef64cdba4ab46a6c6f653a93fa2ca220a7b8f`
- Output standard: `7b91fcdda0c6bea63d05788f25815af16321c647d5342463601599a81db1b8f2`

Gli hash si riferiscono ai byte dei file UTF-8 originali. Copiando da una pagina web possono cambiare ritorni a capo o spazi e quindi l'hash; i numeri rimangono comunque verificabili. L'esperimento non ha interrogato database di prezzi in produzione né conti di trading reali.

## Altre due insidie: eventi duplicati nel database e liquidazione errata

Rileggendo note di manutenzione di uno strumento di tracciamento dei segnali, ho trovato ragionevole una scelta progettuale: **conservare ogni vero evento di selezione e mostrare insieme il win rate per evento, per titolo distinto e per coorte giornaliera**. Le definizioni derivano da documentazione tecnica interna storica; per questo articolo non ho interrogato nuovamente il database live. L'esempio Python verifica il calcolo, non le prestazioni storiche di una strategia reale.

La prima distinzione riguarda due tipi di ripetizione. Lo stesso titolo selezionato lunedì e martedì produce due segnali tracciabili. Se invece un job ripetuto salva **due volte lo stesso evento con identici giorno, mercato, strategia, titolo e orizzonte**, può trattarsi di un inserimento duplicato. Non bisogna eliminare indiscriminatamente i primi né contare i secondi come nuova evidenza. La chiave esatta dell'evento dipende anche dalla sorgente e dal contratto della strategia.

Un problema più sottile riguarda **quando l'esito matura**. T+5 dovrebbe indicare cinque sessioni di mercato valide, non cinque esecuzioni di un job pianificato. Il prezzo d'uscita deve corrispondere al giorno previsto, non alla quotazione corrente quando il job viene recuperato in ritardo. I record ancora `pending`, perché non maturi o senza dati di prezzo, non devono finire nel denominatore dei casi chiusi. Le note storiche parlano di calendario di mercato, prezzi di chiusura storici e separazione delle versioni, ma non costituiscono una verifica indipendente del sistema oggi in produzione.

«Nessun candidato», «esito ancora da maturare» e «tutti gli esiti maturati negativi» sono tre stati differenti. **Verifica che gli eventi non siano duplicati, che il prezzo alla scadenza sia corretto e che si contino solo record maturati prima di confrontare le percentuali.** È molto più utile che scegliere subito il numero più alto.

## Orizzonti sovrapposti: deduplicare i titoli non rende indipendenti i campioni

Anche raggruppando per titolo, le osservazioni possono restare dipendenti. Se A viene selezionato per sei giorni consecutivi e ogni segnale misura le cinque sessioni successive, gli intervalli futuri condividono una parte dello stesso percorso dei prezzi. La media per titolo non elimina nemmeno fattori di mercato comuni a più azioni. Numero di titoli distinti, numero di date e numerosità campionaria statisticamente effettiva non sono la stessa cosa.

Per questo non possiamo distribuire casualmente dieci etichette T+5 contigue tra training e test e dichiarare una verifica indipendente della generalizzazione. La [documentazione TimeSeriesSplit di scikit-learn](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.TimeSeriesSplit.html) descrive partizioni cronologiche che evitano di addestrare su dati futuri; la [guida alla cross-validation per gruppi](https://scikit-learn.org/stable/modules/cross_validation.html) affronta la contaminazione tra fold di esempi correlati. Raggruppare per titolo e dividere per tempo protegge da problemi diversi.

Se le etichette coprono cinque sessioni future, anche gli **intervalli informativi** delle etichette di training e test possono sovrapporsi oltre il confine della partizione. L'[implementazione PurgedKFold di Hudson & Thames](https://github.com/hudson-and-thames/mlfinlab/blob/79dcc7120ec84110578f75b025a75850eb72fc73/mlfinlab/cross_validation/cross_validation.py) mostra come escludere esempi di training i cui intervalli si sovrappongono alle etichette di test. È una **referenza al codice sorgente** di terzi, non un modello quantitativo eseguito qui. Ordinare per data o aggiungere arbitrariamente un giorno vuoto non elimina necessariamente la sovrapposizione.

Queste tecniche migliorano l'affidabilità delle inferenze; non obbligano ogni screener a mostrare un singolo «numero effettivo di campioni» dall'aria precisa. Per scegliere fra strategie è spesso più utile vedere eventi grezzi, titoli distinti, date coperte, orizzonti di tenuta e quota di esiti ancora in sospeso.

## Diagnosi SQL in sola lettura: eventi maturati, titoli e date

Torniamo alla decisione pratica: **questo win rate è utilizzabile per confrontare strategie?** Se abbiamo i dati, teniamo distinti gli eventi originali e i risultati maturati. Confrontiamo stesso mercato, versione della strategia, ipotesi d'ingresso e orizzonte T+N, guardando tutte e tre le percentuali. Bisogna anche indicare se i rendimenti sono lordi o netti dei costi.

Invece di affidarci al tasso totale mostrato a schermo, partiamo solo dagli eventi maturati dello **stesso mercato, stessa versione, stesso T+N e medesima finestra di selezione**. La query seguente è in sola lettura e usa aggregazioni comuni a PostgreSQL/SQLite. `your_settled_outcomes`, `example-v2` e i nomi delle colonne sono segnaposto, **non la struttura di un database reale**. Vanno adattati ai propri dati, verificando lo stato di maturazione e usando una copia accessibile in sola lettura.

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

La query SQL usa gli stessi tre livelli del Python: `settled` per gli eventi conclusi, `by_symbol` per la media di ogni titolo e `by_day` per la media per data di selezione. Ho eseguito una query equivalente su dieci righe sintetiche con il modulo `sqlite3` di Python 3.13.5 su Linux x86_64 (`sqlite3.sqlite_version` = `3.46.1`). Il risultato è **10 eventi, 4 titoli, 6 date e tassi 0.6 / 0.25 / 0.833333…**. Non sono stati interrogati né PostgreSQL né un database di produzione.

Se sospetti **un inserimento ripetuto dello stesso titolo nella stessa data**, esegui una seconda query di sola lettura sulla tabella originale degli eventi di selezione, non soltanto sui risultati maturati. Nome tabella e filtri sono dimostrativi: sostituiscili con mercato, versione, orizzonte e periodo corretti.

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

Il risultato elenca combinazioni ripetute di data e titolo nello stesso mercato, strategia e orizzonte. Ho testato la query in SQLite con i **dieci eventi inventati**: inizialmente non trovava nulla. Inserendo intenzionalmente una copia dell'evento A del 2026-07-01, restituisce `repeat_rows = 2`. È una verifica della query, **non una prova** che un sistema reale abbia righe duplicate.

**Un risultato non è un ordine di cancellazione.** Sorgenti diverse o segnali in momenti differenti della stessa giornata possono condividere quei campi legittimamente. Prima vanno confrontati ID dell'evento, ora di generazione e provenienza. Lo stesso titolo in giorni differenti non è un errore di importazione. Conta separatamente record pending, prezzi mancanti e vecchie versioni; per valutare il trading reale servono anche distribuzioni dei rendimenti e costi.

Per affermare che cosa accadrebbe «comprando ogni giorno il paniere suggerito», serve un backtest di portafoglio con capitale, posizioni sovrapposte, commissioni, liquidità, impatto sul mercato e slippage. La media +1,58% delle coorti T+5 non considera questi aspetti e **non è un rendimento giornaliero di portafoglio realizzabile**.

Un tema collegato, ma distinto, è [confrontare punteggi azionari tra strategie](/posts/stock-score-calibration-simpsons-paradox/): quell'articolo chiede se due «80 punti» prodotti da sorgenti diverse siano confrontabili. Qui risolviamo una questione ancora più a monte: un evento di selezione, un titolo, una data e un'operazione eseguibile **non sono la stessa unità statistica**.

Quindi, la prossima volta che compare «win rate 60%, 10 campioni», chiediamoci: **dieci segnali o dieci titoli distinti? In quante sessioni sono distribuiti? Quanti esiti T+N sono davvero maturati?** Solo dopo queste risposte Python e SQL possono aiutarci a valutare il numero. Successivamente hanno senso le considerazioni su orizzonti sovrapposti, costi e rischio prima di scegliere la strategia più credibile.

*Ambito della verifica: Python 3.13.5 e SQLite 3.46.1 su campioni sintetici, con vecchie note di manutenzione come contesto tecnico. Non sono stati verificati dati reali di mercato né eseguiti backtest di portafoglio attuabili.*
