Lesson 0.3.6 · 55 min

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.

Fig. — Time to import each course library in a fresh process (Python 3.14.8, median of seven runs on a desktop machine)
06/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

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:

IN [01]python
cierre_anterior = 0.0      # bad data from the vendor
cierre = 142.22
rentabilidad = cierre / cierre_anterior - 1
OUT [01]A zero price
ZeroDivisionError: division by zero
IN [02]python
precios = {"SINT": 142.22, "ORO": 171.44, "TECH": 77.82}
precios["BTC"]
OUT [02]A ticker that is not there
KeyError: 'BTC'
IN [03]python
float("1.426,71")
OUT [03]A number in Spanish format
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:

IN [04]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")
OUT [04]Returns that do not break the calculation
[-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.

ExceptionInherits fromWhen it shows up in trading
ZeroDivisionErrorArithmeticErrorPrice, volatility or distance to the stop at zero
KeyErrorLookupErrorTicker or field that is not in a dictionary
IndexErrorLookupErrorAsking for a bar beyond the end of the list
ValueErrorExceptionText that is not a number, impossible quantity
TypeErrorExceptionMixing types: Decimal with float, text with a number
ConnectionErrorOSErrorThe 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.

yesnotryException?exceptelsefinally
Mermaid source
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:

IN [05]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")
OUT [05]Sending orders with try, except, else and finally
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:

IN [06]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)
OUT [06]Validating with raise
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”:

IN [07]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']}")
OUT [07]A custom exception for orders
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.

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:

IN [08]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:

IN [09]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]))
OUT [09]Three ways to import
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:

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:

IN [10]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))
OUT [10]A package with two modules
13.24
58
IN [11]
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)1Note 1 · PEP 810, “Explicit lazy imports”, status Final, accepted on November 3, 2025 for Python 3.15 (peps.python.org/pep-0810). The language reference documents it at docs.python.org/3.15, and the release notes, for the version released on October 9, 2026, at “What's new in Python 3.15”.. 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:

IN [12]informe.py (Python 3.15)python
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.

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
IN [13]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)
OUT [13]Return with validation
+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.

  1. 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
IN [14]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)
OUT [14]Validating the limit price
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.

  1. 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
IN [15]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])])
OUT [15]One more module in the package
[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).

Footnotes

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

Each answer is folded under its question

Self-assessment

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

    • AValueError
    • BIndexError
    • CKeyError
    • DIt returns None
    Show answer

    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?

    • AAlways
    • BOnly if the try block raised no exception
    • COnly if an exception was caught
    • DBefore finally, whether or not there was an exception
    Show answer

    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?

    • ANothing: it keeps the program from stopping
    • BIt is slower
    • CIt hides any error, including programming bugs: the order is not sent and nobody finds out
    • DIt does not catch KeyError
    Show answer

    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?

    • AImports pandas in a separate thread
    • BCreates a deferred object: pandas is loaded the first time pd is used
    • CImports only the pandas functions that are used
    • DNothing in 3.15: it is a rejected proposal
    Show answer

    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