Lección 0.4.5 · 50 min

Logging profesional y testing con pytest

Un sistema que opera solo tiene que contar qué hace y demostrar que hace lo que dice. El módulo logging deja un rastro con niveles y destinos; pytest comprueba, cada vez que cambia el código, que los cálculos siguen dando lo que deben. Esta lección construye las dos cosas alrededor de una función de drawdown.

Lecciones del nivel 0
  1. 0.1 · Bienvenida y expectativas

    1. 0.1.1 Trading sistemático, algorítmico y cuantitativo frente al discrecional
    2. 0.1.2 Expectativas realistas: costes, tiempo y riesgo de pérdida
    3. 0.1.3 Estructura del curso, tests de entrada, repositorio personal y aprendizaje con IA
  2. 0.2 · Entorno de trabajo moderno

    1. 0.2.1 Instalación de Python con uv: versiones y ciclo de soporte
    2. 0.2.2 Entornos virtuales y dependencias: uv init, uv add, pyproject.toml y lockfiles
    3. 0.2.3 Editores (VS Code, Cursor, PyCharm) y notebooks (Jupyter, JupyterLab, marimo)
    4. 0.2.4 Terminal básica, rutas, variables de entorno y ficheros .env
    5. 0.2.5 Git y GitHub: commits, ramas, pull requests y .gitignore
    6. 0.2.6 Introducción a los asistentes de código con IA: prompts, revisión y tests
  3. 0.3 · Fundamentos del lenguaje

    1. 0.3.1 Variables, tipos y operadores; coma flotante y decimal para precios y ticks
    2. 0.3.2 Cadenas, f-strings y formato de números y fechas
    3. 0.3.3 Listas, tuplas, diccionarios, conjuntos y comprensiones
    4. 0.3.4 Control de flujo: if, elif, else, for, while y match-case
    5. 0.3.5 Funciones: argumentos, *args y **kwargs, lambdas y ámbito
    6. 0.3.6 Excepciones; módulos y paquetes, con los imports perezosos de Python 3.15
  4. 0.4 · Python intermedio

    1. 0.4.1 Programación orientada a objetos: clases, herencia, composición y dataclasses
    2. 0.4.2 Tipado: type hints, mypy o ty y Pydantic para configuraciones
    3. 0.4.3 Iteradores, generadores, decoradores y context managers
    4. 0.4.4 Ficheros (CSV, JSON, YAML, TOML) y fechas con zonas horarias
    5. 0.4.5 Logging profesional y testing con pytest
    6. 0.4.6 Concurrencia: threading, multiprocessing, asyncio y free-threading
  5. 0.5 · Computación numérica y datos

    1. 0.5.1 NumPy 2: arrays, broadcasting, vectorización, álgebra lineal y generadores aleatorios
    2. 0.5.2 pandas 3.0: Series, DataFrame, índices temporales, Copy-on-Write, tipo str y pd.col()
    3. 0.5.3 Selección, filtrado, groupby, merge, pivot, resample y ventanas móviles
    4. 0.5.4 Polars: API lazy frente a eager, expresiones, rendimiento y cuándo elegirlo frente a pandas
    5. 0.5.5 DuckDB y SQL esencial: SELECT, JOIN, GROUP BY y funciones de ventana sobre Parquet
  6. 0.6 · Visualización

    1. 0.6.1 Matplotlib orientado a objetos, Seaborn y Plotly: gráficos de precios, drawdown y velas
    2. 0.6.2 Dashboards rápidos con Streamlit: un panel de seguimiento en un solo script
  7. 0.7 · Matemáticas y estadística mínimas

    1. 0.7.1 Rentabilidades simples y logarítmicas, interés compuesto y estadística descriptiva
    2. 0.7.2 Probabilidad y distribuciones, correlación frente a causalidad y regresión simple
    3. 0.7.3 Álgebra lineal intuitiva: vectores y matrices como preparación para carteras

Por qué no basta con print

