← Todos los backtests

Backtest sin humo: la Tortuga Coja, y por qué la comparación lo cambia todo

Un sistema al que ya habían hecho un backtest… comparándolo mal. Lo repetimos con la vara de medir correcta, y aparece algo más interesante que si gana o pierde.

Este backtest nos lo trajo un lector, igual que el anterior. Harruinado, usuario veterano del foro de bolsa de Rankia, lleva años proponiendo sistemas propios, y cuando publicamos el del cruce de medias nos ofreció los suyos para que los midiéramos. A uno de ellos lo llama «la Tortuga Coja».

La idea tiene su lógica: nunca ir contra la tendencia, pero no comprar en los picos de euforia. Se espera a que el precio «cojee» —que retroceda— para entrar con descuento. En reglas: solo se compra con las medias alineadas al alza (9 > 18 > 40), la entrada llega cuando el estocástico gira desde sobreventa, y un índice propio de miedo y codicia afina el momento. Stop dinámico de 4 ATR y un 20% del capital por posición.

Lo interesante es que este sistema YA tenía un backtest publicado, con el código abierto, hecho por otro blog de Rankia sobre ocho acciones españolas y europeas desde 2006. Su conclusión: comprar y mantener gana en siete de las ocho. Podríamos habernos quedado ahí. Pero al leerlo encontramos un problema de medición, y va A FAVOR de la estrategia.

El propio artículo explica que el sistema «solo invierte un 20-30% del capital, el resto está parado rindiendo un 3%». Y acto seguido lo compara contra un comprar y mantener con el 100% del dinero invertido. Eso no mide la estrategia: mide la diferencia de exposición. Un sistema que está invertido al 11% de media va a perder siempre contra uno que está al 100% en un mercado que sube, por buenas que sean sus reglas. Es comparar un coche con el freno de mano puesto contra otro sin freno y concluir que el motor es peor.

La comparación honesta es contra una cartera pasiva con la MISMA exposición media y el resto en liquidez al mismo 3%. Es la misma corrección que aplicamos en el backtest del sistema de miedo extremo, y es la que casi nunca se hace. Además lo probamos en versión cartera —varios valores a la vez, que es como está pensado— en lugar de acción por acción.

Universo: las mismas ocho acciones del análisis original (Repsol, Endesa, BBVA, Inditex, BNP Paribas, LVMH, ASML y Deutsche Bank), de 2007 a 2026. Siete variantes distintas. Este es el resultado sobre el periodo completo:

VarianteAnualPeor caídaExposiciónFrente al pasivo equivalente
Cesta al 100% (comprar y mantener)14,57%-54,2%100%
Base del artículo (5 pos., 20%, 4 ATR)4,00%-13,6%11%pierde 1,98 pts
Sin filtro de sentimiento6,17%-29,4%53%pierde 5,33 pts
Sin venta por codicia3,91%-13,6%11%pierde 2,15 pts
Stop ancho (10 ATR)6,71%-25,2%27%pierde 2,10 pts
Agresiva (2 pos., 50%)4,09%-36,6%24%pierde 4,22 pts
Agresiva + sin codicia + 10 ATR10,85%-53,4%67%pierde 1,77 pts
Todo suelto7,10%-73,9%94%pierde 7,13 pts

Ninguna de las siete variantes bate a una cartera pasiva con su misma exposición media.

Ninguna gana. Pierden entre 1,8 y 7,1 puntos anuales contra la referencia justa. Pero el dato que de verdad explica lo que pasa es otro.

Fíjate en la versión base: una caída máxima del -13,6%, frente al -54,2% de la cesta al 100%. Visto así parece una protección magnífica. Ahora mira la cartera pasiva que lleva ese mismo 11% en bolsa y el 89% restante en un monetario: cayó un -11,3%. Cayó MENOS que la estrategia.

Ahí está la lección, y va mucho más allá de este sistema concreto: la protección no la dan las medias, ni el estocástico, ni el stop de 4 ATR. La da estar en liquidez. Cualquiera consigue ese mismo resultado dejando el dinero quieto, sin indicadores y sin operar 45 veces.

La prueba definitiva es partir los datos en dos mitades y mirar si las victorias se quedan quietas:

Variante2007-20162017-2026
Base del artículogana 0,76 ptspierde 2,87 pts
Sin venta por codiciagana 0,75 ptspierde 3,16 pts
Stop ancho (10 ATR)gana 0,67 ptspierde 2,48 pts
Agresiva (2 pos., 50%)gana 1,38 ptspierde 7,24 pts
Todo sueltopierde 10,72 ptsgana 5,11 pts

