Lesson 0.4.3 · 50 min

Iterators, generators, decorators and context managers

A trading system processes streams: bars that arrive one after another, windows that slide, broker calls that fail and are retried, sessions that must be closed no matter what. Python has a tool for each of those shapes, and all of them rest on simple protocols.

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

What for does under the hood

A for loop over a list of closes looks like a single, indivisible operation. It is not: Python asks the list for an iterator with iter() and asks it for values with next() until the iterator raises StopIteration. Doing it by hand shows the mechanism:

IN [01]python
cierres = [99.14, 99.25, 101.02]
it = iter(cierres)
print(next(it), next(it), next(it))
try:
    next(it)
except StopIteration:
    print("StopIteration: no closes left")
OUT [01]The iteration protocol by hand
99.14 99.25 101.02
StopIteration: no closes left

An iterable is any object that can provide an iterator (lists, tuples, dictionaries, files, strings). An iterator is the object that does the traversal: it remembers where it is and hands over the next value. The distinction matters because an iterator gets used up: once the last value has been delivered, it does not start over.

Any class with the __iter__ and __next__ methods is an iterator. Writing one by hand is tedious; generators do the same with much less code.

Generators: bars on demand

A function that contains yield is a generator. Calling it does not run it: it returns a generator object. Each next() runs the body up to the next yield, hands over the value and leaves the function frozen at that point, with its variables intact, until the next request. Printing inside makes the lazy execution visible:

IN [02]python
def feed_simulado():
    print("  connecting to feed")
    yield 142.10
    print("  second tick")
    yield 142.15
    print("  feed closed")

feed = feed_simulado()
print("generator created")
print(next(feed))
print(next(feed))
print(list(feed))
OUT [02]A generator runs in pieces
generator created
  connecting to feed
142.1
  second tick
142.15
  feed closed
[]

"Connecting" does not appear when the generator is created, but on the first next(). The third pass (list(feed)) runs the rest of the body, finds no more yield and ends with an empty list.

This way of working fits market data. A file of bars can have millions of rows; a generator reads it one at a time, with the memory of a single bar, and hands over each bar already converted to Python types. The standard library's csv module takes care of the format (lesson 0.4.4 covers it in more detail):

IN [03]python
import csv
from collections.abc import Iterable, Iterator
from dataclasses import dataclass
from datetime import date

@dataclass(frozen=True, slots=True)
class Barra:
    fecha: date
    apertura: float
    maximo: float
    minimo: float
    cierre: float
    volumen: int

def barras(ruta: str) -> Iterator[Barra]:
    with open(ruta, newline="", encoding="utf-8") as f:
        for fila in csv.DictReader(f):
            yield Barra(date.fromisoformat(fila["fecha"]),
                        float(fila["apertura"]),
                        float(fila["maximo"]),
                        float(fila["minimo"]),
                        float(fila["cierre"]),
                        int(fila["volumen"]))

flujo = barras("sint_diario.csv")
print(type(flujo).__name__)
print(next(flujo))
print(next(flujo).cierre)
print(sum(1 for _ in flujo), "more bars")
OUT [03]SINT bars as a stream
generator
Barra(fecha=datetime.date(2021, 1, 4), apertura=100.02, maximo=100.64, minimo=99.07, cierre=99.14, volumen=2322609)
99.25
1301 more bars

The annotation Iterator[Barra] says what the generator produces (lesson 0.4.2). The last line counts the remaining bars with a generator expression, (1 for _ in flujo): the same syntax as a list comprehension, in parentheses, and without building any list.

The with open(...) inside the generator matters: the file is closed when the generator finishes. If it is abandoned halfway, the file stays open until Python collects the generator, a detail that in a process running for weeks can exhaust the file descriptors.

Rolling windows and consecutive pairs

Two operations appear in almost every indicator: looking at the last nn bars (averages, highs, volatility) and looking at each bar next to the previous one (returns, crossovers). Over a stream, without indexes, they are solved with two pieces of the standard library.

collections.deque(maxlen=n) is a fixed-size queue: when an element is added to a full queue, the oldest one drops out at the other end. It is a ready-made rolling window:

IN [04]python
from collections import deque

def medias_moviles(flujo: Iterable[Barra],
                   n: int) -> Iterator[tuple[date, float]]:
    ventana: deque[float] = deque(maxlen=n)
    for b in flujo:
        ventana.append(b.cierre)
        if len(ventana) == n:
            yield b.fecha, sum(ventana) / n

ultimas = deque(medias_moviles(barras("sint_diario.csv"), 200),
                maxlen=3)
for fecha, media in ultimas:
    print(fecha, round(media, 2))
OUT [04]Moving average over the stream
2025-12-29 136.62
2025-12-30 136.66
2025-12-31 136.72