Mientras se escribe un script, print es suficiente. Cuando el script se convierte en un sistema que opera solo, durante semanas, y algo sale mal a las 03:00, hace falta saber qué hizo, en qué orden y con qué datos. print no dice cuándo pasó algo, no distingue un detalle de un error grave, no se puede apagar por partes y escribe siempre en el mismo sitio.

El módulo logging de la biblioteca estándar resuelve las cuatro cosas. Cada mensaje tiene un nivel:

NivelValorUso en un sistema de trading
DEBUG10Valores intermedios: medias, señales candidatas, tamaño calculado
INFO20Hechos normales: señal generada, orden enviada, orden ejecutada
WARNING30Algo raro que no impide seguir: deslizamiento alto, dato tardío
ERROR40Algo falló: orden rechazada, desconexión del bróker
CRITICAL50El sistema no puede seguir: límite de pérdidas alcanzado

La configuración mínima, basicConfig, fija el nivel, el destino y el formato. Aquí el formato no lleva hora para que la salida de la lección sea reproducible; en producción sí la lleva, como se ve más abajo:

IN [01]python
import logging
import sys

logging.basicConfig(
    level=logging.INFO, stream=sys.stdout, force=True,
    format="%(levelname)-8s %(name)s | %(message)s")

log = logging.getLogger("estrategia.cruce")
log.debug("media de 50 sesiones: %.2f", 141.87)
log.info("señal de compra en %s a %.2f", "SINT", 142.22)
log.warning("deslizamiento de %.1f pb (máximo %d)", 7.5, 5)
log.error("orden rechazada: %s", "margen insuficiente")
OUT [01]Primeros mensajes con niveles
INFO     estrategia.cruce | señal de compra en SINT a 142.22
WARNING  estrategia.cruce | deslizamiento de 7.5 pb (máximo 5)
ERROR    estrategia.cruce | orden rechazada: margen insuficiente

El mensaje DEBUG no aparece: el nivel INFO es un umbral y todo lo que esté por debajo se descarta. Para investigar un problema basta con bajar el umbral a DEBUG, sin tocar ninguna línea más.

Los argumentos van aparte de la plantilla ("a %.2f", 142.22), no dentro de una f-string. Así el texto solo se formatea si el mensaje se emite, y todos los mensajes del mismo tipo comparten plantilla, lo que permite contarlos y buscarlos en un fichero de registro.

Una jerarquía de loggers

getLogger("estrategia.cruce") no crea un logger suelto: los puntos del nombre forman una jerarquía, como las carpetas de una ruta. estrategia.cruce es hijo de estrategia, que es hijo del logger raíz. Un logger sin nivel propio hereda el de su padre, y sus mensajes suben hasta los handlers de sus antepasados.

La costumbre es un logger por módulo, con logging.getLogger(__name__): el nombre del módulo da la jerarquía gratis. Así se puede, por ejemplo, ver con todo detalle la estrategia y solo los avisos del conector del bróker, que suele ser muy hablador:

IN [02]python
logging.getLogger("broker").setLevel(logging.WARNING)
logging.getLogger("estrategia").setLevel(logging.DEBUG)

ordenes = logging.getLogger("broker.ordenes")
ordenes.info("latido del websocket")  # INFO < WARNING: no sale
ordenes.warning("respuesta lenta del bróker: %d ms", 850)
log.debug("media de 50 sesiones: %.2f", 141.87)

for nombre in ("broker.ordenes", "estrategia.cruce", "datos"):
    nivel = logging.getLogger(nombre).getEffectiveLevel()
    print(f"{nombre:<17} {logging.getLevelName(nivel)}")
OUT [02]Niveles por rama de la jerarquía
WARNING  broker.ordenes | respuesta lenta del bróker: 850 ms
DEBUG    estrategia.cruce | media de 50 sesiones: 141.87
broker.ordenes    WARNING
estrategia.cruce  DEBUG
datos             INFO

