Due segnali azionari da 80 punti: paradosso di Simpson e calibrazione delle probabilità
Due azioni compaiono in uno screener con lo stesso punteggio: 80. La prima arriva da un motore che combina segnali tecnici; la seconda ha superato un preset di filtri su trend, prezzo e volumi. Se l’interfaccia mostra un’unica colonna, viene spontaneo ordinarle insieme.
C’è però una domanda che viene prima di tutto: quegli 80 punti misurano davvero la stessa cosa?
Rileggendo gli appunti di sviluppo di un sistema di screening multi-sorgente, ho ritrovato una scelta architetturale importante. Un segnale tecnico composito e uno screener basato su condizioni possono avere ingressi diversi. Penalizzare una sorgente perché non possiede una caratteristica esclusiva di un’altra significa confondere assenza del campo e qualità del segnale. Lo scoring consapevole della sorgente (source-aware scoring) evita questo errore, ma non trasforma automaticamente tutti i punteggi 0–100 in quantità statisticamente confrontabili.
80 punti assegnati da una regola non sono una probabilità dell’80% di un rendimento positivo. Per confrontare strategie differenti bisogna definire lo stesso evento futuro e valutare risultati effettivamente maturati, su periodi e condizioni comparabili. In assenza di prove sufficienti, una classifica distinta per sorgente è più utile di una graduatoria generale apparentemente precisa.
Perché sorgenti diverse richiedono profili di scoring diversi
Una nota di progetto del giugno 2026 descriveva la signal_quality di un segnale tecnico composto usando resonance_score e technical_rating, con un esempio di ponderazione del 70% e 30% in quella fase di progettazione.
La strada dello screener, invece, poteva utilizzare technical_rating, giudizi sulle medie mobili, RSI, MFI, distanza dai massimi a 52 settimane e numero di filtri soddisfatti. Non era tenuta a produrre il resonance_score specifico dei segnali composti. Trasformare il campo mancante in zero avrebbe penalizzato lo screener solo perché appartiene a una categoria differente.
| Sorgente | Cosa chiede il suo punteggio interno | Cosa sarebbe scorretto |
|---|---|---|
| technical_signal | Più segnali tecnici si confermano a vicenda? | Ignorare una misura di risonanza disponibile |
| screener_preset | Trend, prezzo e volumi rispettano le condizioni? | Sottrarre punti per un campo esclusivo di un’altra sorgente |
| factor_strategy | Le condizioni definite dai fattori sono soddisfatte? | Richiedere variabili di un altro tipo di strategia |
Sono note storiche di progettazione, non una verifica della configurazione oggi in produzione. Servono a mostrare perché distinguere le sorgenti abbia senso. Non dimostrano che il loro punteggio di 80 corrisponda allo stesso rischio o alla stessa frequenza di successo.
Per A, 80 può significare alta coerenza con le regole di risonanza; per B, alta coerenza con un preset di screening. Una scala grafica uguale non implica probabilità uguali. La documentazione di scikit-learn sulla calibrazione delle probabilità spiega come confrontare probabilità stimate e frequenze osservate su dati indipendenti. Un punteggio euristico non va interpretato direttamente come 0,80 di probabilità.
Sarebbe sufficiente confrontare la frequenza storica di successo di tutti gli 80 punti per sorgente? Non se la composizione dei campioni è diversa.
Paradosso di Simpson: stessi 80 punti, classifica aggregata invertita
Per isolare il problema di aggregazione dal rumore di mercato, uso 80 osservazioni didattiche costruite intenzionalmente (Toy Example, non un backtest reale). Tutte hanno raw_score = 80. Supponiamo due sorgenti, due regimi di mercato immaginari (trend e range) e un’etichetta binaria a cinque sedute impostata in anticipo.
| Sorgente | Mercato in trend | Mercato laterale | Tasso aggregato |
|---|---|---|---|
| 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% |
Guardando solo l’ultima colonna sembra migliore A. Ma all’interno di ciascun regime vince B: 80% contro 70% in trend e 50% contro 30% in range. A ha più osservazioni nel regime che produce più facilmente l’evento positivo; B ha più osservazioni in quello laterale. È la composizione del campione, non una superiorità dimostrata, a invertire il risultato totale.
Applichiamo a entrambe le sorgenti lo stesso mix ipotetico: 50% trend e 50% range.
| Sorgente | Mix originale del campione | Mix comune 50/50 |
|---|---|---|
| A | 60% | 50% |
| B | 57.5% | 65% |
Ora B è davanti. È il classico paradosso di Simpson (Simpson’s paradox). Il mix 50/50 è una scelta didattica e non descrive la distribuzione di alcun mercato osservato. In un’analisi vera occorre fissare prima la popolazione obiettivo e i suoi pesi, non sceglierli dopo aver visto il risultato preferito.
Il calcolo delle divisioni non è la parte più utile da automatizzare. Molto più interessante è capire quanto sia fragile una percentuale alta in un gruppo minuscolo. Nel trend, B ha otto esiti positivi su dieci: apparentemente 80%. Con l’ipotesi di prove binarie indipendenti, il limite inferiore dell’intervallo Wilson al 95% è solo circa 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%
La funzione genera volutamente un errore se n=0: nessuna osservazione non significa successo misurato pari a zero. Un limite Wilson non garantisce neppure un tasso di vincita prudenziale per operazioni reali. Segnali ripetuti sullo stesso titolo e orizzonti di cinque giorni sovrapposti violano l’indipendenza semplice; l’incertezza va calcolata tenendo conto della dipendenza.
Script Python completo con 80 osservazioni sintetiche e quattro assert
#!/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()
Lo script completo usa solo la libreria standard di Python, costruisce le 80 righe artificiali, calcola quote per regime, quote aggregate e intervalli Wilson, e controlla quattro assert. Verifica un meccanismo statistico, non la redditività di nessuna strategia di trading.
Nei dati veri lo stesso titolo può comparire in due sorgenti nello stesso giorno, mentre i forward return a cinque sedute di segnali emessi a t e t+1 si sovrappongono. Sommare le righe aumenta il conteggio, ma non necessariamente la quantità di informazione indipendente.
Definire l’evento prima della calibrazione
La prima decisione non riguarda Logistic Regression o Isotonic Regression: riguarda cosa chiamiamo «successo».
«Il prezzo è più alto dopo cinque sedute», «batte il benchmark dopo cinque sedute» e «produce un profitto netto dopo esecuzione, commissioni e slippage» sono eventi diversi. Anche etichette a uno e dieci giorni non possono essere mescolate in un’unica curva di calibrazione.
Un test di ricerca delimitato potrebbe fissare mercato e istante decisionale alla chiusura, e definire positivo il rendimento a cinque sedute superiore a zero secondo un modello di costi esplicito. Finché il periodo non è terminato, il risultato resta pending. Senza prezzi effettivi di esecuzione, parliamo di proxy basato sui prezzi, non di P&L realizzato.
Ogni decisione dovrebbe conservare uno snapshot immutabile delle informazioni allora disponibili:
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 identifica il profilo di scoring, model_version evita di confrontare come uguali punteggi calcolati prima e dopo una modifica dei pesi, e as_of_time delimita ciò che era noto al momento della decisione. Ricalcolare in seguito i punteggi storici con dati revisionati e codice nuovo significa spesso valutare un oggetto diverso.
Lo stesso titolo selezionato nello stesso giorno da due sorgenti può avere due righe per studiare l’attribuzione, ma non rappresenta due movimenti indipendenti ai fini degli intervalli di confidenza o dei rendimenti di portafoglio. Serve una regola chiara per duplicati e correlazioni per titolo e data.
Anche i pending outcomes sono fondamentali: prima del quinto giorno un segnale non è un fallimento e non ha rendimento zero. Se una sorgente contiene soprattutto segnali recenti e si filtrano solo le righe completed, il confronto può subire un bias di maturazione. Universi diversi, titoli delistati, ritardi nella pubblicazione dei fondamentali, rettifiche dei prezzi e vincoli di liquidità complicano ulteriormente le popolazioni di partenza.
Sono dettagli operativi, ma stabiliscono se una futura curva probabilistica avrà davvero un significato.
Distinguere capacità di ranking, calibrazione e payoff
Partirei da bucket di raw_score separati per sorgente, periodo, mercato e regime. Per ogni gruppo mostrerei numerosità, quota di etichette definitive, frequenza positiva, distribuzione dei rendimenti e incertezza. Solo dopo chiederei se ci siano dati sufficienti per mappare i punteggi su probabilità di eventi.
Con gruppi piccoli preferisco mantenere il ranking interno oppure valutare stime gerarchiche con pooling e shrinkage, invece di addestrare un calibratore flessibile per ogni combinazione source × regime × session. Un apparente 100% su pochissimi casi può diventare precisione ingannevole.
Quando ci sono abbastanza esiti maturi, si possono confrontare una calibrazione Sigmoid semplice e metodi non parametrici più flessibili. La qualità va misurata su periodi successivi e indipendenti da quelli usati per il fitting. Il reliability diagram descritto da scikit-learn confronta la probabilità media prevista per ogni gruppo con la frequenza reale di eventi positivi. Il Brier Score dipende anche da capacità discriminante e incertezza del risultato; una sua riduzione non dimostra automaticamente maggiori profitti.
Nelle serie finanziarie bisogna inoltre controllare il leakage temporale. Un segnale emesso a t ottiene la sua etichetta a cinque giorni solo a t+5. Se un’osservazione di training vicino al confine usa prezzi verificatisi durante il periodo di test, una parte del futuro è già entrata nel modello. TimeSeriesSplit permette split cronologici e gap, ma impostare meccanicamente gap=5 non è una protezione universale: contano le date di maturazione, il calendario di mercato e la sovrapposizione tra strumenti.
| Domanda di ricerca | Evidenza da controllare |
|---|---|
| Punteggi più alti mettono davvero i candidati migliori in cima? | Metriche di ranking out-of-time, distribuzione dei forward return Top contro Rest |
| Tra gli eventi previsti al 60%, circa il 60% si realizza? | Bucket di reliability out-of-time, numerosità, stabilità per sorgente ed errori probabilistici |
Un buon score per ordinare non è necessariamente una buona probabilità. Un modello che replica soltanto la frequenza media di un evento può essere vicino al valore base senza distinguere bene tra titoli. La spiegazione di Google sulla prediction bias suggerisce un controllo aggregato utile, ma media prevista uguale alla media osservata non dimostra calibrazione in ogni gruppo.
Soprattutto, probabilità non significa payoff. Conoscere bene la probabilità di un rendimento positivo a cinque giorni non basta a stimare ampiezza dei guadagni e delle perdite, slippage, drawdown, capacità o rendimento atteso. Un’alta percentuale di operazioni positive può convivere con un pessimo rapporto profitti/perdite.
Conservare la sorgente del punteggio finché il confronto non è dimostrato
Non unificherei frettolosamente tutte le sorgenti in una sola classifica. Che un motore di risonanza tecnica e uno screener a condizioni usino caratteristiche diverse è una scelta sensata. Quello che manca è la prova empirica che i punteggi siano confrontabili tra sorgenti.
Il prossimo controllo in sola lettura dovrebbe selezionare batch ordinari con risultati definitivi, escludere i test manuali, fissare source_key, model_version, mercato, session e horizon, quindi analizzare frequenze, rendimenti mediani, perdite estreme, duplicati e copertura per score bin e regime. Soltanto dopo valuterei la mappatura probabilistica su periodi successivi.
La domanda completa gli articoli precedenti sulla validazione di fattori con Bucket Spread e Rank IC e su Regime, Shadow e modifiche dei pesi: il primo chiede se un fattore contenga informazione utile per ordinare; l’altro quando una modifica dei pesi sia pronta per l’uso. Qui ci chiediamo se la parola «forte» abbia lo stesso significato in due motori diversi.
Finché non esistono outcome comparabili, definitivamente maturi e validati fuori dal periodo di fitting, mostrare il punteggio originario accanto alla sua sorgente è più affidabile che inventare una «probabilità universale di successo» con troppe cifre decimali.