---
title: "7.5.3 Public portfolio and quant interviews"
description: "A repository that can be reviewed in ten minutes, Numerai and Kaggle read in their documentation, and a practice bank of probability, statistics and code."
url: "https://pythonparatrading.com/en/python-trading-course/level-7/quant-portfolio-and-interviews/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-7/quant-portfolio-and-interviews.png"
---

# 7.5.3 · 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.

- Course: [Level 7: Production, MLOps, regulation, career and capstone](https://pythonparatrading.com/en/python-trading-course/level-7/)
- Module: 7.5 · Quant career
- Duration: 75 min
- Prerequisites: [0.2.5 · Git and GitHub: commits, branches, pull requests and .gitignore](https://pythonparatrading.com/en/python-trading-course/level-0/git-and-github-for-trading/), [0.4.5 · Professional logging and testing with pytest](https://pythonparatrading.com/en/python-trading-course/level-0/python-logging-and-testing-with-pytest/), [0.7.2 · Probability and distributions, correlation versus causation and simple regression](https://pythonparatrading.com/en/python-trading-course/level-0/probability-distributions-and-regression/), [2.5.4 · Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO)](https://pythonparatrading.com/en/python-trading-course/level-2/deflated-sharpe-ratio-and-pbo/)

## By the end of this lesson

- Prepare a public repository that a reviewer understands and reproduces in ten minutes, and audit it with code before publishing it (secrets, license, tests, notebooks with outputs).
- Explain with the official documentation how Numerai scores (CORR, MMC and stake payout) and reproduce its formulas in Python.
- Show with a simulation why optimizing against a public leaderboard overfits, and what that says about a Kaggle result.
- Solve typical probability and statistics interview problems twice, with the exact solution and with a simulation that checks it.
- Write interview functions (drawdown, best trade, stream merging) with edge case tests and run them with pytest.

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

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

```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)
```
```output
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:

```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")
```
```output
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.

```mermaid
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:

```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}")
```
```output
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:

```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}")
```
```output
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.

```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")
```
```output
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.

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

Staking on Numerai means buying NMR, a crypto-asset with a volatile price, and accepting burns in negative rounds that
can follow one another. This lesson explains how it scores in order to understand the tournament as evidence for a
portfolio; it is not a recommendation to take part or to buy the token.

</aside>

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

```bash
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.

```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))
```
```output
 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:

```mermaid
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”: $E_0 = 1 + \tfrac12 E_1 + \tfrac12 E_0$ and
$E_1 = 1 + \tfrac12 \cdot 0 + \tfrac12 E_0$ for HH, from which $E_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 = 4$. **2.** The three
pieces form a triangle if none is longer than half: $1/4$. **3.** $1 - \prod_{k=0}^{22} (365-k)/365$. **4.** With
$r = q/p$, the gambler's ruin probability is

$$
P = \frac{r^{i} - 1}{r^{N} - 1},\qquad r = \frac{0.51}{0.49},\ i = 10,\ N = 20 .
$$

**5.** Bayes: $0.1 \cdot 0.8 / (0.1 \cdot 0.8 + 0.9 \cdot 0.05) = 0.64$. The simulation of the five:

```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)
```
```output
          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:

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

*Fig. — Mean absolute error of the broken-stick simulation by number of trials (200 repetitions per size).* Two lines on log-log axes falling in parallel: the measured error, from 0.032 with 100 trials to 0.0011 with 100,000 (highlighted), and the dashed theoretical curve proportional to one over the square root of n.

The mean error (`error_medio`) follows the theoretical curve (`teorico`) $\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 + SR^2/2)/T$ (Lo, 2002); annualizing multiplies it by $\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:

```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}")
```
```output
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.

```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))
```
```output
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:
$\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: $\lfloor 100/5 \rfloor + \lfloor 100/25 \rfloor = 24$. The last two are checked in one line:

```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)
```
```output
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.**

```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)))
```
```output
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.

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

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

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

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.

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

## 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?

   - A. 2%
   - B. 13.5%
   - C. 7.5%
   - D. Nothing: only CORR counts

   *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?

   - A. Nothing: CORR only uses the order of the predictions (rank and gaussianization)
   - B. It is multiplied by 10
   - C. It goes down, because the predictions leave the interval [0, 1]
   - D. It goes up, because the Pearson correlation rewards scale

   *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?

   - A. It goes up by the same amount
   - B. It goes up somewhat less
   - C. It stays around 50%: the improvements were fitting to the noise of the public part
   - D. It drops to 43%

   *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?

   - A. 0.1
   - B. 0.3
   - C. Zero, if the data are daily
   - D. 1.0

   *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?

   - A. A `uv.lock` with the exact versions
   - B. A `.env` file with keys or a notebook with outputs of data under a restricted license
   - C. Edge case tests
   - D. A section that explains how to reproduce the results

   *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

### Research

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

### Documentation

- [Numerai: Correlation (CORR)](https://docs.numer.ai/numerai-tournament/scoring/correlation-corr) · docs.numer.ai
- [Numerai: Meta Model Contribution (MMC)](https://docs.numer.ai/numerai-tournament/scoring/meta-model-contribution-mmc) · docs.numer.ai
- [Numerai: Staking (payouts, burns and multipliers)](https://docs.numer.ai/numerai-tournament/staking) · docs.numer.ai
- [Kaggle CLI: competition commands (submissions, public and private score, leaderboard)](https://github.com/Kaggle/kaggle-api/blob/main/docs/competitions.md) · github.com
- [GitHub Docs: managing your profile README](https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-github-profile/customizing-your-profile/managing-your-profile-readme) · docs.github.com

### Books

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

Previous lesson: [7.5.2 · Where to work: what each firm does and what the registers say](https://pythonparatrading.com/en/python-trading-course/level-7/quant-jobs-spain-europe-ecosystem/)

Next lesson: [7.5.4 · Certifications, training and prop firms: what each one certifies and what a challenge measures](https://pythonparatrading.com/en/python-trading-course/level-7/quant-certifications-training-prop-firms/)