broker.ordenes no tiene nivel propio y hereda WARNING de broker; estrategia.cruce hereda DEBUG de estrategia, así que ahora su mensaje de depuración sí sale; datos no tiene antepasado con nivel y hereda el INFO del raíz.

Para los errores con excepción, log.exception("mensaje") dentro de un except registra el mensaje con nivel ERROR y el traceback completo. Es la forma de no perder el contexto de un fallo sin parar el sistema.

Consola y fichero: handlers

Un handler decide adónde va un mensaje, con su propio nivel y su propio formato. La configuración habitual de un sistema de trading tiene dos: la consola, con avisos y errores para quien lo vigila, y un fichero con todo, para reconstruir después lo que pasó.

loggerconsola WARNINGfichero DEBUGTerminalsistema.log
Fuente Mermaid
flowchart LR
  L[logger] --> HC[consola WARNING]
  L --> HF[fichero DEBUG]
  HC --> T[Terminal]
  HF --> F[sistema.log]

Para configuraciones con varias piezas, logging.config.dictConfig recibe un diccionario que se puede guardar en un YAML o un TOML (lección 0.4.4). El fichero usa RotatingFileHandler, que cambia de fichero al llegar a un tamaño y guarda unos cuantos anteriores, para que el registro no llene el disco. Y la hora va en UTC (lección 0.4.4), con un formateador que usa time.gmtime:

IN [03]python
import logging.config
import time
from pathlib import Path

class FormatoUTC(logging.Formatter):
    converter = time.gmtime

CONFIG = {
    "version": 1,
    "disable_existing_loggers": False,
    "formatters": {
        "consola": {"format": "%(levelname)-8s %(name)s | %(message)s"},
        "fichero": {
            "()": FormatoUTC,
            "fmt": ("%(asctime)s | %(levelname)s | %(name)s"
                    " | %(message)s"),
            "datefmt": "%Y-%m-%dT%H:%M:%SZ"},
    },
    "handlers": {
        "consola": {"class": "logging.StreamHandler",
                    "stream": "ext://sys.stdout",
                    "level": "WARNING", "formatter": "consola"},
        "fichero": {"class": "logging.handlers.RotatingFileHandler",
                    "filename": "sistema.log", "encoding": "utf-8",
                    "maxBytes": 5_000_000, "backupCount": 5,
                    "level": "DEBUG", "formatter": "fichero"},
    },
    "root": {"level": "DEBUG", "handlers": ["consola", "fichero"]},
}
logging.config.dictConfig(CONFIG)

log.info("sesión iniciada con %d símbolos", 3)
log.debug("tamaño calculado: %d títulos", 70)
log.warning("sin precio de ORO desde hace %d s", 45)
OUT [03]Configuración con dictConfig
WARNING  estrategia.cruce | sin precio de ORO desde hace 45 s

En la consola solo aparece el aviso. El fichero tiene los tres mensajes, cada uno con su marca de tiempo en UTC. Para que la salida de la lección no cambie en cada ejecución, se imprime cada línea sin la hora:

IN [04]python
for linea in Path("sistema.log").read_text("utf-8").splitlines():
    hora, resto = linea.split(" | ", 1)
    print(len(hora), "|", resto)
OUT [04]Lo que quedó en el fichero
20 | INFO | estrategia.cruce | sesión iniciada con 3 símbolos
20 | DEBUG | estrategia.cruce | tamaño calculado: 70 títulos
20 | WARNING | estrategia.cruce | sin precio de ORO desde hace 45 s

Cada línea empieza con una marca de 20 caracteres (2026-10-21T09:30:00Z) y sigue con el nivel, el logger y el mensaje, separados por |: un formato fácil de filtrar con grep o de cargar en pandas.

Tests con pytest

Un test es código que comprueba que otro código hace lo que debe. Con pytest, un test es una función cuyo nombre empieza por test_ y que usa assert: si la condición es falsa, el test falla y pytest muestra los valores implicados. Los tests viven en ficheros test_*.py, separados del código que prueban.

