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.

Fig. — Public and private accuracy of a participant with no information who only keeps changes that improve the public leaderboard (lesson simulation)
Level 7 lessons
  1. 7.1 · Software engineering for trading

    1. 7.1.1 Project structure, configuration and quality: from notebook to repository
    2. 7.1.2 Performance: measure, profile and compile only what is needed
    3. 7.1.3 Patterns: events, message queues and hexagonal architecture
  2. 7.2 · Infrastructure and deployment

    1. 7.2.1 Docker and docker-compose: reproducible images with uv
    2. 7.2.2 VPS, cloud and latency; CI/CD with GitHub Actions
    3. 7.2.3 Orchestration and monitoring: task graphs, structured logs, metrics, alerts and heartbeats
    4. 7.2.4 Security and continuity: 2FA, IP allowlists, backups and failure procedures
  3. 7.3 · MLOps for trading models

    1. 7.3.1 Tracking experiments, models and data: MLflow, DVC and a run reproduced end to end
    2. 7.3.2 Scheduled retraining, automatic validation and champion/challenger
    3. 7.3.3 Live model monitoring and automatic shutdown
    4. 7.3.4 Model serving: ONNX, CPU or GPU inference and the cost of LLMs in production
  4. 7.4 · Regulation, ethics and taxation

    1. 7.4.1 MiFID II: clients, suitability and algorithmic trading
    2. 7.4.2 ESMA and CFDs: leverage, close-out and negative balance
    3. 7.4.3 PRIIPs, the KID and US ETFs
    4. 7.4.4 MiCA after the transitional period: checking a provider in the registers
    5. 7.4.5 Market abuse: spoofing, layering and the bot that imitates them by accident
    6. 7.4.6 Trading taxes in Spain
    7. 7.4.7 Ethics: personal data, artificial intelligence and selling signals
  5. 7.5 · Quant career

    1. 7.5.1 Quant roles: six jobs around the same system
    2. 7.5.2 Where to work: what each firm does and what the registers say
    3. 7.5.3 Public portfolio and interviews: show judgment, not returns
    4. 7.5.4 Certifications, training and prop firms: what each one certifies and what a challenge measures
  6. 7.6 · Final capstone

    1. 7.6.1 Capstone: common requirements, the panel's rubric and preparing the defense
    2. 7.6.2 Capstone C1: a multi-asset futures system, from forecast to paper account
    3. 7.6.3 Capstone C2: systematic options book, volatility surface and real-time Greeks
    4. 7.6.4 Capstone C3: stock and ETF portfolio with ML, HRP and Spanish taxation
    5. 7.6.5 Capstone C4: a research platform with LLMs and alternative data
    6. 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:

PieceWhat it showsWhat to avoid
GitHub repositoryReadable code, tests, reproducibilityKeys, data under a restricted license, 50 MB notebooks
Blog or research notesAbility to explain an idea and its limitsPromises of returns, screenshots of real accounts
NumeraiModels evaluated live, out of sample, by third partiesPresenting the stake as an investment; confusing validation with live rounds
KaggleModeling with a fixed metric and comparison with othersPresenting the position on the public leaderboard as a result
Course capstoneA complete system with a risk report and paper tradingLeaving 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:

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

It is tested with an example repository created in a temporary folder, with the typical mistakes of a first published project:

IN [02]python
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)
OUT [02]A first repository with typical mistakes
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 secret

Seven 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:

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("## Reproduce\n")
    (raiz / "analisis.ipynb").write_text(json.dumps({"cells": [
        {"cell_type": "code", "source": "df.head()", "outputs": []}]}))
    print(auditar(raiz) or "no warnings")
OUT [03]The same repository, fixed
no warnings

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

PredictionsGaussian rankMeta modelNeutralizeCORRMMCPayout
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:

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)
# 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}")
OUT [04]Numerai's CORR, as its documentation defines it
CORR:               0.1290
CORR of preds**3:   0.1290
CORR of 10*preds+3: 0.1290
Direct Pearson:     0.1317

Any 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:

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)   # 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}")
OUT [05]Meta Model Contribution (MMC)
copy    CORR 0.1243  MMC 0.0002
own     CORR 0.1232  MMC 0.0448

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

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 per 10")
OUT [06]Payout of a round (staking v3)
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 10

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

IN [07]python
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))
OUT [07]Climbing the public leaderboard knowing nothing
 envio  publica  privada
     0   0.5024   0.5041
   100   0.5438   0.4998
   200   0.5642   0.4927
   300   0.5716   0.4957

The 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:

CV and portfolioOnline testTechnicalCase or projectFinal and team
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.

  1. 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)?
  2. 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?
  3. With what probability are there at least two people with the same birthday in a group of 23 (365 equally likely days)?
  4. 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?
  5. 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”: E0=1+12E1+12E0E_0 = 1 + \tfrac12 E_1 + \tfrac12 E_0 and E1=1+12⋅0+12E0E_1 = 1 + \tfrac12 \cdot 0 + \tfrac12 E_0 for HH, from which E0=6E_0 = 6. 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: E=4E = 4. 2. The three pieces form a triangle if none is longer than half: 1/41/4. 3. 1−∏k=022(365−k)/3651 - \prod_{k=0}^{22} (365-k)/365. 4. With r=q/pr = q/p, the gambler's ruin probability is

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. The simulation of the five:

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 = 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)
OUT [08]Exact versus simulated (100,000 trials)
          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.6366

The 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:

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 of a simulation by number of trials
     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. — Mean absolute error of the broken-stick simulation by number of trials (200 repetitions per size)

The mean error (error_medio) follows the theoretical curve (teorico) 2/π p(1−p)/n\sqrt{2/\pi}\,\sqrt{p(1-p)/n}: 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

  1. 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?
  2. 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?
  3. Write max_drawdown(precios), mejor_operacion(precios) (maximum profit of a buy followed by a sell) and fusionar(*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 (1+SR2/2)/T(1 + SR^2/2)/T (Lo, 2002); annualizing multiplies it by 252\sqrt{252}. 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:

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"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}")
OUT [10]Standard error of the Sharpe and maximum of 100 strategies without edge
Mean annual Sharpe 1.57, std dev 1.015, formula 1.002
Best of 100 without edge: 2.50

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

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("exit code:", int(codigo))
OUT [11]Functions and tests of a coding interview
6 passed
exit code: 0

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

  1. You have two ropes that each take one hour to burn, unevenly. How do you measure 45 minutes?
  2. 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?
  3. 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: 12⋅5+12⋅3.5=4.25\tfrac12 \cdot 5 + \tfrac12 \cdot 3.5 = 4.25. 3. Each zero comes from a factor 10 = 2 × 5, and there are fewer fives than twos: ⌊100/5⌋+⌊100/25⌋=24\lfloor 100/5 \rfloor + \lfloor 100/25 \rfloor = 24. The last two are checked in one line:

IN [12]python
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)
OUT [12]Checking the die and the zeros of 100!
4.25 24

The 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

  1. Extend auditar so 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
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(), "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)))
OUT [13]Checking the data license in the README
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 secret

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

  1. 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
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]The size of the public part
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.

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

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

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

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

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

  5. What should a public repository of a trading project not have?

    • AA uv.lock with the exact versions
    • BA .env file 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 .env file with keys or a notebook with outputs of data under a restricted license

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

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, .env outside .gitignore, a missing license, uv.lock or 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.

Resources