---
title: "7.5.3 Portfolio público y entrevistas quant"
description: "Un repositorio que se revisa en diez minutos, Numerai y Kaggle leídos en su documentación, y un banco de práctica de probabilidad, estadística y código."
url: "https://pythonparatrading.com/curso-python-trading/nivel-7/portfolio-publico-y-entrevistas-quant/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-7/portfolio-publico-y-entrevistas-quant.png"
---

# 7.5.3 · Portfolio público y entrevistas: enseñar criterio, no rentabilidades

> Un portfolio cuantitativo no convence por el Sharpe que enseña sino por cómo lo obtiene: datos con licencia, validación honesta y código que otro puede ejecutar. Esta lección audita un repositorio con código, lee cómo puntúan Numerai y Kaggle en su documentación, simula por qué el marcador público engaña y termina con un banco de práctica de entrevistas: probabilidad exacta frente a simulada, estadística, código con tests y brainteasers, con las soluciones plegadas.

- Curso: [Nivel 7: Producción, MLOps, regulación, carrera y capstone](https://pythonparatrading.com/curso-python-trading/nivel-7/)
- Módulo: 7.5 · Carrera profesional quant
- Duración: 75 min
- Requisitos: [0.2.5 · Git y GitHub: commits, ramas, pull requests y .gitignore](https://pythonparatrading.com/curso-python-trading/nivel-0/git-y-github-para-trading/), [0.4.5 · Logging profesional y testing con pytest](https://pythonparatrading.com/curso-python-trading/nivel-0/logging-y-testing-con-pytest/), [0.7.2 · Probabilidad y distribuciones, correlación frente a causalidad y regresión simple](https://pythonparatrading.com/curso-python-trading/nivel-0/probabilidad-distribuciones-y-regresion/), [2.5.4 · Deflated Sharpe Ratio y Probability of Backtest Overfitting (PBO)](https://pythonparatrading.com/curso-python-trading/nivel-2/deflated-sharpe-ratio-y-pbo/)

## Al terminar esta lección

- Preparar un repositorio público que un revisor entienda y reproduzca en diez minutos, y auditarlo con código antes de publicarlo (secretos, licencia, tests, cuadernos con salidas).
- Explicar con la documentación oficial cómo puntúa Numerai (CORR, MMC y liquidación del stake) y reproducir sus fórmulas en Python.
- Mostrar con una simulación por qué optimizar contra un marcador público sobreajusta, y qué dice eso de un resultado en Kaggle.
- Resolver problemas típicos de entrevista de probabilidad y estadística dos veces, con la solución exacta y con una simulación que la compruebe.
- Escribir funciones de entrevista (drawdown, mejor operación, fusión de flujos) con tests de casos límite y ejecutarlos con pytest.

## Qué demuestra un portfolio

Quien revisa un portfolio para un puesto cuantitativo (los roles de la lección 7.5.1, en los tipos de empresa de la
7.5.2) busca respuestas a tres preguntas: ¿sabe plantear una pregunta que se pueda contestar con datos?, ¿sabe
distinguir una señal del ruido? y ¿se puede trabajar con su código? Un backtest con Sharpe de 3 no responde a ninguna;
al revés, levanta la sospecha de que el autor no conoce los sesgos de la lección 2.5.1. Un proyecto modesto con la
validación bien hecha, la Deflated Sharpe Ratio de la lección 2.5.4 calculada con el número real de intentos y una
sección de limitaciones honesta dice mucho más.

Las piezas habituales y lo que aporta cada una:

| Pieza | Qué enseña | Qué evitar |
|---|---|---|
| Repositorio en GitHub | Código legible, tests, reproducibilidad | Claves, datos con licencia restringida, cuadernos de 50 MB |
| Blog o notas de research | Capacidad de explicar una idea y sus límites | Promesas de rentabilidad, capturas de cuentas reales |
| Numerai | Modelos evaluados en vivo, fuera de muestra, por terceros | Presentar el stake como inversión; confundir validación con rondas en vivo |
| Kaggle | Modelado con métrica fija y comparación con otros | Presentar la posición en el marcador público como resultado |
| Capstone del curso | Un sistema completo con informe de riesgos y paper trading | Omitir lo que salió mal |

## Un repositorio que se revisa en diez minutos

La estructura interna (paquete en `src/`, configuración, Ruff, pre-commit, pytest) es la de la lección 7.1.1. Lo
que cambia en un repositorio **público** es el lector: alguien que dedicará diez minutos y abrirá el README antes que
el código. Un README útil responde, en este orden, a cinco preguntas: **Pregunta** (qué se investiga y por qué),
**Datos** (fuente, licencia, periodo; lección 1.3.7), **Método** (validación incluida), **Resultados** (con su
incertidumbre y lo que no funcionó) y **Reproducir** (`uv sync` y un comando).

Antes de hacer público un repositorio conviene pasarle un auditor. Este busca lo que más daño hace al publicar:

```python
import json
import re
import tempfile
from pathlib import Path

SECCIONES = ["Pregunta", "Datos", "Método", "Resultados",
             "Reproducir"]
OBLIGATORIOS = ["README.md", "LICENSE", "pyproject.toml", "uv.lock"]
SECRETO = re.compile(
    r"(?i)(api_?key|secret|token|password)\w*\s*[=:]\s*['\"]?"
    r"[A-Za-z0-9_\-]{8,}")

def auditar(raiz: Path) -> list[str]:
    avisos = [f"falta {f}" for f in OBLIGATORIOS
              if not (raiz / f).exists()]
    if not list(raiz.glob("tests/test_*.py")):
        avisos.append("no hay tests en tests/")
    readme = raiz / "README.md"
    texto = readme.read_text() if readme.exists() else ""
    titulos = re.findall(r"^## (.+)$", texto, re.M)
    avisos += [f"README sin sección «{s}»" for s in SECCIONES
               if s not in titulos]
    gi = raiz / ".gitignore"
    ignorados = gi.read_text().split() if gi.exists() else []
    if (raiz / ".env").exists() and ".env" not in ignorados:
        avisos.append(".env existe y no está en .gitignore")
    for f in sorted(raiz.rglob("*")):
        rel = f.relative_to(raiz).as_posix()
        if f.is_dir() or rel in ignorados:
            continue
        if f.stat().st_size > 50 * 2**20:
            avisos.append(f"{rel}: más de 50 MB")
        if f.suffix == ".ipynb":
            celdas = json.loads(f.read_text())["cells"]
            if any(c.get("outputs") for c in celdas):
                avisos.append(f"{rel}: cuaderno con salidas")
        elif f.suffix in {".py", ".toml", ".md", ".yaml", ""}:
            if SECRETO.search(f.read_text()):
                avisos.append(f"{rel}: posible secreto")
    return avisos
```

Se prueba con un repositorio de ejemplo creado en una carpeta temporal, con los errores típicos de un primer proyecto
publicado:

```python
def crear_repo(raiz: Path) -> None:
    (raiz / "src").mkdir()
    (raiz / "tests").mkdir()
    (raiz / "README.md").write_text(
        "# Momentum en futuros\n## Pregunta\n## Datos\n"
        "## Método\n## Resultados\n")
    (raiz / "pyproject.toml").write_text('[project]\nname = "mom"\n')
    (raiz / "src" / "senal.py").write_text(
        'BROKER_API_KEY = "pk_live_8f3a9c2d71"\n')
    (raiz / "tests" / "test_senal.py").write_text(
        "def test_vacio():\n    assert True\n")
    (raiz / ".env").write_text("BROKER_API_KEY=pk_live_8f3a9c2d71\n")
    (raiz / "analisis.ipynb").write_text(json.dumps(
        {"cells": [{"cell_type": "code", "source": "df.head()",
                    "outputs": [{"output_type": "stream"}]}]}))

with tempfile.TemporaryDirectory() as d:
    raiz = Path(d)
    crear_repo(raiz)
    for aviso in auditar(raiz):
        print(aviso)
```
```output
falta LICENSE
falta uv.lock
README sin sección «Reproducir»
.env existe y no está en .gitignore
.env: posible secreto
analisis.ipynb: cuaderno con salidas
src/senal.py: posible secreto
```

Siete avisos, y los graves son dos: una clave en el código y un `.env` que se subiría con el primer `git add .`. Una
clave publicada en un repositorio hay que darla por comprometida aunque se borre después, porque queda en el historial
de Git: se revoca y se genera otra (lección 4.4.5). El cuaderno con salidas es un riesgo distinto: puede contener
filas de datos que la licencia del proveedor no deja redistribuir. Corregido todo, el auditor no dice nada:

```python
with tempfile.TemporaryDirectory() as d:
    raiz = Path(d)
    crear_repo(raiz)
    (raiz / "src" / "senal.py").write_text(
        'import os\nBROKER_API_KEY = os.environ["BROKER_API_KEY"]\n')
    (raiz / ".gitignore").write_text(".env\n.venv/\n")
    (raiz / "LICENSE").write_text("MIT License\n")
    (raiz / "uv.lock").write_text("version = 1\n")
    with open(raiz / "README.md", "a") as f:
        f.write("## Reproducir\n")
    (raiz / "analisis.ipynb").write_text(json.dumps({"cells": [
        {"cell_type": "code", "source": "df.head()", "outputs": []}]}))
    print(auditar(raiz) or "sin avisos")
```
```output
sin avisos
```

El auditor no sustituye a una herramienta especializada en secretos ni a la revisión humana: busca patrones con nombre
de variable y no ve una clave pegada en un comentario sin etiqueta. Su valor es que se ejecuta en un segundo y se puede
añadir a pre-commit (lección 7.1.1).

## Numerai: puntuar el orden y la aportación

Numerai es un fondo que publica datos para que los participantes envíen predicciones sobre acciones y las combina en
un **meta modelo**, la media de las predicciones ponderada por el stake, con el que opera. Su documentación
(consultada el 23 de octubre de 2026) define las puntuaciones con código. La más simple es **CORR**: las predicciones
se ordenan, se convierten en cuantiles de una normal, se elevan a 1,5 conservando el signo, igual que el objetivo
centrado, y se calcula la correlación de Pearson. La potencia 1,5 da más peso a las colas, porque el fondo opera sobre
todo los valores con predicciones más altas y más bajas.

```mermaid
flowchart TB
  P[Predicciones] --> R[Rango gaussiano]
  M[Meta modelo] --> N[Neutralizar]
  R --> C[CORR]
  R --> N
  N --> X[MMC]
  C --> L[Liquidación]
  X --> L
```

La función de la documentación, transcrita, con unos datos sintéticos de 5.000 valores:

```python
import numpy as np
import pandas as pd
from scipy import stats

def numerai_corr(preds: pd.Series, target: pd.Series) -> float:
    ranked = ((preds.rank(method="average").values - 0.5)
              / preds.count())
    gauss = stats.norm.ppf(ranked)
    centered = target - target.mean()
    p15 = np.sign(gauss) * np.abs(gauss) ** 1.5
    t15 = np.sign(centered) * np.abs(centered) ** 1.5
    return np.corrcoef(p15, t15)[0, 1]

rng = np.random.default_rng(3)
n = 5000
latente = rng.normal(size=n)
# objetivo en cinco niveles, como los de Numerai (0, 0,25… 1)
target = pd.Series(pd.qcut(latente + rng.normal(scale=6, size=n),
                           5, labels=False) / 4)
preds = pd.Series(latente + rng.normal(scale=1, size=n))
print(f"CORR:               {numerai_corr(preds, target):.4f}")
print(f"CORR de preds**3:   {numerai_corr(preds**3, target):.4f}")
print(f"CORR de 10*preds+3: {numerai_corr(10*preds + 3, target):.4f}")
print(f"Pearson directo:    {np.corrcoef(preds, target)[0, 1]:.4f}")
```
```output
CORR:               0.1290
CORR de preds**3:   0.1290
CORR de 10*preds+3: 0.1290
Pearson directo:    0.1317
```

Cualquier transformación creciente deja el CORR igual: solo importa el orden. Es una consecuencia práctica para quien
participa: calibrar la escala de las predicciones no sirve de nada, y la correlación de Pearson directa, que sí depende
de la forma, da otro número. Los valores típicos son pequeños, de centésimas, porque predecir rentabilidades de acciones
es difícil.

**MMC** mide lo que un modelo aporta **que el meta modelo no tenga ya**: se normalizan predicciones y meta modelo, se
neutralizan las predicciones respecto al meta modelo (se les quita la parte que es proporcional a él) y se calcula la
covarianza de lo que queda con el objetivo centrado. La documentación da la función entera; esta versión sigue los
mismos pasos para una sola columna:

```python
def gauss_rank(s: pd.Series) -> np.ndarray:
    g = stats.norm.ppf((s.rank(method="average").values - 0.5)
                       / s.count())
    return (g - g.mean()) / g.std()

def contribucion(preds, meta, target) -> float:
    p, m = gauss_rank(preds), gauss_rank(meta)
    neutral = p - m * (m @ p) / (m @ m)   # ortogonal al meta modelo
    t = target.values - target.values.mean()
    return float(t @ neutral) / len(t)

meta = pd.Series(latente + rng.normal(scale=1, size=n))
copia = meta + rng.normal(scale=0.05, size=n)   # casi el meta modelo
propio = pd.Series(rng.normal(size=n))           # otra información
propio = propio + 0.1 * (target - target.mean()) * 4
for nombre, x in [("copia", copia), ("propio", propio)]:
    print(f"{nombre:7s} CORR {numerai_corr(x, target):.4f}  "
          f"MMC {contribucion(x, meta, target):.4f}")
```
```output
copia   CORR 0.1243  MMC 0.0002
propio  CORR 0.1232  MMC 0.0448
```

La copia del meta modelo y el modelo «propio» tienen el mismo CORR, 0,12, pero la copia tiene un MMC casi nulo
(0,0002) y el propio, construido con información que el meta modelo no tiene, 0,045: doscientas veces más. Es el
incentivo que Numerai quiere crear: predicciones distintas, no copias del consenso.

La liquidación de una ronda sigue la fórmula de la página de staking. Desde la ronda 1343 del torneo clásico (abierta
el 28 de agosto de 2026) los multiplicadores son 3 × CORR60 + 15 × MMC60, el resultado se recorta a ±1 y se multiplica
por el stake de la ronda: lo positivo se paga en NMR, el token de Numerai, y lo negativo se **quema** (se destruye).
Los multiplicadores son configuración de cada torneo y ronda, no constantes; antes de hacer cuentas hay que
comprobarlos en la ronda vigente.

```python
def liquidacion(stake: float, corr60: float, mmc60: float,
                multiplicadores=(3, 15)) -> float:
    ponderada = multiplicadores[0] * corr60 + multiplicadores[1] * mmc60
    return stake * float(np.clip(ponderada, -1, 1))

for corr60, mmc60 in [(0.02, 0.005), (0.02, -0.005), (-0.03, -0.01)]:
    print(f"CORR {corr60:+.3f} MMC {mmc60:+.3f}: "
          f"{liquidacion(10, corr60, mmc60):+.2f} NMR por 10")
```
```output
CORR +0.020 MMC +0.005: +1.35 NMR por 10
CORR +0.020 MMC -0.005: -0.15 NMR por 10
CORR -0.030 MMC -0.010: -2.40 NMR por 10
```

Con estos multiplicadores, el MMC pesa cinco veces más que el CORR por unidad, y un MMC negativo puede convertir un
buen CORR en una quema. El stake es opcional: la documentación insiste en que los envíos sin stake se puntúan igual y
recomienda empezar sin stake o con uno muy pequeño. Para un portfolio basta el historial de puntuaciones en vivo, que es
evaluación fuera de muestra hecha por un tercero; arriesgar NMR, un criptoactivo, no añade nada como prueba de
competencia y la propia Numerai advierte de que no es una inversión en el fondo.

<aside class="callout" data-kind="riesgo">

Hacer stake en Numerai implica comprar NMR, un criptoactivo con precio volátil, y aceptar quemas en rondas negativas
que pueden encadenarse. Esta lección explica cómo puntúa para entender el torneo como evidencia de un portfolio; no es
una recomendación de participar ni de comprar el token.

</aside>

## Kaggle y el marcador público

En una competición de Kaggle cada envío tiene dos puntuaciones, una **pública** y una **privada**, calculadas sobre
partes distintas del conjunto de prueba; la CLI oficial muestra las dos (`Public Score` y `Private Score`) con
`kaggle competitions submission`. Qué parte va a cada una y con cuál se hace la clasificación final lo fijan las
reglas y la página de evaluación de cada competición. Kaggle tiene además un sistema de progresión, descrito en
kaggle.com/progression. Para un portfolio, lo valioso de una competición es un notebook bien explicado y el resultado
con el que se cierra la clasificación, no la posición en el marcador público durante la competición.

```bash
kaggle competitions submit <competicion> -f submission.csv -m "v3"
```

La razón se ve con una simulación. Un participante sin ninguna información (predicciones al azar) cambia el 5 % de sus
predicciones en cada envío y se queda con el cambio si mejora el marcador público. El 30 % público es un supuesto de la
simulación; cada competición fija su reparto.

```python
rng = np.random.default_rng(11)
filas = 10_000
y = rng.integers(0, 2, filas)
publica = rng.random(filas) < 0.30        # supuesto: 30 % público
pred = rng.integers(0, 2, filas)

def precision(p, mascara):
    return (p[mascara] == y[mascara]).mean()

mejor = precision(pred, publica)
historia = [(0, mejor, precision(pred, ~publica))]
for envio in range(1, 301):
    cand = pred.copy()
    idx = rng.choice(filas, 500, replace=False)
    cand[idx] = 1 - cand[idx]
    if precision(cand, publica) > mejor:
        pred, mejor = cand, precision(cand, publica)
    historia.append((envio, mejor, precision(pred, ~publica)))
hist = pd.DataFrame(historia, columns=["envio", "publica", "privada"])
print(hist.iloc[::100].round(4).to_string(index=False))
```
```output
 envio  publica  privada
     0   0.5024   0.5041
   100   0.5438   0.4998
   200   0.5642   0.4927
   300   0.5716   0.4957
```

La figura de la cabecera dibuja esa historia: la precisión pública sube de forma sostenida y la privada se queda donde
estaba, alrededor del 50 %. Cada cambio aceptado acierta por azar unas filas más de la parte pública; sobre la
privada no tiene ningún efecto. Es la misma selección sobre ruido que la lección 2.5.4 corrige en los backtests, y la
razón por la que una posición alta en el marcador público durante la competición dice poco. En una entrevista, «este
es el notebook y esta la puntuación privada final» es un dato; «llegué a estar tercero en el público», no.

## El proceso de selección

Los procesos cambian entre empresas, pero suelen combinar varias de estas fases, cada una con un objetivo distinto:

```mermaid
flowchart TB
  A[CV y portfolio] --> B[Prueba en línea]
  B --> C[Técnicas]
  C --> D[Caso o proyecto]
  D --> E[Final y equipo]
```

- **Prueba en línea.** Problemas de código con tiempo y, en algunos puestos, de probabilidad y aritmética mental. Mide
  rapidez y corrección bajo presión.
- **Entrevistas técnicas.** Probabilidad, estadística, código y, según el rol, finanzas, microestructura o sistemas.
  Importa tanto el razonamiento en voz alta como la respuesta.
- **Caso o proyecto.** Un conjunto de datos y unos días para analizarlo, o la defensa de un proyecto propio. Aquí el
  portfolio y el capstone se convierten en conversación: qué validación usaste, qué harías distinto.
- **Final.** Encaje con el equipo y preguntas del candidato. Las de la lección 7.5.2 (de dónde sale el ingreso, quién
  asume las pérdidas, cómo se mide el trabajo) caben aquí.

Las secciones siguientes son un banco de práctica. Cada bloque plantea los problemas y pliega la solución: conviene
intentarlos antes de abrirla.

## Probabilidad: exacta y simulada

En una entrevista se pide la respuesta exacta; en el trabajo, una simulación rápida evita errores en la exacta. Hacer
las dos es el mejor entrenamiento.

1. Se lanza una moneda equilibrada hasta obtener dos caras seguidas (CC). ¿Cuántos lanzamientos hacen falta en
   promedio? ¿Y hasta obtener cara y después cruz (CX)?
2. Se rompe un palo en tres trozos eligiendo dos puntos al azar, uniformes e independientes. ¿Con qué probabilidad
   los tres trozos forman un triángulo?
3. ¿Con qué probabilidad hay al menos dos personas con el mismo cumpleaños en un grupo de 23 (365 días
   equiprobables)?
4. Un jugador empieza con 10 € y apuesta 1 € cada vez con probabilidad de ganar 0,49. ¿Con qué probabilidad llega a
   20 € antes de arruinarse?
5. El 10 % de las estrategias que pruebas tiene ventaja real. Tu backtest detecta una ventaja real con probabilidad 0,8
   y da un falso positivo con probabilidad 0,05. Si una estrategia pasa el backtest, ¿con qué probabilidad tiene
   ventaja?

**Soluciones.**

**1.** Con estados «sin racha» y «una cara»: $E_0 = 1 + \tfrac12 E_1 + \tfrac12 E_0$ y
$E_1 = 1 + \tfrac12 \cdot 0 + \tfrac12 E_0$ para CC, de donde $E_0 = 6$. Para CX, una vez salida la primera cara
basta esperar una cruz (dos lanzamientos en promedio), y la primera cara tarda otros dos: $E = 4$. **2.** Los tres
trozos forman triángulo si ninguno mide más de la mitad: $1/4$. **3.** $1 - \prod_{k=0}^{22} (365-k)/365$. **4.** Con
$r = q/p$, la probabilidad de la ruina del jugador es

$$
P = \frac{r^{i} - 1}{r^{N} - 1},\qquad r = \frac{0{,}51}{0{,}49},\ i = 10,\ N = 20 .
$$

**5.** Bayes: $0{,}1 \cdot 0{,}8 / (0{,}1 \cdot 0{,}8 + 0{,}9 \cdot 0{,}05) = 0{,}64$. La simulación de las cinco:

```python
from fractions import Fraction
from math import prod

rng = np.random.default_rng(7)
N = 100_000

def espera(patron):
    m = rng.integers(0, 2, (N, 200))       # 1 = cara
    hit = (m[:, :-1] == patron[0]) & (m[:, 1:] == patron[1])
    assert hit.any(axis=1).all()
    return (hit.argmax(axis=1) + 2).mean()

u = np.sort(rng.random((N, 2)), axis=1)
trozos = np.column_stack([u[:, 0], u[:, 1] - u[:, 0], 1 - u[:, 1]])
cumple = rng.integers(0, 365, (N, 23))
repite = (np.diff(np.sort(cumple, axis=1), axis=1) == 0).any(axis=1)

def ruina(p=0.49, i=10, meta=20):
    x = np.full(N, i)
    vivos = np.ones(N, bool)
    while vivos.any():
        paso = np.where(rng.random(vivos.sum()) < p, 1, -1)
        x[vivos] += paso
        vivos &= (x > 0) & (x < meta)
    return (x == meta).mean()

ventaja = rng.random(N) < 0.10
pasa = np.where(ventaja, rng.random(N) < 0.8, rng.random(N) < 0.05)
r = 0.51 / 0.49
exactos = {"CC": 6, "CX": 4, "triángulo": 0.25,
           "cumpleaños": 1 - prod((365 - k) / 365 for k in range(23)),
           "ruina": (r**10 - 1) / (r**20 - 1),
           "bayes": float(Fraction(8, 100) / (Fraction(8, 100)
                                              + Fraction(45, 1000)))}
simulados = {"CC": espera((1, 1)), "CX": espera((1, 0)),
             "triángulo": (trozos < 0.5).all(axis=1).mean(),
             "cumpleaños": repite.mean(), "ruina": ruina(),
             "bayes": ventaja[pasa].mean()}
pd.DataFrame({"exacto": exactos, "simulado": simulados}).round(4)
```
```output
            exacto  simulado
CC          6.0000    5.9992
CX          4.0000    4.0003
triángulo   0.2500    0.2514
cumpleaños  0.5073    0.5106
ruina       0.4013    0.3986
bayes       0.6400    0.6366
```

Las simulaciones caen a una o dos centésimas de lo exacto. La pregunta 5 es la que más importa en este oficio: con un
10 % de ideas buenas, un tercio de lo que pasa un backtest razonable es ruido, y eso con una sola prueba por idea.

Una simulación no es exacta, pero su error se puede medir. Para el palo roto, la media del error absoluto en 200
repeticiones con cada tamaño de muestra:

```python
tabla = []
for n in [100, 300, 1_000, 3_000, 10_000, 30_000, 100_000]:
    err = []
    for _ in range(200):
        u = np.sort(rng.random((n, 2)), axis=1)
        tr = np.column_stack([u[:, 0], u[:, 1] - u[:, 0], 1 - u[:, 1]])
        err.append(abs((tr < 0.5).all(axis=1).mean() - 0.25))
    teorico = np.sqrt(2 / np.pi) * np.sqrt(0.25 * 0.75 / n)
    tabla.append((n, np.mean(err), teorico))
errores = pd.DataFrame(tabla, columns=["n", "error_medio", "teorico"])
print(errores.round(5).to_string(index=False))
```
```output
     n  error_medio  teorico
   100      0.03215  0.03455
   300      0.01793  0.01995
  1000      0.01045  0.01093
  3000      0.00601  0.00631
 10000      0.00354  0.00345
 30000      0.00200  0.00199
100000      0.00106  0.00109
```

*Fig. — Error absoluto medio de la simulación del palo roto según el número de ensayos (200 repeticiones por tamaño).* Dos líneas en escala logarítmica en los dos ejes que bajan en paralelo: el error medido, de 0,032 con 100 ensayos a 0,0011 con 100.000 (destacado), y la curva teórica proporcional a uno entre la raíz de n, discontinua.

El error medio sigue la curva teórica $\sqrt{2/\pi}\,\sqrt{p(1-p)/n}$: para ganar un decimal de precisión hacen falta
cien veces más ensayos. Con 100.000, el error típico es de una milésima, suficiente para comprobar una respuesta de
entrevista y no para distinguir 0,250 de 0,251.

## Estadística, código y brainteasers

1. Las rentabilidades diarias de una estrategia son iid normales con un Sharpe diario de 0,1 (unos 1,6 anuales).
   ¿Cuál es el error estándar del Sharpe anual estimado con un año de datos?
2. Pruebas 100 estrategias sin ninguna ventaja durante un año de datos diarios. ¿Qué Sharpe anual esperas para la
   mejor?
3. Escribe `max_drawdown(precios)`, `mejor_operacion(precios)` (máximo beneficio de una compra seguida de una venta) y
   `fusionar(*flujos)` (une flujos de operaciones ya ordenados por tiempo), todas en una pasada, con tests de casos
   límite.

**Soluciones.**

**1 y 2.** Con rentabilidades iid normales, la varianza del Sharpe estimado por periodo es aproximadamente
$(1 + SR^2/2)/T$ (Lo, 2002); al anualizar se multiplica por $\sqrt{252}$. Para la segunda, el máximo de 100 normales
estándar tiene una media de unos 2,5, y con T = 252 el error estándar del Sharpe anual sin ventaja es 1:

```python
T, sr_d, sims = 252, 0.1, 20_000
r = rng.normal(sr_d, 1.0, (sims, T))
sr_anual = r.mean(axis=1) / r.std(axis=1, ddof=1) * np.sqrt(T)
formula = np.sqrt((1 + sr_d**2 / 2) / T) * np.sqrt(T)
print(f"Sharpe anual medio {sr_anual.mean():.2f}, "
      f"desviación {sr_anual.std():.3f}, fórmula {formula:.3f}")

ruido = rng.normal(0, 1.0, (500, 100, T))
sr_ruido = ruido.mean(axis=2) / ruido.std(axis=2, ddof=1) * np.sqrt(T)
print(f"Mejor de 100 sin ventaja: {sr_ruido.max(axis=1).mean():.2f}")
```
```output
Sharpe anual medio 1.57, desviación 1.015, fórmula 1.002
Mejor de 100 sin ventaja: 2.50
```

Un año de datos no distingue un Sharpe de 1,6 de uno de 0,6, y la mejor de 100 estrategias sin ninguna ventaja
muestra un Sharpe anual cercano a 2,5. Son las dos cifras que conviene tener en la cabeza al presentar un backtest.

**3.** Las tres funciones recorren los datos una sola vez; `fusionar` usa `heapq.merge`, que no carga los flujos en
memoria. Los tests cubren lo que suele fallar: listas vacías, series monótonas y empates.

```python
import contextlib
import io
import sys
import pytest

Path("entrevista.py").write_text('''
import heapq

def max_drawdown(precios):
    pico, peor = float("-inf"), 0.0
    for p in precios:
        pico = max(pico, p)
        peor = min(peor, p / pico - 1)
    return peor

def mejor_operacion(precios):
    minimo, mejor = float("inf"), 0.0
    for p in precios:
        minimo = min(minimo, p)
        mejor = max(mejor, p - minimo)
    return mejor

def fusionar(*flujos):
    return list(heapq.merge(*flujos, key=lambda op: op[0]))
''')
Path("test_entrevista.py").write_text('''
import pytest
from entrevista import fusionar, max_drawdown, mejor_operacion

def test_drawdown_basico():
    serie = [100, 120, 90, 130, 117]
    assert max_drawdown(serie) == pytest.approx(-0.25)

@pytest.mark.parametrize("serie", [[], [5], [1, 2, 3]])
def test_sin_caidas(serie):
    assert max_drawdown(serie) == 0.0

def test_mejor_operacion():
    assert mejor_operacion([7, 1, 5, 3, 6, 4]) == 5
    assert mejor_operacion([5, 4, 3]) == 0

def test_fusionar_conserva_empates():
    a, b = [(1, "a1"), (3, "a3")], [(1, "b1"), (2, "b2")]
    assert fusionar(a, b) == [(1, "a1"), (1, "b1"), (2, "b2"),
                              (3, "a3")]
''')
sys.modules.pop("entrevista", None)
salida = io.StringIO()
with contextlib.redirect_stdout(salida):
    codigo = pytest.main(["-q", "-p", "no:cacheprovider", "--tb=short",
                          "test_entrevista.py"])
print(re.sub(r" in [\d.]+s", "", salida.getvalue().splitlines()[-1]))
print("código de salida:", int(codigo))
```
```output
6 passed
código de salida: 0
```

En la entrevista, los tests se escriben antes o junto con la función y en voz alta: «¿qué devuelve con una lista
vacía?» es la pregunta que el entrevistador espera oír. El empate del último test documenta una decisión:
`heapq.merge` es estable y respeta el orden de los flujos de entrada.

Los brainteasers no miden conocimientos de finanzas sino la forma de razonar: plantear, simplificar, comprobar casos
extremos.

1. Tienes dos cuerdas que tardan una hora cada una en arder, de forma irregular. ¿Cómo mides 45 minutos?
2. Un dado justo: puedes lanzarlo una vez y cobrar lo que salga, o lanzarlo y, si no te gusta, volver a lanzar una
   vez y cobrar el segundo resultado. ¿Cuánto vale el juego con la segunda opción y con qué resultados relanzas?
3. ¿Cuántos ceros hay al final de 100! (el factorial de 100)?

**Soluciones.**

**1.** Se enciende la primera cuerda por los dos extremos y la segunda por uno. La primera se consume en 30 minutos;
en ese instante se enciende el otro extremo de la segunda, a la que le quedan 30 minutos de combustión, que ahora se
consumen en 15. Total, 45. **2.** El segundo lanzamiento vale 3,5 en promedio, así que se relanza con 1, 2 o 3 y se
cobra con 4, 5 o 6: $\tfrac12 \cdot 5 + \tfrac12 \cdot 3{,}5 = 4{,}25$. **3.** Cada cero sale de un factor 10 = 2 × 5,
y hay menos cincos que doses: $\lfloor 100/5 \rfloor + \lfloor 100/25 \rfloor = 24$. Las dos últimas se comprueban
en una línea:

```python
from math import factorial

dado = np.arange(1, 7)
valor = np.mean(np.where(dado >= 4, dado, 3.5))  # relanzar si < 4
ceros = len(str(factorial(100))) - len(str(factorial(100)).rstrip("0"))
print(valor, ceros)
```
```output
4.25 24
```

El patrón del dado, comparar lo que tienes con el valor esperado de seguir, es el de una parada óptima, y se repite
en problemas de ejecución y de opciones americanas (lección 3.4.2).

## Ejercicios

1. Amplía `auditar` para que avise si el README no enlaza a la licencia de los datos (busca la palabra «licencia» en
   la sección «Datos») y pruébalo con el repositorio corregido.

**Solución.**

```python
def seccion(texto: str, titulo: str) -> str:
    m = re.search(rf"^## {titulo}\n(.*?)(?=^## |\Z)", texto,
                  re.M | re.S)
    return m.group(1) if m else ""

def auditar_datos(raiz: Path) -> list[str]:
    avisos = auditar(raiz)
    datos = seccion((raiz / "README.md").read_text(), "Datos")
    if "licencia" not in datos.lower():
        avisos.append("la sección «Datos» no cita la licencia")
    return avisos

with tempfile.TemporaryDirectory() as d:
    raiz = Path(d)
    crear_repo(raiz)
    readme = (raiz / "README.md").read_text().replace(
        "## Datos\n", "## Datos\nFuente: X. Licencia: CC BY 4.0.\n")
    (raiz / "README.md").write_text(readme)
    print("\n".join(auditar_datos(raiz)))
```
```output
falta LICENSE
falta uv.lock
README sin sección «Reproducir»
.env existe y no está en .gitignore
.env: posible secreto
analisis.ipynb: cuaderno con salidas
src/senal.py: posible secreto
```

El aviso nuevo no aparece porque la sección «Datos» ya cita la licencia; siguen los siete del primer repositorio,
porque esta prueba parte de él. La
función `seccion` corta el README por títulos de segundo nivel, la misma estructura que el auditor ya exige.

2. En la simulación del marcador, repite el experimento con un 10 % y con un 50 % de filas públicas. ¿En cuál sube más
   la precisión pública al cabo de 300 envíos? ¿Por qué?

**Solución.**

```python
def escalar(frac, envios=300, semilla=11):
    g = np.random.default_rng(semilla)
    y = g.integers(0, 2, filas)
    pub = g.random(filas) < frac
    p = g.integers(0, 2, filas)
    acc = lambda q, m: (q[m] == y[m]).mean()
    mejor = acc(p, pub)
    for _ in range(envios):
        c = p.copy()
        i = g.choice(filas, 500, replace=False)
        c[i] = 1 - c[i]
        if acc(c, pub) > mejor:
            p, mejor = c, acc(c, pub)
    return round(float(mejor), 4), round(float(acc(p, ~pub)), 4)

for frac in [0.10, 0.30, 0.50]:
    print(frac, escalar(frac))
```
```output
0.1 (0.6143, 0.5051)
0.3 (0.5716, 0.4957)
0.5 (0.5499, 0.5039)
```

Cuanto más pequeña es la parte pública, más ruidosa es su precisión y más se puede «mejorar» por azar: con el 10 %
público la precisión pública sube mucho más que con el 50 %. La privada no se mueve en ningún caso. Un marcador público
pequeño es más fácil de sobreajustar.

3. Prepara la defensa de dos minutos de un proyecto propio (o del capstone) para una entrevista: qué pregunta, qué
   datos, qué validación, qué salió mal y qué harías después.

**Solución.**

Un guion que funciona: una frase con la pregunta y por qué importa; la fuente de datos con su periodo y su licencia;
la validación (walk-forward o CPCV, número de configuraciones probadas y Deflated Sharpe Ratio); el resultado con su
intervalo y no solo la estimación puntual, recordando que con un año de datos el error estándar del Sharpe anual es del
orden de 1; lo que salió mal (un sesgo encontrado tarde, un coste subestimado) y cómo se detectó; y el siguiente paso.
Lo que suele faltar es lo cuarto, y es lo que más dice del candidato.

## Autoevaluación

1. Un modelo tiene en Numerai un CORR de 0,02 y un MMC de 0,005 en una ronda con los multiplicadores 3 × CORR60 + 15 × MMC60. ¿Qué parte del stake gana?

   - A. El 2 %
   - B. El 13,5 %
   - C. El 7,5 %
   - D. Nada: solo cuenta el CORR

   *Respuesta B: El 13,5 %.* La puntuación ponderada es 3 × 0,02 + 15 × 0,005 = 0,135, dentro del recorte de ±1, así que la liquidación es el 13,5 % del stake de la ronda. Con un CORR de −0,03 y un MMC de −0,01 se quemaría el 24 %. Los multiplicadores son configuración de cada torneo y ronda: hay que comprobarlos antes de hacer cuentas.

2. Multiplicas todas tus predicciones de Numerai por 10 y les sumas 3. ¿Qué le pasa al CORR?

   - A. Nada: CORR solo usa el orden de las predicciones (rango y gaussianización)
   - B. Se multiplica por 10
   - C. Baja, porque las predicciones salen del intervalo [0, 1]
   - D. Sube, porque la correlación de Pearson premia la escala

   *Respuesta A: Nada: CORR solo usa el orden de las predicciones (rango y gaussianización).* La función de la documentación ordena las predicciones, las convierte en cuantiles normales y solo después calcula la correlación. Cualquier transformación creciente deja el CORR igual; en la lección, elevar al cubo tampoco lo cambia.

3. En la simulación de la lección, 300 envíos que solo se quedan con los cambios que mejoran el marcador público suben la precisión pública del 50,2 % a más del 57 %. ¿Qué pasa con la privada?

   - A. Sube lo mismo
   - B. Sube algo menos
   - C. Se queda alrededor del 50 %: las mejoras eran ajuste al ruido de la parte pública
   - D. Baja al 43 %

   *Respuesta C: Se queda alrededor del 50 %: las mejoras eran ajuste al ruido de la parte pública.* Las predicciones no tienen ninguna información; cada cambio aceptado acierta por azar unas filas más del 30 % público. Es la misma selección sobre ruido que la Deflated Sharpe Ratio (lección 2.5.4) corrige en un backtest.

4. Con un año de rentabilidades diarias y un Sharpe anual verdadero de 1,6, ¿cuál es aproximadamente el error estándar del Sharpe anual estimado?

   - A. 0,1
   - B. 0,3
   - C. Cero, si los datos son diarios
   - D. 1,0

   *Respuesta D: 1,0.* Con rentabilidades iid normales, la varianza del Sharpe por periodo es (1 + SR²/2)/T (Lo, 2002). Con SR diario 0,1 y T = 252, el error estándar diario es 0,063 y el anual, 1,0. La simulación de la lección da 1,0: un año de datos no distingue un Sharpe de 1,6 de uno de 0,6.

5. ¿Qué no debería tener un repositorio público de un proyecto de trading?

   - A. Un `uv.lock` con las versiones exactas
   - B. Un fichero `.env` con claves o un cuaderno con salidas de datos con licencia restringida
   - C. Tests de casos límite
   - D. Una sección que explique cómo reproducir los resultados

   *Respuesta B: Un fichero `.env` con claves o un cuaderno con salidas de datos con licencia restringida.* Las credenciales nunca van al repositorio (lección 4.4.5) y las salidas de un cuaderno pueden redistribuir datos que la licencia no permite (lección 1.3.7). El auditor de la lección busca las dos cosas antes de publicar.

## Ideas clave

- Un portfolio enseña criterio: una pregunta clara, datos con licencia, validación que descuenta los intentos y un repositorio que otro reproduce con un comando.
- Antes de publicar, un auditor sencillo encuentra lo que más daño hace: claves en el código, `.env` fuera del `.gitignore`, falta de licencia, de `uv.lock` o de tests, y cuadernos con salidas.
- Numerai puntúa el orden de las predicciones (CORR con rango gaussiano y potencia 1,5) y la aportación ortogonal al meta modelo (MMC); desde la ronda 1343 del torneo clásico liquida 3 × CORR60 + 15 × MMC60 recortado a ±1 del stake.
- Optimizar contra el marcador público sobreajusta: en la simulación, la precisión pública sube del 50,2 % al 57,2 % y la privada se queda en el 49,6 %.
- En probabilidad, una respuesta exacta comprobada con una simulación de 100.000 ensayos convence más que cualquiera de las dos sola; el error de la simulación cae como 1/√n.
- Con un año de datos diarios, el error estándar del Sharpe anual es del orden de 1: conviene saberlo antes de presentar un backtest en una entrevista.

## Recursos

### Research

- [Quant research](https://pythonparatrading.com/quant-research/) · Research-0023 · 8 min

### Documentación

- [Numerai: Correlation (CORR)](https://docs.numer.ai/numerai-tournament/scoring/correlation-corr) · docs.numer.ai
- [Numerai: Meta Model Contribution (MMC)](https://docs.numer.ai/numerai-tournament/scoring/meta-model-contribution-mmc) · docs.numer.ai
- [Numerai: Staking (pagos, quemas y multiplicadores)](https://docs.numer.ai/numerai-tournament/staking) · docs.numer.ai
- [Kaggle CLI: comandos de competiciones (envíos, puntuación pública y privada, leaderboard)](https://github.com/Kaggle/kaggle-api/blob/main/docs/competitions.md) · github.com
- [GitHub Docs: gestionar el README del perfil](https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-github-profile/customizing-your-profile/managing-your-profile-readme) · docs.github.com

### Libros

- [Lo, A. W. (2002): The Statistics of Sharpe Ratios. Financial Analysts Journal, 58(4), 36-52](https://doi.org/10.2469/faj.v58.n4.2453) · doi.org

Lección anterior: [7.5.2 · Dónde trabajar: qué hace cada empresa y qué dicen los registros](https://pythonparatrading.com/curso-python-trading/nivel-7/donde-trabajar-ecosistema-espana-y-europa/)

Lección siguiente: [7.5.4 · Certificaciones, formación y prop firms: qué certifica cada cosa y qué mide un examen](https://pythonparatrading.com/curso-python-trading/nivel-7/certificaciones-formacion-y-prop-firms/)
