Backtest sin humo: el cruce de medias, a examen
La estrategia más vendida en los cursos de trading, probada sobre 36 años del S&P 500. Ni milagro ni estafa: esto es lo que sale de verdad.
Si has visto un curso de trading, has visto esta estrategia: comprar cuando la media de 50 días cruza por encima de la de 200 (el famoso golden cross) y vender cuando cruza por debajo. Se vende como el secreto de los profesionales. La hemos backtesteado de verdad: S&P 500, desde 1990, 36 años de datos, sin trampas.
Las reglas exactas, para que cualquiera pueda repetirlo: señal con el cierre del día y operada en ese mismo cierre; cuando la estrategia está fuera, el dinero se queda en liquidez; y sin contar comisiones ni impuestos, así que la realidad sería peor que estos números, nunca mejor.
Resultado: la estrategia ganó un 8,3% anual. Comprar el índice y no tocarlo, un 9,4%. Sí, has leído bien: la estrategia estrella de los cursos gana menos que no hacer absolutamente nada.
¿Entonces es una estafa? No exactamente. Donde sí cumple es en el sufrimiento: su peor caída fue del -34%, frente al -57% del índice. En el pinchazo puntocom perdió un -8% mientras el índice se dejaba un -49%. En la crisis de 2008, un -5% frente al -57%. De los desplomes lentos te saca a tiempo.
Pero ojo: en el Covid de 2020 se comió el -34% entero, exactamente igual que el índice. La caída fue tan rápida que una media de 200 días ni se enteró. Y en 2022 perdió un -13% (el índice, -25%). Protege de los incendios lentos, no de los accidentes de coche.
El dato más jugoso: el 87% de sus operaciones fueron ganadoras (13 de 15). Imagina el anuncio: «87% de aciertos, comprobado con 36 años de datos». Suena imbatible… y aun así gana menos que el índice. Que no te vendan porcentajes de acierto: se puede acertar mucho y ganar poco.
La conclusión honesta: el cruce de medias no es magia ni es humo. Es un filtro de tendencia defensivo: ganas algo menos a cambio de un viaje mucho más tranquilo (y de estar fuera del mercado una cuarta parte del tiempo). Para según qué inversor, ese cambio puede tener sentido. Lo que no tiene sentido es pagar 1.000 € por él: son dos medias móviles, gratis en internet desde hace décadas.
Este es el primer «backtest sin humo» de CarteraReal. Cuando alguien te venda una estrategia, pídele esto: reglas exactas, un periodo largo, la comparación con no hacer nada y los resultados con sus años malos incluidos. Si no te lo da, ya sabes lo que es.
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_cruce_medias.py (135 líneas)
# -*- coding: utf-8 -*-
"""
Backtest honesto del "cruce de medias" (golden cross 50/200) sobre el S&P 500.
LA estrategia de curso de trading: comprar cuando la media móvil de 50 sesiones
cruza por encima de la de 200 (golden cross) y salir a liquidez cuando cruza
por debajo (death cross). Reglas objetivas, sin letra pequeña:
- Datos: S&P 500 (^GSPC) diario desde 1990, precios ajustados (yfinance).
- La señal se calcula con el cierre y se opera en ese mismo cierre (el cruce
se conoce antes de cerrar la sesión). Nunca se usa información futura.
- Fuera del mercado = liquidez al 0% (ni remunerada ni penalizada).
- SIN comisiones, SIN impuestos, SIN deslizamiento: la realidad sería PEOR.
Alimenta el artículo "El cruce de medias, a examen" de Aprende (articulos.py).
python backtest_cruce_medias.py
Guarda cache/gspc_daily.csv la primera vez para poder repetirlo sin red.
"""
import os
import pandas as pd
AQUI = os.path.dirname(os.path.abspath(__file__))
CACHE = os.path.join(AQUI, "cache", "gspc_daily.csv")
RAPIDA, LENTA = 50, 200
DESDE = "1990-01-01"
# Grandes sustos del periodo, para ver qué hizo la estrategia en cada uno
CRISIS = [
("Puntocom 2000-02", "2000-03-24", "2002-10-09"),
("Financiera 2007-09", "2007-10-09", "2009-03-09"),
("Covid 2020", "2020-02-19", "2020-03-23"),
("Inflación 2022", "2022-01-03", "2022-10-12"),
]
def cargar() -> pd.Series:
"""Cierre diario ajustado del S&P 500 (caché local o yfinance)."""
if os.path.exists(CACHE):
s = pd.read_csv(CACHE, index_col=0, parse_dates=True)["close"]
return s
ca = os.path.expanduser("~/.trading_ca/cacert_trading.pem")
if os.path.exists(ca): # fix Avast: SSL con CA bundle propio
os.environ.setdefault("REQUESTS_CA_BUNDLE", ca)
os.environ.setdefault("SSL_CERT_FILE", ca)
import yfinance as yf
df = yf.download("^GSPC", start=DESDE, auto_adjust=True, progress=False)
close = df["Close"]
if isinstance(close, pd.DataFrame): # yfinance nuevo: columnas multinivel
close = close.iloc[:, 0]
close = close.rename("close")
os.makedirs(os.path.dirname(CACHE), exist_ok=True)
close.to_frame().to_csv(CACHE)
return close
def metricas(equity: pd.Series) -> dict:
anios = (equity.index[-1] - equity.index[0]).days / 365.25
cagr = (equity.iloc[-1] / equity.iloc[0]) ** (1 / anios) - 1
dd = (equity / equity.cummax() - 1).min()
return {"cagr": cagr * 100, "maxdd": dd * 100, "total": (equity.iloc[-1] - 1) * 100}
def main() -> None:
close = cargar()
ret = close.pct_change().fillna(0)
sma_r = close.rolling(RAPIDA).mean()
sma_l = close.rolling(LENTA).mean()
# Posición: 1 si la rápida está sobre la lenta (señal del cierre anterior)
senal = (sma_r > sma_l).astype(int)
pos = senal.shift(1).fillna(0)
# Empezamos a contar cuando ya existen las dos medias
valido = sma_l.notna()
ret, pos, senal = ret[valido], pos[valido], senal[valido]
close_v = close[valido]
eq_estrategia = (1 + pos * ret).cumprod()
eq_indice = (1 + ret).cumprod()
m_e, m_i = metricas(eq_estrategia), metricas(eq_indice)
tiempo_dentro = pos.mean() * 100
# Operaciones (entrada 0->1, salida 1->0), ganadora si sale más arriba
cambios = pos.diff()
entradas = list(close_v[cambios == 1].items())
salidas = list(close_v[cambios == -1].items())
ops = list(zip(entradas, salidas))
ganadoras = [
(fs - fe).days
for (fe, pe), (fs, ps) in ops if ps > pe
]
perdedoras = [
(fs - fe).days
for (fe, pe), (fs, ps) in ops if ps <= pe
]
rents_ops = [(ps / pe - 1) * 100 for (fe, pe), (fs, ps) in ops]
print(f"S&P 500 · {eq_indice.index[0].date()} -> {eq_indice.index[-1].date()}"
f" (cruce {RAPIDA}/{LENTA}, sin costes)")
print(f"\n{'':24}{'Cruce 50/200':>14}{'Comprar y mantener':>20}")
print(f"{'Rentabilidad anual':24}{m_e['cagr']:>13.1f}%{m_i['cagr']:>19.1f}%")
print(f"{'Rentabilidad total':24}{m_e['total']:>12.0f}%{m_i['total']:>19.0f}%")
print(f"{'Peor caída (max DD)':24}{m_e['maxdd']:>13.1f}%{m_i['maxdd']:>19.1f}%")
print(f"{'Tiempo en mercado':24}{tiempo_dentro:>13.1f}%{100:>19.1f}%")
n = len(ops)
if n:
pct_win = len(ganadoras) / n * 100
print(f"\nOperaciones: {n} · ganadoras {len(ganadoras)} ({pct_win:.0f}%)"
f" · perdedoras {len(perdedoras)}")
print(f"Mejor operación: {max(rents_ops):+.1f}% · peor: {min(rents_ops):+.1f}%")
if ganadoras and perdedoras:
print(f"Duración media ganadora {sum(ganadoras)/len(ganadoras):.0f} días"
f" · perdedora {sum(perdedoras)/len(perdedoras):.0f} días")
print("\nEn los grandes sustos (rentabilidad en la ventana):")
for nombre, ini, fin in CRISIS:
rango = (eq_indice.index >= ini) & (eq_indice.index <= fin)
if not rango.any():
continue
e = eq_estrategia[rango]
i = eq_indice[rango]
print(f" {nombre:22} cruce {(e.iloc[-1]/e.iloc[0]-1)*100:>+7.1f}%"
f" índice {(i.iloc[-1]/i.iloc[0]-1)*100:>+7.1f}%")
if __name__ == "__main__":
main()