Lesson 0.3.5 · 55 min
Functions: arguments, *args and **kwargs, lambdas and scope
A calculation that repeats is written once, with a name, and tested. The lesson builds trading functions —risk-based position sizing, maximum drawdown, sorting trades— and shows the two classic function mistakes: variable scope and the mutable default argument.
- Module
- 0.3 · Language fundamentals
- Duration
- 55 min
- Author
- Jesús Cuesta
- Updated
Level 0 lessons
0.1 · Welcome and expectations
0.2 · Modern working environment
- 0.2.1 Installing Python with uv: versions and support cycle
- 0.2.2 Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles
- 0.2.3 Editors (VS Code, Cursor, PyCharm) and notebooks (Jupyter, JupyterLab, marimo)
- 0.2.4 Basic terminal, paths, environment variables and .env files
- 0.2.5 Git and GitHub: commits, branches, pull requests and .gitignore
- 0.2.6 Introduction to AI coding assistants: prompts, review and tests
0.3 · Language fundamentals
- 0.3.1 Variables, types and operators; floating point and decimal for prices and ticks
- 0.3.2 Strings, f-strings and formatting numbers and dates
- 0.3.3 Lists, tuples, dictionaries, sets and comprehensions
- 0.3.4 Control flow: if, elif, else, for, while and match-case
- 0.3.5 Functions: arguments, *args and **kwargs, lambdas and scope
- 0.3.6 Exceptions; modules and packages, with Python 3.15 lazy imports
0.4 · Intermediate Python
- 0.4.1 Object-oriented programming: classes, inheritance, composition and dataclasses
- 0.4.2 Typing: type hints, mypy or ty, and Pydantic for configurations
- 0.4.3 Iterators, generators, decorators and context managers
- 0.4.4 Files (CSV, JSON, YAML, TOML) and dates with time zones
- 0.4.5 Professional logging and testing with pytest
- 0.4.6 Concurrency: threading, multiprocessing, asyncio and free-threading
0.5 · Numerical computing and data
- 0.5.1 NumPy 2: arrays, broadcasting, vectorization, linear algebra and random generators
- 0.5.2 pandas 3.0: Series, DataFrame, time indexes, Copy-on-Write, the str type and pd.col()
- 0.5.3 Selection, filtering, groupby, merge, pivot, resample and moving windows
- 0.5.4 Polars: lazy versus eager API, expressions, performance and when to pick it over pandas
- 0.5.5 DuckDB and essential SQL: SELECT, JOIN, GROUP BY and window functions on Parquet
0.6 · Visualisation
0.7 · Minimum mathematics and statistics
Why functions
In the previous lessons, each calculation was written where it was needed. The trailing stop in lesson 0.3.4 was written twice, once with 5% and once with 8%, changing a number in two places. That is how bugs start: one is changed and the other forgotten. A function gives a name to a calculation, writes it only once and leaves it ready to use with different data. A function can also be tested on its own (lesson 0.4.5): if the position sizing calculation lives in a function, it is checked once and trusted everywhere.
A function is defined with def, its name, its parameters in parentheses and a colon. The body is indented, and
return hands the result back to the caller:
def pnl_operacion(entrada, salida, cantidad, lado="largo"):
"""Gross P&L of a closed trade.
`lado`: "largo" (long, you gain if it rises) or "corto" (short,
if it falls).
"""
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.4The text in triple quotes right below the def is the docstring: the function's documentation, shown by
help(pnl_operacion) and by editors on hover. The values passed when calling it are the arguments; they are
assigned to the parameters by position (the first to entrada, the second to salida…) or by keyword
(lado="corto"). lado="largo" in the definition is a default value: if it is not passed, it is "largo". The
trailing decimals in the first two lines are the floating point of lesson 0.3.1; round removes them when showing
the result. The trades data use "largo" (long) and "corto" (short) as values of lado (side).
A function without return returns None. It is a frequent mistake: the calculation is done, but the result is lost.
def rentabilidad_sin_return(entrada, salida):
r = salida / entrada - 1 # computed… and lost
resultado = rentabilidad_sin_return(102.83, 110.07)
print(resultado)NonePosition sizing: default and keyword arguments
Risk-based position sizing is the most important function in this level. The idea: decide how much money you are willing to lose if the stop is hit (a fixed percentage of capital, the risk per trade) and buy the number of shares that makes the loss, in that case, exactly that amount. If you risk a fraction of capital , with entry and stop :
The brackets mean rounding down: you never buy one share too many, because the loss would exceed the set risk. If the asset trades in lots, you round down to the lot.
def tamano_posicion(capital, entrada, stop, *, riesgo=0.01,
lote=1):
"""Shares that risk `riesgo` × `capital` down to the stop.
Long positions only: `stop` below `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
50With €25,000 and 1% risk, the maximum loss is €250. The distance to the stop is €4.27 per share, so 58 shares fit: if
the stop is hit you lose €247.66, and the position is worth about €8,250, a third of the account. int() truncates
the decimals, which with positive numbers is rounding down, which is what is wanted here; for a number of ticks it was
not (lesson 0.3.1), because there the result had to be exact.
The lone asterisk, *, in the parameter list changes the rules: everything after it can only be passed by
keyword. It is a deliberate safeguard. A function with five numbers in a row is easy to call wrongly, and
tamano_posicion(25_000, 142.22, 137.95, 0.02) does not say whether 0.02 is a risk, a commission or a lot size.
Python rejects it:
tamano_posicion(25_000, 142.22, 137.95, 0.02)TypeError: tamano_posicion() takes 3 positional arguments but 4 were givenThe function has a gap: if the stop is above the entry, the distance is negative and so is the result. Validating the inputs and warning with an exception is the subject of lesson 0.3.6.
The relationship between stop and size is inverse: the closer the stop, the more shares. The figure at the top draws it with the numbers from the next cell:
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 at {distancia:>4.0%} ({stop:>6.2f}): {n:>3} shares,"
f" exposure {n * 142.22 / 25_000:>4.0%}")stop at 1% (140.80): 176 shares, exposure 100%
stop at 2% (139.38): 88 shares, exposure 50%
stop at 3% (137.95): 58 shares, exposure 33%
stop at 5% (135.11): 35 shares, exposure 20%
stop at 8% (130.84): 21 shares, exposure 12%
stop at 10% (128.00): 17 shares, exposure 10%With a stop at 1%, the same €250 risk requires a position the size of the whole account; in fact slightly larger, because 176 shares are worth €25,031, and the function does not prevent it. A very close stop does not reduce risk, it moves it elsewhere: the expected loss is the same, but the exposure is huge, and an opening gap below the stop (lesson 0.3.4) takes much more than €250. Level 4 returns to position sizing with volatility and exposure limits.
A function that walks a series: maximum drawdown
Drawdown is the fall from the previous high; maximum drawdown, the largest of those falls over the whole period. It is a strategy's measure of pain (lesson 0.1.2): how much someone who entered at the worst moment ended up losing. Computing it requires walking the series while remembering the highest value seen up to each day:
def drawdown_maximo(valores):
"""Largest fall from a previous high and its positions.
Returns (drawdown, position of the high, position of the low).
"""
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)A function can return several values by separating them with commas: what it returns is a tuple, which is unpacked when received (lesson 0.3.3). In the test series, the worst fall goes from 120 (position 4) to 90 (position 5): a −25% drop. Testing a function on small data whose result is known in advance is the habit that makes code reliable.
With the function written and tested, applying it to SINT's closes is one line:
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"Maximum drawdown {dd:.1%}: from {cierres[i_ini]:.2f} on "
f"{fechas[i_ini]} to {cierres[i_fin]:.2f} on {fechas[i_fin]}")Maximum drawdown -25.0%: from 125.98 on 2022-03-07 to 94.45 on 2022-10-20From the March 2022 high to the October low, SINT falls 25%. By the arithmetic of lesson 0.1.2, recovering that fall then requires a 33.3% rise. Lesson 0.6.1 plots this series and its full drawdown.
Variable arguments: *args and **kwargs
Some functions do not know in advance how many arguments they will receive. A parameter with one asterisk, *args,
collects all the leftover positional arguments in a tuple; one with two, **kwargs, collects those passed by
keyword that have no parameter of their own in a dictionary. The names args and kwargs are a convention: what
matters are the asterisks.
def coste_total(*comisiones, minimo=1.0):
"""Sums commissions; none below the minimum."""
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):
"""Market order by default; the rest, by keyword."""
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 inside sum is a comprehension without brackets (a generator expression,
covered in lesson 0.4.3). update adds the pairs of another dictionary to the order dictionary, replacing keys that
already exist: that is why tipo="limite" replaces the default type. The order keys and values are the course's
(tipo type, "mercado" market, "limite" limit, validez="dia" valid for the day), as in lesson 0.3.4.
The asterisks also work the other way round, when calling: *lista spreads a sequence into positional arguments
and **diccionario spreads a dictionary into keyword arguments. It is how you keep a strategy's configuration in a
dictionary and pass it to a function:
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
25Use them sparingly. A function with **kwargs accepts any name, including a misspelled one: crear_orden("SINT", 58, presio=141.50) creates an order with a presio key that nobody will read, without any warning. When the
parameters are known, it is better to write them out.
Lambdas: one-line functions
A lambda is a nameless, single-expression function: lambda op: op["pnl"] is equivalent to a function that takes
op and returns op["pnl"]. Its main use is the key argument of sorted, max and min, which says what to
compare the elements by. With the 40 trades in 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("Largest size:", 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
Largest size: 1 200sorted returns the complete trades, ordered from lowest to highest P&L; por_pnl[:2] are the two worst and
por_pnl[-2:], the two best. max with key returns the whole element, not the key value. pnl_operacion, the
function from the start of the lesson, is reused as is: that is what it was written for.
A lambda assigned to a name (riesgo = lambda c: c * 0.01) is a bad idea: that is what def is for, which gives a
name visible in errors and allows a docstring.
Scope: where each variable lives
Variables assigned inside a function are local: they exist only while the function runs and do not affect those outside even if they have the same name. To read a name, Python looks for it in four places, in this order (the LEGB rule):
Mermaid source
flowchart LR L[Local] --> E[Enclosing function] --> G[Global] --> B[Built-in]
First in the function (local), then in the function that contains it if there is one (enclosing), then in the
module (global) and finally in Python's built-in names (print, len, max…). The practical consequence:
capital = 25_000
def capital_tras_perdida(perdida):
capital = 25_000 - perdida # local: a different variable
return capital
print(capital_tras_perdida(250), capital)24750 25000The function has its own capital, and the global one stays intact. If inside the function you try to modify the
global variable based on itself, Python raises an error, because an assignment makes the name local throughout the
function, including the line that tries to read it first:
def pagar_comision(comision):
capital -= comision # reads and assigns: it is local
pagar_comision(5.14)UnboundLocalError: cannot access local variable 'capital' where it is not associated with a valueThe global keyword exists to bypass this rule, and it is best not to use it: a function that changes outside
variables is hard to test and to follow. The clean way is to receive the value as an argument and return the new one:
capital = pagar_comision(capital, 5.14).
The mutable default argument
The last mistake is the most treacherous because it gives no message. A parameter's default value is evaluated only once, when the function is defined, not on every call. If that value is a list or a dictionary, all calls share the same object (the “assigning is not copying” trap from lesson 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']The second day starts with the first day's order inside. In a real system, yesterday's order would be sent again. The
fix is to use None as the default value and create the list inside, on every call:
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 FalseThe rule: never a [], {} or set() as a default value. Numbers, strings, tuples, True, False and None are
immutable and do not have the problem.
Exercises
- Write
rentabilidades(cierres), which returns the list of daily returns from a list of closes (use the comprehension from lesson 0.3.3), and test it with[100, 110, 99]. Then count how many SINT sessions fell more than 3%.
Solution
def rentabilidades(cierres):
"""Return of each close relative to the previous one."""
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]
11The test with three numbers confirms the function (+10% and −10%) before using it on SINT's 1,303 closes.
- Extend
tamano_posicionwith a keyword-only parametermax_exposicion=1.0(fraction of capital) that caps the value of the position. With a stop at 1% (140.80), how many shares does it give withmax_exposicion=0.5?
Solution
def tamano_posicion(capital, entrada, stop, *, riesgo=0.01,
lote=1, max_exposicion=1.0):
"""Shares by risk, with an exposure cap."""
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
87Without passing the cap, the function already gives 175 shares and not the 176 from before: with the default value,
1.0, the position cannot be worth more than the account. With max_exposicion=0.5, 87. The exposure cap takes over
when the stop is very close: the risk per trade stays below €250, but the position can no longer commit more than
half of the account. Since the parameter has a default value, old calls keep working the same way.
- With the lesson's
opslist, usesortedand a lambda to get the three trades with the largest loss as a percentage of the entry value (P&L divided by entry price times quantity).
Solution
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.72When the key calculation does not fit clearly in a lambda, a function defined with def works just as well as a
key. The worst trades in percentage are not necessarily the worst in euros: they depend on the size of each one.
Each answer is folded under its question
Self-assessment
A function
def tamano(capital, entrada, stop, *, riesgo=0.01)is called astamano(25_000, 142.22, 137.95, 0.02). What happens?- AIt uses a 2% risk
- BIt uses a 1% risk and ignores the 0.02
- CIt raises a
TypeError:riesgocan only be passed by keyword - DIt uses 0.02 as the stop
Show answer
Answer C. It raises a
TypeError:riesgocan only be passed by keywordWhatever comes after
*in the definition can only be passed by keyword (riesgo=0.02). That way nobody mistakes the risk for another number because of its position.What does a function that computes a result but has no
returngive back?- AThe last computed value
- B
None - C
0 - DIt raises an error when called
Show answer
Answer B.
NoneWithout
return, a function returnsNone. It is a typical mistake: the calculation is done, but the value is lost and the caller receivesNone.def registrar(orden, libro=[])appends the order tolibroand returns it. After calling it twice withoutlibro, how many orders does the list from the second call hold?- A1
- B2: the default list is created only once and shared between calls
- C0
- DIt raises an error
Show answer
Answer B. 2: the default list is created only once and shared between calls
The default value is evaluated once, when the function is defined. Both calls use the same list, which keeps accumulating orders. The fix is
libro=Noneand creating the list inside.What does
sorted(operaciones, key=lambda op: op["pnl"])do?- ASorts the trades by their P&L, from lowest to highest
- BReturns only the sorted P&Ls
- CSorts by symbol name
- DModifies the
operacioneslist
Show answer
Answer A. Sorts the trades by their P&L, from lowest to highest
keytakes a function applied to each element to know what to sort by. The returned list holds the complete trades, and the original does not change.
Key points
- A function gives a name to a calculation:
def, parameters, docstring andreturn. Withoutreturn, it returnsNone. - Parameters with a default value can be omitted; those after
*can only be passed by keyword, which prevents mixing up risk, lot size or commission by position. *argscollects loose positional arguments in a tuple and**kwargscollects keyword arguments in a dictionary.lambdais a single-expression function, useful as thekeyofsorted,maxandmin.- Variables assigned inside a function are local. A mutable default value (
[],{}) is shared between calls: useNone.