Diferencia anual contra la cartera pasiva de igual exposición.

Las cuatro variantes que ganan en la primera mitad pierden todas en la segunda. Y la única que gana en la segunda es justo la que peor lo hizo en la primera. Cuando las victorias cambian de sitio según el trozo de datos que mires, no hay ventaja: hay ruido. Un sistema con ventaja real aguanta en los dos periodos.

También probamos las tres mejoras que sugiere el propio artículo original para hacerla competir mejor: subir el tamaño de posición, quitar la toma de beneficios por codicia y ampliar el stop a 10 ATR. Ninguna sobrevive fuera de muestra. No buscamos parámetros nuevos a propósito: encontrar la combinación que gana en estos mismos datos es trivial y no significaría nada.

Lo que declaramos por honestidad, porque un backtest sin limitaciones declaradas es propaganda: el universo son solo ocho acciones, que es una muestra pequeña. El índice de miedo y codicia no está definido con fórmula exacta en el artículo original, así que lo implementamos a nuestra manera y lo probamos también sin él (sale peor, así que la conclusión no depende de nuestra interpretación). Y no hay comisiones: con entre 45 y 216 operaciones según la variante, contarlas empeoraría todos los números.

Y una objeción del propio autor que merecía medirse: el sistema está pensado para acciones que reparten dividendo, del 3% para arriba, así que si el análisis no contase el dinero cobrado los números estarían mal. Sí lo cuenta: usamos precios ajustados, o sea rentabilidad total. Y su instinto era bueno, porque el peso es enorme:

AcciónAnual con dividendosSolo precioAporta el dividendo
Repsol6,61%0,27%6,34 pts
Endesa14,51%3,24%11,27 pts
BBVA7,91%2,50%5,41 pts
Inditex19,75%11,91%7,84 pts
BNP Paribas7,38%2,36%5,03 pts
Deutsche Bank-0,89%-3,30%2,41 pts

En Repsol, el 96% de lo ganado en veinte años son dividendos. Un análisis que no los cuente no vale nada.

Pero su segunda idea era mejor todavía, y no se nos había ocurrido: el gráfico que uno mira en la pantalla del bróker NO es el ajustado. Las medias y el estocástico que ve el operador se calculan sobre el precio de cotización, con su hueco cada vez que se descuenta un dividendo. Nuestro backtest leía las señales sobre un gráfico que en realidad nadie mira.

Repetido en la versión fiel —señales sobre el gráfico sin ajustar, rentabilidad cobrando los dividendos— sale peor: 3,24% anual frente al 7,26% de su pasivo equivalente, cuatro puntos de diferencia en vez de dos, y la caída máxima se dispara del -13,6% al -34,1%. Por dentro, las operaciones pasan de durar 75 días de media a 141, y la peor empeora del -32% al -55%.

Nuestra primera hipótesis fue que el hueco del dividendo activaba el stop. Al medirlo no se sostiene: el stop de 4 ATR está entre un 9% y un 13% por debajo del máximo según la acción, y un dividendo suelto ronda el 4-7%. Casi nunca llega. Así que por qué las operaciones duran el doble sobre el gráfico crudo, no lo sabemos todavía. Está medido, no explicado, y preferimos decirlo así antes que ofrecer una teoría bonita sin comprobar.

Una corrección nuestra, que toca contar. En el foro, antes de correr las cuentas, escribimos que la comparación injusta probablemente estaba escondiendo mérito y que bien medida saldría mejor. No ha salido mejor. Pasa de ganar en 1 de 8 acciones a ganar en 2 de 8, y en versión cartera no gana en ninguna configuración. Anunciamos una hipótesis, la medimos y resultó falsa. Publicarlo igual es la única forma de que esto valga algo.

Queda una conclusión que no depende de ninguna de las variantes, y es la que nos llevamos. La Tortuga cae menos que el índice —un -13,6% frente al -54,2%— y visto así parece que protege. Pero cae MÁS que una cartera pasiva que tuvo exactamente el mismo dinero fuera del mercado: -13,6% contra -11,3%. La protección no venía de las reglas; venía del dinero parado. Y eso es invisible si comparas contra comprar y mantener.

El mismo autor nos pasó un segundo sistema, este de rupturas de resistencia, y ahí la respuesta es distinta y da para su propio artículo: sus reglas sí reducen la caída más de lo que explica la liquidez, aunque si funciona o no depende del mercado donde lo pongas. Está medido y publicado aparte, con sus números y su código.

El código

Estas son las cuentas exactas, tal cual se ejecutaron. Si encuentras un fallo, escríbenos: preferimos corregir a acertar.

