Backtest sin humo: comprar rupturas de resistencia, 36 años de datos
El segundo sistema del mismo lector, y esta vez sin indicadores raros: dos medias y el máximo anterior. No bate al índice, pero a igualdad de riesgo depende de dónde lo pongas.
Este es el segundo sistema que nos trae el mismo lector, Harruinado, veterano del foro de bolsa de Rankia. El primero era de sentimiento y lo publicamos aquí hace poco. Este es de precio puro, y nos lo propuso con una frase que sonaba a reto: «si eres capaz de programar lo de mirar a tu izquierda, es sencillo».
Lo era. «Mirar a la izquierda» significa buscar la última punta que dejó el precio atrás, la resistencia más cercana, y esperar a que la supere. Eso se programa con lo que en jerga se llama un pivote: una vela cuyo máximo es el mayor de las sesiones que tiene a cada lado. Con un detalle que es donde se cuela el autoengaño en la mayoría de backtests: una punta no se conoce hasta varios días después de formarse, así que solo se pueden usar las ya confirmadas. Si te saltas eso, estás comprando con el periódico de mañana.
Las reglas completas, tal como él las dio: gráfico diario, dos medias exponenciales de 10 y 20 sesiones, que deben estar alcistas o muy juntas y a punto de cruzarse. Se entra cuando el precio supera la punta anterior más cercana. El stop se pone en el mínimo relevante anterior. Y se sale cuando la media rápida corta a la baja a la lenta. Él insiste en que el cruce de medias no es la señal: es solo la confirmación de que se ha roto la resistencia.
Nos pidió además algo que agradecemos, porque es exactamente lo que hay que hacer: repasar las operaciones una a una antes de mirar el resultado, para comprobar que las entradas caen donde deben y no donde el programador cree. Lo hicimos. Ejemplo del 25 de enero de 1993: el precio cierra en 440,01 superando la punta confirmada de 439,49, el día anterior cerraba por debajo, las medias estaban separadas un 0,16% (pegadas) y el stop quedaba en 426,88. Es su sistema.
Resultados sobre el S&P 500, de 1990 a 2026, solo largos y sin costes: 4,3% anual con una caída máxima del -25,7%, en 135 operaciones de las que gana el 44%. Comprar y mantener: 8,6% anual con una caída del -56,8%. Ese perfil de acertar menos de la mitad de las veces y compensarlo con ganancias grandes es, palabra por palabra, lo que él mismo describía de los sistemas tendenciales en su primer mensaje.
Pero en el gráfico que nos mandó para aclarar una duda vimos algo: no estaba operando el S&P, sino el Nasdaq. Lo repetimos ahí y cambia la película. Nasdaq 100, mismo periodo: 8,3% anual con una caída del -34,1%, contra el 14,1% y el -82,9% de comprar y mantener. En el pinchazo de las puntocom el índice se dejó un 83% y el sistema un 34%.
Sigue sin batir al índice en rentabilidad. Así que hicimos la comparación que de verdad decide, y que casi nadie hace: a igualdad de riesgo. En vez de comparar contra el índice entero, comparamos contra comprar solo una parte del índice y dejar el resto en letras del Tesoro, ajustando esa parte hasta que la caída máxima sea idéntica a la del sistema.
En el S&P 500: el sistema da 4,25% anual; comprar el 43% del índice y el resto en letras da 5,62% con la misma caída. El sistema pierde por 1,37 puntos al año. En el Nasdaq 100: el sistema da 8,37%; su equivalente pasivo, 6,99%. Gana por 1,38 puntos al año, durante 36 años y 168 operaciones.
Ese es el hallazgo, y no es el que esperábamos: el sistema no es bueno ni malo en abstracto. En un índice tranquilo destruye valor y en uno volátil lo añade. La misma estrategia, las mismas reglas, el mismo programador, y el signo del resultado cambia según dónde la pongas. Probablemente por eso su autor lleva años operándola donde la opera, y probablemente por eso a mucha gente que copia sistemas ajenos no le funcionan: los copian sin el mercado para el que fueron pensados.
Un detalle que se repite respecto a su otro sistema: el filtro de las medias apenas influye. Quitándolo entero el resultado pasa de 4,32% a 4,23%. Lo que hace el trabajo es la ruptura de la punta. Las medias, como él mismo dice, son el dibujito que te ayuda a ver el mapa.
Las pegas de nuestro trabajo, como siempre. «La punta anterior» admite lecturas: probamos pivotes de 2, 3, 5 y 10 sesiones a cada lado y el resultado se mueve entre el 3,5% y el 4,8% en el S&P, así que la conclusión no depende de haber acertado con el número exacto, pero los decimales sí. Es solo con largos, un solo índice cada vez, y sin comisiones ni impuestos: con 168 operaciones en 36 años los costes reales se notarían.
La conclusión sin humo: es un sistema honesto que hace lo que promete, no bate al índice en rentabilidad y sí lo bate en rentabilidad por riesgo asumido, pero solo en el mercado adecuado. Y esa última frase es la que no viene en ningún curso. Gracias otra vez a Harruinado, que ha contestado a todas las preguntas con detalle y nos ha dejado publicar sus reglas con su nombre.
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_puntas_harruinado.py (193 líneas)
# -*- coding: utf-8 -*-
"""Segundo sistema de Harruinado: "mirar a la izquierda" (Rankia, 30/07/2026).
Sus reglas, tal como las describió en el hilo:
- Gráfico DIARIO, con dos medias exponenciales: EMA 10 y EMA 20.
- CONTEXTO: las medias en posición alcista, o muy juntas y a punto de
cruzarse al alza. Si están muy separadas, no vale.
- ENTRADA: cuando el precio SUPERA la cresta (la punta, el máximo relevante)
anterior más cercana que quedó a la izquierda. El cruce de medias no es la
señal: es la confirmación de que se ha superado esa resistencia.
- STOP: el mínimo relevante más cercano anterior.
- SALIDA: sin optimizar, cuando la EMA10 corta a la baja a la EMA20.
(Él dice que se puede optimizar vendiendo por lotes; eso va aparte.)
"Mirar a la izquierda" se programa con pivotes: una cresta es una vela cuyo
máximo es el mayor de las K sesiones anteriores y las K siguientes. Ojo: una
cresta NO se conoce hasta K sesiones después de formarse, y el backtest solo usa
crestas ya confirmadas. Lo mismo para los mínimos del stop.
Él avisa de algo importante y con razón: un backtest puede estar bien escrito y
mal reflejado. Por eso al final se imprimen las operaciones una a una, para
repasar que las entradas caen donde deben.
Datos: S&P 500 diario con máximos y mínimos (yfinance, ajustado), 1990-hoy.
Solo largos. Sin comisiones ni impuestos: la realidad sería algo peor.
python backtest_puntas_harruinado.py
"""
import os
import pandas as pd
AQUI = os.path.dirname(os.path.abspath(__file__))
CACHE = os.path.join(AQUI, "cache", "gspc_ohlc.csv")
RAPIDA, LENTA = 10, 20
K = 5 # sesiones a cada lado para considerar una punta relevante
JUNTAS = 0.005 # "muy juntas": separación entre medias < 0,5% del precio
CORTE_OOS = "2015-01-01"
def cargar() -> pd.DataFrame:
"""OHLC diario del S&P 500 (caché local o yfinance)."""
if os.path.exists(CACHE):
return pd.read_csv(CACHE, index_col=0, parse_dates=True)
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="1990-01-01", auto_adjust=True, progress=False)
if isinstance(df.columns, pd.MultiIndex):
df.columns = df.columns.get_level_values(0)
df = df[["Open", "High", "Low", "Close"]].dropna()
os.makedirs(os.path.dirname(CACHE), exist_ok=True)
df.to_csv(CACHE)
return df
def pivotes(serie: pd.Series, k: int, alto: bool) -> pd.Series:
"""Nivel del último pivote CONFIRMADO en cada sesión.
Una punta en la sesión i solo se conoce en la i+k, cuando ya han pasado las
k velas de la derecha. Devolver el nivel con ese retraso evita mirar al
futuro, que es el error clásico de programar "resistencias anteriores".
"""
v = serie.values
nivel = [None] * len(v)
ultimo = None
for i in range(len(v)):
j = i - k # la vela que hoy se podría confirmar
if j - k >= 0:
ventana = v[j - k:j + k + 1]
centro = v[j]
if (centro == ventana.max() if alto else centro == ventana.min()):
ultimo = centro
nivel[i] = ultimo
return pd.Series(nivel, index=serie.index, dtype="float64")
def backtest(df: pd.DataFrame, k: int = K, juntas: float = JUNTAS,
salida_ema: bool = True) -> tuple:
"""Devuelve (equity diaria, operaciones). Todo el capital por operación."""
close, alto, bajo = df["Close"], df["High"], df["Low"]
ema_r = close.ewm(span=RAPIDA, adjust=False).mean()
ema_l = close.ewm(span=LENTA, adjust=False).mean()
cresta = pivotes(alto, k, alto=True)
suelo = pivotes(bajo, k, alto=False)
# Contexto: medias alcistas, o tan pegadas que están a punto de cruzarse
separacion = (ema_r - ema_l).abs() / close
contexto = (ema_r > ema_l) | (separacion < juntas)
capital, acciones, stop = 1.0, 0.0, None
entrada = fecha_ini = None
equity, ops = [], []
for i in range(1, len(df)):
f = df.index[i]
p, res = close.iloc[i], cresta.iloc[i]
if acciones == 0:
rompe = (res is not None and not pd.isna(res)
and p > res and close.iloc[i - 1] <= res)
if rompe and contexto.iloc[i] and not pd.isna(suelo.iloc[i]):
acciones = capital / p
capital = 0.0
entrada, stop, fecha_ini = p, suelo.iloc[i], f
else:
motivo = None
if bajo.iloc[i] <= stop: # salta el stop
capital = acciones * stop
motivo = "stop"
elif salida_ema and ema_r.iloc[i] < ema_l.iloc[i]:
capital = acciones * p
motivo = "cruce EMA a la baja"
if motivo:
ops.append({
"ini": fecha_ini, "fin": f, "entrada": entrada,
"salida": capital / acciones, "motivo": motivo,
"rent": (capital / acciones / entrada - 1) * 100,
"dias": (f - fecha_ini).days,
"riesgo": (entrada / stop - 1) * 100,
})
acciones, stop = 0.0, None
equity.append((f, capital + acciones * close.iloc[i]))
return pd.Series(dict(equity)), ops
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 informe(df: pd.DataFrame, titulo: str, **kw) -> pd.DataFrame:
eq, ops = backtest(df, **kw)
bh = df["Close"] / df["Close"].iloc[0]
m, b = metricas(eq), metricas(bh)
d = pd.DataFrame(ops)
dentro = sum(o["dias"] for o in ops) / max((eq.index[-1] - eq.index[0]).days, 1) * 100
print(f"\n### {titulo} ({eq.index[0].date()} -> {eq.index[-1].date()})")
print(f"{'':26}{'Sistema':>11}{'Comprar y mantener':>21}")
print(f"{'Rentabilidad anual':26}{m['cagr']:>10.2f}%{b['cagr']:>20.2f}%")
print(f"{'Peor caída':26}{m['dd']:>10.1f}%{b['dd']:>20.1f}%")
if len(d):
print(f" {len(d)} operaciones · ganadoras {(d['rent'] > 0).mean()*100:.0f}%"
f" · media {d['rent'].mean():+.2f}% · mejor {d['rent'].max():+.1f}%"
f" · peor {d['rent'].min():+.1f}%")
print(f" Duración media {d['dias'].mean():.0f} días · tiempo dentro {dentro:.0f}%"
f" · riesgo medio al stop {d['riesgo'].mean():.1f}%"
f" · salidas por stop {(d['motivo'] == 'stop').mean()*100:.0f}%")
return d
def main() -> None:
df = cargar()
print(__doc__.strip().split("\n\n")[0])
print(f"\nDatos: {len(df)} sesiones · {df.index[0].date()} -> {df.index[-1].date()}")
d = informe(df, "Sus reglas, tal cual (EMA10/20, K=5, salida por cruce)")
informe(df[df.index < CORTE_OOS], "In-sample (hasta 2014)")
informe(df[df.index >= CORTE_OOS], "Out-of-sample (2015 en adelante)")
print("\n### Sensibilidad a lo que él no concretó")
for k in (3, 5, 10, 20):
eq, ops = backtest(df, k=k)
m = metricas(eq)
print(f" punta con K={k:>2} sesiones a cada lado: {m['cagr']:5.2f}% anual"
f" DD {m['dd']:6.1f}% {len(ops):3} ops")
for j in (0.0025, 0.005, 0.01, 1.0):
eq, ops = backtest(df, juntas=j)
m = metricas(eq)
etiqueta = "sin filtro de cercanía" if j == 1.0 else f"medias juntas < {j*100:.2f}%"
print(f" {etiqueta:32} {m['cagr']:5.2f}% anual DD {m['dd']:6.1f}% {len(ops):3} ops")
print("\n### Las 15 primeras operaciones, para repasar que entran donde deben")
for _, o in d.head(15).iterrows():
print(f" {o['ini'].date()} -> {o['fin'].date()} entra {o['entrada']:8.2f}"
f" sale {o['salida']:8.2f} {o['rent']:>+6.2f}% {o['dias']:>4}d"
f" stop a {o['riesgo']:.1f}% ({o['motivo']})")
if __name__ == "__main__":
main()