De ejercicios a una biblioteca
Has escrito un centenar de funciones pequeñas. Una biblioteca es esas funciones organizadas para que otra persona (incluido tú dentro de seis meses) pueda usarlas con confianza: una interfaz pública clara, cada pieza probada por separado, las piezas compuestas en una canalización y todo instalable, documentado y comprobado automáticamente. Este proyecto final es el patrón en miniatura. Una biblioteca de estrategias es el lugar más fácil para practicarlo: la señal convierte precios en posiciones, el backtest convierte posiciones en rendimientos tras costes, las métricas resumen los rendimientos y un ejecutor lo conecta todo. Cada etapa recibe arrays sencillos y devuelve arrays sencillos, así que cada una se puede probar sola con números que se comprueban a mano.
# The shape of a small library: a package, its tests, its metadata and its automated checks
tree = """\
quantlib/
├── pyproject.toml name, version, dependencies (numpy)
├── src/quantlib/
│ ├── __init__.py the public API: sma_positions, strategy_returns, performance_summary, run_strategy
│ ├── signals.py prices -> positions
│ ├── backtest.py positions -> returns after costs
│ └── metrics.py returns -> total return, volatility, Sharpe, drawdown
├── tests/ one test file per module, fixed seeds, hand-checked cases
└── .github/workflows/ci.yml lint, types, tests on every push
"""
print(tree.splitlines()[0]) # quantlib/
print(sum(1 for line in tree.splitlines() if ".py" in line)) # 4 the four module filesSeñal y backtest: sin anticipación y con costes
La señal es aquí un cruce de medias móviles: estar largo cuando la media rápida de precios está por encima de la lenta, corto cuando está por debajo, plano cuando son iguales o la lenta aún no existe. Las dos reglas que deciden si un backtest es honesto se ven en el código. Primero, sin anticipación: la posición decidida al cierre del día t solo puede usar precios hasta el día t, y gana el rendimiento del día t + 1, por eso el rendimiento del paso t usa positions[t - 1]. Un test lo prueba: quitar los últimos precios no debe cambiar las posiciones anteriores. Segundo, los costes: cada cambio de posición paga cost * |cambio|, así que una estrategia que cambia sin parar puede ganar antes de costes y perder después.
# The signal and the honest backtest: positions decided at the close are earned on the NEXT step, and trading costs money
import numpy as np
def sma_positions(prices, fast, slow):
prices = np.asarray(prices, dtype=float)
positions = np.zeros(len(prices), dtype=int)
for t in range(slow - 1, len(prices)):
f = prices[t - fast + 1 : t + 1].mean()
s = prices[t - slow + 1 : t + 1].mean()
positions[t] = 1 if f > s else -1 if f < s else 0
return positions
def strategy_returns(prices, positions, cost):
returns = []
for t in range(1, len(prices)):
before = positions[t - 2] if t >= 2 else 0
returns.append(positions[t - 1] * (prices[t] / prices[t - 1] - 1) - cost * abs(positions[t - 1] - before))
return np.array(returns)
prices = np.array([100, 101, 103, 102, 105, 108, 107, 110, 112, 111, 115, 118], dtype=float)
positions = sma_positions(prices, 2, 4)
print(positions) # [0 0 0 1 1 1 1 1 1 1 1 1] flat until the 4-day average exists, then long
print(np.allclose(sma_positions(prices[:8], 2, 4), positions[:8])) # True later prices never change earlier positions
print(np.round(strategy_returns(prices, positions, 0.0).sum() - strategy_returns(prices, positions, 0.001).sum(), 3)) # 0.001 one purchase at 0.1%Las cifras de rendimiento y el ejecutor
Una serie de rendimientos se resume con unas pocas cifras. El rendimiento total compone los periodos, prod(1 + r) - 1; el rendimiento anual lo reescala a un año, (1 + total)^(periodos_por_año / n) - 1; la volatilidad anual es la desviación típica muestral por sqrt(periodos_por_año); el ratio de Sharpe divide el rendimiento medio por esa desviación, anualizado, y es la medida principal de rendimiento por unidad de riesgo (0 para una serie plana en vez de un error de división); el drawdown máximo es la peor caída desde un máximo de la curva de capital, la cifra que dice cuánto dolor te pide soportar la estrategia. Una función ejecutor lo ata todo: posiciones, luego rendimientos, luego el resumen. Solo debe componer las demás funciones, para que un error siempre se rastree hasta una etapa.
# Performance statistics of a return series, and the runner that wires the three stages together
import math
import numpy as np
def performance_summary(returns, periods_per_year):
returns = np.asarray(returns, dtype=float)
n = len(returns)
equity = np.concatenate(([1.0], np.cumprod(1 + returns)))
total = float(equity[-1] - 1)
deviation = float(np.std(returns, ddof=1))
peak = np.maximum.accumulate(equity)
return {
"total_return": total,
"annual_return": float((1 + total) ** (periods_per_year / n) - 1),
"annual_vol": deviation * math.sqrt(periods_per_year),
"sharpe": float(returns.mean() / deviation * math.sqrt(periods_per_year)) if deviation > 0 else 0.0,
"max_drawdown": float(np.max((peak - equity) / peak)),
}
summary = performance_summary([0.10, -0.20, 0.05], 252)
print(round(summary["total_return"], 4), round(summary["max_drawdown"], 4)) # -0.076 0.2 equity 1 -> 1.1 -> 0.88 -> 0.924: a 20% fall from the peak
print(performance_summary([0.0, 0.0, 0.0], 252)["sharpe"]) # 0.0 no variation: a Sharpe of 0, not an error# The runner only composes: if a number looks wrong you can test each stage on its own
def run_strategy(prices, fast, slow, cost, periods_per_year=252):
positions = sma_positions(prices, fast, slow)
returns = strategy_returns(prices, positions, cost)
return performance_summary(returns, periods_per_year)
# (sma_positions, strategy_returns and performance_summary are the functions you wrote in this lab)
steps = ["sma_positions", "strategy_returns", "performance_summary"]
print(" -> ".join(steps)) # sma_positions -> strategy_returns -> performance_summary
print(len(steps), "stages, one result: a dict of five numbers") # 3 stages, one result: a dict of five numbersTu proyecto final
Ahora construye el tuyo, en tu propio repositorio. Elige una de dos direcciones. Una biblioteca de valoración: Black-Scholes y las griegas, un valorador Monte Carlo con variables antitéticas, un resolutor de volatilidad implícita y la calibración de un modelo a un conjunto de cotizaciones. O una biblioteca de estrategias: varias señales, un backtest con costes y límites de posición, evaluación walk-forward y un informe. En ambos casos entrega lo mismo: un paquete con una API pública clara y anotaciones de tipo; pruebas con semillas fijas y casos comprobados a mano; un pyproject.toml y un README que muestre cómo instalar y ejecutar un ejemplo; un flujo de CI con lint, tipos y pruebas; una nota de rendimiento con un cuello de botella perfilado; y un breve texto sobre los supuestos y lo que rompería el modelo. Reparte tu tiempo: diseña primero la interfaz, construye la canalización funcional más pequeña y luego amplíala.
- Una biblioteca son pequeñas funciones probadas compuestas en una canalización tras una interfaz clara: señal -> posiciones -> rendimientos tras costes -> métricas, conectadas por un ejecutor.
- Un backtest honesto no tiene anticipación (la posición en t usa precios hasta t y gana el rendimiento siguiente) y cobra costes por cada cambio de posición.
- Resume con rendimiento total y anual, volatilidad, Sharpe y drawdown máximo; luego entrega un paquete con pruebas, CI, un README y un texto honesto.
Con esto termina la ruta. Ahora tienes el lenguaje, las herramientas numéricas, las piezas de datos de mercado y de ejecución, los modelos y los hábitos de ingeniería de un desarrollador cuantitativo en activo. Conserva tu repositorio final: es la mejor prueba de las habilidades que has construido aquí.
Lab: qué se califica
- Convertir una tendencia en posiciones con
sma_positions25 pts - Hacer backtest con costes en
strategy_returns25 pts - Juzgar el resultado con
performance_summary25 pts - Conectarlo todo en
run_strategy25 pts