Ver el código de backtest_tortuga_coja.py (255 líneas)
# -*- coding: utf-8 -*-
"""La "Tortuga Coja" de Harruinado, con la comparación JUSTA.

El sistema lo propuso Harruinado y lo programó el blog "Gestión conservadora de
activos financieros" en Rankia (artículo 7416776). Su conclusión: el Buy & Hold
gana en 7 de las 8 acciones probadas.

PERO ese análisis reconoce por qué pierde -- "solo invierte un 20-30% del
capital, el resto está parado rindiendo un 3%" -- y aun así lo compara contra un
Buy & Hold con el 100% del dinero invertido. Eso no mide la estrategia: mide la
diferencia de exposición. Un sistema que está al 25% de media SIEMPRE va a
perder contra uno que está al 100% en un mercado que sube.

La referencia justa es una cartera pasiva con la MISMA exposición media y el
resto en liquidez al mismo 3%. Es lo que se hace aquí.

Reglas (las tres del artículo):
  1. TENDENCIA: solo se compra con las medias alineadas (SMA 9 > 18 > 40).
  2. COJERA: entrada cuando el estocástico gira al alza saliendo de sobreventa
     (%K cruza por encima de %D viniendo de zona baja).
  3. SENTIMIENTO: el "CRI" combina RSI y volatilidad. Compra con miedo
     (CRI < 40), venta con codicia (CRI > 80).
  Salida adicional: trailing stop dinámico de 4xATR desde el máximo alcanzado.
  Tamaño: 20% del capital por operación. La liquidez renta al 3% anual.

El CRI no está definido con fórmula exacta en el artículo ("combinando el RSI y
la volatilidad"), así que aquí se implementa de forma explícita Y se prueba
también SIN él, para que la conclusión no dependa de mi interpretación.

Precios ajustados por dividendos y splits (los dividendos cuentan, y mucho:
es el motor de Endesa).

Sin comisiones ni impuestos: la realidad sería algo peor.

    python backtest_tortuga_coja.py
"""

import os

import pandas as pd

AQUI = os.path.dirname(os.path.abspath(__file__))
CACHE = os.path.join(AQUI, "cache", "tortuga")

DESDE = "2006-01-01"
PESO = 0.20            # 20% del capital por operación (max_posiciones=5)
CASH_ANUAL = 0.03      # el monetario al 3%, como en el artículo
ATR_STOP = 4.0
CRI_MIEDO, CRI_CODICIA = 40, 80
SOBREVENTA = 30

ACCIONES = [
    ("REP.MC", "Repsol (cíclica)"),
    ("ELE.MC", "Endesa (dividendo)"),
    ("BBVA.MC", "BBVA (banco)"),
    ("ITX.MC", "Inditex (crecimiento)"),
    ("BNP.PA", "BNP Paribas (banco)"),
    ("MC.PA", "LVMH (lujo)"),
    ("ASML.AS", "ASML (tecnología)"),
    ("DBK.DE", "Deutsche Bank (declive)"),
]


def _sesion(verificar):
    from curl_cffi import requests as cffi
    return cffi.Session(impersonate="chrome120", verify=verificar)


def cargar(ticker: str) -> pd.DataFrame:
    destino = os.path.join(CACHE, f"{ticker.replace('.', '_')}.csv")
    if os.path.exists(destino):
        df = pd.read_csv(destino, index_col=0, parse_dates=True)
        if not df.empty:
            return df
        os.remove(destino)

    import yfinance as yf
    ca = os.path.expanduser("~/.trading_ca/cacert_trading.pem")
    df = None
    for verificar in ([ca] if os.path.exists(ca) else []) + [False]:
        try:
            df = yf.download(ticker, start=DESDE, auto_adjust=True, progress=False,
                             threads=False, session=_sesion(verificar))
        except Exception:
            continue
        if df is not None and not df.empty:
            break
    if df is None or df.empty:
        raise RuntimeError(f"sin datos de {ticker}")
    if isinstance(df.columns, pd.MultiIndex):      # yfinance nuevo
        df.columns = df.columns.get_level_values(0)
    df = df[["Open", "High", "Low", "Close"]].dropna()
    os.makedirs(CACHE, exist_ok=True)
    df.to_csv(destino)
    return df


