Lección 7.5.3 · 75 min

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.

Fig. — Precisión pública y privada de un participante sin información que solo acepta los cambios que mejoran el marcador público (simulación de la lección)
Lecciones del nivel 7
  1. 7.1 · Ingeniería de software para trading

    1. 7.1.1 Estructura de proyecto, configuración y calidad: del notebook al repositorio
    2. 7.1.2 Rendimiento: medir, perfilar y compilar solo lo que hace falta
    3. 7.1.3 Patrones: eventos, colas de mensajes y arquitectura hexagonal
  2. 7.2 · Infraestructura y despliegue

    1. 7.2.1 Docker y docker-compose: imágenes reproducibles con uv
    2. 7.2.2 VPS, nube y latencia; CI/CD con GitHub Actions
    3. 7.2.3 Orquestación y monitorización: grafos de tareas, logs estructurados, métricas, alertas y latidos
    4. 7.2.4 Seguridad y continuidad: 2FA, IP, copias y procedimientos ante fallos
  3. 7.3 · MLOps para modelos de trading

    1. 7.3.1 Registro de experimentos, modelos y datos: MLflow, DVC y una ejecución reproducida de principio a fin
    2. 7.3.2 Reentrenamiento programado, validación automática y champion/challenger
    3. 7.3.3 Monitorización del modelo en vivo y desactivación automática
    4. 7.3.4 Servir modelos: ONNX, inferencia en CPU o GPU y el coste de los LLM en producción
  4. 7.4 · Regulación, ética y fiscalidad

    1. 7.4.1 MiFID II: clientes, idoneidad y trading algorítmico
    2. 7.4.2 ESMA y CFD: apalancamiento, cierre y saldo negativo
    3. 7.4.3 PRIIPs, KID y ETFs estadounidenses
    4. 7.4.4 MiCA después del transitorio: comprobar un proveedor en los registros
    5. 7.4.5 Abuso de mercado: spoofing, layering y el bot que los imita sin querer
    6. 7.4.6 Fiscalidad del trading en España
    7. 7.4.7 Ética: datos personales, inteligencia artificial y la venta de señales
  5. 7.5 · Carrera profesional quant

    1. 7.5.1 Roles del quant: seis oficios alrededor de un mismo sistema
    2. 7.5.2 Dónde trabajar: qué hace cada empresa y qué dicen los registros
    3. 7.5.3 Portfolio público y entrevistas: enseñar criterio, no rentabilidades
    4. 7.5.4 Certificaciones, formación y prop firms: qué certifica cada cosa y qué mide un examen

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:

PiezaQué enseñaQué evitar
Repositorio en GitHubCódigo legible, tests, reproducibilidadClaves, datos con licencia restringida, cuadernos de 50 MB
Blog o notas de researchCapacidad de explicar una idea y sus límitesPromesas de rentabilidad, capturas de cuentas reales
NumeraiModelos evaluados en vivo, fuera de muestra, por tercerosPresentar el stake como inversión; confundir validación con rondas en vivo
KaggleModelado con métrica fija y comparación con otrosPresentar la posición en el marcador público como resultado
Capstone del cursoUn sistema completo con informe de riesgos y paper tradingOmitir 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:

IN [01]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:

IN [02]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)
OUT [02]Un primer repositorio con errores típicos
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:

IN [03]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")
OUT [03]El mismo repositorio, corregido
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.

PrediccionesRango gaussianoMeta modeloNeutralizarCORRMMCLiquidación
Fuente 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:

IN [04]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}")
OUT [04]CORR de Numerai, tal como lo define su documentación
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:

IN [05]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}")
OUT [05]Contribución al meta modelo (MMC)
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.

IN [06]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")
OUT [06]Liquidación de una ronda (staking v3)
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.

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.

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.

IN [07]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))
OUT [07]Subir en el marcador público sin saber nada
 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:

CV y portfolioPrueba en líneaTécnicasCaso o proyectoFinal y equipo
Fuente 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»: E0=1+12E1+12E0E_0 = 1 + \tfrac12 E_1 + \tfrac12 E_0 y E1=1+12⋅0+12E0E_1 = 1 + \tfrac12 \cdot 0 + \tfrac12 E_0 para CC, de donde E0=6E_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=4E = 4. 2. Los tres trozos forman triángulo si ninguno mide más de la mitad: 1/41/4. 3. 1−∏k=022(365−k)/3651 - \prod_{k=0}^{22} (365-k)/365. 4. Con r=q/pr = q/p, la probabilidad de la ruina del jugador es

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

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

IN [08]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)
OUT [08]Exacto frente a simulado (100.000 ensayos)
            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:

IN [09]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))
OUT [09]Error de una simulación según el número de ensayos
     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)

El error medio sigue la curva teórica 2/π p(1−p)/n\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+SR2/2)/T(1 + SR^2/2)/T (Lo, 2002); al anualizar se multiplica por 252\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:

IN [10]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}")
OUT [10]Error estándar del Sharpe y máximo de 100 estrategias sin ventaja
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.

IN [11]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))
OUT [11]Funciones y tests de una entrevista de código
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: 12⋅5+12⋅3,5=4,25\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: ⌊100/5⌋+⌊100/25⌋=24\lfloor 100/5 \rfloor + \lfloor 100/25 \rfloor = 24. Las dos últimas se comprueban en una línea:

IN [12]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)
OUT [12]Comprobar el dado y los ceros de 100!
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
IN [13]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)))
OUT [13]Comprobar la licencia de los datos en el README
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.

  1. 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
IN [14]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))
OUT [14]El tamaño de la parte pública
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.

  1. 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.

Respuesta plegada en cada pregunta

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?

    • AEl 2 %
    • BEl 13,5 %
    • CEl 7,5 %
    • DNada: solo cuenta el CORR
    Ver respuesta

    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?

    • ANada: CORR solo usa el orden de las predicciones (rango y gaussianización)
    • BSe multiplica por 10
    • CBaja, porque las predicciones salen del intervalo [0, 1]
    • DSube, porque la correlación de Pearson premia la escala
    Ver respuesta

    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?

    • ASube lo mismo
    • BSube algo menos
    • CSe queda alrededor del 50 %: las mejoras eran ajuste al ruido de la parte pública
    • DBaja al 43 %
    Ver respuesta

    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?

    • A0,1
    • B0,3
    • CCero, si los datos son diarios
    • D1,0
    Ver respuesta

    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?

    • AUn uv.lock con las versiones exactas
    • BUn fichero .env con claves o un cuaderno con salidas de datos con licencia restringida
    • CTests de casos límite
    • DUna sección que explique cómo reproducir los resultados
    Ver respuesta

    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