Lección 0.3.5 · 55 min
Funciones: argumentos, *args y **kwargs, lambdas y ámbito
Un cálculo que se repite se escribe una vez, con nombre, y se prueba. La lección construye funciones de trading —tamaño de posición por riesgo, drawdown máximo, ordenar operaciones— y muestra los dos errores clásicos de las funciones: el ámbito de las variables y el argumento por defecto mutable.
- Módulo
- 0.3 · Fundamentos del lenguaje
- Duración
- 55 min
Lecciones del nivel 0
0.1 · Bienvenida y expectativas
0.2 · Entorno de trabajo moderno
- 0.2.1 Instalación de Python con uv: versiones y ciclo de soporte
- 0.2.2 Entornos virtuales y dependencias: uv init, uv add, pyproject.toml y lockfiles
- 0.2.3 Editores (VS Code, Cursor, PyCharm) y notebooks (Jupyter, JupyterLab, marimo)
- 0.2.4 Terminal básica, rutas, variables de entorno y ficheros .env
- 0.2.5 Git y GitHub: commits, ramas, pull requests y .gitignore
- 0.2.6 Introducción a los asistentes de código con IA: prompts, revisión y tests
0.3 · Fundamentos del lenguaje
- 0.3.1 Variables, tipos y operadores; coma flotante y decimal para precios y ticks
- 0.3.2 Cadenas, f-strings y formato de números y fechas
- 0.3.3 Listas, tuplas, diccionarios, conjuntos y comprensiones
- 0.3.4 Control de flujo: if, elif, else, for, while y match-case
- 0.3.5 Funciones: argumentos, *args y **kwargs, lambdas y ámbito
- 0.3.6 Excepciones; módulos y paquetes, con los imports perezosos de Python 3.15
0.4 · Python intermedio
- 0.4.1 Programación orientada a objetos: clases, herencia, composición y dataclasses
- 0.4.2 Tipado: type hints, mypy o ty y Pydantic para configuraciones
- 0.4.3 Iteradores, generadores, decoradores y context managers
- 0.4.4 Ficheros (CSV, JSON, YAML, TOML) y fechas con zonas horarias
- 0.4.5 Logging profesional y testing con pytest
- 0.4.6 Concurrencia: threading, multiprocessing, asyncio y free-threading
0.5 · Computación numérica y datos
- 0.5.1 NumPy 2: arrays, broadcasting, vectorización, álgebra lineal y generadores aleatorios
- 0.5.2 pandas 3.0: Series, DataFrame, índices temporales, Copy-on-Write, tipo str y pd.col()
- 0.5.3 Selección, filtrado, groupby, merge, pivot, resample y ventanas móviles
- 0.5.4 Polars: API lazy frente a eager, expresiones, rendimiento y cuándo elegirlo frente a pandas
- 0.5.5 DuckDB y SQL esencial: SELECT, JOIN, GROUP BY y funciones de ventana sobre Parquet
0.6 · Visualización
0.7 · Matemáticas y estadística mínimas
Por qué funciones
En las lecciones anteriores, cada cálculo se escribía donde se necesitaba. El trailing stop de la lección 0.3.4 se escribió dos veces, una con el 5 % y otra con el 8 %, cambiando un número en dos sitios. Así empiezan los errores: se cambia uno y se olvida el otro. Una función da nombre a un cálculo, lo escribe una sola vez y lo deja listo para usarlo con datos distintos. Además, una función se puede probar por separado (lección 0.4.5): si el cálculo del tamaño de posición está en una función, se comprueba una vez y se confía en él en todas partes.
Una función se define con def, su nombre, sus parámetros entre paréntesis y dos puntos. El cuerpo va sangrado, y
return devuelve el resultado a quien la llama:
def pnl_operacion(entrada, salida, cantidad, lado="largo"):
"""P&L bruto de una operación cerrada.
`lado`: "largo" (ganas si sube) o "corto" (si baja).
"""
signo = 1 if lado == "largo" else -1
return signo * (salida - entrada) * cantidad
print(pnl_operacion(102.83, 110.07, 200))
print(pnl_operacion(104.30, 111.86, 40, "corto"))
print(round(pnl_operacion(104.30, 111.86, 40, lado="corto"), 2))1447.999999999999
-302.4000000000001
-302.4El texto entre triples comillas justo debajo del def es el docstring: la documentación de la función, que
muestran help(pnl_operacion) y los editores al pasar el ratón. Los valores que se pasan al llamarla son los
argumentos; se asignan a los parámetros por posición (el primero a entrada, el segundo a salida…) o por
nombre (lado="corto"). lado="largo" en la definición es un valor por defecto: si no se pasa, vale
"largo". Las colas de decimales de las dos primeras líneas son la coma flotante de la lección 0.3.1; round las
quita al mostrar el resultado.
Una función sin return devuelve None. Es un error frecuente: el cálculo se hace, pero el resultado se pierde.
def rentabilidad_sin_return(entrada, salida):
r = salida / entrada - 1 # se calcula… y se pierde
resultado = rentabilidad_sin_return(102.83, 110.07)
print(resultado)NoneTamaño de posición: argumentos por defecto y por nombre
El tamaño de posición por riesgo es la función más importante de este nivel. La idea: decidir cuánto dinero se está dispuesto a perder si salta el stop (un porcentaje fijo del capital, el riesgo por operación) y comprar las acciones que hacen que, en ese caso, la pérdida sea exactamente esa. Si se arriesga una fracción de un capital , con entrada y stop :
Los corchetes indican redondeo hacia abajo: nunca se compra una acción de más, porque la pérdida superaría el riesgo fijado. Si el activo se opera por lotes, se redondea hacia abajo al lote.
def tamano_posicion(capital, entrada, stop, *, riesgo=0.01,
lote=1):
"""Acciones que arriesgan `riesgo` × `capital` hasta el stop.
Solo posiciones largas: `stop` por debajo de `entrada`.
"""
riesgo_por_accion = entrada - stop
acciones = int(capital * riesgo / riesgo_por_accion)
return acciones // lote * lote
n = tamano_posicion(25_000, 142.22, 137.95)
print(n, f"{n * (142.22 - 137.95):.2f}", f"{n * 142.22:,.2f}")
print(tamano_posicion(25_000, 142.22, 137.95, riesgo=0.02))
print(tamano_posicion(25_000, 142.22, 137.95, lote=10))58 247.66 8,248.76
117
50Con 25.000 € y un 1 % de riesgo, la pérdida máxima es 250 €. La distancia al stop es 4,27 € por acción, así que caben
58 acciones: si salta el stop se pierden 247,66 €, y la posición vale unos 8.250 €, un tercio de la cuenta. int()
corta los decimales, que con números positivos es redondear hacia abajo, lo que aquí se busca; para un número de
ticks no lo era (lección 0.3.1), porque allí el resultado debía ser exacto.
El asterisco solo, *, en la lista de parámetros cambia las reglas: todo lo que va detrás solo se puede pasar
por nombre. Es una protección deliberada. Una función con cinco números seguidos es fácil de llamar mal, y
tamano_posicion(25_000, 142.22, 137.95, 0.02) no dice si 0,02 es un riesgo, una comisión o un lote. Python lo
rechaza:
tamano_posicion(25_000, 142.22, 137.95, 0.02)TypeError: tamano_posicion() takes 3 positional arguments but 4 were givenLa función tiene un hueco: si el stop está por encima de la entrada, la distancia es negativa y el resultado también. Validar las entradas y avisar con una excepción es materia de la lección 0.3.6.
La relación entre stop y tamaño es inversa: cuanto más cerca el stop, más acciones. La figura de la cabecera la dibuja con los números de la celda siguiente:
for distancia in (0.01, 0.02, 0.03, 0.05, 0.08, 0.10):
stop = round(142.22 * (1 - distancia), 2)
n = tamano_posicion(25_000, 142.22, stop)
print(f"stop a {distancia:>4.0%} ({stop:>6.2f}): {n:>3} acciones,"
f" exposición {n * 142.22 / 25_000:>4.0%}")stop a 1% (140.80): 176 acciones, exposición 100%
stop a 2% (139.38): 88 acciones, exposición 50%
stop a 3% (137.95): 58 acciones, exposición 33%
stop a 5% (135.11): 35 acciones, exposición 20%
stop a 8% (130.84): 21 acciones, exposición 12%
stop a 10% (128.00): 17 acciones, exposición 10%Con un stop al 1 %, el mismo riesgo de 250 € exige una posición del tamaño de toda la cuenta; de hecho algo mayor, porque 176 acciones valen 25.031 €, y la función no lo impide. Un stop muy cercano no reduce el riesgo, lo cambia de sitio: la pérdida prevista es la misma, pero la exposición es enorme, y un hueco de apertura por debajo del stop (lección 0.3.4) se lleva mucho más de 250 €. El nivel 4 vuelve sobre el tamaño de posición con volatilidad y con límites de exposición.
Una función que recorre una serie: el drawdown máximo
El drawdown es la caída desde el máximo anterior; el drawdown máximo, la mayor de esas caídas en todo el periodo. Es la medida de dolor de una estrategia (lección 0.1.2): cuánto llegó a perder quien entró en el peor momento. Calcularlo exige recorrer la serie recordando el máximo visto hasta cada día:
def drawdown_maximo(valores):
"""Mayor caída desde un máximo previo y sus posiciones.
Devuelve (drawdown, posición del máximo, posición del mínimo).
"""
pico, i_pico = valores[0], 0
peor, i_ini, i_fin = 0.0, 0, 0
for i, v in enumerate(valores):
if v > pico:
pico, i_pico = v, i
caida = v / pico - 1
if caida < peor:
peor, i_ini, i_fin = caida, i_pico, i
return peor, i_ini, i_fin
print(drawdown_maximo([100, 110, 99, 105, 120, 90, 130]))(-0.25, 4, 5)Una función puede devolver varios valores separándolos con comas: lo que devuelve es una tupla, que se desempaqueta al recibirla (lección 0.3.3). En la serie de prueba, la peor caída va de 120 (posición 4) a 90 (posición 5): un −25 %. Probar una función con datos pequeños cuyo resultado se conoce de antemano es la costumbre que convierte el código en algo fiable.
Con la función escrita y probada, aplicarla a los cierres de SINT es una línea:
import csv
with open("sint_diario.csv", encoding="utf-8") as f:
filas = list(csv.DictReader(f))
fechas = [r["fecha"] for r in filas]
cierres = [float(r["cierre"]) for r in filas]
dd, i_ini, i_fin = drawdown_maximo(cierres)
print(f"Drawdown máximo {dd:.1%}: de {cierres[i_ini]:.2f} el "
f"{fechas[i_ini]} a {cierres[i_fin]:.2f} el {fechas[i_fin]}")Drawdown máximo -25.0%: de 125.98 el 2022-03-07 a 94.45 el 2022-10-20Del máximo de marzo de 2022 al mínimo de octubre, SINT cae un 25 %. Según la cuenta de la lección 0.1.2, recuperar esa caída exige después una subida del 33,3 %. La lección 0.6.1 dibuja esta serie y su drawdown completo.
Argumentos variables: *args y **kwargs
Algunas funciones no saben de antemano cuántos argumentos van a recibir. Un parámetro con un asterisco, *args,
recoge todos los argumentos posicionales que sobren en una tupla; uno con dos, **kwargs, recoge los que se pasan
por nombre y no tienen parámetro propio en un diccionario. Los nombres args y kwargs son una convención:
lo que importa son los asteriscos.
def coste_total(*comisiones, minimo=1.0):
"""Suma comisiones; ninguna por debajo del mínimo."""
return round(sum(max(c, minimo) for c in comisiones), 2)
print(coste_total(5.14, 0.42, 7.10))
print(coste_total(5.14, 0.42, 7.10, minimo=0.0))
def crear_orden(simbolo, cantidad, **opciones):
"""Orden de mercado por defecto; el resto, por nombre."""
orden = {"simbolo": simbolo, "cantidad": cantidad,
"tipo": "mercado"}
orden.update(opciones)
return orden
print(crear_orden("SINT", 58))
print(crear_orden("SINT", 58, tipo="limite", precio=141.50,
validez="dia"))13.24
12.66
{'simbolo': 'SINT', 'cantidad': 58, 'tipo': 'mercado'}
{'simbolo': 'SINT', 'cantidad': 58, 'tipo': 'limite', 'precio': 141.5, 'validez': 'dia'}max(c, minimo) for c in comisiones dentro de sum es una comprensión sin corchetes (una expresión generadora,
que se ve en la lección 0.4.3). update añade al diccionario de la orden las parejas de otro diccionario,
sustituyendo las claves que ya existan: por eso tipo="limite" reemplaza al tipo por defecto.
Los asteriscos también funcionan al revés, al llamar: *lista reparte una secuencia en argumentos posicionales y
**diccionario reparte un diccionario en argumentos por nombre. Es la forma de guardar la configuración de una
estrategia en un diccionario y pasársela a una función:
comisiones_dia = [5.14, 0.42, 7.10, 2.00]
print(coste_total(*comisiones_dia))
config = {"riesgo": 0.005, "lote": 5}
print(tamano_posicion(25_000, 142.22, 137.95, **config))15.24
25Úsalos con mesura. Una función con **kwargs acepta cualquier nombre, también uno mal escrito: crear_orden("SINT", 58, presio=141.50) crea una orden con una clave presio que nadie va a leer, sin ningún aviso. Cuando los parámetros
se conocen, es mejor escribirlos.
Lambdas: funciones de una línea
Una lambda es una función sin nombre y de una sola expresión: lambda op: op["pnl"] equivale a una función que
recibe op y devuelve op["pnl"]. Su uso principal es el argumento key de sorted, max y min, que dice
por qué comparar los elementos. Con las 40 operaciones de operaciones_sint.csv:
with open("operaciones_sint.csv", encoding="utf-8") as f:
ops = list(csv.DictReader(f))
for op in ops:
bruto = pnl_operacion(float(op["precio_entrada"]),
float(op["precio_salida"]),
int(op["cantidad"]), op["lado"])
op["pnl"] = round(bruto - float(op["comision"]), 2)
por_pnl = sorted(ops, key=lambda op: op["pnl"])
for op in por_pnl[:2] + por_pnl[-2:]:
print(op["id"], op["simbolo"], op["lado"], f"{op['pnl']:+,.2f}")
mayor = max(ops, key=lambda op: int(op["cantidad"]))
print("Mayor tamaño:", mayor["id"], mayor["cantidad"])1 ORO corto -3,123.56
8 SINT largo -1,288.04
13 SINT largo +1,011.70
7 SINT largo +1,426.71
Mayor tamaño: 1 200sorted devuelve las operaciones completas, ordenadas de menor a mayor P&L; por_pnl[:2] son las dos peores y
por_pnl[-2:], las dos mejores. max con key devuelve el elemento entero, no el valor de la clave. pnl_operacion,
la función del principio de la lección, se reutiliza tal cual: para eso se escribió.
Una lambda que se asigna a un nombre (riesgo = lambda c: c * 0.01) es una mala idea: para eso está def, que da un
nombre visible en los errores y admite docstring.
Ámbito: dónde vive cada variable
Las variables que se asignan dentro de una función son locales: existen solo mientras la función se ejecuta y no afectan a las de fuera aunque se llamen igual. Para leer un nombre, Python lo busca en cuatro sitios, en este orden (la regla LEGB):
Fuente Mermaid
flowchart LR L[Local] --> E[Función externa] --> G[Global] --> B[Integrados]
Primero en la función (local), después en la función que la contiene si la hay (enclosing), después en el módulo
(global) y por último en los nombres integrados de Python (print, len, max…). La consecuencia práctica:
capital = 25_000
def capital_tras_perdida(perdida):
capital = 25_000 - perdida # local: otra variable
return capital
print(capital_tras_perdida(250), capital)24750 25000La función tiene su propio capital, y el global sigue intacto. Si dentro de la función se intenta modificar la
variable global a partir de sí misma, Python da un error, porque una asignación hace local el nombre en toda la
función, también en la línea que intenta leerlo antes:
def pagar_comision(comision):
capital -= comision # lee y asigna: es local
pagar_comision(5.14)UnboundLocalError: cannot access local variable 'capital' where it is not associated with a valueExiste la palabra global para saltarse esta regla, y no conviene usarla: una función que cambia variables de fuera
es difícil de probar y de seguir. La forma limpia es recibir el valor como argumento y devolver el nuevo:
capital = pagar_comision(capital, 5.14).
El argumento por defecto mutable
El último error es el más traicionero porque no da ningún mensaje. El valor por defecto de un parámetro se evalúa una sola vez, al definir la función, no en cada llamada. Si ese valor es una lista o un diccionario, todas las llamadas comparten el mismo objeto (la trampa de «asignar no es copiar» de la lección 0.3.3):
def registrar_orden(orden, libro=[]):
libro.append(orden)
return libro
dia_1 = registrar_orden({"simbolo": "SINT", "cantidad": 58})
dia_2 = registrar_orden({"simbolo": "ORO", "cantidad": 20})
print(len(dia_2), dia_1 is dia_2)
print([o["simbolo"] for o in dia_2])2 True
['SINT', 'ORO']El segundo día empieza con la orden del primero dentro. En un sistema real, la orden de ayer se volvería a enviar. La
solución es usar None como valor por defecto y crear la lista dentro, en cada llamada:
def registrar_orden(orden, libro=None):
if libro is None:
libro = []
libro.append(orden)
return libro
dia_1 = registrar_orden({"simbolo": "SINT", "cantidad": 58})
dia_2 = registrar_orden({"simbolo": "ORO", "cantidad": 20})
print(len(dia_1), len(dia_2), dia_1 is dia_2)1 1 FalseLa regla: nunca un [], {} o set() como valor por defecto. Números, cadenas, tuplas, True, False y None son
inmutables y no tienen el problema.
Ejercicios
- Escribe
rentabilidades(cierres), que devuelva la lista de rentabilidades diarias de una lista de cierres (usa la comprensión de la lección 0.3.3), y pruébala con[100, 110, 99]. Después calcula cuántas sesiones de SINT bajaron más de un 3 %.
Solución
def rentabilidades(cierres):
"""Rentabilidad de cada cierre respecto al anterior."""
return [c1 / c0 - 1 for c0, c1 in zip(cierres, cierres[1:])]
print([round(r, 4) for r in rentabilidades([100, 110, 99])])
caidas = [r for r in rentabilidades(cierres) if r < -0.03]
print(len(caidas))[0.1, -0.1]
11La prueba con tres números confirma la función (+10 % y −10 %) antes de usarla con los 1.303 cierres de SINT.
- Amplía
tamano_posicioncon un parámetro solo por nombremax_exposicion=1.0(fracción del capital) que limite el valor de la posición. Con un stop al 1 % (140,80), ¿cuántas acciones da conmax_exposicion=0.5?
Solución
def tamano_posicion(capital, entrada, stop, *, riesgo=0.01,
lote=1, max_exposicion=1.0):
"""Acciones por riesgo, con un tope de exposición."""
por_riesgo = int(capital * riesgo / (entrada - stop))
por_exposicion = int(capital * max_exposicion / entrada)
acciones = min(por_riesgo, por_exposicion)
return acciones // lote * lote
print(tamano_posicion(25_000, 142.22, 140.80))
print(tamano_posicion(25_000, 142.22, 140.80, max_exposicion=0.5))175
87Sin pasar el tope, la función ya da 175 acciones y no las 176 de antes: con el valor por defecto, 1.0, la posición
no puede valer más que la cuenta. Con max_exposicion=0.5, 87. El tope de exposición manda cuando el stop es muy
cercano: el riesgo por operación sigue por debajo de 250 €, pero la
posición ya no puede comprometer más de la mitad de la cuenta. Como el parámetro tiene valor por defecto, las
llamadas antiguas siguen funcionando igual.
- Con la lista
opsde la lección, usasortedy una lambda para obtener las tres operaciones con mayor pérdida en porcentaje sobre el valor de entrada (P&L entre precio de entrada por cantidad).
Solución
def pct(op):
return op["pnl"] / (float(op["precio_entrada"])
* int(op["cantidad"]))
for op in sorted(ops, key=pct)[:3]:
print(op["id"], op["simbolo"], f"{pct(op):+.2%}",
f"{op['pnl']:+,.2f}")8 SINT -10.16% -1,288.04
1 ORO -8.22% -3,123.56
4 SINT -7.35% -306.72Cuando el cálculo de la clave no cabe con claridad en una lambda, una función con def sirve igual como key. Las
peores operaciones en porcentaje no tienen por qué ser las peores en euros: dependen del tamaño de cada una.
Respuesta plegada en cada pregunta
Autoevaluación
Una función
def tamano(capital, entrada, stop, *, riesgo=0.01)se llama contamano(25_000, 142.22, 137.95, 0.02). ¿Qué pasa?- AUsa un riesgo del 2 %
- BUsa un riesgo del 1 % e ignora el 0,02
- CDa un
TypeError:riesgosolo se puede pasar por nombre - DUsa 0,02 como stop
Ver respuesta
Respuesta C. Da un
TypeError:riesgosolo se puede pasar por nombreLo que va detrás de
*en la definición solo se puede pasar por nombre (riesgo=0.02). Así nadie confunde el riesgo con otro número por su posición.¿Qué devuelve una función que calcula un resultado pero no tiene
return?- AEl último valor calculado
- B
None - C
0 - DDa un error al llamarla
Ver respuesta
Respuesta B.
NoneSin
return, una función devuelveNone. Es un error típico: el cálculo se hace, pero el valor se pierde y quien la llama recibeNone.def registrar(orden, libro=[])añade la orden alibroy lo devuelve. Tras llamarla dos veces sinlibro, ¿cuántas órdenes tiene la lista de la segunda llamada?- A1
- B2: la lista por defecto se crea una sola vez y se comparte entre llamadas
- C0
- DDa un error
Ver respuesta
Respuesta B. 2: la lista por defecto se crea una sola vez y se comparte entre llamadas
El valor por defecto se evalúa una vez, al definir la función. Las dos llamadas usan la misma lista, que va acumulando órdenes. La solución es
libro=Noney crear la lista dentro.¿Qué hace
sorted(operaciones, key=lambda op: op["pnl"])?- AOrdena las operaciones por su P&L, de menor a mayor
- BDevuelve solo los P&L ordenados
- COrdena por el nombre del símbolo
- DModifica la lista
operaciones
Ver respuesta
Respuesta A. Ordena las operaciones por su P&L, de menor a mayor
keyrecibe una función que se aplica a cada elemento para saber por qué ordenar. La lista que devuelve tiene las operaciones completas, y la original no cambia.
Ideas clave
- Una función da nombre a un cálculo:
def, parámetros, docstring yreturn. Sinreturn, devuelveNone. - Los parámetros con valor por defecto se pueden omitir; los que van tras
*solo se pasan por nombre, lo que evita confundir riesgo, lote o comisión por su posición. *argsrecoge argumentos posicionales sueltos en una tupla y**kwargslos que van por nombre en un diccionario.lambdaes una función de una expresión, útil comokeydesorted,maxymin.- Las variables asignadas dentro de una función son locales. Un valor por defecto mutable (
[],{}) se comparte entre llamadas: usaNone.