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.
- Módulo
- 7.5 · Carrera profesional quant
- Duración
- 75 min
Lecciones del nivel 7
7.1 · Ingeniería de software para trading
7.2 · Infraestructura y despliegue
7.3 · MLOps para modelos de trading
- 7.3.1 Registro de experimentos, modelos y datos: MLflow, DVC y una ejecución reproducida de principio a fin
- 7.3.2 Reentrenamiento programado, validación automática y champion/challenger
- 7.3.3 Monitorización del modelo en vivo y desactivación automática
- 7.3.4 Servir modelos: ONNX, inferencia en CPU o GPU y el coste de los LLM en producción
7.4 · Regulación, ética y fiscalidad
- 7.4.1 MiFID II: clientes, idoneidad y trading algorítmico
- 7.4.2 ESMA y CFD: apalancamiento, cierre y saldo negativo
- 7.4.3 PRIIPs, KID y ETFs estadounidenses
- 7.4.4 MiCA después del transitorio: comprobar un proveedor en los registros
- 7.4.5 Abuso de mercado: spoofing, layering y el bot que los imita sin querer
- 7.4.6 Fiscalidad del trading en España
- 7.4.7 Ética: datos personales, inteligencia artificial y la venta de señales
7.5 · Carrera profesional quant
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:
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 avisosSe prueba con un repositorio de ejemplo creado en una carpeta temporal, con los errores típicos de un primer proyecto publicado:
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)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 secretoSiete 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:
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")sin avisosEl 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.
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:
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}")CORR: 0.1290
CORR de preds**3: 0.1290
CORR de 10*preds+3: 0.1290
Pearson directo: 0.1317Cualquier 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:
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}")copia CORR 0.1243 MMC 0.0002
propio CORR 0.1232 MMC 0.0448La 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.
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")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 10Con 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.
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)) envio publica privada
0 0.5024 0.5041
100 0.5438 0.4998
200 0.5642 0.4927
300 0.5716 0.4957La 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:
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.
- 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)?
- 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?
- ¿Con qué probabilidad hay al menos dos personas con el mismo cumpleaños en un grupo de 23 (365 días equiprobables)?
- 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?
- 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»: y para CC, de donde . Para CX, una vez salida la primera cara basta esperar una cruz (dos lanzamientos en promedio), y la primera cara tarda otros dos: . 2. Los tres trozos forman triángulo si ninguno mide más de la mitad: . 3. . 4. Con , la probabilidad de la ruina del jugador es
5. Bayes: . La simulación de las cinco:
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) 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.6366Las 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:
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)) 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.00109El error medio sigue la curva teórica : 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
- 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?
- Pruebas 100 estrategias sin ninguna ventaja durante un año de datos diarios. ¿Qué Sharpe anual esperas para la mejor?
- Escribe
max_drawdown(precios),mejor_operacion(precios)(máximo beneficio de una compra seguida de una venta) yfusionar(*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 (Lo, 2002); al anualizar se multiplica por . 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:
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}")Sharpe anual medio 1.57, desviación 1.015, fórmula 1.002
Mejor de 100 sin ventaja: 2.50Un 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.
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))6 passed
código de salida: 0En 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.
- Tienes dos cuerdas que tardan una hora cada una en arder, de forma irregular. ¿Cómo mides 45 minutos?
- 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?
- ¿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: . 3. Cada cero sale de un factor 10 = 2 × 5, y hay menos cincos que doses: . Las dos últimas se comprueban en una línea:
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)4.25 24El 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
- Amplía
auditarpara 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
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)))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 secretoEl 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.
- 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
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))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.
- 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
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.
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.
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.
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.
¿Qué no debería tener un repositorio público de un proyecto de trading?
- AUn
uv.lockcon las versiones exactas - BUn fichero
.envcon 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
.envcon claves o un cuaderno con salidas de datos con licencia restringidaLas 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.
- AUn
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,
.envfuera del.gitignore, falta de licencia, deuv.locko 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.