The first average comes out at bar 200: before that there is no complete window, just as with rolling(200) in lesson 0.1.1. The second deque is a common trick: consume an entire generator and keep only its last elements, without storing the rest.

itertools.pairwise (Python 3.10+) hands over each element together with the next one: (b0, b1), (b1, b2)… It is exactly what a return needs:

IN [05]python
from itertools import islice, pairwise

def rentabilidades(flujo: Iterable[Barra]
                   ) -> Iterator[tuple[date, float]]:
    for ayer, hoy in pairwise(flujo):
        yield hoy.fecha, hoy.cierre / ayer.cierre - 1

primeras = islice(rentabilidades(barras("sint_diario.csv")), 3)
print([(str(f), round(r, 5)) for f, r in primeras])
fecha, peor = min(rentabilidades(barras("sint_diario.csv")),
                  key=lambda x: x[1])
print(f"Worst day: {fecha} ({peor:.2%})")
OUT [05]Returns with pairwise
[('2021-01-05', 0.00111), ('2021-01-06', -0.01884), ('2021-01-07', 0.01212)]
Worst day: 2022-04-22 (-4.81%)

The worst session of the five years, a −4.81% day, falls in April 2022, in the middle of that year's decline. islice slices an iterator as if it were a list ([:3]), without traversing the rest. The pieces chain together: each generator consumes the previous one and nobody stores the full history.

sint_diario.csvbarras()rentabilidades()min()
Mermaid source
flowchart TB
  F[sint_diario.csv] --> B["barras()"]
  B --> R["rentabilidades()"]
  R --> M["min()"]

When the first day's bar arrives, min() has not yet seen anything; when the last one arrives, it has already compared them all. It is the same scheme as a real-time system that processes each bar as it arrives, and that is why generators are the foundation of many event-driven backtest engines (level 2).

IN [06]python
caidas = (r for _, r in rentabilidades(barras("sint_diario.csv"))
          if r < -0.02)
print("Days with a drop > 2%:", sum(1 for _ in caidas))
print("Second pass:", sum(1 for _ in caidas))
OUT [06]A generator can only be iterated once
Days with a drop > 2%: 45
Second pass: 0

The second count gives zero without any warning. If the data has to be traversed several times, it is stored in a list or the generator is created again, as has been done in every cell of this lesson.

Decorators: wrapping a function

In Python, functions are objects: they can be passed as arguments and returned from another function. A decorator is a function that takes a function and returns another that wraps it, usually to add something before or after the call. The @decorador syntax above a definition is shorthand for funcion = decorador(funcion). Several have already been used: @dataclass, @property, @classmethod.

A natural use in trading is validating inputs in a single place. A zero or negative price in a position sizing calculation should not produce an absurd quantity, but an error:

IN [07]python
import functools

def positivos(func):
    """Rejects numeric arguments less than or equal to zero."""
    @functools.wraps(func)
    def envoltura(*args, **kwargs):
        for valor in (*args, *kwargs.values()):
            if isinstance(valor, (int, float)) and valor <= 0:
                raise ValueError(
                    f"{func.__name__}: non-positive value {valor}")
        return func(*args, **kwargs)
    return envoltura

@positivos
def titulos(capital: float, precio: float, riesgo: float = 0.01,
            stop: float = 0.05) -> int:
    """Shares such that a stop at `stop` loses `riesgo`."""
    return int(capital * riesgo / (precio * stop))

print(titulos(50_000, 142.22))
print(titulos.__name__, "|", titulos.__doc__)
try:
    titulos(50_000, 0.0)
except ValueError as e:
    print(e)
OUT [07]A validating decorator
70
titulos | Shares such that a stop at `stop` loses `riesgo`.
titulos: non-positive value 0.0

With €50,000 of capital, 1% risk and a stop 5% below 142.22, the size is 70 shares: if the stop is hit, about €500 is lost. With a zero price, the original function would divide by zero; the decorated one rejects it with a message that says which function and which value.

envoltura receives *args and **kwargs (lesson 0.3.5) so it can wrap any function, whatever its arguments. @functools.wraps(func) copies the original's name and docstring to the wrapper: without it, titulos would be called envoltura in logs and tracebacks.

Decorators with arguments

When the decorator needs parameters, one more level is required: a function that takes the parameters and returns the decorator. The classic example is retrying a call that fails because of the network, such as requesting a price from a broker. The simulated broker fails the first two times and responds on the third, always the same way, so the output is reproducible:

IN [08]python
def reintentar(veces: int, errores=(ConnectionError,)):
    def decorador(func):
        @functools.wraps(func)
        def envoltura(*args, **kwargs):
            for intento in range(1, veces + 1):
                try:
                    return func(*args, **kwargs)
                except errores as e:
                    print(f"  attempt {intento}/{veces}: {e}")
            raise ConnectionError(
                f"{func.__name__}: no response after {veces}")
        return envoltura
    return decorador