La función que se va a probar es el drawdown máximo: la mayor caída desde un máximo previo, en tanto por uno. Con precios ptp_t y el máximo acumulado Mt=max⁡s≤tpsM_t = \max_{s \le t} p_s:

DDmax⁡=min⁡t(ptMt−1)\text{DD}_{\max} = \min_t \left( \frac{p_t}{M_t} - 1 \right)

La primera versión tiene un error a propósito, de los que se cometen de verdad: usa el máximo de toda la serie en lugar del máximo hasta ese día. En un proyecto real, el módulo y sus tests se escriben en el editor; aquí la celda los crea en la carpeta de trabajo:

IN [05]python
Path("riesgo.py").write_text('''\
"""Métricas de riesgo sobre una lista de precios."""


def drawdown_maximo(precios: list[float]) -> float:
    """Mayor caída desde un máximo previo (≤ 0)."""
    if not precios:
        raise ValueError("serie vacía")
    if any(p <= 0 for p in precios):
        raise ValueError("precios no positivos")
    pico = max(precios)
    return min(p / pico - 1 for p in precios)
''', encoding="utf-8");

Los tests prueban un ejemplo calculado a mano, los casos límite (serie creciente, un solo precio, serie vacía, precios no válidos), una propiedad que cualquier drawdown debe cumplir y un caso con datos reales:

IN [06]python
Path("test_riesgo.py").write_text('''\
import csv

import pytest

from riesgo import drawdown_maximo


@pytest.fixture
def cierres_sint() -> list[float]:
    with open("sint_diario.csv", newline="", encoding="utf-8") as f:
        return [float(fila["cierre"]) for fila in csv.DictReader(f)]


def test_ejemplo_a_mano():
    # pico 120, valle 90: -25 %
    resultado = drawdown_maximo([100, 120, 90, 130, 117])
    assert resultado == pytest.approx(-0.25)


def test_serie_creciente_sin_drawdown():
    assert drawdown_maximo([100, 101, 105, 110]) == 0


def test_un_solo_precio():
    assert drawdown_maximo([142.22]) == 0


def test_serie_vacia():
    with pytest.raises(ValueError, match="vacía"):
        drawdown_maximo([])


@pytest.mark.parametrize("malo", [0, -1.5])
def test_precios_no_positivos(malo):
    with pytest.raises(ValueError, match="no positivos"):
        drawdown_maximo([100, malo, 90])


def test_el_futuro_no_cuenta():
    # cuando cotizaba a 90, el pico conocido era 100
    assert drawdown_maximo([100, 90, 200]) == pytest.approx(-0.10)


def test_sint_caida_de_2022(cierres_sint):
    assert -0.35 < drawdown_maximo(cierres_sint) < -0.15
''', encoding="utf-8");

Las piezas de pytest que aparecen:

  • pytest.approx compara floats con tolerancia. 90 / 120 - 1 no tiene por qué dar exactamente -0.25 en coma flotante (lección 0.3.1); un == directo haría el test frágil.
  • pytest.raises comprueba que se lanza una excepción, y match que su mensaje contiene un texto.
  • @pytest.mark.parametrize repite un test con varios valores: test_precios_no_positivos cuenta como dos tests.
  • Una fixture (@pytest.fixture) prepara datos para los tests que la piden por nombre como argumento: test_sint_caida_de_2022 recibe la lista de cierres sin saber de dónde sale.

El último test no fija un número exacto, sino un intervalo: SINT tiene una caída en torno al 25 % en 2022 (lo dice el README de los datos). Es un test de cordura: no demuestra que la función sea correcta, pero falla si el resultado deja de tener sentido.

Ejecutar los tests

Desde la terminal, en un proyecto con pytest como dependencia de desarrollo, basta con:

uv add --dev pytest
uv run pytest            # todos los test_*.py del proyecto
uv run pytest -q tests/test_riesgo.py::test_serie_vacia

pytest busca los ficheros test_*.py, ejecuta cada test por separado, muestra un punto por test que pasa y una F por test que falla, con el detalle de cada fallo, y termina con un resumen y un código de salida: 0 si todo ha pasado, 1 si algo ha fallado.