def indicadores(df: pd.DataFrame) -> pd.DataFrame:
    d = df.copy()
    c, h, l = d["Close"], d["High"], d["Low"]

    d["sma9"] = c.rolling(9).mean()
    d["sma18"] = c.rolling(18).mean()
    d["sma40"] = c.rolling(40).mean()

    # Estocástico (14, 3)
    bajo, alto = l.rolling(14).min(), h.rolling(14).max()
    d["k"] = 100 * (c - bajo) / (alto - bajo).replace(0, pd.NA)
    d["d"] = d["k"].rolling(3).mean()

    # RSI (14) de Wilder
    delta = c.diff()
    sube = delta.clip(lower=0).ewm(alpha=1 / 14, adjust=False).mean()
    baja = (-delta.clip(upper=0)).ewm(alpha=1 / 14, adjust=False).mean()
    d["rsi"] = 100 - 100 / (1 + sube / baja.replace(0, pd.NA))

    # ATR (14) de Wilder
    tr = pd.concat([h - l, (h - c.shift()).abs(), (l - c.shift()).abs()],
                   axis=1).max(axis=1)
    d["atr"] = tr.ewm(alpha=1 / 14, adjust=False).mean()

    # CRI: confianza (RSI) penalizada por la volatilidad reciente. Cuando la
    # volatilidad esta alta respecto a su propio historial, hay miedo aunque el
    # RSI no este en suelo. Es UNA interpretacion; por eso se prueba sin el.
    vol = c.pct_change().rolling(20).std()
    vol_pct = vol.rolling(252).rank(pct=True) * 100
    d["cri"] = 0.7 * d["rsi"] + 0.3 * (100 - vol_pct)

    return d.dropna()


def backtest(d: pd.DataFrame, usar_cri: bool = True) -> tuple:
    """Devuelve (equity, exposicion diaria, operaciones)."""
    tasa_dia = (1 + CASH_ANUAL) ** (1 / 252) - 1
    cash, acciones = 1.0, 0.0
    en_trade, maximo, eq_ini, fecha_ini = False, 0.0, 0.0, None

    equity, expo, ops = [], [], []
    previo = None
    for fecha, f in d.iterrows():
        p = f["Close"]
        cash *= 1 + tasa_dia

        if not en_trade:
            tendencia = f["sma9"] > f["sma18"] > f["sma40"]
            cruce = (previo is not None and previo["k"] <= previo["d"]
                     and f["k"] > f["d"] and previo["k"] < SOBREVENTA)
            miedo = (not usar_cri) or f["cri"] < CRI_MIEDO
            if tendencia and cruce and miedo:
                total = cash + acciones * p
                importe = min(total * PESO, cash)
                acciones = importe / p
                cash -= importe
                en_trade, maximo = True, p
                eq_ini, fecha_ini = total, fecha
        else:
            maximo = max(maximo, p)
            stop = p < maximo - ATR_STOP * f["atr"]
            codicia = usar_cri and f["cri"] > CRI_CODICIA
            if stop or codicia:
                cash += acciones * p
                acciones = 0.0
                en_trade = False
                total = cash
                ops.append({
                    "ini": fecha_ini, "fin": fecha,
                    "dias": (fecha - fecha_ini).days,
                    "rent": (total / eq_ini - 1) * 100,
                    "salida": "stop 4xATR" if stop else "codicia",
                })

        previo = f
        total = cash + acciones * p
        equity.append((fecha, total))
        expo.append((fecha, acciones * p / total))

    return pd.Series(dict(equity)), pd.Series(dict(expo)), ops


def pasivo(d: pd.DataFrame, peso: float) -> pd.Series:
    """`peso` en la accion y el resto en liquidez al 3%. La referencia justa."""
    tasa_dia = (1 + CASH_ANUAL) ** (1 / 252) - 1
    bolsa = d["Close"] / d["Close"].iloc[0]
    liquidez = pd.Series((1 + tasa_dia) ** pd.RangeIndex(len(d)), index=d.index)
    return peso * bolsa + (1 - peso) * liquidez


def metricas(eq: pd.Series) -> dict:
    anios = (eq.index[-1] - eq.index[0]).days / 365.25
    return {
        "cagr": ((eq.iloc[-1] / eq.iloc[0]) ** (1 / anios) - 1) * 100,
        "dd": (eq / eq.cummax() - 1).min() * 100,
        "total": (eq.iloc[-1] / eq.iloc[0] - 1) * 100,
    }


