---
title: "0.3.6 Exceptions, modules and packages"
description: "Errors from a zero price, a missing ticker or an invalid order: try/except, custom exceptions, modules, packages and the lazy imports of 3.15."
url: "https://pythonparatrading.com/en/python-trading-course/level-0/python-exceptions-modules-packages/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-0/python-exceptions-modules-packages.png"
---

# 0.3.6 · Exceptions; modules and packages, with Python 3.15 lazy imports

> A zero price, a ticker that does not exist or a malformed order must not bring down a system, nor pass silently. The lesson shows how to read, catch and raise exceptions, how to split code into modules and packages, and what is new in Python 3.15: lazy imports.

- 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.5 · Functions: arguments, *args and **kwargs, lambdas and scope](https://pythonparatrading.com/en/python-trading-course/level-0/python-functions-arguments-scope/)

## By the end of this lesson

- Read a traceback and identify the exception type (`ZeroDivisionError`, `KeyError`, `ValueError`) and the line that causes it.
- Catch specific exceptions with `try`/`except`/`else`/`finally` without hiding errors.
- Raise exceptions with `raise` and define a custom one, `OrdenInvalida`, to reject orders.
- Write your own module and package, import them and explain what changes with `lazy import` in Python 3.15.

## When something goes wrong

So far, Python errors have shown up in passing: dividing a `Decimal` by a `float` (lesson 0.3.1), passing the risk
by position or modifying a global variable (lesson 0.3.5). In a trading program, errors are not a rarity: a vendor
sends a zero price, a ticker is delisted, an order arrives with a misspelled field, the broker does not respond. The
question is not whether they will happen, but what the program does when they do.

When Python cannot continue, it raises an **exception**: an object that describes the error and interrupts execution.
If nobody catches it, the program stops and shows a **traceback**: the chain of calls down to the line that failed
and, on the last line, the exception type and the message. That last line is the first thing to read.

Three exceptions that show up in any trading code. The following cells **must** fail; below each one, the last line
of the traceback:

```python raises
cierre_anterior = 0.0      # bad data from the vendor
cierre = 142.22
rentabilidad = cierre / cierre_anterior - 1
```
```output
ZeroDivisionError: division by zero
```

```python raises
precios = {"SINT": 142.22, "ORO": 171.44, "TECH": 77.82}
precios["BTC"]
```
```output
KeyError: 'BTC'
```

```python raises
float("1.426,71")
```
```output
ValueError: could not convert string to float: '1.426,71'
```

`ZeroDivisionError`, `KeyError` and `ValueError` say exactly what happened. The last one is the reverse problem of
lesson 0.3.2: a number written the Spanish way (dot for thousands, comma for decimals) is not converted by `float`.

Having the program stop is often the right thing. A zero price has no return, and a system that kept trading with an
`inf` would do something much worse than stopping. Exceptions are caught only when you know what to do with them.

## Catching with try and except

`try` runs a block and, if an exception of the type given in `except` is raised inside it, jumps to that block
instead of stopping the program. With `as`, the block receives the exception and can use its message:

```python
cierres = [139.36, 139.32, 0.0, 139.56, 139.98]

rentabilidades = []
for c0, c1 in zip(cierres, cierres[1:]):
    try:
        r = c1 / c0 - 1
    except ZeroDivisionError:
        r = None                     # no data, not a zero
    rentabilidades.append(r)
print(rentabilidades)

for ticker in ["SINT", "BTC"]:
    try:
        print(ticker, precios[ticker])
    except KeyError as e:
        print(f"No price for {e}: the position is not valued")
```
```output
[-0.0002870264064295247, -1.0, None, 0.003009458297506429]
SINT 142.22
No price for 'BTC': the position is not valued
```

The zero close causes two problems in a chain: that day's return (−100%, even though nobody lost anything) and the
next day's, which is left without a denominator and is marked with `None`. One bad data point contaminates more than
one calculation; the right thing would be to fix or discard the close before computing anything (level 1, data
cleaning). Marking the absence with `None`, rather than with a 0, is the rule from lesson 0.3.3: a missing value is
not a value of zero.

Three rules for catching well:

- **Catch the specific type.** `except ZeroDivisionError` lets any other error through, which is what you want: a
  `NameError` from a misspelled variable is a program bug, not bad data.
- **Never a bare `except:`, nor `except Exception: pass`.** They catch everything, including programming bugs, and
  turn them into silence. An order that is not sent and leaves no trace is the worst possible error in a system that
  trades.
- **Keep the `try` block short.** The fewer lines it has, the clearer it is which exception is expected and from where.

Exceptions form a **hierarchy**: `ZeroDivisionError` is a case of `ArithmeticError`, and `KeyError` and `IndexError`
are cases of `LookupError`. An `except` catches its type and all the types below it.

| Exception | Inherits from | When it shows up in trading |
|---|---|---|
| `ZeroDivisionError` | `ArithmeticError` | Price, volatility or distance to the stop at zero |
| `KeyError` | `LookupError` | Ticker or field that is not in a dictionary |
| `IndexError` | `LookupError` | Asking for a bar beyond the end of the list |
| `ValueError` | `Exception` | Text that is not a number, impossible quantity |
| `TypeError` | `Exception` | Mixing types: `Decimal` with `float`, text with a number |
| `ConnectionError` | `OSError` | The broker or the data vendor does not respond |

## else and finally

A `try` can have two more blocks. `else` runs only if the `try` finished **without** exceptions: it is the place for
what should happen only if everything went well. `finally` runs **always**, whether or not there was an exception,
and even if the exception was not caught: it is the place to close connections, release resources and keep a record.

```mermaid
flowchart TB
  T[try] --> Q{Exception?}
  Q -- yes --> X[except]
  Q -- no --> E[else]
  X --> F[finally]
  E --> F
```

The example simulates sending orders to a broker that fails with one of them. The `enviar` (send) function is
fictitious: it raises `ConnectionError` for ORO orders, as if the connection dropped:

```python
def enviar(orden):
    """Simulates sending: the connection fails for ORO orders."""
    if orden["simbolo"] == "ORO":
        raise ConnectionError("timed out")
    return f"ID-{orden['simbolo']}-{orden['cantidad']}"

registro = []
for orden in [{"simbolo": "SINT", "cantidad": 58},
              {"simbolo": "ORO", "cantidad": 20}]:
    try:
        id_orden = enviar(orden)
    except ConnectionError as e:
        registro.append(f"{orden['simbolo']}: NOT SENT ({e})")
    else:
        registro.append(f"{orden['simbolo']}: sent {id_orden}")
    finally:
        registro.append(f"{orden['simbolo']}: attempt logged")
print(*registro, sep="\n")
```
```output
SINT: sent ID-SINT-58
SINT: attempt logged
ORO: NOT SENT (timed out)
ORO: attempt logged
```

`print(*registro, sep="\n")` unpacks the list into arguments (lesson 0.3.5) and separates them with line breaks. The
ORO order is not sent and there is a record of why; the SINT order is confirmed in the `else`. In both cases, the
`finally` logs the attempt. What to do next with the failed order (retry, alert, cancel the strategy) is a design
decision for level 6; what you never do is forget it.

## Raising exceptions: raise and OrdenInvalida

Your own functions must also warn when they receive something that makes no sense. The position sizing function from
lesson 0.3.5 returned a negative number with a stop above the entry; with `raise`, it refuses:

```python
def tamano_posicion(capital, entrada, stop, *, riesgo=0.01):
    """Shares that risk `riesgo` × `capital` down to the stop."""
    if stop >= entrada:
        raise ValueError(f"stop {stop} is not below "
                         f"the entry {entrada}")
    return int(capital * riesgo / (entrada - stop))

print(tamano_posicion(25_000, 142.22, 137.95))
try:
    tamano_posicion(25_000, 142.22, 145.00)
except ValueError as e:
    print("Rejected:", e)
```
```output
58
Rejected: stop 145.0 is not below the entry 142.22
```

`raise` raises an exception with a message that explains the problem with the concrete values. The caller decides
whether to catch it or let it stop the program.

For domain-specific errors, such as a malformed order, an exception with its own name is better. It is defined in
two lines with `class`, stating which exception it inherits from (classes are explained in lesson 0.4.1; here it is
enough to copy the form). `OrdenInvalida` means “invalid order”:

```python
class OrdenInvalida(ValueError):
    """Order that cannot be sent to the broker."""

LADOS = {"compra", "venta"}

def validar_orden(orden):
    """Returns the order if valid; otherwise, OrdenInvalida."""
    for campo in ("simbolo", "lado", "cantidad"):
        if campo not in orden:
            raise OrdenInvalida(f"missing field {campo!r}")
    if orden["lado"] not in LADOS:
        raise OrdenInvalida(f"unknown side {orden['lado']!r}")
    if not isinstance(orden["cantidad"], int) or orden["cantidad"] <= 0:
        raise OrdenInvalida(f"invalid quantity {orden['cantidad']!r}")
    return orden

ordenes = [{"simbolo": "SINT", "lado": "compra", "cantidad": 58},
           {"simbolo": "ORO", "lado": "comprar", "cantidad": 20},
           {"simbolo": "TECH", "lado": "venta", "cantidad": 2.5},
           {"simbolo": "RFIJ", "cantidad": 10}]
for orden in ordenes:
    try:
        validar_orden(orden)
    except OrdenInvalida as e:
        print(f"REJECTED {orden['simbolo']}: {e}")
    else:
        print(f"VALID    {orden['simbolo']}")
```
```output
VALID    SINT
REJECTED ORO: unknown side 'comprar'
REJECTED TECH: invalid quantity 2.5
REJECTED RFIJ: missing field 'lado'
```

The valid sides are the course's data values `"compra"` (buy) and `"venta"` (sell); `"comprar"` (“to buy”) is a
near miss. `isinstance(valor, int)` asks whether a value is of a type: 2.5 shares is not a valid quantity. Since
`OrdenInvalida` inherits from `ValueError`, code that already caught `ValueError` also catches it; and anyone who
wants to handle only order rejections can catch `OrdenInvalida` without touching the other errors. The order of the
checks matters: first that the fields exist, then their values. If the side were checked before knowing it exists,
an order without a side would raise a `KeyError` instead of a clear rejection.

<aside class="callout" data-kind="riesgo">

Rejecting is better than guessing. A function that “fixes” an order (rounds 2.5 to 2, reads “comprar” as “compra”)
ends up sending something nobody decided. Lesson 0.4.2 does these validations declaratively with pydantic.

</aside>

## Modules: your code in files

When a notebook grows, the functions that already work (position sizing, drawdown, order validation) are moved to
`.py` files. Each file is a **module**, and its name without `.py` is the name it is imported by. Normally the file is
created in the editor (lesson 0.2.3); here the cell writes it so that the example can be run as is. The content is a
short version of the earlier function:

```python
codigo = '''"""Risk management: position sizing."""

RIESGO_POR_DEFECTO = 0.01


def tamano_posicion(capital, entrada, stop, *,
                    riesgo=RIESGO_POR_DEFECTO):
    """Shares that risk `riesgo` × `capital`."""
    if stop >= entrada:
        raise ValueError("the stop must be below the entry")
    return int(capital * riesgo / (entrada - stop))


if __name__ == "__main__":
    print(tamano_posicion(25_000, 142.22, 137.95))
'''
with open("riesgo.py", "w", encoding="utf-8") as f:
    f.write(codigo)
```

With the file in the working folder, `import` loads it. There are three forms, and all three are common:

```python
import riesgo
print(riesgo.tamano_posicion(25_000, 142.22, 137.95))
print(riesgo.RIESGO_POR_DEFECTO, riesgo.__name__)

from riesgo import tamano_posicion as tamano
print(tamano(25_000, 142.22, 137.95, riesgo=0.02))

import statistics as st
print(st.mean([139.36, 139.32, 139.56]))
```
```output
58
0.01 riesgo
117
139.41333333333333
```

- `import riesgo` loads the module and gives access to its contents with a dot: `riesgo.tamano_posicion`. It is clear
  where each name comes from.
- `from riesgo import tamano_posicion` brings in a specific name; `as` gives it another one (for example, to avoid
  clashing with the `tamano_posicion` defined earlier in the notebook).
- `import statistics as st` is the same with an alias. Libraries have conventional aliases that everyone uses:
  `import numpy as np`, `import pandas as pd`, `import polars as pl`.

**Standard library** modules (`statistics`, `decimal`, `datetime`, `csv`…) come with Python; third-party ones
(pandas, numpy) are installed in the project environment with `uv add` (lesson 0.2.2). Python looks for modules first
in the script's folder and then in the environment; that is why your own file named `statistics.py` or `pandas.py`
in the working folder **shadows** the real library. Never name a file after a library.

The module's last line, `if __name__ == "__main__":`, separates two uses of the same file. When it is imported,
`__name__` holds the module name (`"riesgo"`, as the cell shows) and the block does not run. When it is run as a
script from the terminal, `__name__` is `"__main__"` and the block does run:

```bash
uv run python riesgo.py
```

That command would print the position size from the example, 58 shares. It is the place for a quick test or for a
script's entry point, without importing the module triggering anything.

A module runs **only once** per session: the second `import riesgo` reuses the one already loaded. In a notebook, if
the file is edited, you have to restart the kernel (or use `importlib.reload(riesgo)`) to see the changes. It is a
common source of confusion: the file says one thing and the notebook runs the old version.

## Packages: several modules together

A **package** is a folder of modules with an `__init__.py` file, which can be empty and marks the folder as a
package. It is how the course's code is organized: in your repository, the `ppt` package lives in `src/ppt/`
(lessons 0.1.3 and 0.2.2). The cell creates a minimal version in the working folder:

```python
import os

os.makedirs("ppt", exist_ok=True)
ficheros = {
    "ppt/__init__.py": '"""Code for the Python Para Trading course."""\n',
    "ppt/costes.py": (
        "def coste_total(*comisiones, minimo=1.0):\n"
        "    return round(sum(max(c, minimo) for c in comisiones), 2)\n"
    ),
    "ppt/riesgo.py": codigo,
}
for ruta, texto in ficheros.items():
    with open(ruta, "w", encoding="utf-8") as f:
        f.write(texto)

from ppt.costes import coste_total
from ppt import riesgo as ppt_riesgo
print(coste_total(5.14, 0.42, 7.10))
print(ppt_riesgo.tamano_posicion(25_000, 142.22, 137.95))
```
```output
13.24
58
```

```text
ppt/
    __init__.py
    costes.py
    riesgo.py
```

`ppt.costes` is the `costes` module inside the `ppt` package: the dot walks through the folders. Inside the package,
one module can import another with a **relative import**, `from .costes import coste_total`, where the dot means
“from this same package”. That way the package can be moved or renamed without changing its internal imports.

## New in Python 3.15: lazy imports

An `import` loads the module right away: it finds the file, runs it entirely and, if the module imports others, loads
them too. With large libraries, that takes time. The figure at the top shows how long each course library takes to
import in the `curso-code` environment (Python 3.14.8, median of seven startups on a desktop machine): pandas, about
355 ms; `matplotlib.pyplot`, about 430 ms. The figures change with each machine, but the order of magnitude does not.

For a notebook opened once a day it does not matter. For a command-line tool, it does: a script that imports pandas
and matplotlib at the top takes more than half a second to respond even to `--help`, which uses neither (they share
numpy, which is loaded only once). Python 3.15, released on October 9, 2026, adds a solution in the language itself:
**explicit lazy imports** (PEP 810)[^1]. The word `lazy` in front of an `import` defers it: the name is bound to a
placeholder object and the module is loaded the first time it is used.

The course uses Python 3.14 (lesson 0.2.1), so this block is not run in the course; it is 3.15 syntax and in 3.14 it
raises a `SyntaxError`:

```python skip title="informe.py (Python 3.15)"
import sys
lazy import pandas as pd
lazy from matplotlib import pyplot as plt

if "--help" in sys.argv:
    print("usage: informe.py FILE.csv")    # neither pandas nor pyplot
    sys.exit(0)

datos = pd.read_csv(sys.argv[1])           # pandas is loaded here
datos["cierre"].plot()                     # and pyplot here
plt.savefig("informe.png")
```

What the specification says and is worth knowing before using it:

- `lazy` is a **soft keyword**: it only has meaning in front of `import` or `from`. Anywhere else it is still a normal
  name, so code that already used `lazy` as a variable does not break.
- It is only allowed at the **top level of the module**. Inside a function, a class or a `try`/`except` block it is a
  `SyntaxError`, and it is not allowed with `from module import *` either.
- If the module does not exist or fails to load, the error does not appear on the `import` line but **on first use**,
  with a traceback that points to both lines. A script with a broken lazy import can work for weeks until someone
  goes through the branch that uses it.
- For code that also has to work on earlier versions, a module can declare `__lazy_modules__ = ["pandas"]`: in 3.15
  that module's normal `import pandas` statements become lazy, and earlier versions ignore the variable.
- There is a global mode, `-X lazy_imports=all` or the `PYTHON_LAZY_IMPORTS` environment variable, which makes all
  top-level imports lazy without touching the code. It is an advanced tool: a module that does something on import
  (registering a plugin, configuring logging) stops doing it at the expected moment.

<aside class="callout" data-kind="nota">

Before 3.15, the trick for the same thing was to move the `import` inside the function that uses the library. It
works, and you will see it in existing code, but it hides the module's dependencies in the middle of the code. In
3.15, `lazy import` keeps the imports at the top, where they are read, and defers only the loading.

</aside>

## Exercises

1. Write `rentabilidad_segura(c0, c1)`, which returns the return of a close relative to the previous one and raises
   `ValueError` with a clear message if either of the two prices is not positive. Test it with `(139.98, 142.22)`
   and, catching the exception, with `(0.0, 142.22)`.

**Solution.**

```python
def rentabilidad_segura(c0, c1):
    """Return from c0 to c1; both prices must be > 0."""
    if c0 <= 0 or c1 <= 0:
        raise ValueError(f"non-positive prices: {c0}, {c1}")
    return c1 / c0 - 1

print(f"{rentabilidad_segura(139.98, 142.22):+.2%}")
try:
    rentabilidad_segura(0.0, 142.22)
except ValueError as e:
    print("Error:", e)
```
```output
+1.60%
Error: non-positive prices: 0.0, 142.22
```

The function checks before dividing, so the error says which value is bad instead of a generic `ZeroDivisionError`.
It also rejects negative prices, which would raise no error and would produce an absurd return.

2. Add a check to `validar_orden`: if the order has the `"precio"` key, it must be a number greater than zero. Check
   that it rejects `{"simbolo": "SINT", "lado": "compra", "cantidad": 10, "precio": -1}`.

**Solution.**

```python
def validar_orden_con_precio(orden):
    """validar_orden plus the limit price, if there is one."""
    validar_orden(orden)
    if "precio" in orden:
        precio = orden["precio"]
        if not isinstance(precio, (int, float)) or precio <= 0:
            raise OrdenInvalida(f"invalid price {precio!r}")
    return orden

try:
    validar_orden_con_precio({"simbolo": "SINT", "lado": "compra",
                              "cantidad": 10, "precio": -1})
except OrdenInvalida as e:
    print("REJECTED:", e)
```
```output
REJECTED: invalid price -1
```

The new function reuses `validar_orden` instead of copying its checks, and `isinstance` accepts a tuple of types. If
`validar_orden` raises the exception, it leaves `validar_orden_con_precio` without going through the rest.

3. Add to the `ppt` package a `rentabilidades.py` module with the `rentabilidades(cierres)` function from lesson
   0.3.5, import it and use it with `[100, 110, 99]`.

**Solution.**

```python
texto = (
    "def rentabilidades(cierres):\n"
    "    return [c1 / c0 - 1 for c0, c1 in zip(cierres, cierres[1:])]\n"
)
with open("ppt/rentabilidades.py", "w", encoding="utf-8") as f:
    f.write(texto)

from ppt.rentabilidades import rentabilidades
print([round(r, 4) for r in rentabilidades([100, 110, 99])])
```
```output
[0.1, -0.1]
```

Creating the file inside the package folder is enough. In your repository, that module would go in `src/ppt/` with
its test (lesson 0.4.5).

[^1]: PEP 810, “Explicit lazy imports”, status *Final*, accepted on November 3, 2025 for Python 3.15
([peps.python.org/pep-0810](https://peps.python.org/pep-0810/)). The language reference documents it at
[docs.python.org/3.15](https://docs.python.org/3.15/reference/simple_stmts.html#lazy-imports), and the release notes,
for the version released on October 9, 2026, at [“What's new in Python 3.15”](https://docs.python.org/3.15/whatsnew/3.15.html#pep-810-explicit-lazy-imports).

## Self-assessment

1. What exception does `precios["BTC"]` raise if the `precios` dictionary does not have that key?

   - A. `ValueError`
   - B. `IndexError`
   - C. `KeyError`
   - D. It returns `None`

   *Answer C: `KeyError`.* Reading a missing key with square brackets raises `KeyError` with the key in the message. `None` is what `precios.get("BTC")` would return.

2. When does the `else` block of a `try` run?

   - A. Always
   - B. Only if the `try` block raised no exception
   - C. Only if an exception was caught
   - D. Before `finally`, whether or not there was an exception

   *Answer B: Only if the `try` block raised no exception.* `else` is for what should happen only if everything went well (confirming a sent order). `finally` is what always runs (logging, closing the connection).

3. What is wrong with `try: enviar(orden)` followed by `except Exception: pass`?

   - A. Nothing: it keeps the program from stopping
   - B. It is slower
   - C. It hides any error, including programming bugs: the order is not sent and nobody finds out
   - D. It does not catch `KeyError`

   *Answer C: It hides any error, including programming bugs: the order is not sent and nobody finds out.* Catching everything and doing nothing turns any failure into silence. Catch only the exceptions you expect and know how to handle; the rest should stop the program or, at the very least, be logged.

4. In Python 3.15, what does `lazy import pandas as pd` at the top of a script do?

   - A. Imports pandas in a separate thread
   - B. Creates a deferred object: pandas is loaded the first time `pd` is used
   - C. Imports only the pandas functions that are used
   - D. Nothing in 3.15: it is a rejected proposal

   *Answer B: Creates a deferred object: pandas is loaded the first time `pd` is used.* PEP 810, accepted and included in Python 3.15: the name is bound to a deferred object and the whole module is loaded on first use. If the script never uses `pd`, it does not pay the cost of importing it.

## Key points

- An exception stops the program and leaves a traceback: the last line gives the type and the message; the lines above, where it happened.
- Catch only what you expect and know how to handle (`except KeyError`), never a bare `except:`. `else` is what runs if everything went well; `finally`, what always runs.
- Rejecting is better than guessing: `raise OrdenInvalida(...)` with a clear message instead of silently fixing the order.
- A `.py` file is a module; a folder with `__init__.py`, a package. `if __name__ == "__main__":` separates what runs as a script from what is imported.
- Python 3.15 adds `lazy import` (PEP 810): the module is loaded on first use. The course uses 3.14; in 3.15, it helps command-line tools start fast.

## Resources

### Documentation

- [Python: errors and exceptions (official tutorial)](https://docs.python.org/3/tutorial/errors.html) · docs.python.org
- [Python: built-in exceptions and their hierarchy (official documentation)](https://docs.python.org/3/library/exceptions.html) · docs.python.org
- [Python: modules and packages (official tutorial)](https://docs.python.org/3/tutorial/modules.html) · docs.python.org
- [PEP 810: Explicit lazy imports (accepted; Python 3.15)](https://peps.python.org/pep-0810/) · peps.python.org
- [Python 3.15: lazy imports (language reference)](https://docs.python.org/3.15/reference/simple_stmts.html#lazy-imports) · docs.python.org

Previous lesson: [0.3.5 · Functions: arguments, *args and **kwargs, lambdas and scope](https://pythonparatrading.com/en/python-trading-course/level-0/python-functions-arguments-scope/)

Next lesson: [0.4.1 · Object-oriented programming: classes, inheritance, composition and dataclasses](https://pythonparatrading.com/en/python-trading-course/level-0/python-classes-inheritance-dataclasses/)