En esta lección, pytest se llama desde Python con pytest.main, la misma ejecución sin salir del cuaderno. La función auxiliar se queda con las líneas de los tests fallidos, con la última línea del resumen sin el tiempo y con el código de salida. Además borra los módulos ya importados para que cada ejecución lea los ficheros nuevos; desde la terminal no hace falta, porque cada pytest es un proceso nuevo.

IN [07]python
import contextlib
import io
import re

import pytest

sys.dont_write_bytecode = True  # sin cachés de .pyc entre versiones

def pasar_tests(*args: str) -> int:
    for modulo in ("riesgo", "test_riesgo"):
        sys.modules.pop(modulo, None)
    salida = io.StringIO()
    with contextlib.redirect_stdout(salida):
        codigo = pytest.main(["-q", "-p", "no:cacheprovider",
                              "-rf", "--tb=no", *args])
    lineas = salida.getvalue().strip().splitlines()
    for linea in lineas:
        if linea.startswith("FAILED"):
            print(linea)
    print(re.sub(r" in [\d.]+s$", "", lineas[-1]))
    print("código de salida:", int(codigo))
    return int(codigo)

pasar_tests("test_riesgo.py");
OUT [07]Primera ejecución: la versión con el error
FAILED test_riesgo.py::test_ejemplo_a_mano - assert -0.3076923076923077 == -0.25 ± 2.5e-07
FAILED test_riesgo.py::test_serie_creciente_sin_drawdown - assert -0.09090909090909094 == 0
FAILED test_riesgo.py::test_el_futuro_no_cuenta - assert -0.55 == -0.1 ± 1.0e-07
FAILED test_riesgo.py::test_sint_caida_de_2022 - assert -0.35 < -0.36924001602778156
4 failed, 4 passed
código de salida: 1

Cuatro tests fallan, y los cuatro por el mismo motivo. En el ejemplo a mano, el máximo de toda la serie es 130, que llega después del valle de 90, así que la función mide 90 / 130 − 1 en lugar de 90 / 120 − 1. En la serie creciente, compara el primer precio con el último y ve una «caída» que nunca ocurrió. En test_el_futuro_no_cuenta, el 200 final convierte un −10 % en un −55 %. Y con datos reales, la caída de SINT sale del −36,9 %, fuera del intervalo razonable: el máximo global de la serie llegó años después del mínimo.

Es un sesgo de anticipación (lección 0.1.1) dentro de una métrica de riesgo: la función usa un precio que, en el momento que mide, todavía no existía. Un drawdown así exagera el riesgo de cualquier estrategia que haya subido mucho después de una caída. Sin el test de la serie creciente, nadie lo habría visto.

Corregir y volver a pasar los tests

La versión correcta recorre los precios en orden y actualiza el pico solo con lo ya visto:

IN [08]python
Path("riesgo.py").write_text('''\
"""Métricas de riesgo sobre una lista de precios."""


def drawdown_maximo(precios: list[float]) -> float:
    """Mayor caída desde un máximo previo (≤ 0)."""
    if not precios:
        raise ValueError("serie vacía")
    if any(p <= 0 for p in precios):
        raise ValueError("precios no positivos")
    pico, peor = precios[0], 0.0
    for p in precios:
        pico = max(pico, p)
        peor = min(peor, p / pico - 1)
    return peor
''', encoding="utf-8")

pasar_tests("test_riesgo.py");
OUT [08]riesgo.py corregido
8 passed
código de salida: 0

Ocho tests (siete funciones, una de ellas con dos parámetros) y código de salida 0. A partir de aquí, cada cambio en riesgo.py se valida con un solo comando. Ese es el valor real de los tests: no tanto encontrar el error de hoy como impedir que vuelva mañana, cuando alguien «optimice» la función.

En un proyecto, la organización habitual separa el código de los tests y deja la configuración de pytest en pyproject.toml (lección 0.2.2):

