---
title: "0.3.5 Functions: arguments, lambdas and scope"
description: "Trading functions in Python: position sizing with default and keyword-only arguments, maximum drawdown, *args, **kwargs, lambdas and scope."
url: "https://pythonparatrading.com/en/python-trading-course/level-0/python-functions-arguments-scope/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-0/python-functions-arguments-scope.png"
---

# 0.3.5 · 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.

- Course: [Level 0: Python from scratch and a data mindset](https://pythonparatrading.com/en/python-trading-course/level-0/)
- Module: 0.3 · Language fundamentals
- Duration: 55 min
- Prerequisites: [0.3.4 · Control flow: if, elif, else, for, while and match-case](https://pythonparatrading.com/en/python-trading-course/level-0/python-control-flow-if-for-while-match/)

## By the end of this lesson

- Define functions with `def`, document them and return one or several values with `return`.
- Compute risk-based position sizing with default arguments and keyword-only arguments.
- Accept a variable number of arguments with `*args` and `**kwargs`, and sort trades by their P&L with `lambda`.
- Explain a variable's scope and avoid the mutable default argument mistake.

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

```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))
```
```output
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.

```python
def rentabilidad_sin_return(entrada, salida):
    r = salida / entrada - 1          # computed… and lost

resultado = rentabilidad_sin_return(102.83, 110.07)
print(resultado)
```
```output
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 $f$ of capital $C$, with entry $E$ and
stop $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.

```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))
```
```output
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:

```python raises
tamano_posicion(25_000, 142.22, 137.95, 0.02)
```
```output
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:

```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%}")
```
```output
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:

```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]))
```
```output
(-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:

```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]}")
```
```output
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.

```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"))
```
```output
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:

```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))
```
```output
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`:

```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"])
```
```output
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):

```mermaid
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:

```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)
```
```output
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:

```python raises
def pagar_comision(comision):
    capital -= comision            # reads and assigns: it is local

pagar_comision(5.14)
```
```output
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):

```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])
```
```output
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:

```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)
```
```output
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.**

```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))
```
```output
[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.

2. 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.**

```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))
```
```output
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.

3. 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.**

```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}")
```
```output
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.

## 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?

   - A. It uses a 2% risk
   - B. It uses a 1% risk and ignores the 0.02
   - C. It raises a `TypeError`: `riesgo` can only be passed by keyword
   - D. It uses 0.02 as the stop

   *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?

   - A. The last computed value
   - B. `None`
   - C. `0`
   - D. It raises an error when called

   *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?

   - A. 1
   - B. 2: the default list is created only once and shared between calls
   - C. 0
   - D. It raises an error

   *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?

   - A. Sorts the trades by their P&L, from lowest to highest
   - B. Returns only the sorted P&Ls
   - C. Sorts by symbol name
   - D. Modifies the `operaciones` list

   *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

### Research

- [Volatility Targeting in Python](https://pythonparatrading.com/en/volatility-targeting-2/) · Research-0004 · 7 min

### Documentation

- [Python: defining functions (official tutorial)](https://docs.python.org/3/tutorial/controlflow.html#defining-functions) · docs.python.org
- [Python: sorting guide, `key` functions (official documentation)](https://docs.python.org/3/howto/sorting.html) · docs.python.org
- [Python: scopes and namespaces (official tutorial)](https://docs.python.org/3/tutorial/classes.html#python-scopes-and-namespaces) · docs.python.org

### Data

- [Level 0 data: 40 fictitious trades (CSV)](https://pythonparatrading.com/curso/datos/nivel-0/operaciones_sint.csv) · Download · operaciones_sint.csv

Previous lesson: [0.3.4 · Control flow: if, elif, else, for, while and match-case](https://pythonparatrading.com/en/python-trading-course/level-0/python-control-flow-if-for-while-match/)

Next lesson: [0.3.6 · Exceptions; modules and packages, with Python 3.15 lazy imports](https://pythonparatrading.com/en/python-trading-course/level-0/python-exceptions-modules-packages/)
