Lesson 7.5.3 · 75 min
Public portfolio and interviews: show judgment, not returns
A quantitative portfolio does not convince by the Sharpe it shows but by how it gets it: licensed data, honest validation and code that someone else can run. This lesson audits a repository with code, reads how Numerai and Kaggle score in their documentation, simulates why the public leaderboard deceives and ends with an interview practice bank: exact versus simulated probability, statistics, code with tests and brainteasers, with the solutions folded.
- Module
- 7.5 · Quant career
- Duration
- 75 min
- Prerequisites
- 0.2.5 · Git and GitHub: commits, branches, pull requests and .gitignore, 0.4.5 · Professional logging and testing with pytest, 0.7.2 · Probability and distributions, correlation versus causation and simple regression, 2.5.4 · Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)
- Author
- Jesús Cuesta
- Updated
Level 7 lessons
7.1 · Software engineering for trading
7.2 · Infrastructure and deployment
7.3 · MLOps for trading models
7.4 · Regulation, ethics and taxation
- 7.4.1 MiFID II: clients, suitability and algorithmic trading
- 7.4.2 ESMA and CFDs: leverage, close-out and negative balance
- 7.4.3 PRIIPs, the KID and US ETFs
- 7.4.4 MiCA after the transitional period: checking a provider in the registers
- 7.4.5 Market abuse: spoofing, layering and the bot that imitates them by accident
- 7.4.6 Trading taxes in Spain
- 7.4.7 Ethics: personal data, artificial intelligence and selling signals
7.5 · Quant career
7.6 · Final capstone
- 7.6.1 Capstone: common requirements, the panel's rubric and preparing the defense
- 7.6.2 Capstone C1: a multi-asset futures system, from forecast to paper account
- 7.6.3 Capstone C2: systematic options book, volatility surface and real-time Greeks
- 7.6.4 Capstone C3: stock and ETF portfolio with ML, HRP and Spanish taxation
- 7.6.5 Capstone C4: a research platform with LLMs and alternative data
- 7.6.6 Capstone C5: crypto market making or arbitrage with counterparty control
What a portfolio demonstrates
Whoever reviews a portfolio for a quantitative position (the roles of lesson 7.5.1, in the types of firm of 7.5.2) looks for answers to three questions: can they pose a question that can be answered with data? Can they tell a signal from noise? And can you work with their code? A backtest with a Sharpe of 3 answers none of them; on the contrary, it raises the suspicion that the author does not know the biases of lesson 2.5.1. A modest project with validation done right, the Deflated Sharpe Ratio of lesson 2.5.4 computed with the real number of attempts and an honest limitations section says much more.
The usual pieces and what each one contributes:
| Piece | What it shows | What to avoid |
|---|---|---|
| GitHub repository | Readable code, tests, reproducibility | Keys, data under a restricted license, 50 MB notebooks |
| Blog or research notes | Ability to explain an idea and its limits | Promises of returns, screenshots of real accounts |
| Numerai | Models evaluated live, out of sample, by third parties | Presenting the stake as an investment; confusing validation with live rounds |
| Kaggle | Modeling with a fixed metric and comparison with others | Presenting the position on the public leaderboard as a result |
| Course capstone | A complete system with a risk report and paper trading | Leaving out what went wrong |
A repository that can be reviewed in ten minutes
The internal structure (package in src/, configuration, Ruff, pre-commit, pytest) is that of lesson 7.1.1. What
changes in a public repository is the reader: someone who will spend ten minutes and will open the README before
the code. A useful README answers, in this order, five questions: Question (what is being researched and why),
Data (source, license, period; lesson 1.3.7), Method (validation included), Results (with their
uncertainty and what did not work) and Reproduce (uv sync and one command).
Before making a repository public it is worth running an auditor on it. This one looks for what does the most damage when publishing:
import json
import re
import tempfile
from pathlib import Path
SECCIONES = ["Question", "Data", "Method", "Results",
"Reproduce"]
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"missing {f}" for f in OBLIGATORIOS
if not (raiz / f).exists()]
if not list(raiz.glob("tests/test_*.py")):
avisos.append("no tests in tests/")
readme = raiz / "README.md"
texto = readme.read_text() if readme.exists() else ""
titulos = re.findall(r"^## (.+)$", texto, re.M)
avisos += [f"README without a “{s}” section" 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 exists and is not in .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}: more than 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}: notebook with outputs")
elif f.suffix in {".py", ".toml", ".md", ".yaml", ""}:
if SECRETO.search(f.read_text()):
avisos.append(f"{rel}: possible secret")
return avisosIt is tested with an example repository created in a temporary folder, with the typical mistakes of a first published project:
def crear_repo(raiz: Path) -> None:
(raiz / "src").mkdir()
(raiz / "tests").mkdir()
(raiz / "README.md").write_text(
"# Futures momentum\n## Question\n## Data\n"
"## Method\n## Results\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)missing LICENSE
missing uv.lock
README without a “Reproduce” section
.env exists and is not in .gitignore
.env: possible secret
analisis.ipynb: notebook with outputs
src/senal.py: possible secretSeven warnings, and the serious ones are two: a key in the code and a .env that would be uploaded with the first
git add .. A key published in a repository must be considered compromised even if it is deleted afterwards, because
it stays in the Git history: it is revoked and a new one is generated (lesson 4.4.5). The notebook with outputs is a
different risk: it may contain rows of data that the provider's license does not allow to be redistributed. With
everything fixed, the auditor says nothing:
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("## Reproduce\n")
(raiz / "analisis.ipynb").write_text(json.dumps({"cells": [
{"cell_type": "code", "source": "df.head()", "outputs": []}]}))
print(auditar(raiz) or "no warnings")no warningsThe auditor does not replace a specialized secrets tool or human review: it looks for patterns with a variable name and does not see a key pasted into a comment without a label. Its value is that it runs in a second and can be added to pre-commit (lesson 7.1.1).
Numerai: scoring the order and the contribution
Numerai is a fund that publishes data so that participants submit predictions about stocks, and it combines them into a meta model, the stake-weighted average of the predictions, which it trades. Its documentation (accessed on October 23, 2026) defines the scores with code. The simplest is CORR: the predictions are ranked, converted into quantiles of a normal distribution, raised to 1.5 keeping the sign, just like the centered target, and the Pearson correlation is computed. The 1.5 power gives more weight to the tails, because the fund mostly trades the stocks with the highest and lowest predictions.
Mermaid source
flowchart TB P[Predictions] --> R[Gaussian rank] M[Meta model] --> N[Neutralize] R --> C[CORR] R --> N N --> X[MMC] C --> L[Payout] X --> L
The documentation's function, transcribed, with synthetic data for 5,000 stocks:
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)
# target in five levels, like Numerai's (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 of preds**3: {numerai_corr(preds**3, target):.4f}")
print(f"CORR of 10*preds+3: {numerai_corr(10*preds + 3, target):.4f}")
print(f"Direct Pearson: {np.corrcoef(preds, target)[0, 1]:.4f}")CORR: 0.1290
CORR of preds**3: 0.1290
CORR of 10*preds+3: 0.1290
Direct Pearson: 0.1317Any increasing transformation leaves CORR unchanged: only the order matters. It is a practical consequence for participants: calibrating the scale of the predictions is useless, and the direct Pearson correlation, which does depend on the shape, gives a different number. Typical values are small, in the hundredths, because predicting stock returns is hard.
MMC measures what a model contributes that the meta model does not already have: predictions and meta model are normalized, the predictions are neutralized with respect to the meta model (the part proportional to it is removed) and the covariance of what remains with the centered target is computed. The documentation gives the whole function; this version follows the same steps for a single column:
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) # orthogonal to the meta model
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) # almost the meta model
propio = pd.Series(rng.normal(size=n)) # other information
propio = propio + 0.1 * (target - target.mean()) * 4
for nombre, x in [("copy", copia), ("own", propio)]:
print(f"{nombre:7s} CORR {numerai_corr(x, target):.4f} "
f"MMC {contribucion(x, meta, target):.4f}")copy CORR 0.1243 MMC 0.0002
own CORR 0.1232 MMC 0.0448The copy of the meta model and the “own” model have the same CORR, 0.12, but the copy has an almost zero MMC (0.0002) and the own model, built with information the meta model does not have, 0.045: two hundred times more. It is the incentive Numerai wants to create: different predictions, not copies of the consensus.
The payout of a round follows the formula on the staking page. Since round 1343 of the classic tournament (opened on August 28, 2026) the multipliers are 3 × CORR60 + 15 × MMC60, the result is clipped to ±1 and multiplied by the round's stake: the positive part is paid in NMR, Numerai's token, and the negative part is burned (destroyed). The multipliers are configuration of each tournament and round, not constants; before doing the math you have to check them in the current round.
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 per 10")CORR +0.020 MMC +0.005: +1.35 NMR per 10
CORR +0.020 MMC -0.005: -0.15 NMR per 10
CORR -0.030 MMC -0.010: -2.40 NMR per 10With these multipliers, MMC weighs five times more than CORR per unit, and a negative MMC can turn a good CORR into a burn. The stake is optional: the documentation insists that submissions without stake are scored the same way and recommends starting without a stake or with a very small one. For a portfolio, the history of live scores is enough, since it is out-of-sample evaluation done by a third party; risking NMR, a crypto-asset, adds nothing as proof of competence, and Numerai itself warns that it is not an investment in the fund.
Kaggle and the public leaderboard
In a Kaggle competition each submission has two scores, a public one and a private one, computed on different
parts of the test set; the official CLI shows both (Public Score and Private Score) with kaggle competitions submission. Which part goes to each and which one decides the final ranking are set by the rules and the evaluation
page of each competition. Kaggle also has a progression system, described at kaggle.com/progression. For a portfolio,
what is valuable about a competition is a well-explained notebook and the result with which the ranking closes, not the
position on the public leaderboard during the competition.
kaggle competitions submit <competition> -f submission.csv -m "v3"The reason can be seen with a simulation. A participant with no information at all (random predictions) changes 5% of their predictions in each submission and keeps the change if it improves the public leaderboard. The 30% public share is an assumption of the simulation; each competition sets its own split.
rng = np.random.default_rng(11)
filas = 10_000
y = rng.integers(0, 2, filas)
publica = rng.random(filas) < 0.30 # assumption: 30% public
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.4957The columns are the submission number and the public and private accuracy. The header figure draws that history: public accuracy rises steadily and the private one stays where it was, around 50%. Each accepted change gets a few more rows of the public part right by chance; on the private part it has no effect at all. It is the same selection on noise that lesson 2.5.4 corrects in backtests, and the reason why a high position on the public leaderboard during the competition says little. In an interview, “this is the notebook and this is the final private score” is a fact; “I got to third place on the public one” is not.
The hiring process
Processes vary between firms, but they usually combine several of these stages, each with a different goal:
Mermaid source
flowchart TB A[CV and portfolio] --> B[Online test] B --> C[Technical] C --> D[Case or project] D --> E[Final and team]
- Online test. Timed coding problems and, for some positions, probability and mental arithmetic. It measures speed and correctness under pressure.
- Technical interviews. Probability, statistics, code and, depending on the role, finance, microstructure or systems. Reasoning out loud matters as much as the answer.
- Case or project. A dataset and a few days to analyze it, or the defense of your own project. Here the portfolio and the capstone become a conversation: what validation you used, what you would do differently.
- Final. Fit with the team and the candidate's questions. Those of lesson 7.5.2 (where the revenue comes from, who bears the losses, how the work is measured) fit here.
The following sections are a practice bank. Each block poses the problems and folds the solution: it is worth trying them before opening it.
Probability: exact and simulated
In an interview the exact answer is asked for; at work, a quick simulation avoids mistakes in the exact one. Doing both is the best training.
- A fair coin is tossed until two heads in a row (HH) come up. How many tosses are needed on average? And until heads followed by tails (HT)?
- A stick is broken into three pieces by choosing two points at random, uniform and independent. With what probability do the three pieces form a triangle?
- With what probability are there at least two people with the same birthday in a group of 23 (365 equally likely days)?
- A gambler starts with €10 and bets €1 each time with a probability of winning of 0.49. With what probability does the gambler reach €20 before going broke?
- 10% of the strategies you test have a real edge. Your backtest detects a real edge with probability 0.8 and gives a false positive with probability 0.05. If a strategy passes the backtest, with what probability does it have an edge?
Solutions
1. With the states “no streak” and “one heads”: and for HH, from which . For HT, once the first heads has come up it is enough to wait for a tails (two tosses on average), and the first heads takes another two: . 2. The three pieces form a triangle if none is longer than half: . 3. . 4. With , the gambler's ruin probability is
5. Bayes: . The simulation of the five:
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 = heads
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 = {"HH": 6, "HT": 4, "triangle": 0.25,
"birthday": 1 - prod((365 - k) / 365 for k in range(23)),
"ruin": (r**10 - 1) / (r**20 - 1),
"bayes": float(Fraction(8, 100) / (Fraction(8, 100)
+ Fraction(45, 1000)))}
simulados = {"HH": espera((1, 1)), "HT": espera((1, 0)),
"triangle": (trozos < 0.5).all(axis=1).mean(),
"birthday": repite.mean(), "ruin": ruina(),
"bayes": ventaja[pasa].mean()}
pd.DataFrame({"exacto": exactos, "simulado": simulados}).round(4) exacto simulado
HH 6.0000 5.9992
HT 4.0000 4.0003
triangle 0.2500 0.2514
birthday 0.5073 0.5106
ruin 0.4013 0.3986
bayes 0.6400 0.6366The columns are the exact and the simulated value. The simulations land within one or two hundredths of the exact values. Question 5 is the one that matters most in this profession: with 10% of good ideas, a third of what passes a reasonable backtest is noise, and that with a single test per idea.
A simulation is not exact, but its error can be measured. For the broken stick, the mean absolute error over 200 repetitions with each sample size:
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.00109The mean error (error_medio) follows the theoretical curve (teorico) : gaining one
decimal of precision takes a hundred times more trials. With 100,000, the typical error is one thousandth, enough to
check an interview answer and not to distinguish 0.250 from 0.251.
Statistics, code and brainteasers
- The daily returns of a strategy are iid normal with a daily Sharpe of 0.1 (about 1.6 annualized). What is the standard error of the annual Sharpe estimated with one year of data?
- You test 100 strategies with no edge at all over one year of daily data. What annual Sharpe do you expect for the best one?
- Write
max_drawdown(precios),mejor_operacion(precios)(maximum profit of a buy followed by a sell) andfusionar(*flujos)(merges trade streams already sorted by time), all in a single pass, with edge case tests.
Solutions
1 and 2. With iid normal returns, the variance of the estimated per-period Sharpe is approximately (Lo, 2002); annualizing multiplies it by . For the second, the maximum of 100 standard normals has a mean of about 2.5, and with T = 252 the standard error of the annual Sharpe without edge is 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"Mean annual Sharpe {sr_anual.mean():.2f}, "
f"std dev {sr_anual.std():.3f}, formula {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"Best of 100 without edge: {sr_ruido.max(axis=1).mean():.2f}")Mean annual Sharpe 1.57, std dev 1.015, formula 1.002
Best of 100 without edge: 2.50One year of data does not distinguish a Sharpe of 1.6 from one of 0.6, and the best of 100 strategies with no edge at all shows an annual Sharpe close to 2.5. They are the two figures worth keeping in mind when presenting a backtest.
3. The three functions go through the data only once; fusionar (merge) uses heapq.merge, which does not load
the streams into memory. The tests cover what usually fails: empty lists, monotonic series and ties.
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("exit code:", int(codigo))6 passed
exit code: 0In the interview, the tests are written before or together with the function and out loud: “what does it return with
an empty list?” is the question the interviewer expects to hear. The tie in the last test documents a decision:
heapq.merge is stable and respects the order of the input streams.
Brainteasers do not measure knowledge of finance but the way of reasoning: framing, simplifying, checking extreme cases.
- You have two ropes that each take one hour to burn, unevenly. How do you measure 45 minutes?
- A fair die: you can roll it once and collect what comes up, or roll it and, if you do not like it, roll once more and collect the second result. How much is the game worth with the second option, and on which results do you reroll?
- How many zeros are there at the end of 100! (the factorial of 100)?
Solutions
1. Light the first rope at both ends and the second at one. The first burns out in 30 minutes; at that instant light the other end of the second, which has 30 minutes of burning left, now consumed in 15. Total, 45. 2. The second roll is worth 3.5 on average, so you reroll on 1, 2 or 3 and collect on 4, 5 or 6: . 3. Each zero comes from a factor 10 = 2 × 5, and there are fewer fives than twos: . The last two are checked in one line:
from math import factorial
dado = np.arange(1, 7)
valor = np.mean(np.where(dado >= 4, dado, 3.5)) # reroll if < 4
ceros = len(str(factorial(100))) - len(str(factorial(100)).rstrip("0"))
print(valor, ceros)4.25 24The die pattern, comparing what you have with the expected value of continuing, is that of optimal stopping, and it recurs in execution problems and in American options (lesson 3.4.2).
Exercises
- Extend
auditarso that it warns if the README does not link to the data license (look for the word “license” in the “Data” section) and test it with the fixed repository.
Solution
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(), "Data")
if "license" not in datos.lower():
avisos.append("the “Data” section does not cite the license")
return avisos
with tempfile.TemporaryDirectory() as d:
raiz = Path(d)
crear_repo(raiz)
readme = (raiz / "README.md").read_text().replace(
"## Data\n", "## Data\nSource: X. License: CC BY 4.0.\n")
(raiz / "README.md").write_text(readme)
print("\n".join(auditar_datos(raiz)))missing LICENSE
missing uv.lock
README without a “Reproduce” section
.env exists and is not in .gitignore
.env: possible secret
analisis.ipynb: notebook with outputs
src/senal.py: possible secretThe new warning does not appear because the “Data” section already cites the license; the seven of the first
repository remain, because this test starts from it. The seccion function cuts the README by second-level headings,
the same structure the auditor already requires.
- In the leaderboard simulation, repeat the experiment with 10% and with 50% public rows. In which one does public accuracy rise more after 300 submissions? Why?
Solution
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)The smaller the public part, the noisier its accuracy and the more it can be “improved” by chance: with 10% public, public accuracy rises much more than with 50%. The private one does not move in any case. A small public leaderboard is easier to overfit.
- Prepare the two-minute defense of your own project (or of the capstone) for an interview: what question, what data, what validation, what went wrong and what you would do next.
Solution
A script that works: one sentence with the question and why it matters; the data source with its period and its license; the validation (walk-forward or CPCV, number of configurations tested and Deflated Sharpe Ratio); the result with its interval and not just the point estimate, remembering that with one year of data the standard error of the annual Sharpe is on the order of 1; what went wrong (a bias found late, an underestimated cost) and how it was detected; and the next step. What is usually missing is the fourth item, and it is what says the most about the candidate.
Each answer is folded under its question
Self-assessment
A model on Numerai has a CORR of 0.02 and an MMC of 0.005 in a round with the multipliers 3 × CORR60 + 15 × MMC60. What share of the stake does it earn?
- A2%
- B13.5%
- C7.5%
- DNothing: only CORR counts
Show answer
Answer B. 13.5%
The weighted score is 3 × 0.02 + 15 × 0.005 = 0.135, within the ±1 clip, so the payout is 13.5% of the round's stake. With a CORR of −0.03 and an MMC of −0.01, 24% would be burned. The multipliers are configuration of each tournament and round: check them before doing the math.
You multiply all your Numerai predictions by 10 and add 3. What happens to CORR?
- ANothing: CORR only uses the order of the predictions (rank and gaussianization)
- BIt is multiplied by 10
- CIt goes down, because the predictions leave the interval [0, 1]
- DIt goes up, because the Pearson correlation rewards scale
Show answer
Answer A. Nothing: CORR only uses the order of the predictions (rank and gaussianization)
The documentation's function ranks the predictions, converts them into normal quantiles and only then computes the correlation. Any increasing transformation leaves CORR unchanged; in the lesson, cubing does not change it either.
In the lesson's simulation, 300 submissions that only keep the changes that improve the public leaderboard raise public accuracy from 50.2% to more than 57%. What happens to the private one?
- AIt goes up by the same amount
- BIt goes up somewhat less
- CIt stays around 50%: the improvements were fitting to the noise of the public part
- DIt drops to 43%
Show answer
Answer C. It stays around 50%: the improvements were fitting to the noise of the public part
The predictions contain no information at all; each accepted change gets a few more rows of the public 30% right by chance. It is the same selection on noise that the Deflated Sharpe Ratio (lesson 2.5.4) corrects in a backtest.
With one year of daily returns and a true annual Sharpe of 1.6, what is approximately the standard error of the estimated annual Sharpe?
- A0.1
- B0.3
- CZero, if the data are daily
- D1.0
Show answer
Answer D. 1.0
With iid normal returns, the variance of the per-period Sharpe is (1 + SR²/2)/T (Lo, 2002). With a daily SR of 0.1 and T = 252, the daily standard error is 0.063 and the annual one, 1.0. The lesson's simulation gives 1.0: one year of data does not distinguish a Sharpe of 1.6 from one of 0.6.
What should a public repository of a trading project not have?
- AA
uv.lockwith the exact versions - BA
.envfile with keys or a notebook with outputs of data under a restricted license - CEdge case tests
- DA section that explains how to reproduce the results
Show answer
Answer B. A
.envfile with keys or a notebook with outputs of data under a restricted licenseCredentials never go into the repository (lesson 4.4.5) and the outputs of a notebook can redistribute data that the license does not allow (lesson 1.3.7). The lesson's auditor looks for both before publishing.
- AA
Key points
- A portfolio shows judgment: a clear question, licensed data, validation that discounts the attempts and a repository that someone else reproduces with one command.
- Before publishing, a simple auditor finds what does the most damage: keys in the code,
.envoutside.gitignore, a missing license,uv.lockor tests, and notebooks with outputs. - Numerai scores the order of the predictions (CORR with Gaussian rank and power 1.5) and the orthogonal contribution to the meta model (MMC); since round 1343 of the classic tournament it pays out 3 × CORR60 + 15 × MMC60 clipped to ±1 of the stake.
- Optimizing against the public leaderboard overfits: in the simulation, public accuracy rises from 50.2% to 57.2% and the private one stays at 49.6%.
- In probability, an exact answer checked with a simulation of 100,000 trials is more convincing than either one alone; the simulation error falls as 1/√n.
- With one year of daily data, the standard error of the annual Sharpe is on the order of 1: worth knowing before presenting a backtest in an interview.