respuestas = iter([False, False, True])

@reintentar(veces=3)
def precio_actual(simbolo: str) -> float:
    if not next(respuestas):
        raise ConnectionError(f"timeout requesting {simbolo}")
    return 142.22

print(precio_actual("SINT"))
OUT [08]A retry decorator
  attempt 1/3: timeout requesting SINT
  attempt 2/3: timeout requesting SINT
142.22

A real retry waits between attempts, longer each time (exponential backoff), so as not to overload the server; here it is left out to avoid measuring time. What is never left out is thinking about what gets retried.

Context managers: cleaning up no matter what

with open(...) as f guarantees that the file is closed when the block is exited, also if an exception is raised inside. Any object with the __enter__ and __exit__ methods works with with: it is a context manager. __enter__ runs on entry and its result is what the as variable receives; __exit__ runs on exit, always, and receives the exception if there was one.

In trading, cleanup matters: when a session ends, normally or because of an error, there must be no pending orders left with nobody watching them. A simulated trading session:

IN [09]python
class SesionOperativa:
    def __init__(self, cuenta: str):
        self.cuenta = cuenta
        self.pendientes: list[str] = []

    def __enter__(self):
        print(f"[{self.cuenta}] session opened")
        return self

    def enviar(self, orden: str) -> None:
        self.pendientes.append(orden)
        print(f"[{self.cuenta}] sent: {orden}")

    def ejecutada(self, orden: str) -> None:
        self.pendientes.remove(orden)

    def __exit__(self, tipo, valor, traza):
        for orden in self.pendientes:
            print(f"[{self.cuenta}] canceled: {orden}")
        self.pendientes.clear()
        motivo = f"error: {valor}" if tipo else "normal"
        print(f"[{self.cuenta}] session closed ({motivo})")
        return False  # does not suppress the exception

with SesionOperativa("demo") as s:
    s.enviar("buy 70 SINT lim 141.50")
    s.enviar("sell 40 ORO stop 165.00")
    s.ejecutada("buy 70 SINT lim 141.50")
OUT [09]A trading session with with
[demo] session opened
[demo] sent: buy 70 SINT lim 141.50
[demo] sent: sell 40 ORO stop 165.00
[demo] canceled: sell 40 ORO stop 165.00
[demo] session closed (normal)

The filled order leaves the list; the one still live is canceled on close. With an error mid-session, the close happens all the same and the exception continues on its way to whoever catches it:

IN [10]python
try:
    with SesionOperativa("demo") as s:
        s.enviar("buy 100 TECH lim 77.50")
        raise RuntimeError("price feed down")
        s.enviar("sell 100 TECH lim 79.00")  # never reached
except RuntimeError as e:
    print("caught outside:", e)
OUT [10]Cleanup also happens with an error
[demo] session opened
[demo] sent: buy 100 TECH lim 77.50
[demo] canceled: buy 100 TECH lim 77.50
[demo] session closed (error: price feed down)
caught outside: price feed down

__exit__ returns False, so the exception is not lost. Returning True would suppress it: the program would carry on as if nothing had happened, which is almost always worse than a visible error.

For simple cases, contextlib.contextmanager turns a generator into a context manager: what comes before the yield is the entry, and what goes in the finally, the guaranteed exit. An example: suspending trading during the release of a macro data point and resuming it afterwards, no matter what:

IN [11]python
from contextlib import contextmanager

estado = {"operativa": True}

@contextmanager
def operativa_suspendida(motivo: str):
    estado["operativa"] = False
    print(f"trading suspended: {motivo}")
    try:
        yield
    finally:
        estado["operativa"] = True
        print("trading resumed")

with operativa_suspendida("US jobs report"):
    print("inside:", estado)
print("outside:", estado)
OUT [11]contextmanager with a generator
trading suspended: US jobs report
inside: {'operativa': False}
trading resumed
outside: {'operativa': True}

Without the try/finally, an exception inside the block would leave trading suspended forever.

ToolFormUse in trading
Generatorfunction with yieldReading bars, ticks or large files as a stream
deque(maxlen=n)fixed-size queueRolling windows for indicators
itertools.pairwise, isliceiterator utilitiesReturns, crossovers, first nn bars
Decoratorfunction that wraps anotherValidating, logging, retrying queries
Context manager__enter__/__exit__ or @contextmanagerSessions, connections, files, suspensions

Exercises

  1. Write a generator barras_del_anio(flujo, anio) that only lets through the bars of that year. Use it with barras("sint_diario.csv") to count the sessions of 2022 and find that year's lowest close.
Solution
IN [12]python
def barras_del_anio(flujo: Iterable[Barra],
                    anio: int) -> Iterator[Barra]:
    for b in flujo:
        if b.fecha.year == anio:
            yield b

