# Dos señales bursátiles con 80 puntos: paradoja de Simpson y calibración probabilística

> Canonical: https://vxi.me/es/posts/stock-score-calibration-simpsons-paradox/
> Locale: es-es
> Published: 2026-10-15
> Updated: 2026-10-15
> Markdown: https://vxi.me/es/posts/stock-score-calibration-simpsons-paradox.md

Un screener de acciones muestra dos candidatos con 80 puntos. El primero procede de una señal técnica que combina varios indicadores; el segundo aparece porque cumple filtros de tendencia, precio y volumen. Ambos comparten la misma columna de puntuación. Ordenarlos juntos parece lo más natural.

Pero hay una pregunta previa: **¿miden lo mismo esos dos 80?**

Al revisar las notas de desarrollo de un sistema de selección de acciones con varias fuentes, encontré una decisión de diseño importante. Una señal técnica compuesta y un screener por condiciones no disponen necesariamente de las mismas variables. Penalizar con cero una característica que solo existe en otra fuente confunde ausencia de información con mala calidad. El scoring sensible a la fuente (source-aware scoring) resuelve esa injusticia, pero no convierte automáticamente todas las escalas de 0 a 100 en una probabilidad común.

**Una puntuación reglada de 80 no significa un 80% de probabilidad de rentabilidad positiva.** Para comparar distintas estrategias hay que definir primero el mismo evento futuro y evaluar resultados ya liquidados, en condiciones y periodos comparables. Cuando faltan esos datos, mantener clasificaciones separadas por fuente es más razonable que publicar una tabla global con falsa precisión.

## Por qué las fuentes necesitan perfiles de puntuación distintos

Una nota del proyecto de junio de 2026 describía el campo signal_quality de una señal técnica compuesta utilizando resonance_score y technical_rating, con una ponderación ilustrativa del 70% y 30% en aquel diseño.

El screener, en cambio, podía utilizar technical_rating, evaluación de medias móviles, RSI, MFI, distancia al máximo de 52 semanas y número de condiciones satisfechas. No tenía por qué disponer del resonance_score exclusivo del motor de señales compuestas. Tratar ese campo ausente como un cero penaliza al screener por pertenecer a otro tipo de estrategia.

| Fuente | Qué evalúa su puntuación interna | Qué sería injusto |
| --- | --- | --- |
| technical_signal | ¿Coinciden y se refuerzan las señales técnicas? | Ignorar datos de resonancia disponibles |
| screener_preset | ¿Se cumplen las reglas de tendencia, precio y volumen? | Descontar puntos por un campo exclusivo de otra fuente |
| factor_strategy | ¿Se cumplen los factores definidos por la estrategia? | Exigir variables pertenecientes a otro sistema |

Esta es una decisión recogida en notas históricas, no una auditoría de la configuración actual en producción. Su valor está en explicar por qué conviene respetar las diferencias entre fuentes.