def main() -> None:
    print(__doc__.strip().split("\n\n")[0])
    print(f"\n{'':26}{'anual':>8}{'peor DD':>10}{'total':>10}{'expos.':>9}{'ops':>6}")
    print("-" * 78)

    resumen = []
    for ticker, nombre in ACCIONES:
        try:
            d = indicadores(cargar(ticker))
        except Exception as exc:
            print(f"{nombre:26}  sin datos ({exc})")
            continue

        bh = metricas(d["Close"] / d["Close"].iloc[0])
        eq, expo, ops = backtest(d, usar_cri=True)
        e = expo.mean()
        m = metricas(eq)
        just = metricas(pasivo(d, e))
        eq2, expo2, ops2 = backtest(d, usar_cri=False)
        m2, just2 = metricas(eq2), metricas(pasivo(d, expo2.mean()))

        print(f"\n{nombre}  ({d.index[0].date()} -> {d.index[-1].date()})")
        print(f"  {'Comprar y mantener (100%)':24}{bh['cagr']:>8.2f}%"
              f"{bh['dd']:>9.1f}%{bh['total']:>9.0f}%{100:>8.0f}%")
        print(f"  {'Tortuga Coja':24}{m['cagr']:>8.2f}%"
              f"{m['dd']:>9.1f}%{m['total']:>9.0f}%{e*100:>8.0f}%{len(ops):>6}")
        print(f"  {'-> pasivo a igual expos.':24}{just['cagr']:>8.2f}%"
              f"{just['dd']:>9.1f}%{just['total']:>9.0f}%{e*100:>8.0f}%")
        print(f"  {'Tortuga sin filtro CRI':24}{m2['cagr']:>8.2f}%"
              f"{m2['dd']:>9.1f}%{m2['total']:>9.0f}%{expo2.mean()*100:>8.0f}%{len(ops2):>6}")
        print(f"  {'-> pasivo a igual expos.':24}{just2['cagr']:>8.2f}%"
              f"{just2['dd']:>9.1f}%{just2['total']:>9.0f}%{expo2.mean()*100:>8.0f}%")

        resumen.append({
            "nombre": nombre,
            "gana_bh": m["cagr"] > bh["cagr"],
            "gana_justo": m["cagr"] > just["cagr"],
            "dd_mejor": m["dd"] > bh["dd"],
            "dif_justa": m["cagr"] - just["cagr"],
        })

    if resumen:
        r = pd.DataFrame(resumen)
        n = len(r)
        print("\n" + "=" * 78)
        print("RECUENTO")
        print(f"  Gana al Buy & Hold del 100%:              "
              f"{int(r['gana_bh'].sum())} de {n}")
        print(f"  Gana al pasivo A IGUAL EXPOSICION:        "
              f"{int(r['gana_justo'].sum())} de {n}")
        print(f"  Sufre menos caida que el Buy & Hold:      "
              f"{int(r['dd_mejor'].sum())} de {n}")
        print(f"  Ventaja media sobre la referencia justa:  "
              f"{r['dif_justa'].mean():+.2f} puntos anuales")


if __name__ == "__main__":
    main()
Ver el código de backtest_tortuga_cartera.py (202 líneas)
# -*- coding: utf-8 -*-
"""La "Tortuga Coja" como CARTERA (varios valores a la vez) y con la comparación justa.

Continuación de backtest_tortuga_coja.py, que la probaba acción por acción igual
que el artículo original de Rankia (7416776). Ese test le hace un flaco favor a
la estrategia: con un tope del 20% por operación y entradas muy raras, la
exposición media queda en el 1-2% y el sistema es casi todo liquidez.

El sistema está pensado para llevar VARIOS valores simultáneos. Aquí se prueba
así: un universo de 8 acciones, hasta N posiciones abiertas a la vez, cada una
con un peso fijo del capital.

La referencia justa NO es la cesta al 100%: es la MISMA cesta equiponderada con
la MISMA exposición media y el resto en liquidez al 3%.

VARIANTES. Las "mejoras" no son mías: son las que propone el propio artículo
para hacerla competir en mercados alcistas (subir el tamaño de posición, quitar
la toma de beneficios por codicia y ampliar el stop a 10 ATR). Se validan
IN-SAMPLE y OUT-OF-SAMPLE por separado: si una variante solo gana en los datos
donde se eligió, es sobreajuste y hay que decirlo.

Sin comisiones ni impuestos: la realidad sería algo peor.

    python backtest_tortuga_cartera.py
"""

import os

import pandas as pd

from backtest_tortuga_coja import (ACCIONES, ATR_STOP, CASH_ANUAL, CRI_CODICIA,
                                   CRI_MIEDO, SOBREVENTA, cargar, indicadores)

CORTE_OOS = "2017-01-01"


def preparar() -> dict:
    datos = {}
    for ticker, nombre in ACCIONES:
        try:
            datos[nombre] = indicadores(cargar(ticker))
        except Exception as exc:
            print(f"  [aviso] sin datos de {ticker}: {exc}")
    return datos


