---
title: "7.6.4 · Capstone C3: cartera con ML, HRP e IRPF"
description: "Cartera de acciones con ranking de LightGBM frente a una ridge, HRP, rebalanceo con costes, lotes FIFO, regla de los dos meses e IRPF del ahorro."
url: "https://pythonparatrading.com/curso-python-trading/nivel-7/capstone-c3-cartera-ml-hrp-y-fiscalidad/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-7/capstone-c3-cartera-ml-hrp-y-fiscalidad.png"
---

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

- Curso: [Nivel 7: Producción, MLOps, regulación, carrera y capstone](https://pythonparatrading.com/curso-python-trading/nivel-7/)
- 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](https://pythonparatrading.com/curso-python-trading/nivel-7/capstone-requisitos-rubrica-y-defensa/), [5.2.4 · Normalización sin leakage y rank transversal](https://pythonparatrading.com/curso-python-trading/nivel-5/normalizacion-sin-leakage-y-rank-transversal/), [5.4.3 · Walk-forward con reentrenamiento: ventanas y frecuencia](https://pythonparatrading.com/curso-python-trading/nivel-5/walk-forward-con-reentrenamiento/), [5.4.4 · Backtest de señales de ML con costes y Deflated Sharpe](https://pythonparatrading.com/curso-python-trading/nivel-5/backtest-de-senales-de-ml-y-deflated-sharpe/), [5.5.6 · Learning to rank para la selección de acciones](https://pythonparatrading.com/curso-python-trading/nivel-5/learning-to-rank-para-seleccion-de-acciones/), [4.1.4 · Risk parity, mínima varianza, HRP y HERC](https://pythonparatrading.com/curso-python-trading/nivel-4/risk-parity-minima-varianza-hrp-y-herc/), [7.4.6 · Fiscalidad del trading en España](https://pythonparatrading.com/curso-python-trading/nivel-7/fiscalidad-del-trading-en-espana/)

## Al terminar esta lección

- Delimitar el encargo de una cartera de acciones y ETFs gestionada con un modelo de ranking, para un inversor particular residente en España, y lo que queda fuera.
- Montar la columna vertebral con el panel del nivel 5: auditoría de fugas, ranking en walk-forward con LightGBM y una ridge de referencia, selección con banda, HRP y rebalanceo mensual con costes.
- Llevar lotes FIFO con la regla de los dos meses y liquidar el IRPF del ahorro cada año para medir la rentabilidad después de impuestos, reutilizando la lógica de 7.4.6.
- Validar la elección de modelo y de pesos con un registro de pruebas y el Deflated Sharpe, y escribir antes el criterio de parada.
- Planificar el paper trading para que vea dos rebalanceos y aplicar la rúbrica común de 7.6.1 a C3.

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

**No es asesoramiento fiscal ni de inversión.** El proyecto aplica normas generales del IRPF para residentes en
territorio común (lección 7.4.6) a una cartera simulada con datos sintéticos, con simplificaciones que se dicen en el
texto. Para una declaración real, la fuente es la AEAT y, si hace falta, un asesor.

</aside>

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

```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`)[^1]. 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 mes[^2]. 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.

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

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

```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)
```
```output
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.* Seis puntos sobre un eje de IR anual de −0,3 a 0,6. La ridge con pesos iguales queda arriba, con 0,41; las demás entre 0,15 y −0,10. La destacada es la configuración del encargo, LambdaRank con HRP, con −0,01. Una línea discontinua marca 0,23, el IR máximo que se esperaría por azar con 6 pruebas: solo la ridge con pesos iguales la supera, con un Deflated Sharpe de 0,727.

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:

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

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

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

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

```python skip
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

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.

```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}")
```
```output
70 particiones; PBO = 0.30
```

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

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

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

[^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.

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

   - A. Porque su IC es negativo
   - B. Porque es una feature de analistas, y esas nunca funcionan
   - C. Porque tiene datos vacíos
   - 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

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

   - 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
   - B. Busca hiperparámetros del ranker hasta superar a la ridge
   - C. Quita la ridge del informe, porque el encargo pide LightGBM
   - D. Cambia la etiqueta hasta que el IC suba

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

   - A. Porque el bloqueo paga más IRPF
   - 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
   - C. Porque el bloqueo incumple la ley
   - D. Porque con el bloqueo no se aplican los costes

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

   - A. Que el oráculo está mal calculado
   - B. Que HRP siempre destruye valor
   - 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
   - D. Que hay que rebalancear cada semana

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

   - A. Cuatro semanas desde cualquier día: basta un rebalanceo
   - B. Se simula el segundo rebalanceo con el backtest
   - C. Se rebalancea cada semana durante el papel
   - 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

   *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

### Research

- [Alpha Factors y Alpha Trading en Python](https://pythonparatrading.com/alpha-factors-y-alpha-trading-en-python/) · Research-0007 · 5 min

### Documentación

- [LightGBM 4.7.0: parámetros (lambdarank, label_gain, lambdarank_truncation_level)](https://lightgbm.readthedocs.io/en/stable/Parameters.html) · lightgbm.readthedocs.io
- [Ley 35/2006 del IRPF, texto consolidado (BOE): arts. 33.5 f), 37.2, 49, 66 y 76](https://www.boe.es/buscar/act.php?id=BOE-A-2006-20764) · boe.es
- [DGT, consulta vinculante V5316-26: cómo se aplica la regla de los dos meses](https://petete.tributos.hacienda.gob.es/consultas/?num_consulta=V5316-26) · petete.tributos.hacienda.gob.es
- [Kenneth R. French: Data Library (factores y carteras de referencia)](https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/data_library.html) · mba.tuck.dartmouth.edu

### Libros

- [Marcos López de Prado, Machine Learning for Asset Managers (Cambridge University Press, 2020)](https://www.cambridge.org/core/elements/machine-learning-for-asset-managers/6D9211305EA2E425D33A9F38D0AE3545) · cambridge.org

### Datos

- [Datos del nivel 5: features semanales del panel de acciones (Parquet)](https://pythonparatrading.com/curso/datos/nivel-5/panel5_features.parquet) · Descarga · panel5_features.parquet

Lección anterior: [7.6.3 · Capstone C2: libro de opciones sistemático, superficie de volatilidad y griegas en tiempo real](https://pythonparatrading.com/curso-python-trading/nivel-7/capstone-c2-libro-de-opciones-y-griegas/)

Lección siguiente: [7.6.5 · Capstone C4: una plataforma de investigación con LLMs y datos alternativos](https://pythonparatrading.com/curso-python-trading/nivel-7/capstone-c4-investigacion-con-llms/)
