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.
- Module
- 0.3 · Language fundamentals
- Duration
- 55 min
- Author
- Jesús Cuesta
- Updated
Level 0 lessons
0.1 · Welcome and expectations
0.2 · Modern working environment
- 0.2.1 Installing Python with uv: versions and support cycle
- 0.2.2 Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles
- 0.2.3 Editors (VS Code, Cursor, PyCharm) and notebooks (Jupyter, JupyterLab, marimo)
- 0.2.4 Basic terminal, paths, environment variables and .env files
- 0.2.5 Git and GitHub: commits, branches, pull requests and .gitignore
- 0.2.6 Introduction to AI coding assistants: prompts, review and tests
0.3 · Language fundamentals
- 0.3.1 Variables, types and operators; floating point and decimal for prices and ticks
- 0.3.2 Strings, f-strings and formatting numbers and dates
- 0.3.3 Lists, tuples, dictionaries, sets and comprehensions
- 0.3.4 Control flow: if, elif, else, for, while and match-case
- 0.3.5 Functions: arguments, *args and **kwargs, lambdas and scope
- 0.3.6 Exceptions; modules and packages, with Python 3.15 lazy imports
0.4 · Intermediate Python
- 0.4.1 Object-oriented programming: classes, inheritance, composition and dataclasses
- 0.4.2 Typing: type hints, mypy or ty, and Pydantic for configurations
- 0.4.3 Iterators, generators, decorators and context managers
- 0.4.4 Files (CSV, JSON, YAML, TOML) and dates with time zones
- 0.4.5 Professional logging and testing with pytest
- 0.4.6 Concurrency: threading, multiprocessing, asyncio and free-threading
0.5 · Numerical computing and data
- 0.5.1 NumPy 2: arrays, broadcasting, vectorization, linear algebra and random generators
- 0.5.2 pandas 3.0: Series, DataFrame, time indexes, Copy-on-Write, the str type and pd.col()
- 0.5.3 Selection, filtering, groupby, merge, pivot, resample and moving windows
- 0.5.4 Polars: lazy versus eager API, expressions, performance and when to pick it over pandas
- 0.5.5 DuckDB and essential SQL: SELECT, JOIN, GROUP BY and window functions on Parquet
0.6 · Visualisation
0.7 · Minimum mathematics and statistics
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:
cierre_anterior = 0.0 # bad data from the vendor
cierre = 142.22
rentabilidad = cierre / cierre_anterior - 1ZeroDivisionError: division by zeroprecios = {"SINT": 142.22, "ORO": 171.44, "TECH": 77.82}
precios["BTC"]KeyError: 'BTC'float("1.426,71")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:
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")[-0.0002870264064295247, -1.0, None, 0.003009458297506429]
SINT 142.22
No price for 'BTC': the position is not valuedThe 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 ZeroDivisionErrorlets any other error through, which is what you want: aNameErrorfrom a misspelled variable is a program bug, not bad data. - Never a bare
except:, norexcept 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
tryblock 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 source
flowchart TB
T[try] --> Q{Exception?}
Q -- yes --> X[except]
Q -- no --> E[else]
X --> F[finally]
E --> FThe 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:
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")SINT: sent ID-SINT-58
SINT: attempt logged
ORO: NOT SENT (timed out)
ORO: attempt loggedprint(*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:
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)58
Rejected: stop 145.0 is not below the entry 142.22raise 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”:
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']}")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:
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:
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]))58
0.01 riesgo
117
139.41333333333333import riesgoloads 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_posicionbrings in a specific name;asgives it another one (for example, to avoid clashing with thetamano_posiciondefined earlier in the notebook).import statistics as stis 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.pyThat 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:
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))13.24
58ppt/
__init__.py
costes.py
riesgo.pyppt.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:
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:
lazyis a soft keyword: it only has meaning in front ofimportorfrom. Anywhere else it is still a normal name, so code that already usedlazyas a variable does not break.- It is only allowed at the top level of the module. Inside a function, a class or a
try/exceptblock it is aSyntaxError, and it is not allowed withfrom module import *either. - If the module does not exist or fails to load, the error does not appear on the
importline 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 normalimport pandasstatements become lazy, and earlier versions ignore the variable. - There is a global mode,
-X lazy_imports=allor thePYTHON_LAZY_IMPORTSenvironment 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
- Write
rentabilidad_segura(c0, c1), which returns the return of a close relative to the previous one and raisesValueErrorwith 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
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)+1.60%
Error: non-positive prices: 0.0, 142.22The 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.
- 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
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)REJECTED: invalid price -1The 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.
- Add to the
pptpackage arentabilidades.pymodule with therentabilidades(cierres)function from lesson 0.3.5, import it and use it with[100, 110, 99].
Solution
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])])[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
-
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
What exception does
precios["BTC"]raise if thepreciosdictionary does not have that key?- A
ValueError - B
IndexError - C
KeyError - DIt returns
None
Show answer
Answer C.
KeyErrorReading a missing key with square brackets raises
KeyErrorwith the key in the message.Noneis whatprecios.get("BTC")would return.- A
When does the
elseblock of atryrun?- AAlways
- BOnly if the
tryblock 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
tryblock raised no exceptionelseis for what should happen only if everything went well (confirming a sent order).finallyis what always runs (logging, closing the connection).What is wrong with
try: enviar(orden)followed byexcept 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.
In Python 3.15, what does
lazy import pandas as pdat the top of a script do?- AImports pandas in a separate thread
- BCreates a deferred object: pandas is loaded the first time
pdis 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
pdis usedPEP 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 bareexcept:.elseis 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
.pyfile 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.