def backtest(datos: dict, max_pos: int = 5, peso: float = 0.20,
             usar_cri: bool = True, vender_codicia: bool = True,
             atr_stop: float = ATR_STOP) -> tuple:
    """Cartera con hasta `max_pos` posiciones simultáneas de tamaño `peso`."""
    fechas = sorted(set().union(*[set(d.index) for d in datos.values()]))
    tasa_dia = (1 + CASH_ANUAL) ** (1 / 252) - 1

    # Las bolsas (Madrid, Paris, Amsterdam, Frankfurt) tienen festivos distintos.
    # Para VALORAR se arrastra el ultimo precio conocido; si no, los dias que una
    # plaza cierra sus posiciones desaparecerian y la equity daria saltos falsos.
    panel = pd.DataFrame({n: d["Close"] for n, d in datos.items()})
    panel = panel.reindex(fechas).ffill()

    cash = 1.0
    abiertas = {}          # nombre -> dict(acciones, maximo, eq_ini, fecha_ini)
    previo = {}
    equity, expo, ops = [], [], []

    def valorar(fecha):
        return sum(p["acciones"] * panel.at[fecha, n] for n, p in abiertas.items())

    for i, fecha in enumerate(fechas):
        if i:
            cash *= 1 + tasa_dia

        # solo hay señal en los valores que HOY cotizan
        precios = {}
        for nombre, d in datos.items():
            if fecha in d.index:
                precios[nombre] = d.loc[fecha]
        total = cash + valorar(fecha)

        # --- salidas ---
        for nombre in list(abiertas):
            if nombre not in precios:
                continue
            f, pos = precios[nombre], abiertas[nombre]
            p = f["Close"]
            pos["maximo"] = max(pos["maximo"], p)
            stop = p < pos["maximo"] - atr_stop * f["atr"]
            codicia = vender_codicia and usar_cri and f["cri"] > CRI_CODICIA
            if stop or codicia:
                cash += pos["acciones"] * p
                ops.append({
                    "valor": nombre, "ini": pos["fecha_ini"], "fin": fecha,
                    "dias": (fecha - pos["fecha_ini"]).days,
                    "rent": (p / pos["precio_ini"] - 1) * 100,
                    "salida": "stop" if stop else "codicia",
                })
                del abiertas[nombre]

        # --- entradas ---
        for nombre, f in precios.items():
            if nombre in abiertas or len(abiertas) >= max_pos:
                continue
            ant = previo.get(nombre)
            if ant is None:
                continue
            tendencia = f["sma9"] > f["sma18"] > f["sma40"]
            cruce = (ant["k"] <= ant["d"] and f["k"] > f["d"]
                     and ant["k"] < SOBREVENTA)
            miedo = (not usar_cri) or f["cri"] < CRI_MIEDO
            if tendencia and cruce and miedo:
                importe = min(total * peso, cash)
                if importe <= 0:
                    continue
                p = f["Close"]
                abiertas[nombre] = {
                    "acciones": importe / p, "maximo": p, "precio_ini": p,
                    "fecha_ini": fecha,
                }
                cash -= importe

        previo.update(precios)
        invertido = valorar(fecha)
        total = cash + invertido
        equity.append((fecha, total))
        expo.append((fecha, invertido / total if total else 0.0))

    return pd.Series(dict(equity)), pd.Series(dict(expo)), ops


def cesta(datos: dict, fechas) -> pd.Series:
    """Cesta equiponderada de las mismas acciones, comprada y mantenida."""
    normal = []
    for d in datos.values():
        s = d["Close"].reindex(fechas).ffill()
        normal.append(s / s.dropna().iloc[0])
    return pd.concat(normal, axis=1).mean(axis=1).dropna()


def pasivo(ces: pd.Series, peso: float) -> pd.Series:
    tasa_dia = (1 + CASH_ANUAL) ** (1 / 252) - 1
    liquidez = pd.Series((1 + tasa_dia) ** pd.RangeIndex(len(ces)), index=ces.index)
    return peso * ces + (1 - peso) * liquidez


def metricas(eq: pd.Series) -> dict:
    anios = (eq.index[-1] - eq.index[0]).days / 365.25
    return {
        "cagr": ((eq.iloc[-1] / eq.iloc[0]) ** (1 / anios) - 1) * 100,
        "dd": (eq / eq.cummax() - 1).min() * 100,
    }


