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:
| Variante | Anual | Peor caída | Exposición | Frente 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 sentimiento | 6,17% | -29,4% | 53% | pierde 5,33 pts |
| Sin venta por codicia | 3,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 ATR | 10,85% | -53,4% | 67% | pierde 1,77 pts |
| Todo suelto | 7,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:
| Variante | 2007-2016 | 2017-2026 |
|---|---|---|
| Base del artículo | gana 0,76 pts | pierde 2,87 pts |
| Sin venta por codicia | gana 0,75 pts | pierde 3,16 pts |
| Stop ancho (10 ATR) | gana 0,67 pts | pierde 2,48 pts |
| Agresiva (2 pos., 50%) | gana 1,38 pts | pierde 7,24 pts |
| Todo suelto | pierde 10,72 pts | gana 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ón | Anual con dividendos | Solo precio | Aporta el dividendo |
|---|---|---|---|
| Repsol | 6,61% | 0,27% | 6,34 pts |
| Endesa | 14,51% | 3,24% | 11,27 pts |
| BBVA | 7,91% | 2,50% | 5,41 pts |
| Inditex | 19,75% | 11,91% | 7,84 pts |
| BNP Paribas | 7,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()