IN [09]
mi-sistema/
├── pyproject.toml        # [tool.pytest.ini_options] testpaths = ["tests"]
├── src/mi_sistema/
│   └── riesgo.py
└── tests/
    ├── conftest.py       # fixtures compartidas por varios ficheros
    └── test_riesgo.py

Qué conviene probar primero en código de trading, por orden de daño evitado: los cálculos de dinero (P&L, comisiones, tamaños de posición), las métricas de riesgo, la alineación temporal de señales y posiciones (el shift(1) de la lección 0.1.1) y la validación de configuraciones (lección 0.4.2).

Ejercicios

  1. Añade a riesgo.py una función rentabilidad_total(precios) que devuelva precios[-1] / precios[0] - 1 y lance ValueError con menos de dos precios. Escribe dos tests (uno con pytest.approx y otro con pytest.raises) en test_total.py y pásalos.
Solución
IN [10]python
with open("riesgo.py", "a", encoding="utf-8") as f:
    f.write('''

def rentabilidad_total(precios: list[float]) -> float:
    if len(precios) < 2:
        raise ValueError("hacen falta dos precios")
    return precios[-1] / precios[0] - 1
''')

Path("test_total.py").write_text('''\
import pytest

from riesgo import rentabilidad_total


def test_total():
    assert rentabilidad_total([100, 90, 110]) == pytest.approx(0.10)


def test_un_precio():
    with pytest.raises(ValueError):
        rentabilidad_total([100])
''', encoding="utf-8")

sys.modules.pop("test_total", None)
pasar_tests("test_total.py");
OUT [10]Una función más y sus tests
2 passed
código de salida: 0

open(..., "a") añade al final del fichero sin borrar lo anterior. pasar_tests borra riesgo de los módulos cargados; test_total se borra aparte porque la función auxiliar solo conoce test_riesgo.

  1. Configura un logger riesgo que, al llamar a una función vigilar(dd, limite), registre un WARNING si el drawdown supera la mitad del límite y un CRITICAL si lo supera entero. Prueba con −0,05, −0,12 y −0,22 y un límite de −0,20. (La consola de la configuración anterior muestra WARNING y superiores.)
Solución
IN [11]python
log_riesgo = logging.getLogger("riesgo")

def vigilar(dd: float, limite: float) -> None:
    if dd <= limite:
        log_riesgo.critical("drawdown %.1f%%: límite superado, "
                            "parar el sistema", dd * 100)
    elif dd <= limite / 2:
        log_riesgo.warning("drawdown %.1f%%: mitad del límite",
                           dd * 100)
    else:
        log_riesgo.info("drawdown %.1f%%", dd * 100)

for dd in (-0.05, -0.12, -0.22):
    vigilar(dd, limite=-0.20)
OUT [11]Avisos por drawdown
WARNING  riesgo | drawdown -12.0%: mitad del límite
CRITICAL riesgo | drawdown -22.0%: límite superado, parar el sistema

El INFO de −5 % va solo al fichero: la consola filtra por debajo de WARNING. Los drawdowns son negativos, así que «superar el límite» es ser menor o igual que él. En las plantillas de logging, %% escribe un % literal.

  1. Con parametrize, escribe un test que compruebe para tres series distintas que el drawdown nunca es positivo ni menor que −1. ¿Por qué es una propiedad y no un ejemplo?
Solución
IN [12]python
Path("test_propiedad.py").write_text('''\
import pytest

from riesgo import drawdown_maximo


@pytest.mark.parametrize("serie", [
    [100, 50, 25, 12.5],
    [1, 2, 3],
    [142.22, 139.98, 145.10, 101.30, 150.00],
])
def test_entre_menos_uno_y_cero(serie):
    assert -1 < drawdown_maximo(serie) <= 0
''', encoding="utf-8")

sys.modules.pop("test_propiedad", None)
pasar_tests("test_propiedad.py");
OUT [12]Un test de propiedad
3 passed
código de salida: 0