VARIANTES = [
    ("Base del artículo (5 pos, 20%, 4 ATR)", dict()),
    ("Sin filtro de sentimiento (CRI)", dict(usar_cri=False)),
    ("Sin venta por codicia", dict(vender_codicia=False)),
    ("Stop ancho (10 ATR)", dict(atr_stop=10.0)),
    ("Agresiva del artículo (2 pos, 50%)", dict(max_pos=2, peso=0.50)),
    ("Agresiva + sin codicia + 10 ATR", dict(max_pos=2, peso=0.50,
                                             vender_codicia=False, atr_stop=10.0)),
    ("Todo suelto (2 pos, 50%, sin CRI, 10 ATR)",
     dict(max_pos=2, peso=0.50, usar_cri=False, vender_codicia=False,
          atr_stop=10.0)),
]


def bloque(datos: dict, titulo: str) -> None:
    fechas = sorted(set().union(*[set(d.index) for d in datos.values()]))
    ces = cesta(datos, fechas)
    print(f"\n{titulo}  ({fechas[0].date()} -> {fechas[-1].date()})")
    print("  " + "-" * 76)
    m = metricas(ces)
    print(f"  {'Cesta equiponderada al 100%':<42}{m['cagr']:>7.2f}%"
          f"   DD {m['dd']:>6.1f}%")
    for nombre, kw in VARIANTES:
        eq, ex, ops = backtest(datos, **kw)
        e = ex.mean()
        m, j = metricas(eq), metricas(pasivo(ces, e))
        dif = m["cagr"] - j["cagr"]
        marca = "GANA" if dif > 0 else "pierde"
        print(f"  {nombre:<42}{m['cagr']:>7.2f}%   DD {m['dd']:>6.1f}%"
              f"   expos {e*100:>4.0f}%   {len(ops):>3} ops")
        print(f"  {'  -> pasivo a igual exposición':<42}{j['cagr']:>7.2f}%"
              f"   DD {j['dd']:>6.1f}%   {marca} por {abs(dif):.2f} pts")


def main() -> None:
    print(__doc__.strip().split("\n\n")[0])
    datos = preparar()
    if not datos:
        return
    print(f"\nUniverso: {len(datos)} acciones")

    bloque(datos, "=== PERIODO COMPLETO ===")
    bloque({n: d[d.index < CORTE_OOS] for n, d in datos.items()},
           "=== IN-SAMPLE (2006-2016) ===")
    bloque({n: d[d.index >= CORTE_OOS] for n, d in datos.items()},
           "=== OUT-OF-SAMPLE (2017 en adelante) ===")


if __name__ == "__main__":
    main()
Ver el código de backtest_tortuga_dividendos.py (148 líneas)
# -*- coding: utf-8 -*-
"""¿Cambian los dividendos el veredicto de la Tortuga Coja? (Rankia, 31/07/2026)

Harruinado planteó dos objeciones al backtest, y las dos merecen medirse:

  1. "El descuento del dividendo puede dar errores en la rentabilidad si no se
     ha tenido en cuenta el dinero recibido." -> En el backtest original NO pasa:
     se usó auto_adjust=True, o sea precios de RENTABILIDAD TOTAL, con los
     dividendos ya reinvertidos. Aquí se cuantifica cuánto aportan, para que se
     vea que no es un detalle: en algunas de estas acciones son la mitad de todo
     lo ganado.

  2. "También se puede hacer el análisis en un gráfico que no tenga en cuenta el
     descuento de los dividendos." -> Este punto es MEJOR de lo que parece y no
     lo había considerado. El gráfico que uno mira en la pantalla del bróker es
     el SIN ajustar, así que las señales (medias, estocástico) en la vida real
     se leen sobre ese precio, no sobre el ajustado. Aquí se prueba esa versión:
     señales sobre el precio de pantalla, rentabilidad con dividendos incluidos.
     Es la versión más fiel a cómo se opera de verdad.

Se comparan tres montajes sobre las mismas 8 acciones y el mismo periodo:

  A) Todo ajustado          - el backtest original.
  B) Señal cruda + total    - lo que se ve en pantalla decide; se cobra el
                              dividendo. La más realista.
  C) Todo crudo             - sin dividendos por ningún lado; lo que hace un
                              backtest descuidado. Sirve para medir el sesgo.

    python backtest_tortuga_dividendos.py
"""

import os

import pandas as pd

from backtest_tortuga_coja import ACCIONES, CASH_ANUAL, _sesion, indicadores
from backtest_tortuga_cartera import backtest, cesta, metricas, pasivo

AQUI = os.path.dirname(os.path.abspath(__file__))
CACHE = os.path.join(AQUI, "cache", "tortuga_crudo")
DESDE = "2006-01-01"


