¿Es fiable la tasa de aciertos si siempre aparece la misma acción?
Un screener bursátil muestra «60 % de aciertos, 10 observaciones». Parece un dato útil. Pero ¿y si seis de esas diez observaciones proceden de la misma acción seleccionada varios días seguidos? ¿Qué significa entonces ese 60 %?
El problema afecta tanto a quien compara estrategias en un screener como a quien desarrolla herramientas cuantitativas: el número de muestras puede ser el número de veces que se activó una señal, no el número de acciones distintas y mucho menos el de operaciones ejecutadas. Un único porcentaje puede hacer que observaciones repetidas parezcan una validación extensa.
Al revisar notas históricas de mantenimiento de un sistema de seguimiento de señales, me encontré con este problema del denominador. Con diez registros de selección deliberadamente sintéticos y código reproducible en Python y SQL, obtendremos 60 %, 25 % y 83,3 %. No se trata de elegir el porcentaje más atractivo, sino de aclarar qué cuenta el denominador, cuándo se liquida el resultado y cuánta evidencia independiente aporta repetir una observación.
Selecciones repetidas y denominadores: diez señales en seis días
Fijemos el mercado, la versión de la estrategia y el horizonte de tenencia. Suponemos que todos los registros ya se han liquidado en T+5. Las acciones A, B, C y D son ficticias. Sus rendimientos T+5 son brutos, sin descontar comisiones ni deslizamiento, y están inventados para ilustrar las distintas agrupaciones, no extraídos de cotizaciones reales.
| Fecha de selección | Acciones seleccionadas y rendimiento T+5 | Media T+5 equiponderada de las señales del día |
|---|---|---|
| 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 % |
Seis de las diez filas tienen rendimiento positivo, todas correspondientes a A. Si contamos cada evento de selección liquidado, la tasa de aciertos por señal es 6/10 = 60 %. Para responder «¿qué proporción de señales terminó en positivo al completar su horizonte?», el denominador tiene sentido. Que una acción aparezca varias veces no invalida la aritmética.
Pero esto no significa que la estrategia se haya probado con diez acciones diferentes. Solo hay cuatro. Si primero calculamos el rendimiento T+5 medio de cada acción durante el periodo y contamos cuántas medias son positivas, solo A cumple: tasa de aciertos por acción única, 1/4 = 25 %. Las medias de B, C y D son negativas.
También podemos agrupar por fecha. Primero promediamos por igual los resultados T+5 de las señales liquidadas de cada día; después contamos los días con media positiva. Ocurre en cinco de seis días, por tanto la tasa de aciertos de cohortes diarias es 5/6 = 83,3 %. La media aritmética de esos seis promedios T+5 es aproximadamente +1,58 %. Eso no implica ganar un 1,58 % diario en una cartera real y no debe anualizarse como tal.
| Métrica | Resultado | Qué pregunta responde |
|---|---|---|
| Aciertos por evento | 60 % (6/10) | ¿Qué proporción de señales liquidadas acabó en positivo? |
| Aciertos por acción única | 25 % (1/4) | ¿En cuántas acciones fue positiva la media de T+5? |
| Aciertos por cohorte diaria | 83,3 % (5/6) | ¿En cuántas fechas fue positiva la media equiponderada? |
En este ejemplo, la tasa diaria es la mayor. Las selecciones repetidas no elevan necesariamente la tasa por evento: cambian el peso de cada acción en la estadística. Si la que aparece constantemente pierde dinero, la tasa por evento también puede ser inferior a la tasa por acción única. Antes de elegir una estrategia por su porcentaje, aclaremos si se cuentan señales, acciones distintas o fechas de selección.
Reproducir las tres tasas con Python
Este es el script completo que ejecuté con Python 3.13.5 y únicamente su biblioteca estándar. Contiene diez registros sintéticos de entrada, no un «script de diez líneas». Imprime las medias T+5 por acción y por fecha, además de las tres tasas. Incluye siete aserciones de resultados y una aserción adicional de estructura; la línea ASSERTIONS_PASS=7 cuenta solo las siete de resultados.
#!/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")
Esta es la salida estándar completa (cifras artificiales, no resultados de un backtest de mercado):
{
"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
Los denominadores son 6/10 por señal, 1/4 por acción y 5/6 por fecha. La media T+5 de las cohortes diarias es 0.01583333 (aprox. +1,58 %), no un beneficio neto diario realizado por una cartera. El script ejecutable y su salida están completos para reproducir los cálculos. Para comparar los bytes originales de los archivos UTF-8, estos son sus hashes SHA-256:
- Script de Python:
0b554335c960b4a58eab671a693ef64cdba4ab46a6c6f653a93fa2ca220a7b8f - Salida estándar:
7b91fcdda0c6bea63d05788f25815af16321c647d5342463601599a81db1b8f2
Los hashes se calculan sobre los archivos UTF-8 originales. Al copiar desde la web pueden cambiar los saltos de línea o espacios y variar el hash, aunque los resultados numéricos se pueden contrastar igualmente. El experimento no consultó una base de datos de cotizaciones de producción ni una cuenta real de negociación.
Otras dos trampas: registros duplicados y liquidación incorrecta
Al revisar notas históricas de mantenimiento de un sistema de seguimiento de señales vi una decisión de diseño sensata: conservar cada evento legítimo de selección y mostrar juntas las tasas por evento, acción única y cohorte diaria. Estas definiciones proceden de documentación técnica histórica interna; no he vuelto a consultar la base de datos de producción para este artículo. El ejemplo en Python valida el cálculo, no la rentabilidad real de una estrategia.
Primero hay que distinguir dos tipos de repetición. Si una acción se selecciona el lunes y otra vez el martes, tenemos dos señales trazables. Si un trabajo programado se reintenta y escribe dos veces exactamente el mismo evento, con fecha, mercado, estrategia, acción y horizonte iguales, puede existir una inserción duplicada. No hay que borrar en bloque lo primero ni contar lo segundo como evidencia nueva. La identidad exacta del evento depende también de la fuente y del diseño de la estrategia.
La segunda trampa es la liquidación del resultado. T+5 debería significar cinco sesiones bursátiles válidas del mercado, no cinco ejecuciones de un job. El precio de salida debe corresponder a la fecha de liquidación acordada, no a la cotización actual cuando una tarea atrasada se ejecuta. Los registros todavía pending, por no haber vencido o por carecer de precios, no deben entrar en el denominador de resultados liquidados. Las notas históricas describen calendarios de sesiones, cierres históricos y separación de versiones, pero no constituyen una auditoría independiente del estado actual de producción.
«No se encontraron candidatos», «todavía no se ha liquidado» y «todo lo liquidado terminó en pérdidas» son tres estados distintos. Primero comprueba identidad de eventos, precios de cierre correctos y filtrado de resultados liquidados; después compara tasas de acierto. Resulta más útil que quedarse con el porcentaje más alto.
Horizontes solapados: eliminar acciones duplicadas no crea muestras independientes
Incluso agrupadas por acción, las observaciones pueden seguir siendo dependientes. Si A se selecciona seis días consecutivos y cada registro mide el rendimiento de las cinco sesiones siguientes, los periodos futuros comparten movimientos de precios. Promediar por acción tampoco elimina los factores de mercado comunes entre distintos títulos. Contar acciones distintas, fechas distintas y muestras estadísticamente efectivas son operaciones diferentes.
Por eso no se deben distribuir aleatoriamente diez etiquetas T+5 contiguas entre entrenamiento y prueba y presentarlo como validación independiente. La documentación TimeSeriesSplit de scikit-learn explica la separación cronológica para evitar entrenar con datos futuros; la guía de validación cruzada por grupos muestra cómo muestras relacionadas pueden filtrarse entre particiones. Agrupar por acción y separar por tiempo mitiga problemas distintos.
Si la etiqueta abarca cinco sesiones futuras, los intervalos de información de las etiquetas de entrenamiento y prueba también pueden solaparse a ambos lados del corte. La implementación PurgedKFold de Hudson & Thames ilustra cómo excluir observaciones de entrenamiento cuyo intervalo coincide con las etiquetas de prueba. Es una referencia al código fuente de terceros, no un modelo cuantitativo ejecutado aquí. Ordenar por fechas o dejar un día al azar no soluciona necesariamente todo el solapamiento.
Estas técnicas mejoran la calidad de las inferencias; no significan que un screener necesite mostrar un número aparentemente preciso de «muestras efectivas». Para comparar estrategias suele ser más útil ver el total de eventos, acciones únicas, fechas cubiertas, horizontes y proporción pendiente de liquidación.
Diagnóstico SQL de solo lectura: eventos liquidados, acciones y fechas
Volvamos a la decisión que importa: ¿sirve esta tasa de aciertos para comparar estrategias? Si tienes los datos, conserva los eventos originales y sus resultados. Fija mercado, versión, hipótesis de entrada y mismo horizonte T+N, y examina las tres tasas en paralelo. Especifica también si el rendimiento es bruto o neto de costes.
En lugar de confiar en una tasa global de pantalla, parte de los eventos liquidados del mismo mercado, versión de estrategia, horizonte T+N y ventana de fechas. La siguiente consulta de solo lectura usa agregaciones habituales de PostgreSQL/SQLite. your_settled_outcomes, example-v2 y los nombres de columnas son marcadores de ejemplo, no el esquema real de un servicio. Adáptalos a tus datos, valida el criterio de liquidación y pruébalos en una copia de solo lectura.
-- 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;
El SQL utiliza los mismos tres niveles que Python: settled reúne eventos liquidados, by_symbol calcula medias por acción y by_day medias por fecha. Ejecuté una consulta equivalente sobre diez registros sintéticos con el módulo sqlite3 de Python 3.13.5 en Linux x86_64 (sqlite3.sqlite_version = 3.46.1). Obtuve 10 eventos, 4 acciones, 6 fechas y tasas 0.6 / 0.25 / 0.833333…. No se consultó PostgreSQL ni ninguna base de datos productiva.
Si sospechas inserciones duplicadas de la misma acción y fecha, ejecuta una segunda consulta de solo lectura sobre la tabla original de eventos de selección, no solo sobre resultados liquidados. La tabla y los filtros siguientes son ejemplos: sustitúyelos por tu mercado, versión de estrategia, horizonte y periodo.
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;
La consulta muestra combinaciones repetidas de fecha y acción dentro del mismo mercado, estrategia y horizonte. La ejecuté en SQLite con los diez eventos ficticios anteriores y no encontró duplicados. Al duplicar deliberadamente el evento de A con fecha 2026-07-01, devolvió repeat_rows = 2. Esto comprueba la lógica SQL, no demuestra duplicaciones en una base real.
Un resultado no significa que haya que borrar filas. Fuentes o momentos intradía distintos pueden generar señales legítimas con estos campos. Hay que contrastar ID de evento, hora de generación y procedencia. Que la misma acción aparezca en otros días no es un error de inserción. Cuenta aparte los registros pendientes, los precios ausentes y las versiones antiguas; para evaluar trading real también necesitas la distribución de rendimientos y sus costes.
Si quieres afirmar qué ocurriría al «comprar cada día la cesta recomendada», necesitas un backtest de cartera que modele capital, posiciones solapadas, comisiones, liquidez, impacto de mercado y deslizamiento. La media +1,58 % de las cohortes T+5 no incluye nada de eso y no es un rendimiento diario de cartera realizable.
Una cuestión relacionada, pero distinta, es comparar puntuaciones de selección entre estrategias: allí se pregunta si dos señales de «80 puntos» miden lo mismo. Aquí resolvemos algo previo: un evento, una acción, una fecha de selección y una operación ejecutable no son la misma unidad estadística.
Así, la próxima vez que leas «60 % de aciertos, 10 muestras», pregunta: ¿son diez eventos o diez acciones distintas? ¿Cuántas sesiones cubren? ¿Cuántos resultados T+N están realmente liquidados? Con esas respuestas puedes usar Python y SQL para entender el porcentaje. Después ya tiene sentido evaluar horizontes solapados, costes y riesgos antes de decidir qué estrategia es más fiable.
Alcance de la validación: Python 3.13.5 y SQLite 3.46.1 sobre datos sintéticos, con notas históricas de mantenimiento como contexto técnico. No se auditaron cotizaciones reales ni se realizó un backtest de cartera ejecutable.