El problema comienza después: un 80 de A puede significar «gran coincidencia con las reglas de resonancia», mientras que un 80 de B significa «gran coincidencia con los filtros del screener». Que la interfaz use la misma escala no implica que ambos números representen la misma frecuencia esperada de éxito. La [guía de calibración probabilística de scikit-learn](https://scikit-learn.org/stable/modules/calibration.html) explica cómo confrontar probabilidades previstas con tasas observadas y por qué hacen falta datos independientes para ajustar esa relación. Una puntuación heurística no debe leerse directamente como 0,80 de probabilidad.

¿Bastaría entonces con calcular la «tasa histórica de éxito de los 80 puntos» para cada fuente? No si las poblaciones observadas son diferentes.

## Paradoja de Simpson: dos 80 y una clasificación agregada invertida

Para separar la aritmética del ruido del mercado, usamos **80 observaciones didácticas construidas deliberadamente (Toy Example, no un backtest real)**. Todas tienen raw_score = 80. Suponemos dos fuentes, dos estados hipotéticos del mercado (tendencia y rango lateral) y una etiqueta binaria a cinco sesiones definida de antemano.

| Fuente | Mercado en tendencia | Mercado lateral | Tasa agregada |
| --- | ---: | ---: | ---: |
| 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%** |

La última columna sugiere que A es algo mejor. Sin embargo, **en cada estado del mercado gana B**: 80% frente a 70% en tendencia y 50% frente a 30% en lateral. A concentra más observaciones en el estado donde los eventos positivos son más frecuentes; B tiene más observaciones en el estado menos favorable. Es la mezcla, no una superioridad demostrada, la que invierte el resultado total.

Si normalizamos el peso de ambos estados a una distribución común hipotética de 50% tendencia y 50% lateral:

| Fuente | Mezcla original de muestras | Mezcla común 50/50 |
| --- | ---: | ---: |
| A | 60% | 50% |
| B | 57.5% | 65% |

Ahora B está por encima. Es un ejemplo clásico de la **paradoja de Simpson (Simpson's paradox)**. La ponderación 50/50 es solo una herramienta didáctica, no una estimación de la distribución real de ningún mercado. En un estudio auténtico, la población objetivo y los pesos deben fijarse antes de conocer qué estrategia sale ganando.

Más útil que programar las divisiones de la tabla es responder a otra pregunta: ¿qué nos dice una tasa alta si el grupo es diminuto? En tendencia, B registra 8 casos positivos de 10: un aparente 80%. Bajo la hipótesis de observaciones binarias independientes, el **límite inferior del intervalo Wilson al 95% es solo aproximadamente 49.0%**.

~~~python
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 función rechaza expresamente n=0: **no tener observaciones no equivale a una tasa de éxito verificada del 0%**. Además, el límite Wilson no garantiza una tasa de aciertos conservadora en operaciones reales. Si una acción aparece repetidamente o se solapan los retornos a cinco días, la independencia se rompe y la estimación de incertidumbre debe reflejar esa dependencia.

<details>
<summary>Código Python completo de 80 observaciones sintéticas y cuatro aserciones</summary>

```python
#!/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()
```

</details>

El programa completo, sin librerías externas, construye las 80 filas ficticias, calcula tasas por estado y en conjunto, obtiene los intervalos Wilson y ejecuta cuatro aserciones. Verifica el mecanismo de agregación, no la rentabilidad real de ninguna estrategia.

En datos financieros auténticos, la misma acción puede activar dos fuentes el mismo día y los retornos forward de señales en t y t+1 pueden compartir casi todo su horizonte. Un mayor número de filas no garantiza la misma cantidad de evidencia independiente.

## Antes de calibrar una puntuación, definir qué es «éxito»

La primera decisión no debería ser elegir entre regresión logística e isotónica, sino definir el evento a predecir.

«La cotización sube cinco sesiones después», «supera al índice de referencia» y «deja beneficios tras ejecución, comisiones y deslizamiento» son resultados distintos. Tampoco se pueden mezclar etiquetas a uno y diez días en una única calibración.

Un ejercicio de investigación acotado puede fijar el mercado y el instante de decisión al cierre, y marcar como positivo el retorno a cinco sesiones cuando supera cero bajo un modelo de costes especificado. Hasta que vence el horizonte, la etiqueta permanece pendiente. Sin precios reales de ejecución, eso es un **proxy basado en precios**, no P&L realizado.

Cada decisión necesita una instantánea inmutable de lo que se sabía entonces:

~~~text
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 las reglas de puntuación; model_version impide mezclar puntuaciones anteriores y posteriores a cambios de pesos; as_of_time garantiza que las variables estaban disponibles en el instante de decidir. Recalcular después las puntuaciones históricas con datos corregidos y código nuevo cambia silenciosamente el objeto del análisis.

Si una acción activa dos fuentes el mismo día, pueden conservarse dos filas para estudiar la atribución de cada fuente. No deben contarse como dos movimientos independientes para calcular intervalos de confianza o beneficios de cartera. Hace falta una política explícita de duplicados y agrupación por acción y fecha.

Las etiquetas pendientes también importan. Una señal que todavía no ha alcanzado su quinto día no es un fracaso ni un retorno cero. Si las fuentes tienen distinta antigüedad y se analizan solo registros completed, aparece un sesgo de madurez. Diferencias en universos de activos, exclusión de empresas deslistadas, publicación tardía de resultados, ajustes de precios y restricciones de liquidez pueden cambiar todavía más la población comparada.

Este trabajo de datos aparentemente rutinario determina si la curva de calibración tendrá significado.

## Separar capacidad de ranking, calibración y rentabilidad

Primero agruparía los raw_score por fuente, mercado, régimen y periodo. En cada grupo mostraría el tamaño muestral, el porcentaje de etiquetas liquidadas, la tasa positiva, la distribución de retornos y la incertidumbre. Después preguntaría si hay muestras suficientes para transformar la puntuación en probabilidad estimada.

Con celdas pequeñas suele ser mejor mantener rankings internos o considerar un modelo jerárquico con pooling y shrinkage. Entrenar un calibrador flexible independiente para cada combinación source × regime × session convierte fácilmente el ruido en probabilidades con demasiados decimales.

Cuando existan suficientes resultados liquidados, se puede comparar una calibración Sigmoid sencilla con otras más flexibles. La evaluación debe realizarse en **observaciones posteriores e independientes**, no reutilizando los datos que ajustaron el calibrador. Los [diagramas de fiabilidad de scikit-learn](https://scikit-learn.org/stable/modules/calibration.html) comparan el promedio de probabilidades previstas con la fracción positiva real de cada grupo. La puntuación Brier también depende de la capacidad de discriminación y la incertidumbre del evento: mejorarla no demuestra que el sistema gane más dinero.

Las series temporales añaden el problema de anticipación. Una señal de la fecha t no recibe su etiqueta a cinco días hasta t+5. Si una etiqueta de entrenamiento situada justo antes del inicio de la validación usa movimientos de precios ocurridos dentro del periodo de validación, el modelo ya conoce parte del futuro. [TimeSeriesSplit](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.TimeSeriesSplit.html) permite divisiones cronológicas y un gap, pero escribir gap=5 no constituye un embargo seguro en todos los casos: cuentan las fechas de maduración de las etiquetas, el calendario de negociación y el solapamiento por activo.

| Pregunta | Evidencia que conviene revisar |
| --- | --- |
| ¿Ordenan los scores altos los mejores candidatos futuros? | Métricas de ranking fuera de muestra y distribución de retornos Top frente a Rest |
| ¿Se cumplen aproximadamente el 60% de los eventos previstos al 60%? | Grupos de fiabilidad fuera de periodo, tamaños, estabilidad por fuente y errores probabilísticos |

Un ranking útil puede no estar calibrado como probabilidad. Un predictor que simplemente reproduce la frecuencia base puede estar bien en promedio y discriminar muy poco entre valores. La [explicación de prediction bias de Google](https://developers.google.com/machine-learning/crash-course/classification/prediction-bias) sirve como diagnóstico global, pero acertar el promedio no garantiza calibración dentro de cada grupo.

Y **probabilidad no es rentabilidad**. Una probabilidad bien calibrada de retorno positivo a cinco días no determina cuánto se gana al acertar, cuánto se pierde al fallar, los costes de ejecución, la caída máxima, la capacidad de inversión o el retorno esperado. Una tasa de aciertos alta puede coexistir con una pésima relación beneficio/riesgo.

## Mantener la procedencia de cada score hasta tener evidencia comparable

No me apresuraría a convertir todas las fuentes en un ranking único. Que un modelo de resonancia técnica y un screener utilicen evidencias distintas es razonable. Falta demostrar empíricamente que sus resultados permiten una comparación entre fuentes.

El siguiente análisis de solo lectura debería elegir lotes ordinarios con etiquetas ya liquidadas, excluir pruebas manuales, fijar source_key, model_version, mercado, sesión y horizonte, y comparar tasas y distribuciones de retornos por bins de puntuación y régimen. Después revisaría pérdidas extremas, activos duplicados, cobertura y madurez antes de validar un mapeo probabilístico en periodos posteriores.

Esta pregunta conecta los artículos sobre [validación de factores con Bucket Spread y Rank IC](/posts/quant-factor-validation-bucket-spread-rank-ic/) y sobre [regímenes, shadow mode y cambios de pesos](/posts/adaptive-factor-weighting-regime-shadow-drift/). El primero pregunta si un factor ayuda a ordenar; el segundo, si conviene desplegar pesos nuevos. Este artículo se sitúa en medio: **¿se puede comparar el significado de una «señal fuerte» entre motores distintos?**

Hasta contar con resultados comparables, liquidados y fuera de tiempo, mostrar el raw_score junto con su fuente es más informativo que inventar una «probabilidad unificada de ganar» de precisión dudosa.
