Lección 7.6.4 · 70 min
Capstone C3: cartera de acciones y ETFs con ML, HRP y fiscalidad española
Una cartera de largo plazo que ordena cada mes un universo de acciones con LightGBM, reparte el riesgo con HRP y se rebalancea pagando costes y el IRPF del ahorro, lote a lote. Esta guía es el enunciado del proyecto C3, su arquitectura, un esqueleto que funciona de punta a punta con el panel del nivel 5 y la forma de validarlo, operarlo en papel y defenderlo. No es asesoramiento fiscal ni de inversión.
- Módulo
- 7.6 · Capstone final
- Duración
- 70 min
- Requisitos
- 7.6.1 · Capstone: requisitos comunes, rúbrica del tribunal y preparación de la defensa, 5.2.4 · Normalización sin leakage y rank transversal, 5.4.3 · Walk-forward con reentrenamiento: ventanas y frecuencia, 5.4.4 · Backtest de señales de ML con costes y Deflated Sharpe, 5.5.6 · Learning to rank para la selección de acciones, 4.1.4 · Risk parity, mínima varianza, HRP y HERC, 7.4.6 · Fiscalidad del trading en España
Lecciones del nivel 7
7.1 · Ingeniería de software para trading
7.2 · Infraestructura y despliegue
7.3 · MLOps para modelos de trading
- 7.3.1 Registro de experimentos, modelos y datos: MLflow, DVC y una ejecución reproducida de principio a fin
- 7.3.2 Reentrenamiento programado, validación automática y champion/challenger
- 7.3.3 Monitorización del modelo en vivo y desactivación automática
- 7.3.4 Servir modelos: ONNX, inferencia en CPU o GPU y el coste de los LLM en producción
7.4 · Regulación, ética y fiscalidad
- 7.4.1 MiFID II: clientes, idoneidad y trading algorítmico
- 7.4.2 ESMA y CFD: apalancamiento, cierre y saldo negativo
- 7.4.3 PRIIPs, KID y ETFs estadounidenses
- 7.4.4 MiCA después del transitorio: comprobar un proveedor en los registros
- 7.4.5 Abuso de mercado: spoofing, layering y el bot que los imita sin querer
- 7.4.6 Fiscalidad del trading en España
- 7.4.7 Ética: datos personales, inteligencia artificial y la venta de señales
7.5 · Carrera profesional quant
7.6 · Capstone final
- 7.6.1 Capstone: requisitos comunes, rúbrica del tribunal y preparación de la defensa
- 7.6.2 Capstone C1: un sistema de futuros multi-activo, de la previsión a la cuenta de papel
- 7.6.3 Capstone C2: libro de opciones sistemático, superficie de volatilidad y griegas en tiempo real
- 7.6.4 Capstone C3: cartera de acciones y ETFs con ML, HRP y fiscalidad española
- 7.6.5 Capstone C4: una plataforma de investigación con LLMs y datos alternativos
- 7.6.6 Capstone C5: market making o arbitraje en cripto con control de contraparte
El encargo
Un inversor particular residente en España quiere una cartera de acciones de largo plazo gestionada con un modelo, y quiere saber lo que le queda después de costes y del IRPF, no solo lo que gana en un backtest. El encargo:
- Qué se construye. Un sistema que cada fin de mes ordena un universo de acciones con un modelo de ranking transversal (LightGBM), compra las 40 primeras y las mantiene mientras sigan entre las 120 primeras, reparte el riesgo con HRP, envía las órdenes a una cuenta de papel, lleva los lotes fiscales con FIFO y la regla de los dos meses y liquida cada año el IRPF del ahorro. Una parte de la cartera puede ser un núcleo de ETFs UCITS ponderado con el mismo HRP (ejercicio 2).
- Para quién. Para el propio inversor y para quien revise su declaración: un informe con la rentabilidad bruta, neta de costes y neta de impuestos, la rotación, las pérdidas diferidas y la evidencia estadística, con el número de pruebas.
- Qué no entra. Posiciones cortas, apalancamiento, derivados, ETFs domiciliados en EE. UU. (un minorista de la UE no puede comprarlos sin KID, lección 7.4.3), fondos de inversión, dividendos con retención extranjera (7.4.6 los trata) y cualquier dinero real. Tampoco una optimización fiscal agresiva: el proyecto mide el efecto de la norma y de una regla operativa, no busca pagar menos.
Entregables, además de los comunes de 7.6.1: el paquete con la auditoría de datos, el modelo y su referencia, la
selección, HRP, el simulador con lotes y el motor fiscal, el ejecutor; informe_c3.json regenerado con un comando; el
registro de pruebas; el diario del papel con la conciliación de posiciones y de lotes; y la memoria técnica.
La guía remite por código a lo que ya se enseñó: normalización y rango transversal (5.2.4), walk-forward con reentrenamiento (5.4.3), backtest de señales con costes y Deflated Sharpe (5.4.4), learning to rank y su trampa de la volatilidad (5.5.6), HRP (4.1.4), rebalanceo con bandas (4.1.7), fiscalidad (7.4.6), PRIIPs y ETFs (7.4.3), Interactive Brokers (4.4.2) y monitorización de modelos (7.3.3).
Arquitectura
Fuente Mermaid
flowchart TB D[Precios y features] --> A[Auditoría de fugas] A --> M[Ranking mensual] M --> S[Selección y banda] S --> H[Pesos HRP] H --> O[Órdenes y costes] O --> L[Lotes y dos meses] L --> F[IRPF del ahorro] O --> C[Conciliación] C --> V[Monitor del IC]
| Pieza | Qué hace | Librería | Dónde se aprendió |
|---|---|---|---|
| Auditoría de datos | point-in-time, supervivencia, fugas por IC anómalo | pandas, scipy | 2.5.1, 5.2.2 |
| Features | rango normal transversal de cada fecha | scipy | 5.2.4 |
| Modelo | LambdaRank y regresión de LightGBM; ridge de referencia | lightgbm, scikit-learn | 5.5.3, 5.5.6 |
| Validación | walk-forward anual con purga, registro, DSR, PBO | numpy | 5.4.3, 5.4.4, 2.5.4 |
| Pesos | HRP propio (enlace simple, bisección) | scipy | 4.1.4 |
| Rebalanceo | banda de entrada y salida, acciones enteras, costes | pandas | 4.1.7, 5.4.4 |
| Fiscalidad | lotes FIFO, regla de los dos meses, compensación, escala | Python | 7.4.6 |
| Ejecución y papel | órdenes al cierre, conciliación de posiciones y lotes | ib_async | 4.4.2, 4.5.5 |
| Monitor | IC en vivo frente al esperado, deriva, desactivación | MLflow, logs | 7.3.1, 7.3.3 |
HRP se programa a mano con scipy, como en la lección 4.1.4, por una razón práctica y otra de método. La práctica: el
proyecto corre en el entorno ml (LightGBM), donde no están Riskfolio-Lib ni skfolio. La de método: la lección 4.1.4
comprobó que el código propio y esas librerías dan los mismos pesos con la misma configuración, y fijar el enlace y el
orden de las hojas en el propio código evita depender de los valores por defecto de una librería.
Datos
El esqueleto usa el panel sintético del nivel 5 (public/curso/datos/nivel-5/): 400 acciones ficticias de 2011 a
2025, con 22 features semanales conocidas al cierre de su fecha, etiquetas a futuro y la verdad del generador.
| Archivo | Qué aporta al proyecto |
|---|---|
panel5_features.parquet | 22 features semanales point-in-time, una de ellas con fuga plantada |
panel5_etiquetas.parquet | rendimiento a la semana siguiente y a 21 sesiones, con su fecha de cierre (para la purga) |
panel5_precios.parquet | cierres diarios de rentabilidad total: rendimientos, covarianzas y valoración |
panel5_verdad.parquet | el alfa verdadero de cada acción y semana: el techo de cualquier modelo |
universo4_precios.parquet (nivel 4) | 38 índices ficticios de rentabilidad total por clase de activo, para el núcleo de ETFs |
Tres simplificaciones del panel que la memoria tiene que decir: no hay salidas a bolsa ni deslistados (el sesgo de
supervivencia se trató con universo2 en el nivel 2), los precios son de rentabilidad total (sin dividendos aparte,
así que no hay rendimientos del capital mobiliario en el IRPF) y no hay tipo libre. El esqueleto trata los precios en
USD como si fueran euros; en una cuenta real, cada compra y cada venta se convierten al cambio de su fecha (7.4.6).
Para el proyecto real, fuentes documentadas, sin descargar nada en las celdas:
- SEC EDGAR: las APIs de data.sec.gov, sin autenticación ni claves según la propia SEC, dan los hechos XBRL de
cada empresa (
companyfacts) y un hecho para todas las empresas y un periodo (frames)1Nota 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»; descripción decompanyfactsyframes. Consultada el 24 de octubre de 2026.. Son la base de fundamentales point-in-time si se guarda la fecha de presentación de cada informe. El acceso automatizado tiene que cumplir su política de acceso; la memoria cita la versión de esa política que se consultó. - Massive (antes Polygon.io): su página de precios ofrece, para uso individual, un plan de acciones con más de 20 años de histórico a 199 USD al mes y un plan de estados financieros y ratios a 29 USD al mes2Nota 2 · Massive, «Pricing» (https://massive.com/pricing): «Stocks Advanced», 199 USD al mes, «Individual use only», con más de 20 años de histórico; «Financials & Ratios», 29 USD al mes, «Individual use only». Consultada el 24 de octubre de 2026.. La licencia es de uso individual: no permite redistribuir los datos en el repositorio, así que el repositorio guarda el código de descarga y las huellas, no los datos.
- Kenneth R. French Data Library: factores y carteras de referencia de descarga libre, para la atribución de la cartera (lección 4.1.6).
- ETFs UCITS: el KID y la ficha del emisor de cada ETF del núcleo (lección 7.4.3), con su TER, su política de dividendos (acumulación o distribución) y su divisa.
El problema central de los datos de acciones reales es el universo: un universo construido con las empresas que hoy cotizan tiene sesgo de supervivencia, y uno con fundamentales sin fecha de publicación tiene anticipación. El README de datos del proyecto documenta cómo se fija el universo en cada fecha y de dónde sale la fecha de cada dato.
Esqueleto ejecutable
Seis celdas, de las features al informe. La primera arma el panel mensual (la última fecha del panel de cada mes, el día de rebalanceo) y, antes de entrenar nada, audita las features: el IC mensual de cada una con el rendimiento a 21 sesiones, solo en el primer tramo de entrenamiento (2012-2014), para que el filtro tampoco mire el futuro.
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"])
# la última fecha del panel de cada mes natural: día de rebalanceo
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):
"""Rango normal transversal de cada mes (5.2.4); sin dato, 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)
# auditoría: IC mensual de cada feature en 2012-2014 (solo el primer
# tramo de entrenamiento) y su t; una t enorme es una alarma
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} meses x {nA} acciones; tramo auditado: "
f"{tramo.sum()} meses")
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("fuera:", SOSPECHOSAS, "| quedan", len(FE), "features")168 meses x 400 acciones; tramo auditado: 36 meses
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
fuera: ['revision_analistas'] | quedan 21 featuresCiento sesenta y ocho meses, 400 acciones. revision_analistas tiene un IC de 0,096 con t = 10,8 en 36 meses; la
siguiente, el momentum, 0,038 con t = 2,8. Una feature que ordena dos veces y media mejor que el momentum en un panel de
acciones es una alarma, y aquí el README confirma por qué: el proveedor la fecha al principio de la semana que cubre y
contiene rendimientos posteriores. El umbral (|t| > 5) es una regla de auditoría escrita antes, no una prueba
estadística; lo que no pasa se revisa a mano y se documenta.
El modelo se reentrena cada enero con todos los meses cuya etiqueta ya se cerró antes del primer rebalanceo del año (la purga de 5.4.3: las etiquetas a 21 sesiones del último mes se solapan con el primero de prueba). Tres modelos con las mismas features en rango normal: una ridge de referencia, LightGBM como regresor del rango del rendimiento y LightGBM LambdaRank con quintiles como grados, ganancia lineal y truncado en 40, el número de acciones que se compran (las recomendaciones de 5.5.6). Hiperparámetros moderados y fijos, sin búsqueda; la semilla y los hilos, fijos.
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) # etiqueta
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)
# regla del tiempo: etiquetas cerradas antes del primer
# rebalanceo del año (purga de los meses que se solapan)
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)} meses fuera de muestra (2015-2025)")
pd.DataFrame(filas).T.round(4)131 meses fuera de muestra (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.2739En 131 meses fuera de muestra, la ridge tiene un IC de 0,0221 (t = 3,15), cerca del oráculo (0,0276; t = 3,31), que
conoce el alfa verdadero. LightGBM como regresor da 0,0124 y como LambdaRank, 0,0096, con t = 1,29 y una correlación
de rangos de 0,28 con vol_63d: el ranker vuelve a cargarse de volatilidad, como en la lección 5.5.6. El oráculo
tiene correlación negativa con la volatilidad (−0,27) porque el panel planta una prima de baja volatilidad. Que el
encargo pida LightGBM no cambia el método: el modelo se presenta junto a su referencia.
La cartera compra las 40 primeras y conserva las que ya tiene mientras sigan entre las 120 primeras (la banda del 10 % y el 30 % de 5.4.4, fijada antes), con pesos iguales o con HRP sobre la covarianza de las 252 sesiones anteriores al rebalanceo. Paga 10 pb por lado sobre la rotación. Las seis combinaciones son el registro de pruebas; la referencia es el universo equiponderado, y el rendimiento activo, la diferencia mensual con él.
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 acciones; 10 pb por lado
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 de la lección 4.1.4 (enlace simple, bisección)."""
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 # se mantiene mientras esté en 120
def seleccion(p, i, tenidas, vetadas=()):
"""Las TOP mejores, conservando las que ya se tienen mientras
sigan entre las BANDA primeras (5.4.4); `vetadas` no entran."""
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):
"""Rendimiento mensual neto de costes y rotación."""
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:]]) # universo equiponderado
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)} meses; universo: "
f"{100 * ((1 + mercado).prod() ** (12 / len(mercado)) - 1):.1f}"
f" % anual, Sharpe "
f"{mercado.mean() / mercado.std() * np.sqrt(12):.2f}")
print("techo (oráculo), activo anual:",
{k: f"{100 * v:.1f} %" for k, v in techo.items()})
registro.round(2)131 meses; universo: 6.5 % anual, Sharpe 0.43
techo (oráculo), activo anual: {'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.16El universo sube un 6,5 % anual con un Sharpe de 0,43 (sin tipo libre). La mejor prueba, ridge con pesos iguales, le gana un 1,97 % anual con un IR de 0,41 y la menor rotación (45 % mensual); la del encargo, LambdaRank con HRP, queda en −0,06 % con un IR de −0,01 y una rotación del 89 %. La figura ordena las seis por IR. Los Sharpes absolutos están todos entre 0,40 y 0,49: HRP no cambia mucho el Sharpe, pero sí el rendimiento activo. La respuesta conocida lo explica: el oráculo con pesos iguales gana un 2,0 % al universo y con HRP, un 0,0 %. HRP pondera por riesgo, carga los valores menos volátiles y, contra un universo equiponderado en un mercado que sube, se come lo que aporta la selección. Es un control de riesgo, no una fuente de rendimiento.
La parte fiscal reutiliza la lógica del motor de 7.4.6 en una versión compacta en coma flotante, pensada para simular once años de rebalanceos y no para hacer la declaración (que va con decimales exactos, como en 7.4.6). Los lotes siguen FIFO (art. 37.2) y la regla de los dos meses (art. 33.5 f): las compras posteriores a una venta con pérdida dentro de los dos meses difieren la pérdida de las unidades recompradas, y las anteriores solo cuentan hasta el saldo que queda. La pérdida diferida viaja con el lote recomprado y se integra cuando se vende. Una prueba con un caso calculado a mano:
from collections import defaultdict, deque
from dateutil.relativedelta import relativedelta
DOS_MESES = relativedelta(months=2)
class Lotes:
"""Lotes FIFO por valor (art. 37.2) con la regla de los dos
meses (art. 33.5 f, lección 7.4.6), simplificada: ver el texto."""
def __init__(self):
# lote: [fecha, uds, coste, diferida, uds libres]
self.cola = defaultdict(deque)
self.pend = defaultdict(list) # ventas con pérdida por casar
self.lineas = [] # (año, importe, tipo)
self.encadenadas = 0
def comprar(self, f, t, uds, coste):
dif, libres = 0.0, uds
for v in self.pend[t]: # compra posterior a una venta
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 # compras anteriores, hasta
for lote in reversed(cola): # el saldo que queda
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})
# prueba: compra 100, vende 100 con pérdida, recompra 60 al mes
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("año 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')
año 2025: -1500.0Cien títulos comprados por 10.000 euros y vendidos por 8.000: pérdida de 2.000. Sesenta se recompran al mes: 60 × 20 = 1.200 euros se difieren. Al vender esos 60 con 500 de ganancia, la diferida se integra: el año suma −1.500, la pérdida económica total. Dos simplificaciones frente a 7.4.6, que la memoria tiene que declarar: el caso de una única compra previa sin títulos al empezar el plazo solo se trata a través del límite del saldo que queda, y cuando se venden títulos con pérdida diferida y hay recompras otra vez (el caso que 7.4.6 manda revisar a mano), el simulador vuelve a diferir la pérdida total de esa venta.
El simulador lleva la cartera en acciones enteras, vende antes de comprar, paga los costes y, en el primer rebalanceo de julio, el IRPF del año anterior (cuando ya se cerraron las ventanas de dos meses de las ventas de diciembre), con la escala del ahorro de 2026 y las pérdidas de los cuatro años anteriores. Al final lo vende todo y liquida el último año, para comparar valores netos. Tres variantes de la cartera del encargo: sin IRPF, con IRPF y con IRPF más un bloqueo de dos meses a comprar lo vendido con pérdida (el «aviso de recompra» del ejercicio de 7.4.6).
TRAMOS = [(6000, 0.19), (50000, 0.21), (200000, 0.23),
(300000, 0.27), (np.inf, 0.30)] # escala de 2026
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):
"""Cuota del año y de ganancias y pérdidas (sin dividendos: los
precios son de rentabilidad total), con las pérdidas de los
cuatro años anteriores (art. 49). Modifica `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):
"""Cartera en acciones enteras: vende, compra, paga 10 pb por
lado y, en el primer rebalanceo de julio, el IRPF del año
anterior. Al final lo vende todo y liquida el último año.
`bloqueo`: lo vendido con pérdida no entra ni se amplía en dos
meses (aviso de recompra de 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 = {} # liquidación final
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):
"""Saldo de ganancias y pérdidas por año; sin la regla, como un
motor que no difiere nada."""
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 (("sin IRPF", dict(fiscal=False)),
("con IRPF", {}),
("con IRPF y bloqueo", 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 == "con 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("Saldo del año con la regla menos sin ella (€):")
print(pd.DataFrame({"": (con - sin).round(0).astype(int)}).T
.to_string()) final_neto tae_% irpf perdidas diferidas_%
sin IRPF 198790.8 6.5 0.0 285786.7 46.6
con IRPF 173348.7 5.2 18766.4 275682.5 46.5
con IRPF y bloqueo 164072.1 4.6 16510.8 262669.1 29.8
Saldo del año con la regla menos sin ella (€):
2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025
5414 -2677 -545 4684 -4838 934 -232 799 -1903 1039 -2677Partiendo de 100.000 euros en enero de 2015, la cartera sin IRPF termina en 198.791 (TAE del 6,5 %); con IRPF, en 173.349 (5,2 %), después de pagar 18.766 euros de impuesto en once años. La figura de la cabecera compara las tres con el universo. En la variante con IRPF se realizan 275.683 euros de pérdidas brutas en once años, y un 46,5 % se difieren alguna vez por la regla de los dos meses: la banda conserva las que siguen bien clasificadas, pero HRP recorta y amplía posiciones cada mes, y una venta parcial con pérdida tras una compra del mes anterior cae en la ventana. (En la fila sin IRPF las dos últimas columnas solo describen los lotes; no se paga nada.)
La última tabla es el error de un motor que no modela la regla: la diferencia, año a año, entre el saldo de ganancias y pérdidas con la regla y sin ella. En 2015 la regla aplaza 5.414 euros de pérdidas que un motor ingenuo habría declarado ese año; en 2025, al liquidarlo todo, se integran 2.677 que estaban diferidas. La suma de la fila es cero (salvo el redondeo): la regla mueve base imponible entre años, no cambia el total. El bloqueo reduce los diferimientos al 29,8 % y el impuesto pagado, pero la TAE neta baja al 4,6 %: al vetar valores bien clasificados durante dos meses, la cartera se aleja de la que pide el modelo, y en estos datos eso cuesta más que el calendario fiscal que ahorra.
La última celda descuenta la selección con el Deflated Sharpe sobre el IR mensual de las seis pruebas y escribe el
artefacto, informe_c3.json, con la huella de los datos, lo que la auditoría apartó, la validación y el resumen fiscal.
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() # por mes
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"mejor de {len(ir)}: {mejor}, IR {ir[mejor] * np.sqrt(12):.2f};"
f" umbral {sr0 * np.sqrt(12):.2f}; DSR {dsr:.3f}")
pre = "lgbm_rank + hrp" # la del encargo
xp = activo[pre]
print(f"encargo ({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 cartera ML, HRP e 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))mejor de 6: ridge + igual, IR 0.41; umbral 0.23; DSR 0.727
encargo (lgbm_rank + hrp): IR -0.01, PSR(0) 0.487
{"n_pruebas": 6, "mejor": "ridge + igual", "dsr_mejor": 0.727, "ir_encargo": -0.01}Validación
En C3 se eligen cuatro cosas, y cada una tiene su control:
| Qué se elige | Riesgo | Control en el proyecto |
|---|---|---|
| Features | una fuga que parece la mejor feature | auditoría por IC en el primer tramo; lista de apartadas en el informe |
| Modelo | comparar contra nada o ajustar hasta ganar | ridge de referencia; hiperparámetros fijos; registro |
| Cartera (pesos, banda, tamaño) | elegir la mejor combinación en la misma muestra | registro de pruebas, DSR, PBO |
| Periodo y referencia | elegir la ventana o el índice que favorece | 2015-2025 fijado en la semana 1; universo equiponderado declarado |
El walk-forward está en la propia construcción: cada año se entrena con lo que se sabía el 31 de diciembre, con las etiquetas solapadas purgadas, y se predicen los doce meses siguientes sin tocar el modelo. Sobre ese walk-forward, el registro tiene seis pruebas; la mejor, ridge con pesos iguales, tiene un IR de 0,41 y un DSR de 0,727 con N = 6: el umbral que exige la dispersión de las seis (0,23 de IR anual) se come buena parte de la evidencia. La del encargo tiene un PSR(0) de 0,487: nada distinguible de cero. El ejercicio 1 añade la probabilidad de sobreajuste por CSCV.
El criterio de parada, escrito en la semana 4 antes de calcular:
- Si la auditoría encuentra una fuga que no se puede explicar y eliminar, el proyecto se detiene hasta resolverla.
- Si el IC fuera de muestra del modelo del encargo no es positivo con t > 2, o su rendimiento activo neto de costes no es positivo, la cartera no se presenta como gestión activa: el papel se hace para evaluar el sistema, y la memoria compara con la alternativa pasiva (el universo o un núcleo de ETFs).
- Si el DSR de la mejor prueba es < 0,95, la memoria dice que no hay evidencia estadística de ventaja después de las pruebas hechas.
- En los datos del curso, el modelo del encargo no pasa (IC con t = 1,29, IR de −0,01) y la mejor prueba tampoco alcanza el DSR. El sistema se evalúa en papel; la conclusión de la memoria es que, con este panel y rebalanceo mensual, la selección no compensa costes e impuestos frente al universo con suficiente evidencia.
Que el esqueleto no encuentre ventaja no es un fallo del proyecto: el panel tiene un alfa plantado pequeño, que vive sobre todo una semana, y el rebalanceo mensual a largo plazo solo recoge una parte. La respuesta conocida (el oráculo gana un 2,0 % al año con pesos iguales) marca el techo. Un proyecto que, con estos datos, presentara un IR de 1,5 tendría que explicar de dónde sale antes de que el tribunal pregunte por la fuga.
Riesgos y paper trading
Riesgos que tumban de verdad una cartera de este tipo:
- Datos. Supervivencia, fundamentales sin fecha de publicación, operaciones corporativas mal aplicadas y la fuga de un proveedor. Una sola feature con anticipación invalida todo; por eso la auditoría va antes del modelo y se repite con cada versión de los datos.
- Modelo que deja de funcionar. El IC del panel es pequeño y cambia con los regímenes (el crash del momentum, 5.4.3); el monitor de 7.3.3 compara el IC en vivo con el esperado y desactiva el modelo con su política graduada.
- Concentración y factores. 40 acciones elegidas por un modelo pueden ser una apuesta de sector o de volatilidad; límites por sector y la atribución a factores (4.1.6) lo vigilan.
- Costes y fiscalidad. La rotación de un modelo mensual es alta (89 % al mes en la cartera del encargo); cada venta con ganancia adelanta impuesto. El informe da siempre las tres cifras: bruta, neta de costes y neta de impuestos.
- Operativo. Órdenes que no se ejecutan al cierre, fracciones de acción, cambios de divisa y lotes que no cuadran con el informe del broker.
Límites en la configuración: peso máximo por acción y por sector, rotación máxima por rebalanceo, efectivo mínimo, y una desactivación si el IC en vivo cae por debajo de lo esperado (7.3.3) o si la conciliación no cuadra.
Fuente Mermaid
stateDiagram-v2 [*] --> AUDITANDO AUDITANDO --> PUNTUANDO AUDITANDO --> BLOQUEADO PUNTUANDO --> ORDENANDO ORDENANDO --> CONCILIANDO CONCILIANDO --> ESPERANDO CONCILIANDO --> BLOQUEADO ESPERANDO --> AUDITANDO BLOQUEADO --> AUDITANDO
Plan de paper trading. Con un rebalanceo al mes, cuatro semanas desde un día cualquiera verían uno solo y ninguna venta de lo que ya se tenía. El papel empieza unos días antes de un fin de mes y se mantiene hasta pasado el siguiente: al menos cuatro semanas (28 días y 20 sesiones, 7.6.1) y dos rebalanceos con órdenes reales de papel. Entre los dos, el sistema concilia cada día posiciones y efectivo con el broker, vigila la deriva de pesos y las operaciones corporativas, y registra el IC de la semana. Tras el segundo rebalanceo, el registro de lotes propio se cuadra con el informe de operaciones del broker: si el broker deja elegir el método de asignación de lotes, el informe fiscal propio sigue FIFO (art. 37.2), sea cual sea el del broker. El ejecutor envía órdenes al cierre, que es el precio que usa el backtest; esta celda es el código real y no se ejecuta aquí:
from ib_async import IB, Order, Stock
ib = IB()
ib.connect("127.0.0.1", 4002, clientId=34) # IB Gateway, papel
cartera = {p.contract.symbol: p.position for p in ib.positions()}
objetivo = calcular_objetivo(cartera) # señal + HRP + banda
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) # al cierre
ib.sleep(5)
print([(t.contract.symbol, t.orderStatus.status) for t in ib.trades()])Tres detalles para el ejecutor real: las ventas se envían antes que las compras y las compras se recalculan con el
efectivo realmente disponible si una venta no se ejecuta; la clave de cada orden (orderRef) sale de la fecha, el
valor y el lado (4.5.5); y un valor vetado por el bloqueo de recompras, si se usa, se registra con su fecha de fin en
el propio estado del sistema, no en la memoria del proceso.
Hitos y rúbrica
El calendario es el de 7.6.1 (diez semanas, de 40 a 80 horas), aplicado a C3:
| Semana | Entregable verificable |
|---|---|
| 1 | Repositorio desde la plantilla; README de datos con fuentes, licencias, universo por fecha y huellas; auditor de 7.6.1 en verde |
| 2 | Carga point-in-time, rango normal, auditoría de fugas con su informe; tests de datos |
| 3 | Modelo y referencia en walk-forward con purga; selección con banda, HRP, costes; motor de lotes probado con los casos de 7.4.6 |
| 4 | Registro de pruebas, DSR y PBO; criterio de parada aplicado; simulación fiscal; informe_c3.json |
| 5 | Ejecutor de papel con ib_async contra un broker simulado y la cuenta de papel; Docker; monitor del IC |
| 6-10 | Papel desde unos días antes de un fin de mes hasta pasado el siguiente (unas cinco semanas), con diario y conciliación de posiciones y lotes; memoria en paralelo |
| 10 | Informe de papel al cerrar el segundo rebalanceo; ensayo y defensa |
La rúbrica común de 7.6.1, con la evidencia concreta que pide en C3:
| Criterio (peso) | Evidencia en C3 |
|---|---|
| Reproducibilidad (15 %) | make reproduce regenera informe_c3.json con la huella de la memoria; semillas y hilos de LightGBM fijos; uv.lock, Docker |
| Datos (15 %) | universo por fecha sin supervivencia; fecha de publicación de cada fundamental; licencia de cada fuente; auditoría de fugas |
| Validación (20 %) | walk-forward con purga; referencia lineal; registro completo; DSR y PBO; criterio de parada escrito antes |
| Riesgo y ejecución (15 %) | costes por lado; acciones enteras; límites de peso, sector y rotación; IRPF por lotes con la regla de los dos meses |
| Paper trading (15 %) | al menos 28 días y dos rebalanceos; conciliación diaria; lotes cuadrados con el broker; IC semanal registrado |
| Código (10 %) | tests de la auditoría, de HRP (pesos que suman 1, casos conocidos), del motor de lotes contra casos a mano; ruff y pre-commit |
| Memoria y defensa (10 %) | rentabilidad bruta, neta y después de impuestos; simplificaciones fiscales declaradas; límites del estudio |
Errores que suspenden por sí solos en este proyecto, además de los comunes: entrenar con revision_analistas o con
cualquier feature con fecha posterior a la decisión; normalizar con estadísticos de toda la muestra (5.2.4); un
rendimiento después de impuestos calculado con un tipo fijo sobre la ganancia del año, sin lotes ni escala (no es
el IRPF del ahorro); y presentar el modelo del encargo sin la referencia lineal en el mismo periodo.
Ejercicios
- Calcula la probabilidad de sobreajuste (PBO) de la elección entre las seis pruebas con CSCV: ocho bloques temporales del rendimiento activo, todas las particiones de cuatro bloques de entrenamiento y cuatro de prueba, y la posición fuera de muestra de la mejor dentro de muestra (lección 2.5.4).
Solución
En 70 particiones, la prueba elegida dentro de muestra queda por debajo de la mediana fuera de muestra en el 30 % de los casos: una PBO de 0,30. No es la de una elección al azar (0,5), pero tampoco descarta que la ganadora lo sea por el periodo. Junto con el DSR de 0,727, la conclusión es la misma: con seis pruebas y once años, la evidencia de que una combinación es mejor que las demás es débil.
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)} particiones; PBO = {(logits <= 0).mean():.2f}")70 particiones; PBO = 0.30- Añade un núcleo de ETFs: HRP mensual sobre siete bloques del universo del nivel 4 que tendrían un ETF UCITS (renta variable de EE. UU., Europa, Japón y emergentes, deuda global cubierta, Tesoro de EE. UU. a 3-7 años e inmobiliario global), y combínalo al 60/40 con la cartera de acciones del encargo neta de costes.
Solución
El núcleo con HRP rinde poco en estos datos (TAE del 1,1 % con una volatilidad del 3,8 %): HRP carga los bloques de renta fija, los menos volátiles. La cartera de acciones tiene una TAE del 6,6 % con una volatilidad del 18,5 % y una caída máxima del −30,6 %. El 60/40 queda en una TAE del 3,7 %, con el mejor Sharpe de los tres (0,49) y una caída máxima del −18,0 %. Es el papel del núcleo: bajar el riesgo de la cartera, no subir su rendimiento. En la versión real, cada bloque es un ETF UCITS con su KID (7.4.3), y su TER y su política de dividendos entran en los costes y en el 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 = {"núcleo ETFs": nuc, "satélite": 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_%
núcleo ETFs 1.12 3.76 0.31 -14.07
satélite 6.57 18.46 0.44 -30.64
60/40 3.68 8.05 0.49 -18.02- En la defensa, un miembro del tribunal pregunta por qué no entregas la ridge con pesos iguales, que es la mejor de las seis pruebas. ¿Qué respondes?
Solución
Que el encargo fijaba LightGBM y HRP antes de mirar resultados, y que la memoria presenta las seis pruebas, con la ridge como referencia obligatoria. Que la ridge con pesos iguales es la mejor en esta muestra, pero con un DSR de 0,727 y una PBO de 0,30 no hay evidencia suficiente de que lo sea fuera de ella; cambiar de configuración después de ver el resultado y presentarla como si fuera la elegida es seleccionar a posteriori, y eso suspende. Lo honesto es lo que hace la memoria: decir que, con estos datos, ninguna combinación demuestra ventaja después de costes e impuestos, proponer la ridge como candidata para una validación nueva con datos posteriores y no presentar ninguna como operable con dinero.
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»; descripción de
companyfactsyframes. Consultada el 24 de octubre de 2026. ↩ -
Massive, «Pricing» (https://massive.com/pricing): «Stocks Advanced», 199 USD al mes, «Individual use only», con más de 20 años de histórico; «Financials & Ratios», 29 USD al mes, «Individual use only». Consultada el 24 de octubre de 2026. ↩
Respuesta plegada en cada pregunta
Autoevaluación
La auditoría del esqueleto mide el IC mensual de cada feature en 2012-2014 y aparta
revision_analistas(IC 0,096, t = 10,8). ¿Por qué es una fuga y no una buena feature?- APorque su IC es negativo
- BPorque es una feature de analistas, y esas nunca funcionan
- CPorque tiene datos vacíos
- DPorque en un panel donde las features honradas dan IC de 0,02 a 0,04, un IC de casi 0,10 con t > 10 es la firma de información futura: el proveedor la fecha al principio de la semana que cubre, y contiene rendimientos posteriores a la fecha de decisión
Ver respuesta
Respuesta D. Porque en un panel donde las features honradas dan IC de 0,02 a 0,04, un IC de casi 0,10 con t > 10 es la firma de información futura: el proveedor la fecha al principio de la semana que cubre, y contiene rendimientos posteriores a la fecha de decisión
El README del panel describe la trampa: la revisión mezcla los rendimientos de las dos primeras sesiones de la semana siguiente. Una anticipación demostrable suspende por sí sola; la auditoría usa solo el primer tramo de entrenamiento para que el filtro tampoco mire el futuro.
En el walk-forward, el LambdaRank de LightGBM tiene un IC de 0,0096 (t = 1,29) y una correlación de rangos de 0,28 con
vol_63d; la ridge, 0,0221 (t = 3,15). ¿Qué hace el proyecto?- ALo dice: en estos datos la referencia lineal gana, y el ranker ha aprendido sobre todo a ordenar por volatilidad, como en 5.5.6; se presenta el modelo del encargo con la comparación, sin ajustar hiperparámetros hasta que gane
- BBusca hiperparámetros del ranker hasta superar a la ridge
- CQuita la ridge del informe, porque el encargo pide LightGBM
- DCambia la etiqueta hasta que el IC suba
Ver respuesta
Respuesta A. Lo dice: en estos datos la referencia lineal gana, y el ranker ha aprendido sobre todo a ordenar por volatilidad, como en 5.5.6; se presenta el modelo del encargo con la comparación, sin ajustar hiperparámetros hasta que gane
La referencia ingenua es parte del método del curso. Ajustar el ranker contra el mismo periodo hasta que gane añade pruebas al registro y las penaliza el DSR. Lo que no se puede hacer es ocultar la comparación.
Con IRPF, la cartera del encargo termina con una TAE del 5,2 %; con un bloqueo de dos meses a recomprar lo vendido con pérdida, del 4,6 %. ¿Por qué el bloqueo sale peor?
- APorque el bloqueo paga más IRPF
- BPorque la regla de los dos meses difiere la pérdida, no la elimina; el bloqueo aleja la cartera de la que pide el modelo y esa diferencia cuesta más de lo que ahorra en impuestos adelantados
- CPorque el bloqueo incumple la ley
- DPorque con el bloqueo no se aplican los costes
Ver respuesta
Respuesta B. Porque la regla de los dos meses difiere la pérdida, no la elimina; el bloqueo aleja la cartera de la que pide el modelo y esa diferencia cuesta más de lo que ahorra en impuestos adelantados
La pérdida diferida se integra al vender los títulos recomprados, así que el ahorro del bloqueo es solo de calendario. En esta cartera, con rotación mensual alta, el coste de no seguir el ranking es mayor. Es un resultado de estos datos, no una regla general, y no es asesoramiento.
El oráculo (el alfa verdadero del panel) con pesos iguales gana un 2,0 % anual al universo; con HRP, un 0,0 %. ¿Qué enseña?
- AQue el oráculo está mal calculado
- BQue HRP siempre destruye valor
- CQue HRP decide pesos por riesgo, no por rendimiento esperado: carga los valores menos volátiles y, medido contra un universo equiponderado en un mercado que sube, se come la selección; si el objetivo es alfa, HRP es un control de riesgo, no la fuente
- DQue hay que rebalancear cada semana
Ver respuesta
Respuesta C. Que HRP decide pesos por riesgo, no por rendimiento esperado: carga los valores menos volátiles y, medido contra un universo equiponderado en un mercado que sube, se come la selección; si el objetivo es alfa, HRP es un control de riesgo, no la fuente
La respuesta conocida del panel separa las dos piezas: la selección aporta, la ponderación por riesgo cambia el perfil (Sharpe parecido, menos peso en lo volátil). La memoria tiene que decir cuál de las dos se evalúa contra qué referencia.
La cartera se rebalancea a fin de mes. ¿Cómo se plantea el paper trading?
- ACuatro semanas desde cualquier día: basta un rebalanceo
- BSe simula el segundo rebalanceo con el backtest
- CSe rebalancea cada semana durante el papel
- DSe empieza unos días antes de un fin de mes y se mantiene hasta pasado el siguiente: al menos cuatro semanas y dos rebalanceos con órdenes reales de papel, conciliación diaria y el registro de lotes cuadrado con el broker
Ver respuesta
Respuesta D. Se empieza unos días antes de un fin de mes y se mantiene hasta pasado el siguiente: al menos cuatro semanas y dos rebalanceos con órdenes reales de papel, conciliación diaria y el registro de lotes cuadrado con el broker
Lo que se evalúa en el papel es el ciclo completo: datos, señal, selección, órdenes, ejecuciones, lotes e informe. Con un solo rebalanceo no se ve ninguna venta de lo que ya se tenía, que es donde aparecen la regla de los dos meses y los errores de lotes.
Ideas clave
- C3 es una cartera larga de 40 acciones elegidas cada mes por un modelo de ranking, ponderadas con HRP y rebalanceadas con costes e IRPF; los ETFs entran como núcleo UCITS, no como satélite.
- La auditoría de fugas va antes del modelo: en el panel del nivel 5 aparta la revisión de analistas (IC 0,096, t = 10,8) mirando solo el primer tramo de entrenamiento.
- En el walk-forward, la ridge (IC 0,022) gana a LightGBM como regresor (0,012) y como LambdaRank (0,010), que además se carga de volatilidad. El proyecto lo presenta con la comparación, no lo esconde.
- Con seis pruebas registradas, la mejor (ridge con pesos iguales, IR 0,41) tiene un DSR de 0,727; la del encargo, un IR de −0,01. No hay evidencia de ventaja después de costes y selección.
- El IRPF lleva la TAE de la cartera del 6,5 % al 5,2 %; casi la mitad de las pérdidas realizadas se difieren por la regla de los dos meses, que mueve base imponible entre años sin cambiar el total.
- Un motor fiscal sin la regla adelanta pérdidas (5.414 euros en 2015); bloquear recompras evita parte de los diferimientos pero, en esta cartera, cuesta más de lo que ahorra.
Recursos
Documentación
- LightGBM 4.7.0: parámetros (lambdarank, label_gain, lambdarank_truncation_level)
- Ley 35/2006 del IRPF, texto consolidado (BOE): arts. 33.5 f), 37.2, 49, 66 y 76
- DGT, consulta vinculante V5316-26: cómo se aplica la regla de los dos meses
- Kenneth R. French: Data Library (factores y carteras de referencia)