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.

Fig. — SINT shares at 142.22 that risk €250 by stop distance: the closer the stop, the larger the position
05/06
Module
0.3 · Language fundamentals
Duration
55 min
Updated
Level 0 lessons
  1. 0.1 · Welcome and expectations

    1. 0.1.1 Systematic, algorithmic and quantitative trading versus discretionary trading
    2. 0.1.2 Realistic expectations: costs, time and risk of loss
    3. 0.1.3 Course structure, entry tests, personal repository and learning with AI
  2. 0.2 · Modern working environment

    1. 0.2.1 Installing Python with uv: versions and support cycle
    2. 0.2.2 Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles
    3. 0.2.3 Editors (VS Code, Cursor, PyCharm) and notebooks (Jupyter, JupyterLab, marimo)
    4. 0.2.4 Basic terminal, paths, environment variables and .env files
    5. 0.2.5 Git and GitHub: commits, branches, pull requests and .gitignore
    6. 0.2.6 Introduction to AI coding assistants: prompts, review and tests
  3. 0.3 · Language fundamentals

    1. 0.3.1 Variables, types and operators; floating point and decimal for prices and ticks
    2. 0.3.2 Strings, f-strings and formatting numbers and dates
    3. 0.3.3 Lists, tuples, dictionaries, sets and comprehensions
    4. 0.3.4 Control flow: if, elif, else, for, while and match-case
    5. 0.3.5 Functions: arguments, *args and **kwargs, lambdas and scope
    6. 0.3.6 Exceptions; modules and packages, with Python 3.15 lazy imports
  4. 0.4 · Intermediate Python

    1. 0.4.1 Object-oriented programming: classes, inheritance, composition and dataclasses
    2. 0.4.2 Typing: type hints, mypy or ty, and Pydantic for configurations
    3. 0.4.3 Iterators, generators, decorators and context managers
    4. 0.4.4 Files (CSV, JSON, YAML, TOML) and dates with time zones
    5. 0.4.5 Professional logging and testing with pytest
    6. 0.4.6 Concurrency: threading, multiprocessing, asyncio and free-threading
  5. 0.5 · Numerical computing and data

    1. 0.5.1 NumPy 2: arrays, broadcasting, vectorization, linear algebra and random generators
    2. 0.5.2 pandas 3.0: Series, DataFrame, time indexes, Copy-on-Write, the str type and pd.col()
    3. 0.5.3 Selection, filtering, groupby, merge, pivot, resample and moving windows
    4. 0.5.4 Polars: lazy versus eager API, expressions, performance and when to pick it over pandas
    5. 0.5.5 DuckDB and essential SQL: SELECT, JOIN, GROUP BY and window functions on Parquet
  6. 0.6 · Visualisation

    1. 0.6.1 Object-oriented Matplotlib, Seaborn and Plotly: price, drawdown and candlestick charts
    2. 0.6.2 Quick dashboards with Streamlit: a monitoring dashboard in a single script
  7. 0.7 · Minimum mathematics and statistics

    1. 0.7.1 Simple and log returns, compound interest and descriptive statistics
    2. 0.7.2 Probability and distributions, correlation versus causation and simple regression
    3. 0.7.3 Intuitive linear algebra: vectors and matrices as groundwork for portfolios

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:

IN [01]python
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))
OUT [01]A function with its docstring
1447.999999999999
-302.4000000000001
-302.4

The 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.

IN [02]python
def rentabilidad_sin_return(entrada, salida):
    r = salida / entrada - 1          # computed… and lost

resultado = rentabilidad_sin_return(102.83, 110.07)
print(resultado)
OUT [02]No return, no result
None

Position 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 ff of capital CC, with entry EE and stop SS:

shares=⌊f⋅CE−S⌋\text{shares} = \left\lfloor \frac{f \cdot C}{E - S} \right\rfloor

The brackets ⌊⋅⌋\lfloor \cdot \rfloor 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.

IN [03]python
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))
OUT [03]Risk-based position sizing
58 247.66 8,248.76
117
50

With €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:

IN [04]python
tamano_posicion(25_000, 142.22, 137.95, 0.02)
OUT [04]riesgo can only be passed by keyword
TypeError: tamano_posicion() takes 3 positional arguments but 4 were given

The 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:

IN [05]python
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%}")
OUT [05]Size by distance to the stop
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:

IN [06]python
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]))
OUT [06]Maximum drawdown of a series
(-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:

IN [07]python
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]}")
OUT [07]Maximum drawdown of SINT
Maximum drawdown -25.0%: from 125.98 on 2022-03-07 to 94.45 on 2022-10-20

From 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.

IN [08]python
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"))
OUT [08]Loose commissions and order options
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:

IN [09]python
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))
OUT [09]Unpacking when calling
15.24
25

Use 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:

IN [10]python
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"])
OUT [10]The best and worst trades
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 200

sorted 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):

LocalEnclosing functionGlobalBuilt-in
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:

IN [11]python
capital = 25_000

def capital_tras_perdida(perdida):
    capital = 25_000 - perdida      # local: a different variable
    return capital

print(capital_tras_perdida(250), capital)
OUT [11]Local versus global
24750 25000

The 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:

IN [12]python
def pagar_comision(comision):
    capital -= comision            # reads and assigns: it is local

pagar_comision(5.14)
OUT [12]Modifying a global from a function
UnboundLocalError: cannot access local variable 'capital' where it is not associated with a value

The 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):

IN [13]python
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])
OUT [13]An order book that never empties
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:

IN [14]python
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)
OUT [14]The correct way
1 1 False

The 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

  1. 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
IN [15]python
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))
OUT [15]Returns as a function
[0.1, -0.1]
11

The test with three numbers confirms the function (+10% and −10%) before using it on SINT's 1,303 closes.

  1. Extend tamano_posicion with a keyword-only parameter max_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 with max_exposicion=0.5?
Solution
IN [16]python
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))
OUT [16]Exposure limit
175
87

Without 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.

  1. With the lesson's ops list, use sorted and 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
IN [17]python
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}")
OUT [17]Worst trades in percentage
8 SINT -10.16% -1,288.04
1 ORO -8.22% -3,123.56
4 SINT -7.35% -306.72

When 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

  1. A function def tamano(capital, entrada, stop, *, riesgo=0.01) is called as tamano(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: riesgo can only be passed by keyword
    • DIt uses 0.02 as the stop
    Show answer

    Answer C. It raises a TypeError: riesgo can only be passed by keyword

    Whatever 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.

  2. What does a function that computes a result but has no return give back?

    • AThe last computed value
    • BNone
    • C0
    • DIt raises an error when called
    Show answer

    Answer B. None

    Without return, a function returns None. It is a typical mistake: the calculation is done, but the value is lost and the caller receives None.

  3. def registrar(orden, libro=[]) appends the order to libro and returns it. After calling it twice without libro, 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=None and creating the list inside.

  4. 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 operaciones list
    Show answer

    Answer A. Sorts the trades by their P&L, from lowest to highest

    key takes 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 and return. Without return, it returns None.
  • 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.
  • *args collects loose positional arguments in a tuple and **kwargs collects keyword arguments in a dictionary.
  • lambda is a single-expression function, useful as the key of sorted, max and min.
  • Variables assigned inside a function are local. A mutable default value ([], {}) is shared between calls: use None.

Resources

Data