Backtest sin humo: comprar con miedo, 15 años de datos
Un sistema que su propio autor solo pudo comprobar durante un año, porque CNN no publica más histórico. Conseguimos quince años y lo probamos. Gana a su alternativa aburrida, y aun así no sirve para ganar dinero. La explicación es lo interesante.
Este backtest no lo elegimos nosotros: nos lo propuso un lector. Publicamos el del cruce de medias en el foro de bolsa de Rankia y respondió Harruinado, uno de los usuarios veteranos del foro, con una crítica larga y con un sistema propio explicado en un vídeo. Nos pareció mucho más interesante que las medias, así que lo hemos pasado por el mismo filtro.
El sistema es de sentimiento de mercado, y es de una lógica atractiva: comprar cuando todo el mundo tiene miedo. Se usa el índice Fear & Greed de CNN, que va de 0 (pánico) a 100 (euforia). Cuando baja de 20, o sea miedo extremo, se compra. Y según sube se va aligerando: al llegar a 50 se vende la mitad, o se sale del todo entre 60 y 70.
Y aquí viene lo que hizo que valiera la pena. En el propio vídeo, su autor dice una frase que se le suele escapar a cualquiera que venda algo: «no se puede ver más porque solo da para un año el histórico». Es verdad. La web de CNN publica unos meses y ni un día más. Su sistema nunca se había probado en serio, y él era el primero en decirlo.
Así que fuimos a buscar los datos. Encontramos la serie desde 2011, y antes de usarla la verificamos contra lo que CNN sí sirve: 1.293 sesiones solapadas, coincidencia del 99,9%. El tramo anterior a 2021 no tiene fuente oficial contra la que contrastarlo, así que lo sometimos a otra prueba: marca miedo extremo en el techo de deuda de 2011, en China 2015, en febrero y diciembre de 2018 y en el Covid. Los seis pánicos, en su sitio. Quince años y medio en vez de uno.
También pedimos al autor las reglas exactas, porque el vídeo no las da todas. Nos las dio: se compra cuando el indicador cierra bajo 20, se añade un tramo más cada -5% que caiga el precio desde la primera compra hasta un máximo de cuatro, se vende la mitad cuando el indicador llega a 50 y el resto cuando llega a 70 o cuando el precio pierde su media exponencial de 50 sesiones. Sin stop, deliberadamente.
Antes de los números, lo más importante de todo este artículo: cómo se compara. Este sistema pasa la mayor parte del tiempo fuera del mercado, esperando el miedo, y encima entra por partes. Su exposición media es del 11% del capital. Compararlo contra comprar y mantener sería absurdo. Así que lo comparamos contra una cartera pasiva con su MISMA exposición: ese 11% invertido de forma constante, sin mirar nada, y el 89% restante en letras del Tesoro. Esa es la comparación honesta, y casi nunca se hace.
Los resultados, de 2011 a 2026, sobre el S&P 500. Comprar y mantener: 12,1% anual con una caída máxima del -34%. El sistema: 5,0% anual, caída máxima -17,5%, 24 operaciones y 23 de ellas ganadoras, la peor un -0,0%. Y su pasivo equivalente al 11%: 3,7% anual.
O sea que gana a su alternativa aburrida por 1,3 puntos al año. Partiendo los datos en dos para que nada se eligiera mirando el resultado, de 2019 en adelante la ventaja es todavía mayor: 7,8% contra 4,8%, con las diez operaciones en positivo. Y no depende de comprar en el mismo cierre en que salta la señal: ejecutando al día siguiente sale 4,9% contra 3,7%. La ventaja es real.
¿Y de dónde sale esa ventaja? De un detalle, y esto es lo mejor del asunto. Pasamos por el mismo motor tres versiones cambiando solo una regla. Sin el escalonado por tramos: 2,4% anual contra 3,0% de su referencia, o sea que pierde. Con el escalonado: 5,1% contra 4,1%, gana. Lo que salva al sistema no es comprar con miedo, es comprar más según el miedo empeora. Tres operaciones de las 24 llegaron a usar los cuatro tramos y valen más de la mitad de todo el resultado; la del Covid, ella sola, un +18,7% de la cuenta entera.
Y ahora la pregunta que lo tumba todo: ¿y si metemos más dinero por señal? Si en vez de cuatro tramos del 25% entras con dos del 50%, la ventaja sobre su pasivo pasa de +1,3 puntos a -0,2. Si entras con todo de golpe, a -1,2, y la caída máxima se va del -17,5% al -28,5%. La rentabilidad se queda clavada en el 5% metas lo que metas, mientras el riesgo se dobla.
Esa es la conclusión incómoda: la ventaja no era del sistema, era de ser pequeño. Existe mientras arriesgas poco, y desaparece justo cuando intentas que dé dinero de verdad. Para sacarle un 15% habría que apalancarlo, y ahí aparece el otro problema: la peor pérdida latente del sistema fue del -15,6% durante el Covid. Con un CFD apalancado cinco veces, esa operación no la ves terminar.
Un apunte más, porque el autor propuso mejorarlo alargando la media de salida de 50 a 200 sesiones, con la idea de quedarse dentro años. No ocurre: la operación más larga sigue siendo de 170 días, porque quien te saca no es la media, es la codicia llegando a 70. Y si se quita esa salida para que mande solo la media, entonces sí te quedas dentro años (hasta 506 días), pero la rentabilidad baja al 3,3% y cae por debajo de su referencia. En este sistema la media es decorativa.
Y ahora las pegas de nuestro propio trabajo, que también toca contarlas. El «mejor cerca de un soporte» que él añade al escalonado no es programable, así que usamos solo el -5%. El periodo 2011-2026 es un mercado alcista excepcional, y no incluye 2008: un sistema sin stop no está de verdad probado hasta que pasa por una caída del 50%. Y no hay comisiones ni impuestos, así que la realidad sería algo peor.
Hay una última cosa, y es la más interesante de todas. Cuando le enseñamos estos números, su autor no discutió las cifras: discutió el método. Dijo que los sistemas no se prueban hacia atrás sino hacia adelante, porque al mirar el pasado uno acaba interpretando las entradas y las salidas a conveniencia. Y tiene razón en el fondo: ese sesgo es real y es la razón por la que la mayoría de curvas bonitas que circulan no valen nada.
Por eso vamos a hacer las dos cosas. El backtest queda publicado, con sus pegas. Y a partir de ahora, cada vez que el Fear & Greed baje de 20, lo publicaremos el mismo día, antes de saber qué pasa después. Una señal publicada antes del desenlace no se puede adulterar. Tardará: en quince años el índice solo ha estado bajo 20 el 10,7% de las sesiones.
La conclusión sin humo: comprar con miedo funciona, y no es ninguna estafa. Acierta 23 de cada 24 veces, gana a su alternativa aburrida y aguanta fuera de muestra. Y aun así no sirve para hacerse rico, porque su ventaja vive de arriesgar poco y muere en cuanto arriesgas mucho. Un sistema puede ser bueno y no ser suficiente: son dos preguntas distintas y casi nadie hace la segunda. Gracias a Harruinado por el sistema, por las reglas exactas y por la crítica: las tres cosas han hecho mejor este artículo.
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_fear_greed.py (206 líneas)
"""
Backtest del sistema de sentimiento de Harruinado (foro de Bolsa de Rankia).
Reglas segun su video "EL SECRETO PARA GANAR EN BOLSA" (canal EMBOLSADOS):
- COMPRA : cuando el Fear & Greed de CNN baja de 20 (miedo extremo)
- VENTA A : cuando sube a 50 -> vende la mitad
- VENTA B : cuando llega a 60-70 -> vende todo
El propio autor dice en el video que solo pudo comprobarlo sobre un ano,
porque CNN unicamente publica 12 meses de historico. Aqui se prueba sobre
15 anos y medio (2011-2026).
Datos:
- Fear & Greed: cache/fng_hist.csv (repo whit3rabbit/fear-greed-data,
2011-01-03 en adelante). VERIFICADO contra el endpoint oficial de CNN:
249 dias solapados, 248 identicos, diferencia media 0.004.
- S&P 500: cache/gspc_daily.csv (yfinance ^GSPC, cierres ajustados).
Sin comisiones ni impuestos: la realidad seria algo peor.
"""
import csv
import datetime as dt
import os
import ssl
import urllib.request
AQUI = os.path.dirname(os.path.abspath(__file__))
CACHE = os.path.join(AQUI, "cache")
CAPITAL = 10_000.0
TRAMO = 0.25 # cada senal de compra invierte un 25% del capital inicial
CORTE_OOS = "2019-01-01" # in-sample antes, out-of-sample despues
# ---------------------------------------------------------------- datos
def carga_fng():
ruta = os.path.join(CACHE, "fng_hist.csv")
out = {}
for r in csv.DictReader(open(ruta, encoding="utf-8")):
out[r["Date"]] = float(r["Fear Greed"])
return out
def carga_bil():
"""Liquidez remunerada: ETF de letras del Tesoro (BIL, ya con dividendos)."""
ruta = os.path.join(CACHE, "bil_daily.csv")
return {r["Date"]: float(r["close"]) for r in csv.DictReader(open(ruta, encoding="utf-8"))}
def carga_spx():
ruta = os.path.join(CACHE, "gspc_daily.csv")
filas = list(csv.DictReader(open(ruta, encoding="utf-8")))
ultima = filas[-1]["Date"]
if ultima < dt.date.today().isoformat()[:10]:
refresca_spx(ruta, ultima)
filas = list(csv.DictReader(open(ruta, encoding="utf-8")))
return {r["Date"]: float(r["close"]) for r in filas}
def refresca_spx(ruta, desde):
"""Amplia el cache de ^GSPC con yfinance si se ha quedado corto."""
try:
import yfinance as yf
except ImportError:
print(f" (aviso: yfinance no disponible; uso el cache hasta {desde})")
return
print(f" actualizando ^GSPC desde {desde}...")
df = yf.download("^GSPC", start=desde, progress=False, auto_adjust=True)
if df is None or df.empty:
print(" (aviso: yfinance no devolvio datos nuevos)")
return
previas = {r["Date"]: r["close"] for r in csv.DictReader(open(ruta, encoding="utf-8"))}
for idx, fila in df.iterrows():
cierre = fila["Close"]
if hasattr(cierre, "item"):
cierre = cierre.item()
previas[idx.strftime("%Y-%m-%d")] = str(float(cierre))
with open(ruta, "w", encoding="utf-8", newline="") as fh:
w = csv.writer(fh)
w.writerow(["Date", "close"])
for fecha in sorted(previas):
w.writerow([fecha, previas[fecha]])
print(f" ^GSPC actualizado hasta {max(previas)}")
# ---------------------------------------------------------------- motor
def simula(fechas, fng, spx, umbral_compra, umbral_venta, vender_mitad, bil=None):
"""Devuelve (serie de equity, operaciones, dias invertido, exposiciones).
Si se pasa `bil`, el dinero en caja renta como letras del Tesoro en vez
de estar muerto al 0%.
"""
caja, participaciones = CAPITAL, 0.0
equity, ops, dias_dentro, exposiciones = [], [], 0, []
previo = None
for i, f in enumerate(fechas):
s, precio = fng[f], spx[f]
if bil and i > 0:
anterior = bil.get(fechas[i - 1])
hoy = bil.get(f)
if anterior and hoy:
caja *= hoy / anterior
# Se opera SOLO en el cruce del umbral, no mientras se este al otro lado.
cruza_abajo = previo is not None and previo >= umbral_compra and s < umbral_compra
cruza_arriba = previo is not None and previo < umbral_venta and s >= umbral_venta
if cruza_abajo:
importe = min(CAPITAL * TRAMO, caja)
if importe > 0:
participaciones += importe / precio
caja -= importe
ops.append((f, "COMPRA", s, importe))
if cruza_arriba and participaciones > 0:
vende = participaciones * (0.5 if vender_mitad else 1.0)
caja += vende * precio
participaciones -= vende
ops.append((f, "VENTA", s, vende * precio))
previo = s
valor = caja + participaciones * precio
equity.append(valor)
# Exposicion = que parte del patrimonio esta realmente en mercado.
exposiciones.append(participaciones * precio / valor if valor > 0 else 0.0)
if participaciones > 0:
dias_dentro += 1
return equity, ops, dias_dentro, exposiciones
def metricas(equity, fechas):
anos = (dt.date.fromisoformat(fechas[-1]) - dt.date.fromisoformat(fechas[0])).days / 365.25
total = equity[-1] / equity[0] - 1
cagr = (equity[-1] / equity[0]) ** (1 / anos) - 1
pico, dd = equity[0], 0.0
for v in equity:
pico = max(pico, v)
dd = min(dd, v / pico - 1)
return {"total": total, "cagr": cagr, "dd": dd, "anos": anos}
def bh(fechas, spx, peso=1.0, bil=None):
"""Comprar y mantener. Con peso<1, esa fraccion en bolsa y el resto en
liquidez (remunerada como letras si se pasa `bil`)."""
p0 = spx[fechas[0]]
if bil is None:
return [CAPITAL * (peso * spx[f] / p0 + (1 - peso)) for f in fechas]
b0 = bil[fechas[0]]
return [CAPITAL * (peso * spx[f] / p0 + (1 - peso) * bil.get(f, b0) / b0)
for f in fechas]
def linea(nombre, m, extra=""):
print(f" {nombre:<34} {m['cagr']*100:6.2f}% anual "
f"DD {m['dd']*100:6.1f}% total {m['total']*100:7.1f}%{extra}")
def bloque(titulo, fechas, fng, spx, bil):
print(f"\n{titulo} ({fechas[0]} -> {fechas[-1]}, {len(fechas)} sesiones)")
print(" " + "-" * 74)
linea("Comprar y mantener (100% siempre)", metricas(bh(fechas, spx), fechas))
for nombre, umbral, mitad in [
("Variante A (<20 compra, 50 mitad)", 50, True),
("Variante B (<20 compra, 65 todo)", 65, False),
]:
eq, ops, _dentro, expos = simula(fechas, fng, spx, 20, umbral, mitad, bil)
m = metricas(eq, fechas)
exp_media = sum(expos) / len(expos)
linea(nombre, m, f" ({len(ops)} ops, exposicion media {exp_media*100:.0f}%)")
# Referencia justa: misma exposicion media, pasiva, y el resto en letras.
ref = metricas(bh(fechas, spx, exp_media, bil), fechas)
linea(f" -> pasivo al {exp_media*100:.0f}% + letras (referencia justa)", ref)
return
def main():
print(__doc__.strip().split("\n\n")[0])
fng = carga_fng()
spx = carga_spx()
bil = carga_bil()
fechas = sorted(set(fng) & set(spx) & set(bil))
print(f"\nDatos cruzados: {len(fechas)} sesiones con Fear & Greed y S&P 500")
print(f"Periodo: {fechas[0]} -> {fechas[-1]}")
vals = [fng[f] for f in fechas]
print(f"Fear & Greed: min {min(vals):.0f} / max {max(vals):.0f} / "
f"medio {sum(vals)/len(vals):.1f}")
print(f"Sesiones por debajo de 20 (miedo extremo): "
f"{sum(1 for v in vals if v < 20)} ({sum(1 for v in vals if v<20)/len(vals)*100:.1f}%)")
bloque("=== PERIODO COMPLETO ===", fechas, fng, spx, bil)
ins = [f for f in fechas if f < CORTE_OOS]
oos = [f for f in fechas if f >= CORTE_OOS]
bloque("=== IN-SAMPLE (donde se miran los parametros) ===", ins, fng, spx, bil)
bloque("=== OUT-OF-SAMPLE (datos que no se han tocado) ===", oos, fng, spx, bil)
print("\nNota: sin comisiones ni impuestos. El dinero fuera de mercado renta como letras (BIL).")
if __name__ == "__main__":
main()
Ver el código de backtest_miedo_harruinado.py (200 líneas)
# -*- coding: utf-8 -*-
"""Sistema de Harruinado con sus reglas COMPLETAS (Rankia, 29/07/2026).
Complementa a backtest_fear_greed.py, que prueba las reglas básicas del vídeo.
Aquí se añade lo que él precisó después en el hilo:
- COMPRA: el índice de miedo y codicia de la CNN cierra por debajo de 20.
- ESCALONADO: un tramo más cada -5% de caída del precio desde la PRIMERA
entrada (-5%, -10%, -15%), hasta 4 tramos iguales. Él dice "por lo menos un
5% de pérdida desde el primer punto de entrada, y mejor cerca de un
soporte"; el soporte no es programable, el -5% sí.
- SALIDA: la mitad cuando el indicador llega a 50. El resto, cuando llega a
70 o cuando el precio pierde la EMA de 50 sesiones, lo que pase antes.
- NO hay stop. Es deliberado, y es donde está todo el riesgo.
Datos: cache/fng_hist.csv (Fear & Greed desde 2011), cache/gspc_daily.csv
(S&P 500) y cache/bil_daily.csv (letras del Tesoro: el dinero parado renta).
La comparación honesta NO es contra comprar y mantener al 100%: este sistema
está casi siempre en liquidez y solo mete parte del capital. La referencia justa
es una cartera pasiva con la MISMA exposición media y el resto en letras.
Sin comisiones ni impuestos: la realidad sería algo peor.
python backtest_miedo_harruinado.py
"""
import os
import pandas as pd
AQUI = os.path.dirname(os.path.abspath(__file__))
CACHE = os.path.join(AQUI, "cache")
ENTRADA, MEDIA_FUERA, TODO_FUERA = 20, 50, 70
ESCALON, TRAMOS, EMA_SALIDA = 0.05, 4, 50
CORTE_OOS = "2019-01-01"
HORIZONTE = 32
def cargar() -> pd.DataFrame:
fng = pd.read_csv(os.path.join(CACHE, "fng_hist.csv"), index_col=0,
parse_dates=True)["Fear Greed"]
spx = pd.read_csv(os.path.join(CACHE, "gspc_daily.csv"), index_col=0,
parse_dates=True)["close"]
bil = pd.read_csv(os.path.join(CACHE, "bil_daily.csv"), index_col=0,
parse_dates=True)["close"]
df = pd.DataFrame({"fng": fng, "close": spx, "bil": bil}).dropna()
df["ema"] = df["close"].ewm(span=EMA_SALIDA, adjust=False).mean()
return df
def backtest(df: pd.DataFrame, entrada: int = ENTRADA, escalon: float = ESCALON,
tramos: int = TRAMOS, usar_ema: bool = True, retardo: int = 0,
ema_salida: int = EMA_SALIDA) -> tuple:
"""Devuelve (equity, exposición diaria, operaciones cerradas).
`retardo=1` opera al cierre SIGUIENTE al de la señal. El indicador de la CNN
se conoce con el mercado ya cerrado o cerrándose, así que operar en ese mismo
cierre es optimista; con retardo 1 se comprueba si el resultado aguanta.
"""
if retardo:
df = df.copy()
df["fng"] = df["fng"].shift(retardo)
df = df.dropna(subset=["fng"])
if ema_salida != EMA_SALIDA: # él propone probar con la de 200
df = df.copy()
df["ema"] = df["close"].ewm(span=ema_salida, adjust=False).mean()
cash, acciones = 1.0, 0.0
tramo_eur = precio_1 = 0.0
n_tramos, en_trade, mitad_vendida = 0, False, False
fecha_ini = eq_ini = coste = peor = None
previo_fng = None
equity, expo, ops = [], [], []
for i, (fecha, fila) in enumerate(df.iterrows()):
p, m, em = fila["close"], fila["fng"], fila["ema"]
if i > 0: # la liquidez renta como letras
cash *= fila["bil"] / df["bil"].iloc[i - 1]
if not en_trade:
# se compra al CRUZAR hacia abajo, no cada día que esté por debajo
if previo_fng is not None and previo_fng >= entrada and m < entrada:
eq_ini = cash
tramo_eur = cash / tramos
acciones = tramo_eur / p
cash -= tramo_eur
precio_1, n_tramos = p, 1
coste, peor = tramo_eur, 0.0
fecha_ini, en_trade, mitad_vendida = fecha, True, False
else:
while n_tramos < tramos and p <= precio_1 * (1 - escalon * n_tramos):
acciones += tramo_eur / p
cash -= tramo_eur
coste += tramo_eur
n_tramos += 1
peor = min(peor, (cash + acciones * p) / eq_ini - 1)
if not mitad_vendida and m >= MEDIA_FUERA:
cash += (acciones / 2) * p
acciones /= 2
mitad_vendida = True
elif mitad_vendida and (m >= TODO_FUERA or (usar_ema and p < em)):
cash += acciones * p
acciones = 0.0
en_trade = False
ops.append({
"ini": fecha_ini, "fin": fecha, "tramos": n_tramos,
"dias": (fecha - fecha_ini).days,
"rent": (cash / eq_ini - 1) * 100,
"peor": peor * 100,
"salida": "codicia 70" if m >= TODO_FUERA else f"perdió EMA{EMA_SALIDA}",
})
previo_fng = m
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(df: pd.DataFrame, peso: float) -> pd.Series:
"""Cartera pasiva: `peso` en bolsa y el resto en letras. La referencia justa."""
bolsa = df["close"] / df["close"].iloc[0]
letras = df["bil"] / df["bil"].iloc[0]
return peso * bolsa + (1 - peso) * letras
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 linea(nombre: str, m: dict, extra: str = "") -> None:
print(f" {nombre:<42}{m['cagr']:>6.2f}% anual DD {m['dd']:>6.1f}%"
f" total {m['total']:>7.1f}%{extra}")
def bloque(df: pd.DataFrame, titulo: str) -> None:
print(f"\n{titulo} ({df.index[0].date()} -> {df.index[-1].date()},"
f" {len(df)} sesiones)")
print(" " + "-" * 78)
linea("Comprar y mantener (100% siempre)", metricas(df["close"] / df["close"].iloc[0]))
# Mismo motor, cambiando SOLO los dos matices que él añadió en el hilo:
# el escalonado por tramos y la salida del resto por la EMA de 50.
for nombre, kw in [
("Reglas básicas (sin escalonado, sin EMA)", dict(escalon=9.9, usar_ema=False)),
("Con escalonado, sin salida por EMA", dict(usar_ema=False)),
("Reglas completas (escalonado + EMA)", dict()),
]:
eq, expo, ops = backtest(df, **kw)
e = expo.mean()
d = pd.DataFrame(ops) if ops else None
extra = f" ({len(ops)} ops, exposición media {e*100:.0f}%)"
linea(nombre, metricas(eq), extra)
linea(f" -> pasivo al {e*100:.0f}% + letras (referencia justa)",
metricas(pasivo(df, e)))
if d is not None:
print(f" ganadoras {(d['rent'] > 0).mean()*100:.0f}%"
f" · media {d['rent'].mean():+.1f}% · peor {d['rent'].min():+.1f}%"
f" · tramos medios {d['tramos'].mean():.1f} · peor latente {d['peor'].min():.1f}%")
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()}"
f" · miedo mín {df['fng'].min():.0f} / máx {df['fng'].max():.0f}")
bloque(df, "=== PERIODO COMPLETO ===")
bloque(df[df.index < CORTE_OOS], "=== IN-SAMPLE (hasta 2018) ===")
bloque(df[df.index >= CORTE_OOS], "=== OUT-OF-SAMPLE (2019 en adelante) ===")
# ¿La señal tiene ventaja al margen de la gestión? A plazo fijo, porque medir
# hasta una salida que solo llega cuando el mercado ya rebotó es hacerse trampas.
fw = (df["close"].shift(-HORIZONTE) / df["close"] - 1) * 100
sen = df["fng"] < ENTRADA
print(f"\n=== ¿Tiene ventaja la señal? (índice {HORIZONTE} sesiones después) ===")
print(f" Tras un cierre con miedo < {ENTRADA} ({int(sen.sum())} días):"
f" {fw[sen].mean():+.2f}%")
print(f" En una sesión cualquiera:{'':22}{fw.mean():+.2f}%")
_, _, ops = backtest(df)
print("\n=== Operación por operación ===")
for o in ops:
print(f" {o['ini'].date()} -> {o['fin'].date()} {o['dias']:>4} días"
f" {o['tramos']} tramos rent {o['rent']:>+6.1f}%"
f" peor latente {o['peor']:>+6.1f}% salida: {o['salida']}")
if __name__ == "__main__":
main()