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.

Fig. — Capstone C3: rentabilidad anual de la cartera del encargo (LambdaRank y HRP, 100.000 euros desde 2015) antes y después del IRPF del ahorro, con y sin bloqueo de recompras, frente al universo equiponderado
Lecciones del nivel 7
  1. 7.1 · Ingeniería de software para trading

    1. 7.1.1 Estructura de proyecto, configuración y calidad: del notebook al repositorio
    2. 7.1.2 Rendimiento: medir, perfilar y compilar solo lo que hace falta
    3. 7.1.3 Patrones: eventos, colas de mensajes y arquitectura hexagonal
  2. 7.2 · Infraestructura y despliegue

    1. 7.2.1 Docker y docker-compose: imágenes reproducibles con uv
    2. 7.2.2 VPS, nube y latencia; CI/CD con GitHub Actions
    3. 7.2.3 Orquestación y monitorización: grafos de tareas, logs estructurados, métricas, alertas y latidos
    4. 7.2.4 Seguridad y continuidad: 2FA, IP, copias y procedimientos ante fallos
  3. 7.3 · MLOps para modelos de trading

    1. 7.3.1 Registro de experimentos, modelos y datos: MLflow, DVC y una ejecución reproducida de principio a fin
    2. 7.3.2 Reentrenamiento programado, validación automática y champion/challenger
    3. 7.3.3 Monitorización del modelo en vivo y desactivación automática
    4. 7.3.4 Servir modelos: ONNX, inferencia en CPU o GPU y el coste de los LLM en producción
  4. 7.4 · Regulación, ética y fiscalidad

    1. 7.4.1 MiFID II: clientes, idoneidad y trading algorítmico
    2. 7.4.2 ESMA y CFD: apalancamiento, cierre y saldo negativo
    3. 7.4.3 PRIIPs, KID y ETFs estadounidenses
    4. 7.4.4 MiCA después del transitorio: comprobar un proveedor en los registros
    5. 7.4.5 Abuso de mercado: spoofing, layering y el bot que los imita sin querer
    6. 7.4.6 Fiscalidad del trading en España
    7. 7.4.7 Ética: datos personales, inteligencia artificial y la venta de señales
  5. 7.5 · Carrera profesional quant

    1. 7.5.1 Roles del quant: seis oficios alrededor de un mismo sistema
    2. 7.5.2 Dónde trabajar: qué hace cada empresa y qué dicen los registros
    3. 7.5.3 Portfolio público y entrevistas: enseñar criterio, no rentabilidades
    4. 7.5.4 Certificaciones, formación y prop firms: qué certifica cada cosa y qué mide un examen
  6. 7.6 · Capstone final

    1. 7.6.1 Capstone: requisitos comunes, rúbrica del tribunal y preparación de la defensa
    2. 7.6.2 Capstone C1: un sistema de futuros multi-activo, de la previsión a la cuenta de papel
    3. 7.6.3 Capstone C2: libro de opciones sistemático, superficie de volatilidad y griegas en tiempo real
    4. 7.6.4 Capstone C3: cartera de acciones y ETFs con ML, HRP y fiscalidad española
    5. 7.6.5 Capstone C4: una plataforma de investigación con LLMs y datos alternativos
    6. 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

Precios y featuresAuditoría de fugasRanking mensualSelección y bandaPesos HRPÓrdenes y costesLotes y dos mesesIRPF del ahorroConciliaciónMonitor del IC
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]
PiezaQué haceLibreríaDónde se aprendió
Auditoría de datospoint-in-time, supervivencia, fugas por IC anómalopandas, scipy2.5.1, 5.2.2
Featuresrango normal transversal de cada fechascipy5.2.4
ModeloLambdaRank y regresión de LightGBM; ridge de referencialightgbm, scikit-learn5.5.3, 5.5.6
Validaciónwalk-forward anual con purga, registro, DSR, PBOnumpy5.4.3, 5.4.4, 2.5.4
PesosHRP propio (enlace simple, bisección)scipy4.1.4
Rebalanceobanda de entrada y salida, acciones enteras, costespandas4.1.7, 5.4.4
Fiscalidadlotes FIFO, regla de los dos meses, compensación, escalaPython7.4.6
Ejecución y papelórdenes al cierre, conciliación de posiciones y lotesib_async4.4.2, 4.5.5
MonitorIC en vivo frente al esperado, deriva, desactivaciónMLflow, logs7.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.

ArchivoQué aporta al proyecto
panel5_features.parquet22 features semanales point-in-time, una de ellas con fuga plantada
panel5_etiquetas.parquetrendimiento a la semana siguiente y a 21 sesiones, con su fecha de cierre (para la purga)
panel5_precios.parquetcierres diarios de rentabilidad total: rendimientos, covarianzas y valoración
panel5_verdad.parquetel 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 de companyfacts y frames. 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.

IN [01]python
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")
OUT [01]Panel mensual y auditoría de fugas
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 features

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

IN [02]python
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)
OUT [02]Tres modelos en walk-forward anual
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.2739

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

IN [03]python
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)
OUT [03]HRP de los seleccionados y registro de pruebas
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.16
Fig. — Capstone C3: las 6 pruebas del registro (modelo y pesos), ordenadas por el ratio de información de su rendimiento activo neto de costes frente al universo equiponderado, con el máximo esperado por azar

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

IN [04]python
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))
OUT [04]Lotes FIFO y regla de los dos meses
(2025, -2000.0, 'venta')
(2025, 1200.0, 'difiere')
(2025, 500.0, 'venta')
(2025, -1200.0, 'integra')
año 2025: -1500.0

Cien 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).

IN [05]python
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())
OUT [05]Rebalanceo mensual con costes e IRPF
                    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 -2677

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

IN [06]python
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))
OUT [06]Deflated Sharpe, informe y artefacto
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 eligeRiesgoControl en el proyecto
Featuresuna fuga que parece la mejor featureauditoría por IC en el primer tramo; lista de apartadas en el informe
Modelocomparar contra nada o ajustar hasta ganarridge de referencia; hiperparámetros fijos; registro
Cartera (pesos, banda, tamaño)elegir la mejor combinación en la misma muestraregistro de pruebas, DSR, PBO
Periodo y referenciaelegir la ventana o el índice que favorece2015-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.

AUDITANDOPUNTUANDOBLOQUEADOORDENANDOCONCILIANDOESPERANDO
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í:

IN [07]python
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:

SemanaEntregable verificable
1Repositorio desde la plantilla; README de datos con fuentes, licencias, universo por fecha y huellas; auditor de 7.6.1 en verde
2Carga point-in-time, rango normal, auditoría de fugas con su informe; tests de datos
3Modelo y referencia en walk-forward con purga; selección con banda, HRP, costes; motor de lotes probado con los casos de 7.4.6
4Registro de pruebas, DSR y PBO; criterio de parada aplicado; simulación fiscal; informe_c3.json
5Ejecutor de papel con ib_async contra un broker simulado y la cuenta de papel; Docker; monitor del IC
6-10Papel 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
10Informe 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

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

IN [08]python
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}")
OUT [08]PBO por CSCV de las seis pruebas
70 particiones; PBO = 0.30
  1. 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.

IN [09]python
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)
OUT [09]Núcleo de ETFs con HRP y satélite de acciones
             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
  1. 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

  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 de companyfacts y frames. Consultada el 24 de octubre de 2026. ↩

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

Respuesta plegada en cada pregunta

Autoevaluación

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

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

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

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

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