El test no dice cuánto debe valer el drawdown de cada serie, sino algo que tiene que cumplirse para cualquiera: con precios positivos, nunca se puede caer más del 100 % ni «caer» hacia arriba. Las propiedades encuentran errores que nadie pensó en buscar; la biblioteca Hypothesis genera automáticamente miles de series para comprobarlas.

Respuesta plegada en cada pregunta

Autoevaluación

  1. Un logger tiene nivel INFO. ¿Qué mensajes se emiten?

    • ASolo los de nivel INFO
    • BINFO, WARNING, ERROR y CRITICAL; los DEBUG se descartan
    • CTodos, incluidos los DEBUG
    • DSolo ERROR y CRITICAL
    Ver respuesta

    Respuesta B. INFO, WARNING, ERROR y CRITICAL; los DEBUG se descartan

    El nivel es un umbral: se emite todo lo que tenga ese nivel o uno mayor. DEBUG (10) queda por debajo de INFO (20).

  2. ¿Por qué log.info("precio %.2f", precio) es preferible a log.info(f"precio {precio:.2f}")?

    • APorque la f-string da un error con logging
    • BPorque con % el texto solo se formatea si el mensaje se va a emitir, y las herramientas pueden agrupar mensajes por su plantilla
    • CPorque es más corto
    • DNo hay ninguna diferencia
    Ver respuesta

    Respuesta B. Porque con % el texto solo se formatea si el mensaje se va a emitir, y las herramientas pueden agrupar mensajes por su plantilla

    La f-string se evalúa siempre, aunque el nivel descarte el mensaje. Con los argumentos aparte, logging formatea solo si hace falta, y la plantilla se mantiene igual en todos los mensajes del mismo tipo.

  3. assert drawdown_maximo([100, 120, 90]) == -0.25 falla aunque la función es correcta. ¿Qué cambio arregla el test?

    • ARedondear el resultado a 2 decimales dentro de la función
    • BComparar con pytest.approx(-0.25): el resultado en coma flotante no es exactamente −0,25
    • CUsar is en lugar de ==
    • DQuitar el test
    Ver respuesta

    Respuesta B. Comparar con pytest.approx(-0.25): el resultado en coma flotante no es exactamente −0,25

    90 / 120 − 1 en coma flotante puede diferir de −0,25 en el último bit. pytest.approx compara con una tolerancia relativa (1e-6 por defecto). Redondear dentro de la función cambiaría el resultado para todos sus usuarios.

  4. Un test comprueba que drawdown_maximo([100, 90, 200]) sea −10 %. ¿Qué error detecta si falla con −55 %?

    • AUn error de coma flotante
    • BQue la función usa el máximo de toda la serie, incluido un precio futuro: sesgo de anticipación
    • CQue la serie es demasiado corta
    • DQue falta una comisión
    Ver respuesta

    Respuesta B. Que la función usa el máximo de toda la serie, incluido un precio futuro: sesgo de anticipación

    Con el máximo de toda la serie (200), el precio 90 parece un −55 %. Pero cuando cotizaba a 90, el máximo conocido era 100: el drawdown real fue −10 %. El 200 todavía no había ocurrido.

Ideas clave

  • logging separa qué se registra (nivel), quién lo registra (loggers con nombres jerárquicos) y dónde va (handlers con su propio nivel y formato).
  • En producción, consola para avisos y errores, fichero rotativo con todo y marcas de tiempo en UTC. print no tiene niveles, ni destinos, ni hora.
  • Un test es una función test_… con assert. Para floats, pytest.approx; para excepciones esperadas, pytest.raises; para datos compartidos, fixtures; para muchos casos, parametrize.
  • Los mejores tests prueban casos límite y propiedades: serie vacía, un solo precio, serie creciente, y que un precio futuro no cambie el pasado.
  • uv run pytest devuelve código 0 si todo pasa y distinto de 0 si algo falla: es lo que usan Git, la integración continua y el despliegue para decir que no.

Recursos