Lesson 7.6.4 · 70 min
Capstone C3: stock and ETF portfolio with ML, HRP and Spanish taxation
A long-term portfolio that ranks a stock universe with LightGBM every month, spreads risk with HRP and rebalances paying costs and Spain's IRPF on savings income, lot by lot. This guide is the brief of project C3, its architecture, a skeleton that works end to end with the level 5 panel and the way to validate it, run it on paper and defend it. It is not tax or investment advice.
- Module
- 7.6 · Final capstone
- Duration
- 70 min
- Prerequisites
- 7.6.1 · Capstone: common requirements, the panel's rubric and preparing the defense, 5.2.4 · Leakage-free normalization and cross-sectional rank, 5.4.3 · Walk-forward with retraining: windows and frequency, 5.4.4 · Backtesting ML signals with costs and Deflated Sharpe, 5.5.6 · Learning to rank for stock selection, 4.1.4 · Risk parity, minimum variance, HRP and HERC, 7.4.6 · Trading taxes in Spain
- 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
The brief
An individual investor resident in Spain wants a long-term stock portfolio managed with a model, and wants to know what is left after costs and IRPF, not just what it earns in a backtest. The brief:
- What is built. A system that at the end of each month ranks a stock universe with a cross-sectional ranking model (LightGBM), buys the top 40 and holds them while they stay within the top 120, spreads risk with HRP, sends the orders to a paper account, keeps the tax lots with FIFO and Spain's two-month rule and settles the IRPF on savings income every year. Part of the portfolio can be a core of UCITS ETFs weighted with the same HRP (exercise 2).
- For whom. For the investor and for whoever reviews their tax return: a report with the gross return, the return net of costs and net of taxes, the turnover, the deferred losses and the statistical evidence, with the number of trials.
- What is out. Short positions, leverage, derivatives, US-domiciled ETFs (an EU retail investor cannot buy them without a KID, lesson 7.4.3), investment funds, dividends with foreign withholding (7.4.6 covers them) and any real money. Nor is it aggressive tax optimization: the project measures the effect of the rule and of an operating rule, it does not try to pay less.
Deliverables, in addition to the common ones of 7.6.1: the package with the data audit, the model and its reference,
the selection, HRP, the simulator with lots and the tax engine, the executor; informe_c3.json regenerated with one
command; the trial log; the paper journal with the reconciliation of positions and lots; and the technical report.
The guide refers by code to what has already been taught: cross-sectional normalization and ranking (5.2.4), walk-forward with retraining (5.4.3), signal backtesting with costs and Deflated Sharpe (5.4.4), learning to rank and its volatility trap (5.5.6), HRP (4.1.4), rebalancing with bands (4.1.7), taxation (7.4.6), PRIIPs and ETFs (7.4.3), Interactive Brokers (4.4.2) and model monitoring (7.3.3).
Architecture
Mermaid source
flowchart TB D[Prices and features] --> A[Leakage audit] A --> M[Monthly ranking] M --> S[Selection and band] S --> H[HRP weights] H --> O[Orders and costs] O --> L[Lots and two months] L --> F[IRPF on savings] O --> C[Reconciliation] C --> V[IC monitor]
| Piece | What it does | Library | Where it was learned |
|---|---|---|---|
| Data audit | point-in-time, survivorship, leaks by anomalous IC | pandas, scipy | 2.5.1, 5.2.2 |
| Features | cross-sectional normal rank of each date | scipy | 5.2.4 |
| Model | LightGBM LambdaRank and regression; reference ridge | lightgbm, scikit-learn | 5.5.3, 5.5.6 |
| Validation | annual walk-forward with purging, log, DSR, PBO | numpy | 5.4.3, 5.4.4, 2.5.4 |
| Weights | hand-coded HRP (single linkage, bisection) | scipy | 4.1.4 |
| Rebalancing | entry and exit band, whole shares, costs | pandas | 4.1.7, 5.4.4 |
| Taxation | FIFO lots, two-month rule, loss offsetting, rate schedule | Python | 7.4.6 |
| Execution and paper | orders at the close, reconciliation of positions and lots | ib_async | 4.4.2, 4.5.5 |
| Monitor | live IC versus expected, drift, shutdown | MLflow, logs | 7.3.1, 7.3.3 |
HRP is coded by hand with scipy, as in lesson 4.1.4, for a practical reason and a methodological one. The practical
one: the project runs in the ml environment (LightGBM), where Riskfolio-Lib and skfolio are not installed. The
methodological one: lesson 4.1.4 checked that hand-written code and those libraries give the same weights with the same
configuration, and fixing the linkage and the leaf order in your own code avoids depending on a library's defaults.
Data
The skeleton uses the synthetic level 5 panel (public/curso/datos/nivel-5/): 400 fictitious stocks from 2011 to
2025, with 22 weekly features known at the close of their date, forward labels and the generator's truth.
| File | What it contributes to the project |
|---|---|
panel5_features.parquet | 22 point-in-time weekly features, one of them with a planted leak |
panel5_etiquetas.parquet | return over the next week and over 21 sessions, with its closing date (for purging) |
panel5_precios.parquet | daily total-return closes: returns, covariances and valuation |
panel5_verdad.parquet | the true alpha of each stock and week: the ceiling for any model |
universo4_precios.parquet (level 4) | 38 fictitious total-return indices by asset class, for the ETF core |
Three simplifications of the panel that the report has to state: there are no IPOs or delistings (survivorship bias
was covered with universo2 in level 2), prices are total return (no separate dividends, so there is no income from
movable capital in IRPF) and there is no risk-free rate. The skeleton treats prices in USD as if they were euros; in a
real account, each purchase and each sale is converted at the exchange rate of its date (7.4.6).
For the real project, documented sources, without downloading anything in the cells:
- SEC EDGAR: the data.sec.gov APIs, with no authentication or keys according to the SEC itself, provide the XBRL
facts of each company (
companyfacts) and one fact for all companies and a period (frames)1Note 1 · SEC, “EDGAR Application Programming Interfaces (APIs)” (https://www.sec.gov/search-filings/edgar-application-programming-interfaces): “These APIs do not require any authentication or API keys to access”; description ofcompanyfactsandframes. Accessed October 24, 2026.. They are the base for point-in-time fundamentals if the filing date of each report is saved. Automated access has to comply with its access policy; the report cites the version of that policy that was consulted. - Massive (formerly Polygon.io): its pricing page offers, for individual use, a stocks plan with more than 20 years of history at 199 USD a month and a financials and ratios plan at 29 USD a month2Note 2 · Massive, “Pricing” (https://massive.com/pricing): “Stocks Advanced”, 199 USD a month, “Individual use only”, with more than 20 years of history; “Financials & Ratios”, 29 USD a month, “Individual use only”. Accessed October 24, 2026.. The license is for individual use: it does not allow redistributing the data in the repository, so the repository keeps the download code and the fingerprints, not the data.
- Kenneth R. French Data Library: freely downloadable factors and benchmark portfolios, for the portfolio attribution (lesson 4.1.6).
- UCITS ETFs: the KID and the issuer's factsheet for each ETF in the core (lesson 7.4.3), with its TER, its dividend policy (accumulating or distributing) and its currency.
The central problem with real stock data is the universe: a universe built with the companies listed today has survivorship bias, and one with fundamentals without a publication date has look-ahead. The project's data README documents how the universe is fixed at each date and where the date of each data point comes from.
Runnable skeleton
Six cells, from the features to the report. The first builds the monthly panel (the last panel date of each month, the rebalancing day) and, before training anything, audits the features: the monthly IC of each one with the 21-session return, only in the first training window (2012-2014), so that the filter does not look at the future either.
import numpy as np
import pandas as pd
from scipy.stats import norm, rankdata
feat = pd.read_parquet("panel5_features.parquet")
etiq = pd.read_parquet("panel5_etiquetas.parquet",
columns=["fecha", "ticker", "ret_sig_21d",
"fin_sig_21d"])
d = feat.merge(etiq, on=["fecha", "ticker"])
# the last panel date of each calendar month: rebalancing day
fin_mes = d.groupby(d["fecha"].dt.to_period("M"))["fecha"].max()
d = d[d["fecha"].isin(fin_mes)].sort_values(["fecha", "ticker"])
TODAS = list(feat.columns[3:])
fechas = pd.DatetimeIndex(d["fecha"].unique())
nM, nA = len(fechas), d["ticker"].nunique()
tickers = d["ticker"].unique()[:nA]
F = d[TODAS].to_numpy(float).reshape(nM, nA, -1)
Y = d["ret_sig_21d"].to_numpy().reshape(nM, nA)
fin = d["fin_sig_21d"].to_numpy().reshape(nM, nA)[:, 0]
def rango_normal(X):
"""Cross-sectional normal rank of each month (5.2.4); no data, 0."""
n = np.isfinite(X).sum(axis=1, keepdims=True)
with np.errstate(invalid="ignore", divide="ignore"):
u = (rankdata(X, axis=1, nan_policy="omit") - 0.5) / n
return np.nan_to_num(norm.ppf(u))
G = np.stack([rango_normal(F[:, :, j]) for j in range(len(TODAS))], 2)
# audit: monthly IC of each feature in 2012-2014 (only the first
# training window) and its t; a huge t is an alarm
tramo = (fechas.year < 2015) & np.isfinite(Y).all(axis=1)
RY = rango_normal(Y)
ic = np.array([[np.corrcoef(G[m, :, j], RY[m])[0, 1]
for m in np.flatnonzero(tramo)]
for j in range(len(TODAS))])
t = ic.mean(1) / ic.std(1, ddof=1) * np.sqrt(ic.shape[1])
auditoria = pd.DataFrame({"ic": ic.mean(1), "t": t}, index=TODAS)
print(f"{nM} months x {nA} stocks; audited window: "
f"{tramo.sum()} months")
print(auditoria.sort_values("t", key=abs, ascending=False)
.head(4).round(3).to_string())
SOSPECHOSAS = auditoria.index[auditoria["t"].abs() > 5].tolist()
FE = [j for j, c in enumerate(TODAS) if c not in SOSPECHOSAS]
print("out:", SOSPECHOSAS, "| remaining", len(FE), "features")168 months x 400 stocks; audited window: 36 months
ic t
revision_analistas 0.096 10.815
mom_12_1 0.038 2.765
sue 0.023 2.301
dist_max_52s 0.027 1.979
out: ['revision_analistas'] | remaining 21 featuresOne hundred sixty-eight months, 400 stocks. revision_analistas (the analyst revision) has an IC of 0.096 with
t = 10.8 over 36 months; the next one, momentum, 0.038 with t = 2.8. A feature that ranks two and a half times better
than momentum in a stock panel is an alarm, and here the README confirms why: the vendor dates it at the start of the
week it covers and it contains later returns. The threshold (|t| > 5) is an audit rule written beforehand, not a
statistical test; whatever fails it is reviewed by hand and documented.
The model is retrained every January with all the months whose label had already closed before the first rebalance of the year (the purging of 5.4.3: the 21-session labels of the last month overlap with the first test month). Three models with the same features in normal rank: a reference ridge, LightGBM as a regressor of the return rank and LightGBM LambdaRank with quintiles as grades, linear gain and truncation at 40, the number of stocks bought (the recommendations of 5.5.6). Moderate, fixed hyperparameters, without a search; the seed and the threads, fixed.
import lightgbm as lgb
from sklearn.linear_model import Ridge
ok = np.isfinite(Y).all(axis=1)
RY = np.where(ok[:, None], rango_normal(Y), np.nan) # label
quintil = np.where(ok[:, None], (rankdata(Y, axis=1) - 1) * 5 // nA,
-1).astype(int)
ARB = dict(n_estimators=200, learning_rate=0.03, num_leaves=15,
min_child_samples=200, subsample=0.7, subsample_freq=1,
colsample_bytree=0.7, random_state=0, n_jobs=4,
deterministic=True, force_row_wise=True, verbose=-1)
anyo = fechas.year.to_numpy()
punt = {k: np.full((nM, nA), np.nan)
for k in ("ridge", "lgbm_reg", "lgbm_rank")}
for y in range(2015, 2026):
hoy = np.flatnonzero(anyo == y)
# time rule: labels closed before the first rebalance of the
# year (purging the overlapping months)
tr = np.flatnonzero(ok & (fin < fechas[hoy[0]]))
X = G[tr][:, :, FE].reshape(-1, len(FE))
mod = {"ridge": Ridge(alpha=10.0).fit(X, RY[tr].ravel()),
"lgbm_reg": lgb.LGBMRegressor(**ARB).fit(X, RY[tr].ravel()),
"lgbm_rank": lgb.LGBMRanker(
objective="lambdarank", label_gain=[0, 1, 2, 3, 4],
lambdarank_truncation_level=40, **ARB).fit(
X, quintil[tr].ravel(), group=[nA] * len(tr))}
for k, m in mod.items():
punt[k][hoy] = m.predict(G[hoy][:, :, FE].reshape(
-1, len(FE))).reshape(len(hoy), nA)
oos = np.flatnonzero((anyo >= 2015) & ok)
verdad = pd.read_parquet("panel5_verdad.parquet",
columns=["fecha", "ticker", "alfa_verdadero"])
verdad = verdad[verdad["fecha"].isin(fechas)].sort_values(
["fecha", "ticker"])
punt["oraculo"] = verdad["alfa_verdadero"].to_numpy().reshape(nM, nA)
vol = F[:, :, TODAS.index("vol_63d")]
def corr_r(a, b):
return np.mean([np.corrcoef(rankdata(a[i]), rankdata(b[i]))[0, 1]
for i in oos])
filas = {}
for k, p in punt.items():
x = np.array([np.corrcoef(rankdata(p[i]), rankdata(Y[i]))[0, 1]
for i in oos])
filas[k] = {"IC": x.mean(),
"t": x.mean() / x.std(ddof=1) * np.sqrt(len(x)),
"corr_vol": corr_r(p, vol)}
print(f"{len(oos)} out-of-sample months (2015-2025)")
pd.DataFrame(filas).T.round(4)131 out-of-sample months (2015-2025)
IC t corr_vol
ridge 0.0221 3.1468 0.0999
lgbm_reg 0.0124 2.1227 0.0859
lgbm_rank 0.0096 1.2893 0.2815
oraculo 0.0276 3.3056 -0.2739Over 131 out-of-sample months, the ridge has an IC of 0.0221 (t = 3.15), close to the oracle (oraculo, 0.0276;
t = 3.31), which knows the true alpha. LightGBM as a regressor gives 0.0124 and as LambdaRank, 0.0096, with t = 1.29
and a rank correlation of 0.28 with vol_63d: the ranker once again loads up on volatility, as in lesson 5.5.6. The
oracle has a negative correlation with volatility (−0.27) because the panel plants a low-volatility premium. The fact
that the brief asks for LightGBM does not change the method: the model is presented alongside its reference.
The portfolio buys the top 40 and keeps the ones it already holds while they stay within the top 120 (the 10% and 30%
band of 5.4.4, fixed beforehand), with equal weights (igual) or with HRP on the covariance of the 252 sessions before
the rebalance. It pays 10 bp per side on turnover. The six combinations are the trial log; the reference is the
equal-weighted universe, and the active return, the monthly difference from it.
from scipy.cluster.hierarchy import leaves_list, linkage
from scipy.spatial.distance import squareform
px = pd.read_parquet("panel5_precios.parquet",
columns=["fecha", "ticker", "cierre"])
px = px.pivot(index="fecha", columns="ticker", values="cierre")
px = px[tickers].astype(float)
ret = px.pct_change()
TOP, COSTE = 40, 0.001 # 40 stocks; 10 bp per side
def var_ivp(S, ix):
c = S[np.ix_(ix, ix)]
w = 1 / np.diag(c)
w /= w.sum()
return w @ c @ w
def hrp(S):
"""HRP from lesson 4.1.4 (single linkage, bisection)."""
sd = np.sqrt(np.diag(S))
dist = np.sqrt(np.clip((1 - S / np.outer(sd, sd)) / 2, 0, 1))
orden = leaves_list(linkage(squareform(dist, checks=False),
"single"))
w, grupos = np.ones(len(S)), [orden]
while grupos:
sig = []
for g in grupos:
if len(g) > 1:
a, b = g[: len(g) // 2], g[len(g) // 2:]
va, vb = var_ivp(S, a), var_ivp(S, b)
w[a] *= vb / (va + vb)
w[b] *= va / (va + vb)
sig += [a, b]
grupos = sig
return w
REB = [i for i in range(nM) if anyo[i] >= 2015]
BANDA = 120 # held while within the top 120
def seleccion(p, i, tenidas, vetadas=()):
"""The TOP best, keeping the ones already held while they stay
within the top BANDA (5.4.4); `vetadas` do not come in."""
orden = [j for j in np.argsort(-p[i]) if tickers[j] not in vetadas]
rango = {j: n for n, j in enumerate(orden)}
sel = sorted((j for j in tenidas if rango.get(j, nA) < BANDA),
key=rango.get)[:TOP]
sel += [j for j in orden if j not in sel][:TOP - len(sel)]
return np.array(sel)
def pesos(sel, i, metodo):
w = np.zeros(nA)
if metodo == "igual":
w[sel] = 1 / len(sel)
else:
hist = ret.loc[:fechas[i]].iloc[-252:].to_numpy()[:, sel]
w[sel] = hrp(np.cov(hist, rowvar=False))
return w
def cartera(p, metodo):
"""Monthly return net of costs, and turnover."""
w_ant, filas = np.zeros(nA), []
for a, b in zip(REB[:-1], REB[1:]):
w = pesos(seleccion(p, a, np.flatnonzero(w_ant)), a, metodo)
rot = np.abs(w - w_ant).sum()
r = (px.loc[fechas[b]] / px.loc[fechas[a]] - 1).to_numpy()
bruto = float(w @ r)
filas.append((fechas[b], bruto - COSTE * rot, rot))
w_ant = w * (1 + r) / (1 + bruto)
return pd.DataFrame(filas, columns=["fecha", "neto", "rot"]
).set_index("fecha")
mercado = pd.Series(
[(px.loc[fechas[b]] / px.loc[fechas[a]] - 1).mean()
for a, b in zip(REB[:-1], REB[1:])],
index=fechas[REB[1:]]) # equal-weighted universe
pruebas = {f"{k} + {m}": cartera(punt[k], m)
for k in ("ridge", "lgbm_reg", "lgbm_rank")
for m in ("igual", "hrp")}
activo = pd.DataFrame({k: v["neto"] - mercado
for k, v in pruebas.items()})
registro = pd.DataFrame({
"activo_anual_%": 100 * 12 * activo.mean(),
"ir": activo.mean() / activo.std() * np.sqrt(12),
"sharpe": [v["neto"].mean() / v["neto"].std() * np.sqrt(12)
for v in pruebas.values()],
"rotacion_mes_%": [100 * v["rot"].iloc[1:].mean()
for v in pruebas.values()]})
registro.to_csv("registro_pruebas.csv")
techo = {m: 12 * (cartera(punt["oraculo"], m)["neto"]
- mercado).mean() for m in ("igual", "hrp")}
print(f"{len(mercado)} months; universe: "
f"{100 * ((1 + mercado).prod() ** (12 / len(mercado)) - 1):.1f}"
f" % a year, Sharpe "
f"{mercado.mean() / mercado.std() * np.sqrt(12):.2f}")
print("ceiling (oracle), annual active return:",
{k: f"{100 * v:.1f} %" for k, v in techo.items()})
registro.round(2)131 months; universe: 6.5 % a year, Sharpe 0.43
ceiling (oracle), annual active return: {'igual': '2.0 %', 'hrp': '0.0 %'}
activo_anual_% ir sharpe rotacion_mes_%
ridge + igual 1.97 0.41 0.49 45.42
ridge + hrp 0.81 0.15 0.48 57.87
lgbm_reg + igual 0.63 0.14 0.42 82.24
lgbm_reg + hrp -0.52 -0.10 0.40 91.31
lgbm_rank + igual 0.73 0.13 0.43 76.36
lgbm_rank + hrp -0.06 -0.01 0.44 89.16The universe rises 6.5% a year with a Sharpe of 0.43 (no risk-free rate). The best trial, ridge with equal weights, beats it by 1.97% a year with an IR of 0.41 and the lowest turnover (45% a month); the one of the brief, LambdaRank with HRP, stays at −0.06% with an IR of −0.01 and a turnover of 89%. The figure ranks the six by IR. The absolute Sharpes are all between 0.40 and 0.49: HRP does not change the Sharpe much, but it does change the active return. The known answer explains it: the oracle with equal weights beats the universe by 2.0% and with HRP, by 0.0%. HRP weights by risk, loads the least volatile stocks and, against an equal-weighted universe in a rising market, eats up what the selection contributes. It is a risk control, not a source of return.
The tax part reuses the logic of the 7.4.6 engine in a compact floating-point version, designed to simulate eleven years of rebalances and not to prepare the tax return (which uses exact decimals, as in 7.4.6). The lots follow FIFO (art. 37.2 of the IRPF law) and the two-month rule (art. 33.5 f): purchases made within two months after a sale at a loss defer the loss of the repurchased units, and earlier purchases only count up to the remaining balance. The deferred loss travels with the repurchased lot and is recognized when it is sold. A test with a case computed by hand:
from collections import defaultdict, deque
from dateutil.relativedelta import relativedelta
DOS_MESES = relativedelta(months=2)
class Lotes:
"""FIFO lots by value (art. 37.2) with the two-month rule
(art. 33.5 f, lesson 7.4.6), simplified: see the text."""
def __init__(self):
# lot: [date, units, cost, deferred, free units]
self.cola = defaultdict(deque)
self.pend = defaultdict(list) # loss sales still to match
self.lineas = [] # (year, amount, type)
self.encadenadas = 0
def comprar(self, f, t, uds, coste):
dif, libres = 0.0, uds
for v in self.pend[t]: # purchase after a sale
if v["fecha"] + DOS_MESES >= f and v["uds"] > 0 and libres:
u = min(v["uds"], libres)
dif += u * v["perdida_ud"]
self.encadenadas += v["encadenada"]
self.lineas.append((v["fecha"].year,
u * v["perdida_ud"], "difiere"))
v["uds"] -= u
libres -= u
self.cola[t].append([f, uds, coste, dif, libres])
def vender(self, f, t, uds, importe):
cola, falta, coste, liberada = self.cola[t], uds, 0.0, 0.0
while falta:
lote = cola[0]
u = min(falta, lote[1])
x = u / lote[1]
coste += lote[2] * x
liberada += lote[3] * x
lote[1] -= u
lote[2] -= lote[2] * x
lote[3] -= lote[3] * x
lote[4] = min(lote[4], lote[1])
falta -= u
if lote[1] == 0:
cola.popleft()
res = importe - coste
self.lineas.append((f.year, res, "venta"))
if liberada:
self.lineas.append((f.year, -liberada, "integra"))
res -= liberada
if res >= 0:
return
quedan = sum(l[1] for l in cola)
n = 0 # earlier purchases, up to
for lote in reversed(cola): # the remaining balance
if lote[0] + DOS_MESES >= f and quedan:
u = min(lote[4], quedan - n)
lote[3] += -res / uds * u
lote[4] -= u
n += u
if n:
self.lineas.append((f.year, -res / uds * n, "difiere"))
self.encadenadas += liberada > 0
if uds > n:
self.pend[t].append({"fecha": f, "uds": uds - n,
"perdida_ud": -res / uds,
"encadenada": liberada > 0})
# test: buy 100, sell 100 at a loss, buy back 60 a month later
L = Lotes()
L.comprar(pd.Timestamp("2025-01-10"), "ABCD", 100, 10_000.0)
L.vender(pd.Timestamp("2025-03-10"), "ABCD", 100, 8_000.0)
L.comprar(pd.Timestamp("2025-04-08"), "ABCD", 60, 4_900.0)
L.vender(pd.Timestamp("2025-09-15"), "ABCD", 60, 5_400.0)
for linea in L.lineas:
print(linea)
print("year 2025:", round(sum(i for a, i, _ in L.lineas
if a == 2025), 2))(2025, -2000.0, 'venta')
(2025, 1200.0, 'difiere')
(2025, 500.0, 'venta')
(2025, -1200.0, 'integra')
year 2025: -1500.0One hundred shares bought for €10,000 and sold for €8,000: a loss of €2,000. Sixty are bought back a month later:
60 × 20 = €1,200 is deferred. When those 60 are sold with a gain of €500, the deferred loss is recognized: the year
adds up to −€1,500, the total economic loss. The line types in the output are the engine's identifiers: venta
(sale), difiere (deferral) and integra (deferred loss recognized). Two simplifications relative to 7.4.6, which the
report has to declare: the case of a single prior purchase with no shares held at the start of the period is only
handled through the limit of the remaining balance, and when shares with a deferred loss are sold and there are
repurchases again (the case that 7.4.6 says to review by hand), the simulator defers the total loss of that sale
again.
The simulator keeps the portfolio in whole shares, sells before buying, pays the costs and, at the first July rebalance, the previous year's IRPF (when the two-month windows of the December sales have already closed), with the 2026 savings rate schedule and the losses of the previous four years. At the end it sells everything and settles the last year, to compare net values. Three variants of the portfolio of the brief: without IRPF, with IRPF and with IRPF plus a two-month lockout on buying what was sold at a loss (the “repurchase warning” from the 7.4.6 exercise).
TRAMOS = [(6000, 0.19), (50000, 0.21), (200000, 0.23),
(300000, 0.27), (np.inf, 0.30)] # 2026 schedule
def cuota(base):
c, desde = 0.0, 0.0
for hasta, t in TRAMOS:
c += max(0.0, min(base, hasta) - desde) * t
desde = hasta
return c
def impuesto(lineas, y, pend):
"""Tax due for the year on gains and losses (no dividends: prices
are total return), with the losses of the previous four years
(art. 49). Modifies `pend`."""
saldo = sum(i for a, i, _ in lineas if a == y)
for a in sorted(pend):
if a < y - 4:
del pend[a]
elif saldo > 0:
x = min(pend[a], saldo)
pend[a] -= x
saldo -= x
if saldo < 0:
pend[y] = -saldo
return cuota(max(saldo, 0.0))
def simular(p, metodo, fiscal=True, bloqueo=False, capital=100_000.0):
"""Portfolio in whole shares: sells, buys, pays 10 bp per side
and, at the first July rebalance, the previous year's IRPF. At
the end it sells everything and settles the last year.
`bloqueo`: what was sold at a loss is not bought or increased for
two months (repurchase warning of 7.4.6)."""
caja, uds, L, pend = capital, defaultdict(int), Lotes(), {}
vetos, pagado, valor = {}, {}, {}
for i in REB:
f, precio = fechas[i], px.loc[fechas[i]]
y = f.year - 1
if fiscal and f.month >= 7 and y >= 2015 and y not in pagado:
pagado[y] = impuesto(L.lineas, y, pend)
caja -= pagado[y]
valor[f] = caja + sum(n * precio[t] for t, n in uds.items())
vet = {t for t, h in vetos.items() if h > f and bloqueo}
if i == REB[-1]:
obj = {} # final liquidation
else:
tenidas = [j for j, t in enumerate(tickers) if uds[t] > 0]
fuera = [t for t in vet if uds[t] == 0]
sel = seleccion(p, i, tenidas, fuera)
w = pesos(sel, i, metodo)
obj = {tickers[j]: int(w[j] * valor[f] * 0.995
/ precio.iloc[j]) for j in sel}
for t in [t for t in uds if uds[t] > obj.get(t, 0)]:
n = uds[t] - obj.get(t, 0)
imp = n * precio[t] * (1 - COSTE)
k = len(L.lineas)
L.vender(f, t, n, imp)
if any(x[2] == "difiere" for x in L.lineas[k:]) or (
L.pend[t] and L.pend[t][-1]["fecha"] == f):
vetos[t] = f + DOS_MESES + pd.Timedelta(days=1)
caja += imp
uds[t] -= n
for t, n_obj in obj.items():
n = n_obj - uds[t]
if n > 0 and t not in vet:
coste = n * precio[t] * (1 + COSTE)
L.comprar(f, t, n, coste)
caja -= coste
uds[t] += n
if fiscal:
pagado[f.year] = impuesto(L.lineas, f.year, pend)
caja -= pagado[f.year]
return pd.Series(valor), caja, L, pagado
def lineas_anuales(L, regla=True):
"""Balance of gains and losses per year; without the rule, like
an engine that defers nothing."""
tipos = ("venta", "difiere", "integra") if regla else ("venta",)
return pd.Series([i for _, i, k in L.lineas if k in tipos],
index=[a for a, _, k in L.lineas if k in tipos]
).groupby(level=0).sum()
t_res = {}
for nombre, kw in (("no IRPF", dict(fiscal=False)),
("with IRPF", {}),
("with IRPF and lockout", dict(bloqueo=True))):
v, neto, L, pagado = simular(punt["lgbm_rank"], "hrp", **kw)
anos = (v.index[-1] - v.index[0]).days / 365.25
perd = -sum(i for _, i, k in L.lineas if k == "venta" and i < 0)
t_res[nombre] = {
"final_neto": neto,
"tae_%": 100 * ((neto / v.iloc[0]) ** (1 / anos) - 1),
"irpf": sum(pagado.values()),
"perdidas": perd,
"diferidas_%": 100 * sum(i for _, i, k in L.lineas
if k == "difiere") / perd}
if nombre == "with IRPF":
L_ref, v_ref = L, v
print(pd.DataFrame(t_res).T.round(1).to_string())
con, sin = lineas_anuales(L_ref), lineas_anuales(L_ref, False)
print("Year's balance with the rule minus without it (€):")
print(pd.DataFrame({"": (con - sin).round(0).astype(int)}).T
.to_string()) final_neto tae_% irpf perdidas diferidas_%
no IRPF 198790.8 6.5 0.0 285786.7 46.6
with IRPF 173348.7 5.2 18766.4 275682.5 46.5
with IRPF and lockout 164072.1 4.6 16510.8 262669.1 29.8
Year's balance with the rule minus without it (€):
2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025
5414 -2677 -545 4684 -4838 934 -232 799 -1903 1039 -2677Starting from €100,000 in January 2015, the portfolio without IRPF ends at €198,791 (annualized return, tae_%, of
6.5%); with IRPF, at €173,349 (5.2%), after paying €18,766 of tax over eleven years. The header figure compares the
three with the universe. In the variant with IRPF, €275,683 of gross losses are realized over eleven years, and 46.5%
are deferred at some point by the two-month rule: the band keeps the stocks that remain well ranked, but HRP trims and
increases positions every month, and a partial sale at a loss after a purchase the previous month falls inside the
window. (In the row without IRPF the last two columns only describe the lots; nothing is paid.)
The last table is the error of an engine that does not model the rule: the difference, year by year, between the balance of gains and losses with the rule and without it. In 2015 the rule postpones €5,414 of losses that a naive engine would have declared that year; in 2025, when everything is liquidated, €2,677 that had been deferred are recognized. The row sums to zero (except for rounding): the rule moves taxable base between years, it does not change the total. The lockout reduces the deferrals to 29.8% and the tax paid, but the net annualized return drops to 4.6%: by vetoing well-ranked stocks for two months, the portfolio moves away from the one the model asks for, and on this data that costs more than the tax timing it saves.
The last cell discounts the selection with the Deflated Sharpe on the monthly IR of the six trials and writes the
artifact, informe_c3.json, with the data fingerprint, what the audit set aside, the validation and the tax summary.
import hashlib
import json
def psr(sr, umbral, t, asim, curt):
den = np.sqrt(1 - asim * sr + (curt - 1) / 4 * sr**2)
return norm.cdf((sr - umbral) * np.sqrt(t - 1) / den)
def max_esperado(n):
gm = np.euler_gamma
return ((1 - gm) * norm.ppf(1 - 1 / n)
+ gm * norm.ppf(1 - 1 / (n * np.e)))
ir = activo.mean() / activo.std() # per month
mejor = ir.idxmax()
x = activo[mejor]
sr0 = ir.std() * max_esperado(len(ir))
mom = (len(x), x.skew(), x.kurt() + 3)
dsr = psr(ir[mejor], sr0, *mom)
print(f"best of {len(ir)}: {mejor}, IR {ir[mejor] * np.sqrt(12):.2f};"
f" threshold {sr0 * np.sqrt(12):.2f}; DSR {dsr:.3f}")
pre = "lgbm_rank + hrp" # the one of the brief
xp = activo[pre]
print(f"brief ({pre}): IR {ir[pre] * np.sqrt(12):.2f}, PSR(0) "
f"{psr(ir[pre], 0, len(xp), xp.skew(), xp.kurt() + 3):.3f}")
def sha(ruta):
return hashlib.sha256(open(ruta, "rb").read()).hexdigest()[:12]
informe = {
"proyecto": "C3 ML portfolio, HRP and IRPF", "config": pre,
"datos": {a: sha(a) for a in ("panel5_features.parquet",
"panel5_precios.parquet")},
"fuera": SOSPECHOSAS,
"validacion": {"n_pruebas": len(ir), "mejor": mejor,
"dsr_mejor": round(float(dsr), 3),
"ir_encargo": round(float(ir[pre]) * 12**0.5, 3)},
"fiscal": {k: {c: round(float(v), 1) for c, v in d.items()}
for k, d in t_res.items()}}
with open("informe_c3.json", "w") as fh:
json.dump(informe, fh, ensure_ascii=False, indent=1)
print(json.dumps(informe["validacion"], ensure_ascii=False))best of 6: ridge + igual, IR 0.41; threshold 0.23; DSR 0.727
brief (lgbm_rank + hrp): IR -0.01, PSR(0) 0.487
{"n_pruebas": 6, "mejor": "ridge + igual", "dsr_mejor": 0.727, "ir_encargo": -0.01}Validation
In C3 four things are chosen, and each one has its control:
| What is chosen | Risk | Control in the project |
|---|---|---|
| Features | a leak that looks like the best feature | IC audit on the first window; list of excluded features in the report |
| Model | comparing against nothing or tuning until it wins | reference ridge; fixed hyperparameters; log |
| Portfolio (weights, band, size) | choosing the best combination in the same sample | trial log, DSR, PBO |
| Period and benchmark | choosing the window or index that favors it | 2015-2025 fixed in week 1; equal-weighted universe declared |
The walk-forward is built into the construction itself: each year it trains with what was known on December 31, with the overlapping labels purged, and predicts the next twelve months without touching the model. On top of that walk-forward, the log has six trials; the best, ridge with equal weights, has an IR of 0.41 and a DSR of 0.727 with N = 6: the threshold demanded by the dispersion of the six (0.23 of annual IR) eats up a good part of the evidence. The one of the brief has a PSR(0) of 0.487: nothing distinguishable from zero. Exercise 1 adds the probability of backtest overfitting by CSCV.
The stopping criterion, written in week 4 before computing:
- If the audit finds a leak that cannot be explained and removed, the project stops until it is resolved.
- If the out-of-sample IC of the model of the brief is not positive with t > 2, or its active return net of costs is not positive, the portfolio is not presented as active management: the paper trading is done to evaluate the system, and the report compares it with the passive alternative (the universe or an ETF core).
- If the DSR of the best trial is < 0.95, the report says that there is no statistical evidence of an edge after the trials run.
- With the course data, the model of the brief does not pass (IC with t = 1.29, IR of −0.01) and the best trial does not reach the DSR either. The system is evaluated on paper; the report's conclusion is that, with this panel and monthly rebalancing, the selection does not make up for costs and taxes against the universe with enough evidence.
The skeleton not finding an edge is not a failure of the project: the panel has a small planted alpha, which lives mostly for a week, and long-term monthly rebalancing only captures part of it. The known answer (the oracle beats the universe by 2.0% a year with equal weights) marks the ceiling. A project that, with this data, presented an IR of 1.5 would have to explain where it comes from before the committee asks about the leak.
Risks and paper trading
Risks that really bring down a portfolio of this kind:
- Data. Survivorship, fundamentals without a publication date, wrongly applied corporate actions and a vendor's leak. A single feature with look-ahead invalidates everything; that is why the audit comes before the model and is repeated with every version of the data.
- A model that stops working. The panel's IC is small and changes with regimes (the momentum crash, 5.4.3); the monitor of 7.3.3 compares the live IC with the expected one and shuts the model down with its graduated policy.
- Concentration and factors. 40 stocks chosen by a model can be a sector or volatility bet; limits per sector and factor attribution (4.1.6) watch for it.
- Costs and taxation. The turnover of a monthly model is high (89% a month in the portfolio of the brief); each sale with a gain brings tax forward. The report always gives the three figures: gross, net of costs and net of taxes.
- Operational. Orders that are not filled at the close, fractional shares, currency conversions and lots that do not match the broker's report.
Limits in the configuration: maximum weight per stock and per sector, maximum turnover per rebalance, minimum cash, and a shutdown if the live IC falls below what is expected (7.3.3) or if the reconciliation does not match.
Mermaid source
stateDiagram-v2 [*] --> AUDITING AUDITING --> SCORING AUDITING --> BLOCKED SCORING --> ORDERING ORDERING --> RECONCILING RECONCILING --> WAITING RECONCILING --> BLOCKED WAITING --> AUDITING BLOCKED --> AUDITING
Paper trading plan. With one rebalance a month, four weeks from any given day would see only one and no sale of what was already held. Paper starts a few days before a month-end and runs until after the next one: at least four weeks (28 days and 20 sessions, 7.6.1) and two rebalances with real paper orders. Between the two, the system reconciles positions and cash with the broker every day, watches weight drift and corporate actions, and logs the IC of the week. After the second rebalance, your own lot record is squared with the broker's trade report: if the broker lets you choose the lot assignment method, your own tax report follows FIFO (art. 37.2), whatever the broker's method is. The executor sends orders at the close, which is the price the backtest uses; this cell is the real code and is not run here:
from ib_async import IB, Order, Stock
ib = IB()
ib.connect("127.0.0.1", 4002, clientId=34) # IB Gateway, paper
cartera = {p.contract.symbol: p.position for p in ib.positions()}
objetivo = calcular_objetivo(cartera) # signal + HRP + band
ventas = {t: n - objetivo.get(t, 0) for t, n in cartera.items()
if n > objetivo.get(t, 0)}
compras = {t: n - cartera.get(t, 0) for t, n in objetivo.items()
if n > cartera.get(t, 0)}
for lado, lista in (("SELL", ventas), ("BUY", compras)):
for t, n in sorted(lista.items()):
c = ib.qualifyContracts(Stock(t, "SMART", "USD"))[0]
orden = Order(action=lado, totalQuantity=n, orderType="MOC",
orderRef=f"C3-{t}-{lado}-20261030")
ib.placeOrder(c, orden) # at the close
ib.sleep(5)
print([(t.contract.symbol, t.orderStatus.status) for t in ib.trades()])Three details for the real executor: sales are sent before purchases and purchases are recomputed with the cash
actually available if a sale is not filled; the key of each order (orderRef) comes from the date, the stock and the
side (4.5.5); and a stock vetoed by the repurchase lockout, if used, is recorded with its end date in the system's own
state, not in the process memory.
Milestones and rubric
The schedule is the one from 7.6.1 (ten weeks, 40 to 80 hours), applied to C3:
| Week | Verifiable deliverable |
|---|---|
| 1 | Repository from the template; data README with sources, licenses, universe by date and fingerprints; 7.6.1 auditor green |
| 2 | Point-in-time loading, normal rank, leakage audit with its report; data tests |
| 3 | Model and reference in walk-forward with purging; selection with a band, HRP, costs; lot engine tested with the cases of 7.4.6 |
| 4 | Trial log, DSR and PBO; stopping criterion applied; tax simulation; informe_c3.json |
| 5 | Paper executor with ib_async against a simulated broker and the paper account; Docker; IC monitor |
| 6-10 | Paper from a few days before a month-end until after the next one (about five weeks), with journal and reconciliation of positions and lots; report in parallel |
| 10 | Paper report after closing the second rebalance; rehearsal and defense |
The common rubric of 7.6.1, with the specific evidence it asks for in C3:
| Criterion (weight) | Evidence in C3 |
|---|---|
| Reproducibility (15%) | make reproduce regenerates informe_c3.json with the report's fingerprint; fixed LightGBM seeds and threads; uv.lock, Docker |
| Data (15%) | universe by date without survivorship; publication date of each fundamental; license of each source; leakage audit |
| Validation (20%) | walk-forward with purging; linear reference; complete log; DSR and PBO; stopping criterion written beforehand |
| Risk and execution (15%) | costs per side; whole shares; weight, sector and turnover limits; IRPF by lots with the two-month rule |
| Paper trading (15%) | at least 28 days and two rebalances; daily reconciliation; lots squared with the broker; weekly IC logged |
| Code (10%) | tests of the audit, of HRP (weights summing to 1, known cases), of the lot engine against hand-computed cases; ruff and pre-commit |
| Report and defense (10%) | gross, net and after-tax return; tax simplifications declared; limits of the study |
Mistakes that fail on their own in this project, in addition to the common ones: training with revision_analistas
or with any feature dated after the decision; normalizing with statistics from the whole sample (5.2.4); an after-tax
return computed with a flat rate on the year's gain, without lots or a rate schedule (that is not the IRPF on savings
income); and presenting the model of the brief without the linear reference over the same period.
Exercises
- Compute the probability of backtest overfitting (PBO) of the choice among the six trials with CSCV: eight time blocks of the active return, all the partitions into four training blocks and four test blocks, and the out-of-sample position of the best in-sample trial (lesson 2.5.4).
Solution
Over 70 partitions, the trial chosen in sample falls below the out-of-sample median in 30% of the cases: a PBO of 0.30. It is not that of a random choice (0.5), but it does not rule out that the winner wins because of the period either. Together with the DSR of 0.727, the conclusion is the same: with six trials and eleven years, the evidence that one combination is better than the others is weak.
from itertools import combinations
M = activo.to_numpy()
bloques = np.array_split(np.arange(len(M)), 8)
logits = []
for tr in combinations(range(8), 4):
i_tr = np.concatenate([bloques[b] for b in tr])
i_te = np.concatenate([bloques[b] for b in range(8)
if b not in tr])
ir_tr = M[i_tr].mean(0) / M[i_tr].std(0)
ir_te = M[i_te].mean(0) / M[i_te].std(0)
k = int(np.argmax(ir_tr))
w = (rankdata(ir_te)[k]) / (len(ir_te) + 1)
logits.append(np.log(w / (1 - w)))
logits = np.array(logits)
print(f"{len(logits)} partitions; PBO = {(logits <= 0).mean():.2f}")70 partitions; PBO = 0.30- Add an ETF core: monthly HRP on seven blocks of the level 4 universe that would have a UCITS ETF (US, European, Japanese and emerging-market equities, hedged global bonds, 3-7 year US Treasuries and global real estate), and combine it 60/40 with the stock portfolio of the brief net of costs.
Solution
The HRP core earns little on this data (annualized return of 1.1% with a volatility of 3.8%): HRP loads the fixed-income blocks, the least volatile ones. The stock portfolio has an annualized return of 6.6% with a volatility of 18.5% and a maximum drawdown of −30.6%. The 60/40 ends at an annualized return of 3.7%, with the best Sharpe of the three (0.49) and a maximum drawdown of −18.0%. That is the role of the core: lowering the portfolio's risk, not raising its return. In the real version, each block is a UCITS ETF with its KID (7.4.3), and its TER and its dividend policy go into the costs and into IRPF.
u4 = pd.read_parquet("universo4_precios.parquet").set_index("fecha")
NUCLEO = ["RV_EEUU", "RV_EUR", "RV_JAP", "RV_EMG", "RF_GLB_C",
"RF_T37", "INM_GLB"]
r4 = u4[NUCLEO].pct_change()
nuc = []
for a, b in zip(REB[:-1], REB[1:]):
hist = r4.loc[:fechas[a]].iloc[-252:].to_numpy()
w = hrp(np.cov(hist, rowvar=False))
nuc.append(float(w @ (u4.loc[fechas[b], NUCLEO]
/ u4.loc[fechas[a], NUCLEO] - 1)))
nuc = pd.Series(nuc, index=mercado.index)
sat = pruebas[pre]["neto"]
mix = {"ETF core": nuc, "satellite": sat,
"60/40": 0.6 * nuc + 0.4 * sat}
def resumen(r):
eq = (1 + r).cumprod()
return {"tae_%": 100 * (eq.iloc[-1] ** (12 / len(r)) - 1),
"vol_%": 100 * r.std() * np.sqrt(12),
"sharpe": r.mean() / r.std() * np.sqrt(12),
"max_caida_%": 100 * (eq / eq.cummax() - 1).min()}
pd.DataFrame({k: resumen(v) for k, v in mix.items()}).T.round(2) tae_% vol_% sharpe max_caida_%
ETF core 1.12 3.76 0.31 -14.07
satellite 6.57 18.46 0.44 -30.64
60/40 3.68 8.05 0.49 -18.02- In the defense, a committee member asks why you do not deliver the ridge with equal weights, which is the best of the six trials. What do you answer?
Solution
That the brief set LightGBM and HRP before looking at results, and that the report presents the six trials, with the ridge as the mandatory reference. That the ridge with equal weights is the best in this sample, but with a DSR of 0.727 and a PBO of 0.30 there is not enough evidence that it is outside it; changing configuration after seeing the result and presenting it as if it were the chosen one is selecting after the fact, and that fails. The honest thing is what the report does: say that, with this data, no combination demonstrates an edge after costs and taxes, propose the ridge as a candidate for a new validation with later data and present none as tradable with money.
Footnotes
-
SEC, “EDGAR Application Programming Interfaces (APIs)” (https://www.sec.gov/search-filings/edgar-application-programming-interfaces): “These APIs do not require any authentication or API keys to access”; description of
companyfactsandframes. Accessed October 24, 2026. ↩ -
Massive, “Pricing” (https://massive.com/pricing): “Stocks Advanced”, 199 USD a month, “Individual use only”, with more than 20 years of history; “Financials & Ratios”, 29 USD a month, “Individual use only”. Accessed October 24, 2026. ↩
Each answer is folded under its question
Self-assessment
The skeleton's audit measures the monthly IC of each feature in 2012-2014 and sets aside
revision_analistas(IC 0.096, t = 10.8). Why is it a leak and not a good feature?- ABecause its IC is negative
- BBecause it is an analyst feature, and those never work
- CBecause it has missing data
- DBecause in a panel where honest features give ICs of 0.02 to 0.04, an IC of almost 0.10 with t > 10 is the signature of future information: the vendor dates it at the start of the week it covers, and it contains returns after the decision date
Show answer
Answer D. Because in a panel where honest features give ICs of 0.02 to 0.04, an IC of almost 0.10 with t > 10 is the signature of future information: the vendor dates it at the start of the week it covers, and it contains returns after the decision date
The panel's README describes the trap: the revision mixes in the returns of the first two sessions of the following week. A demonstrable look-ahead fails on its own; the audit uses only the first training window so that the filter does not look at the future either.
In the walk-forward, the LightGBM LambdaRank has an IC of 0.0096 (t = 1.29) and a rank correlation of 0.28 with
vol_63d; the ridge, 0.0221 (t = 3.15). What does the project do?- AIt says so: on this data the linear reference wins, and the ranker has mostly learned to sort by volatility, as in 5.5.6; the model of the brief is presented with the comparison, without tuning hyperparameters until it wins
- BIt searches the ranker's hyperparameters until it beats the ridge
- CIt removes the ridge from the report, because the brief asks for LightGBM
- DIt changes the label until the IC goes up
Show answer
Answer A. It says so: on this data the linear reference wins, and the ranker has mostly learned to sort by volatility, as in 5.5.6; the model of the brief is presented with the comparison, without tuning hyperparameters until it wins
The naive reference is part of the course method. Tuning the ranker against the same period until it wins adds trials to the log, and the DSR penalizes them. What you cannot do is hide the comparison.
With IRPF, the portfolio of the brief ends with an annualized return of 5.2%; with a two-month lockout on buying back what was sold at a loss, 4.6%. Why does the lockout come out worse?
- ABecause the lockout pays more IRPF
- BBecause the two-month rule defers the loss, it does not eliminate it; the lockout moves the portfolio away from the one the model asks for and that difference costs more than it saves in taxes paid early
- CBecause the lockout breaks the law
- DBecause with the lockout costs are not applied
Show answer
Answer B. Because the two-month rule defers the loss, it does not eliminate it; the lockout moves the portfolio away from the one the model asks for and that difference costs more than it saves in taxes paid early
The deferred loss is recognized when the repurchased shares are sold, so the saving from the lockout is only one of timing. In this portfolio, with high monthly turnover, the cost of not following the ranking is greater. It is a result of this data, not a general rule, and it is not advice.
The oracle (the panel's true alpha) with equal weights beats the universe by 2.0% a year; with HRP, by 0.0%. What does it teach?
- AThat the oracle is computed wrongly
- BThat HRP always destroys value
- CThat HRP decides weights by risk, not by expected return: it loads the least volatile stocks and, measured against an equal-weighted universe in a rising market, it eats up the selection; if the goal is alpha, HRP is a risk control, not the source
- DThat you have to rebalance every week
Show answer
Answer C. That HRP decides weights by risk, not by expected return: it loads the least volatile stocks and, measured against an equal-weighted universe in a rising market, it eats up the selection; if the goal is alpha, HRP is a risk control, not the source
The panel's known answer separates the two pieces: the selection adds value, the risk weighting changes the profile (similar Sharpe, less weight in volatile names). The report has to say which of the two is evaluated against which reference.
The portfolio rebalances at month-end. How is the paper trading set up?
- AFour weeks from any day: one rebalance is enough
- BThe second rebalance is simulated with the backtest
- CIt rebalances every week during paper
- DIt starts a few days before a month-end and runs until after the next one: at least four weeks and two rebalances with real paper orders, daily reconciliation and the lot record squared with the broker
Show answer
Answer D. It starts a few days before a month-end and runs until after the next one: at least four weeks and two rebalances with real paper orders, daily reconciliation and the lot record squared with the broker
What is evaluated on paper is the full cycle: data, signal, selection, orders, executions, lots and report. With a single rebalance there is no sale of what was already held, which is where the two-month rule and lot errors show up.
Key points
- C3 is a long portfolio of 40 stocks chosen every month by a ranking model, weighted with HRP and rebalanced with costs and IRPF; ETFs come in as a UCITS core, not as a satellite.
- The leakage audit comes before the model: on the level 5 panel it sets aside the analyst revision (IC 0.096, t = 10.8) looking only at the first training window.
- In the walk-forward, the ridge (IC 0.022) beats LightGBM as a regressor (0.012) and as LambdaRank (0.010), which also loads up on volatility. The project presents it with the comparison, it does not hide it.
- With six logged trials, the best (ridge with equal weights, IR 0.41) has a DSR of 0.727; the one of the brief, an IR of −0.01. There is no evidence of an edge after costs and selection.
- IRPF takes the portfolio's annualized return from 6.5% to 5.2%; almost half of the realized losses are deferred by the two-month rule, which moves taxable base between years without changing the total.
- A tax engine without the rule brings losses forward (€5,414 in 2015); blocking repurchases avoids part of the deferrals but, in this portfolio, costs more than it saves.
Resources
Documentation
- LightGBM 4.7.0: parameters (lambdarank, label_gain, lambdarank_truncation_level)
- Law 35/2006 on IRPF (Spain's personal income tax), consolidated text (BOE, in Spanish): arts. 33.5 f), 37.2, 49, 66 and 76
- DGT (Spain's Directorate General for Taxation), binding ruling V5316-26: how the two-month rule applies (in Spanish)
- Kenneth R. French: Data Library (factors and benchmark portfolios)