de_2022 = list(barras_del_anio(barras("sint_diario.csv"), 2022))
minima = min(de_2022, key=lambda b: b.cierre)
print(len(de_2022), minima.fecha, minima.cierre)
OUT [12]Filtering a stream by year
260 2022-10-20 94.45

It is stored in a list because it is traversed twice (len and min). The generator still reads the whole file: without an index by date, there is no way to jump to 2022 without going through 2021.

  1. Write a decorator contar_llamadas that stores in the llamadas attribute of the decorated function how many times it has been called. Apply it to titulos (the undecorated function is in titulos.__wrapped__) and call it three times.
Solution
IN [13]python
def contar_llamadas(func):
    @functools.wraps(func)
    def envoltura(*args, **kwargs):
        envoltura.llamadas += 1
        return func(*args, **kwargs)
    envoltura.llamadas = 0
    return envoltura

titulos_contados = contar_llamadas(titulos.__wrapped__)
for precio in (142.22, 77.82, 171.44):
    titulos_contados(50_000, precio)
print(titulos_contados.llamadas)
OUT [13]A decorator that counts calls
3

functools.wraps adds __wrapped__, which points to the original function. The count lives in an attribute of the wrapper because the wrapper persists between calls.

  1. With @contextmanager, write registro_ordenes() that hands over an empty list and, on leaving the block, prints how many orders were added and their total amount. Test it with three (cantidad, precio) orders.
Solution
IN [14]python
@contextmanager
def registro_ordenes():
    ordenes: list[tuple[int, float]] = []
    try:
        yield ordenes
    finally:
        total = sum(c * p for c, p in ordenes)
        print(f"{len(ordenes)} orders, amount {total:,.2f}")

with registro_ordenes() as reg:
    reg.append((70, 142.22))
    reg.append((100, 77.82))
    reg.append((20, 171.44))
OUT [14]A log that summarizes on exit
3 orders, amount 21,166.20

What the generator hands over with yield is what the as variable receives. The summary goes in the finally so that it is printed even if the block fails halfway.

Each answer is folded under its question

Self-assessment

  1. A generator of returns is consumed with sum() and, on the next line, again with max(). What happens in the second one?

    • AIt returns the same maximum as if it were a list
    • BIt reads the file again from the start
    • CThe generator is already exhausted: max() raises ValueError for an empty sequence
    • DPython copies the generator automatically
    Show answer

    Answer C. The generator is already exhausted: max() raises ValueError for an empty sequence

    A generator produces each value only once. After the first pass it is exhausted; max() of an empty sequence raises ValueError, and sum() would return 0 without warning. For two passes, create the generator twice or store the values in a list.

  2. What advantage does deque(maxlen=20) have for a moving average over a stream of bars?

    • AIt sorts the prices automatically
    • BIt keeps only the last 20: when one is added, the oldest drops out on its own, and memory does not grow with the history
    • CIt computes the average without adding
    • DIt is more accurate in floating point
    Show answer

    Answer B. It keeps only the last 20: when one is added, the oldest drops out on its own, and memory does not grow with the history

    With maxlen, the queue discards from the other end once full. Memory is constant, whether the history covers five years or fifty.

  3. What is @functools.wraps(func) for inside a decorator?

    • ATo make the decorated function run faster
    • BTo copy the name, docstring and other metadata of the original function to the wrapper
    • CTo let the decorator take arguments
    • DIt is mandatory: without it, the decorator does not work
    Show answer

    Answer B. To copy the name, docstring and other metadata of the original function to the wrapper

    Without wraps, the decorated function would be called envoltura and would lose its docstring: logs, tracebacks and help would show the wrong name. The decorator works the same, but it is harder to debug.

  4. An exception is raised inside a with SesionOperativa(...). What happens if __exit__ returns False?

    • A__exit__ runs (pending orders are canceled) and the exception continues on its way
    • B__exit__ does not run
    • CThe exception disappears
    • DThe program ends without running anything else
    Show answer

    Answer A. __exit__ runs (pending orders are canceled) and the exception continues on its way

    __exit__ always runs, with or without an exception, and receives its type, value and traceback. If it returns a falsy value, the exception propagates; if it returns True, it is suppressed, which is almost never a good idea.

Key points

  • for calls iter() and then next() until StopIteration. Any object that follows that protocol can be iterated.
  • A generator (yield) produces values on demand: it reads a file of bars one at a time and is exhausted after one pass.
  • deque(maxlen=n) and itertools.pairwise handle rolling windows and consecutive pairs without indexes or copies.
  • A decorator takes a function and returns another that wraps it; functools.wraps keeps its name and docstring. Retrying a query is reasonable; retrying an order submission can duplicate it.
  • with guarantees cleanup (closing files, canceling pending orders) even if there is an exception.

Resources