def cargar_doble(ticker: str) -> pd.DataFrame:
    """OHLC SIN ajustar (el gráfico de pantalla) + cierre ajustado (total)."""
    destino = os.path.join(CACHE, f"{ticker.replace('.', '_')}.csv")
    if os.path.exists(destino):
        d = pd.read_csv(destino, index_col=0, parse_dates=True)
        if not d.empty:
            return d

    import yfinance as yf
    ca = os.path.expanduser("~/.trading_ca/cacert_trading.pem")
    df = None
    for verificar in ([ca] if os.path.exists(ca) else []) + [False]:
        try:
            df = yf.download(ticker, start=DESDE, auto_adjust=False,
                             progress=False, threads=False,
                             session=_sesion(verificar))
        except Exception:
            continue
        if df is not None and not df.empty:
            break
    if df is None or df.empty:
        raise RuntimeError(f"sin datos de {ticker}")
    if isinstance(df.columns, pd.MultiIndex):
        df.columns = df.columns.get_level_values(0)
    cols = ["Open", "High", "Low", "Close", "Adj Close"]
    df = df[cols].dropna()
    os.makedirs(CACHE, exist_ok=True)
    df.to_csv(destino)
    return df


def preparar(modo: str) -> dict:
    """modo: 'ajustado' | 'senal_cruda' | 'crudo'."""
    datos = {}
    for ticker, nombre in ACCIONES:
        try:
            d = cargar_doble(ticker)
        except Exception as exc:
            print(f"  [aviso] {ticker}: {exc}")
            continue

        if modo == "ajustado":                 # todo en rentabilidad total
            f = d["Adj Close"] / d["Close"]
            base = pd.DataFrame({c: d[c] * f for c in ("Open", "High", "Low")})
            base["Close"] = d["Adj Close"]
        elif modo == "crudo":                  # nada de dividendos
            base = d[["Open", "High", "Low", "Close"]].copy()
        else:                                  # señal cruda, cobro total
            base = d[["Open", "High", "Low", "Close"]].copy()

        ind = indicadores(base)
        if modo == "senal_cruda":
            # los indicadores quedan calculados sobre el precio de pantalla,
            # pero el dinero se mueve con el precio de rentabilidad total
            ind["Close"] = d["Adj Close"].reindex(ind.index)
            ind = ind.dropna(subset=["Close"])
        datos[nombre] = ind
    return datos


def aporte_dividendos() -> None:
    print("\n=== Cuánto de la rentabilidad vino de los dividendos ===")
    print(f"  {'':26}{'con dividendos':>16}{'solo precio':>14}{'aporte':>10}")
    for ticker, nombre in ACCIONES:
        try:
            d = cargar_doble(ticker)
        except Exception:
            continue
        anios = (d.index[-1] - d.index[0]).days / 365.25
        tot = (d["Adj Close"].iloc[-1] / d["Adj Close"].iloc[0]) ** (1/anios) - 1
        pre = (d["Close"].iloc[-1] / d["Close"].iloc[0]) ** (1/anios) - 1
        print(f"  {nombre:26}{tot*100:>15.2f}%{pre*100:>13.2f}%"
              f"{(tot-pre)*100:>9.2f}")


def main() -> None:
    print(__doc__.strip().split("\n\n")[0])
    for modo, titulo in (("ajustado", "A) Todo ajustado (el backtest original)"),
                         ("senal_cruda", "B) Señal sobre el gráfico de pantalla, "
                                         "cobrando dividendos"),
                         ("crudo", "C) Todo crudo, sin dividendos")):
        datos = preparar(modo)
        if not datos:
            continue
        fechas = sorted(set().union(*[set(d.index) for d in datos.values()]))
        ces = cesta(datos, fechas)
        eq, ex, ops = backtest(datos)          # variante base del artículo
        e = ex.mean()
        m, j = metricas(eq), metricas(pasivo(ces, e))
        print(f"\n{titulo}")
        print(f"  {'Cesta al 100%':<34}{metricas(ces)['cagr']:>7.2f}% anual"
              f"   DD {metricas(ces)['dd']:>6.1f}%")
        print(f"  {'Tortuga Coja':<34}{m['cagr']:>7.2f}% anual"
              f"   DD {m['dd']:>6.1f}%   expos {e*100:.0f}%   {len(ops)} ops")
        print(f"  {'-> pasivo a igual exposición':<34}{j['cagr']:>7.2f}% anual"
              f"   DD {j['dd']:>6.1f}%   "
              f"{'GANA' if m['cagr'] > j['cagr'] else 'pierde'} por "
              f"{abs(m['cagr']-j['cagr']):.2f} pts")

    aporte_dividendos()


if __name__ == "__main__":
    main()
¿Cansado del humo en el trading?
Descubre carteras reales verificadas por el bróker.
Ver CarteraReal