Lesson 0.3.3 · 50 min
Lists, tuples, dictionaries, sets and comprehensions
A series of closes, an OHLC bar, a portfolio and the universe of an index are collections of data, and each one has its own structure in Python. The lesson teaches all four and comprehensions, the short way to build them.
- Module
- 0.3 · Language fundamentals
- Duration
- 50 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
Collections: many pieces of data under one name
So far each variable held one piece of data: a price, a quantity, a ticker (lessons 0.3.1 and 0.3.2). A trading program almost always works with many: the closes of a month, the assets being watched, the positions of a portfolio, the components of an index. Python has four built-in collections to hold them, and each one meets a different need:
| Collection | Literal | Ordered? | Modifiable? | Duplicates? | Typical use in trading |
|---|---|---|---|---|---|
List (list) | [139.32, 139.23] | Yes | Yes | Yes | Series of closes, watchlist, pending orders |
Tuple (tuple) | (140.01, 143.40, 139.67, 142.22) | Yes | No | Yes | An OHLC bar, a fixed record |
Dictionary (dict) | {"SINT": 100, "ORO": 20} | By insertion | Yes | Unique keys | Portfolio, prices by ticker, parameters |
Set (set) | {"SINT", "ORO"} | No | Yes | No | Universe of an index, unique symbols |
Lists: series and watchlists
A list is an ordered sequence of values between square brackets. It is indexed and sliced exactly like a string
(lesson 0.3.2): the first element is [0], the last [-1], and [inicio:fin] returns a new list with that stretch.
SINT's closes for the second half of December 2025 (from the file sint_diario.csv, link in “Resources”) are a list of
floats:
cierres = [146.36, 145.13, 144.22, 143.66, 139.91, 139.43,
139.36, 139.32, 139.23, 139.56, 139.98, 142.22]
print(len(cierres), cierres[0], cierres[-1])
print(cierres[-3:])
print(max(cierres), min(cierres))
print(sum(cierres) / len(cierres))12 146.36 142.22
[139.56, 139.98, 142.22]
146.36 139.23
141.53166666666667len, max, min and sum work with any collection of numbers. cierres[-3:] are the last three closes, and the
last line is the mean of the series. Along the same lines, cierres[-20:] would give the last twenty sessions of a
longer series: that is how the computation of a moving average starts.
Unlike strings, lists are modifiable. Their methods change the list itself and return nothing (they return
None). A watchlist, the list of assets being watched, is the natural example:
watchlist = ["SINT", "RVAR", "ORO"]
watchlist.append("TECH") # appends at the end
watchlist.insert(0, "RFIJ") # inserts at a position
watchlist.remove("ORO") # removes by value
ultimo = watchlist.pop() # removes and returns the last one
print(watchlist, ultimo)
watchlist.sort()
print(watchlist, "SINT" in watchlist, watchlist.index("SINT"))
print(sorted(cierres)[:3])['RFIJ', 'SINT', 'RVAR'] TECH
['RFIJ', 'RVAR', 'SINT'] True 2
[139.23, 139.32, 139.36]sort() sorts the list in place; sorted() returns a new sorted list and leaves the original as it was. The
difference matters with a price series: sorting the closes in place destroys the time order, and with it any return
calculation that comes after. For a time series, sorted, never sort.
The most common trap with lists is this one: assigning a list to another name does not copy it. Both names point to the same list, and whatever is done through one is seen in the other.
pendientes = ["SINT", "ORO"]
para_enviar = pendientes # the same object
para_enviar.append("TECH")
print(pendientes)
copia = pendientes.copy() # a new object
copia.clear()
print(pendientes, copia)
print(para_enviar is pendientes, copia is pendientes)['SINT', 'ORO', 'TECH']
['SINT', 'ORO', 'TECH'] []
True FalseThe is operator asks whether two names point to the same object. In an order system, this error has consequences: a
script that “empties the copy” of the pending order list after sending them may be emptying the original. The same
trap shows up again with the default arguments of functions (lesson 0.3.5).
Tuples: an OHLC bar
A tuple is a sequence like a list, but immutable: once created, it cannot be changed. It is written between parentheses, or simply with commas. It is the right structure for a record with a fixed number of fields that must not change, such as a price bar: open, high, low and close (OHLC).
barra = ("2025-12-31", 140.01, 143.40, 139.67, 142.22)
fecha, apertura, maximo, minimo, cierre = barra
print(fecha, cierre)
print(f"Range: {maximo - minimo:.2f} Body: {cierre - apertura:+.2f}")
print(barra[1:])2025-12-31 142.22
Range: 3.73 Body: +2.21
(140.01, 143.4, 139.67, 142.22)The second line is unpacking: a tuple (or any sequence) is distributed over as many variables as it has elements.
It is what base, cotizada = "EUR", "USD" did in lesson 0.3.2: on the right there was a tuple without parentheses. If
the number of variables does not match the number of elements, Python raises an error.
Immutability is a guarantee: a historical bar should not change, and a tuple does not let it (barra[4] = 150.0
raises an error). A bar that could be modified by accident would be falsified historical data in the middle of a
backtest.
Dictionaries: portfolio and prices by ticker
A dictionary maps keys to values. It is written between braces with clave: valor pairs, and it is looked
up by key, not by position. A portfolio is a dictionary of ticker → shares; prices, another of ticker → last close
(those of SINT and of the assets in cartera_sint.csv on December 31, 2025):
cartera = {"SINT": 100, "ORO": 20, "RFIJ": 150}
precios = {"SINT": 142.22, "ORO": 171.44, "RFIJ": 102.29,
"TECH": 77.82}
print(cartera["SINT"], len(cartera), "TECH" in cartera)
cartera["TECH"] = 30 # open a position
cartera["ORO"] += 10 # increase a position
del cartera["RFIJ"] # close a position
print(cartera)
print(list(cartera.keys()), list(cartera.values()))100 3 False
{'SINT': 100, 'ORO': 30, 'TECH': 30}
['SINT', 'ORO', 'TECH'] [100, 30, 30]Assigning to a key that does not exist creates it; assigning to one that exists replaces its value. in asks about the
keys, not the values. Since Python 3.7, dictionaries preserve the order in which the keys were inserted.
To value the portfolio you have to go through it. items() gives the key-value pairs, and the for loop (lesson
0.3.4) processes them one by one:
valor_total = 0.0
for ticker, acciones in cartera.items():
valor = acciones * precios[ticker]
valor_total += valor
print(f"{ticker:<5} {acciones:>4} x {precios[ticker]:>7.2f}"
f" = {valor:>9,.2f}")
print(f"{'Total':<22}{valor_total:>9,.2f}")SINT 100 x 142.22 = 14,222.00
ORO 30 x 171.44 = 5,143.20
TECH 30 x 77.82 = 2,334.60
Total 21,699.80Reading a key that does not exist with square brackets is an error (KeyError), and in trading it happens often: a
ticker with no price that day, a misspelled symbol. If the absence is normal and has a reasonable value, get gives it
without an error:
print(cartera.get("SINT", 0), cartera.get("BTC", 0))
print(precios.get("BTC"))100 0
NoneWithout a second argument, get returns None. Be careful not to use it to cover up errors: a portfolio that holds no
BTC holds 0 BTC, but a missing price is not a price of 0. Valuing a position at a price of 0 makes it disappear from
the portfolio's value without any warning. When it is better to let the error happen and how to handle it is covered in
lesson 0.3.6.
Sets: the universe of an index
A set is a collection of unique elements with no order, written between braces without pairs. It is used for two things: removing duplicates and doing set operations, which are exactly the questions asked about the universe of an index. The components of an index change over time: companies that have grown join, and those that have fallen, merged or gone bankrupt leave.
The example uses a fictional index with six components at two dates:
universo_2024 = {"ALFA", "BETA", "GAMMA", "DELTA", "SIGMA", "OMEGA"}
universo_2025 = {"ALFA", "BETA", "DELTA", "SIGMA", "KAPPA", "LAMBDA"}
altas = universo_2025 - universo_2024
bajas = universo_2024 - universo_2025
siguen = universo_2024 & universo_2025
alguna_vez = universo_2024 | universo_2025
print("Additions:", sorted(altas))
print("Deletions:", sorted(bajas))
print("Remain:", len(siguen), " Ever:", len(alguna_vez))
print("Changes:", sorted(universo_2024 ^ universo_2025))Additions: ['KAPPA', 'LAMBDA']
Deletions: ['GAMMA', 'OMEGA']
Remain: 4 Ever: 8
Changes: ['GAMMA', 'KAPPA', 'LAMBDA', 'OMEGA']| Operation | Operator | Question |
|---|---|---|
| Difference | a - b | What is in a and not in b? (additions or deletions) |
| Intersection | a & b | What is in both? |
| Union | a | b | What is in either? |
| Symmetric difference | a ^ b | What is in only one? (all the changes) |
Sets have no order, so print would show them in any order; sorted turns them into a sorted list so that the output
is always the same.
The deletions line is the one that matters most in trading. If a backtest of the last ten years uses only today's
components, it never buys GAMMA or OMEGA: the companies that left, often after falling, are not on the list. The
backtest only sees the survivors and comes out better than it really would have been. This is survivorship bias,
and the only remedy is to use the universe that existed at each date, alguna_vez included:
The other use of sets is removing duplicates. The symbols traded in a trade log repeat; set keeps each one once:
simbolos_operados = ["ORO", "SINT", "SINT", "SINT", "ORO", "TECH"]
unicos = set(simbolos_operados)
print(sorted(unicos), len(unicos))['ORO', 'SINT', 'TECH'] 3Comprehensions: returns in one line
A comprehension builds a collection from another one in a single expression: [expression for element in collection]. It reads almost like a sentence: “the expression, for each element of the collection”. It is the
idiomatic way to transform a series.
A day's return is : it needs each close and the previous one. zip pairs the elements of two
collections; if you pass it the series and the series shifted by one position (cierres[1:]), each pair is (previous
close, close of the day):
rentabilidades = [c1 / c0 - 1
for c0, c1 in zip(cierres, cierres[1:])]
print(len(rentabilidades))
print(" ".join([f"{r:+.2%}" for r in rentabilidades]))
peor = min(rentabilidades)
print(f"Worst day: {peor:+.2%}, session {rentabilidades.index(peor) + 1}")11
-0.84% -0.63% -0.39% -2.61% -0.34% -0.05% -0.03% -0.06% +0.24% +0.30% +1.60%
Worst day: -2.61%, session 4Twelve closes give eleven returns: zip stops when the shorter list runs out. The second comprehension turns each
return into a formatted text, and " ".join(…) joins those texts into one, separated by a space. The header figure
plots those eleven returns; the worst one, on December 22, in amber.
A comprehension can filter with an if at the end, and there are versions for dictionaries and sets:
subidas = [r for r in rentabilidades if r > 0]
print(f"Up days: {len(subidas)} of {len(rentabilidades)}")
valores = {t: n * precios[t] for t, n in cartera.items()}
total = sum(valores.values())
pesos = {t: round(v / total, 3) for t, v in valores.items()}
print(pesos)
operaciones = [("SINT", 7.43), ("ORO", -3123.56),
("SINT", -31.50), ("TECH", 1426.71)]
con_perdidas = {s for s, pnl in operaciones if pnl < 0}
print(sorted(con_perdidas))Up days: 3 of 11
{'SINT': 0.655, 'ORO': 0.237, 'TECH': 0.108}
['ORO', 'SINT']Braces with clave: valor give a dictionary; without the colon, a set. The portfolio weights come out in two steps:
first the value of each position and then its fraction of the total.
Comprehensions are convenient as long as they fit on one or two lines and can be read at a glance. If you need more
than one if, or the expression takes up half a line, a normal for loop (lesson 0.3.4) is clearer.
Which collection to use
The decision comes from three questions, in this order:
Mermaid source
flowchart TB
Q1{Key and value?} -- yes --> D[dict]
Q1 -- no --> Q2{Unique only?}
Q2 -- yes --> S[set]
Q2 -- no --> Q3{Will it change?}
Q3 -- yes --> L[list]
Q3 -- no --> T[tuple]If each piece of data is looked up by a name (a ticker, a parameter), dictionary. If what matters is membership (it is in the index, it has been traded), set. If it is a sequence that grows or changes (a series that receives a new close every day, an order queue), list. If it is a fixed record (a bar, a closed trade), tuple.
Exercises
- With the lesson's
cierreslist, compute the total return for the period (last close divided by the first, minus- and the number of sessions in which the close fell relative to the previous one.
Solution
total_periodo = cierres[-1] / cierres[0] - 1
bajadas = [r for r in rentabilidades if r < 0]
print(f"Period return: {total_periodo:+.2%}")
print(f"Down sessions: {len(bajadas)}")Period return: -2.83%
Down sessions: 8The total return only needs the first and the last close. The down sessions come from filtering the negative returns with a comprehension.
- The fictional index changes again in 2026: BETA and KAPPA leave and OMEGA and RHO join. Build
universo_2026fromuniverso_2025with set operations, and compute how many companies have been part of the index at some point in the three years.
Solution
universo_2026 = (universo_2025 - {"BETA", "KAPPA"}) | {"OMEGA", "RHO"}
print(sorted(universo_2026))
historico = universo_2024 | universo_2025 | universo_2026
print(len(historico), sorted(historico))['ALFA', 'DELTA', 'LAMBDA', 'OMEGA', 'RHO', 'SIGMA']
9 ['ALFA', 'BETA', 'DELTA', 'GAMMA', 'KAPPA', 'LAMBDA', 'OMEGA', 'RHO', 'SIGMA']OMEGA leaves in 2025 and returns in 2026; the set counts it only once. An honest backtest from 2024 to 2026 needs all nine names, with the dates on which each one was in the index.
- With the lesson's
preciosdictionary, create a dictionary with the value of a portfolio holding 10 shares of each ticker, and say which ticker has the largest value.
Solution
diez = {t: round(10 * p, 2) for t, p in precios.items()}
print(diez)
mayor = max(diez, key=diez.get)
print(mayor, diez[mayor]){'SINT': 1422.2, 'ORO': 1714.4, 'RFIJ': 1022.9, 'TECH': 778.2}
ORO 1714.4round removes the tail of decimals that floating point leaves in 10 * 102.29 (lesson 0.3.1). max goes through
the dictionary's keys; key=diez.get tells it to compare them by their value and not by their name. Lesson 0.3.5
explains what that key argument is and how to use it with your own functions.
Each answer is folded under its question
Self-assessment
cierres = [139.32, 139.23, 139.56]and thencopia = cierres; copia.append(139.98). How many elements doescierreshave?- A3, because the copy was modified
- B4, because
copiaandcierresare the same list - CIt raises an error
- D1
Show answer
Answer B. 4, because
copiaandcierresare the same listcopia = cierrescopies nothing: it creates another name for the same list. For an independent copy you needcierres.copy()orlist(cierres).Which structure is right for storing the shares you hold of each ticker and looking them up by name?
- AA dictionary
- BA list
- CA tuple
- DA set
Show answer
Answer A. A dictionary
A dictionary maps each key (the ticker) to a value (the shares), and lookup by key is immediate. A list would force you to go through all of it.
antes = {"A", "B", "C"}andahora = {"B", "C", "D"}. What doesantes - ahorareturn?- A
{"D"} - B
{"B", "C"} - C
{"A"} - D
{"A", "D"}
Show answer
Answer C.
{"A"}The difference
antes - ahorais the elements that were there and no longer are: the deletions from the index. The additions would beahora - antes, and^would give both changes together.- A
A backtest uses only the companies in an index today to simulate the last ten years. What bias does it have?
- ALook-ahead bias
- BNone, if the prices are adjusted
- CCost bias
- DSurvivorship bias: the companies that left the index are missing, many of them because they fell
Show answer
Answer D. Survivorship bias: the companies that left the index are missing, many of them because they fell
The companies in the index today are the ones that survived. Those that went bankrupt or fell far enough to leave do not appear, and the backtest comes out better than it would have been. You need the universe at each date.
What does
[c1 / c0 - 1 for c0, c1 in zip(cierres, cierres[1:])]do?- AComputes the total return
- BComputes each day's return relative to the previous one
- CSorts the closes
- DRaises an error because the lists have different lengths
Show answer
Answer B. Computes each day's return relative to the previous one
zippairs each close with the next one (zipstops at the shortest list) and the comprehension computesc1 / c0 - 1for each pair: one return per day, one fewer than the closes.
Key points
- List: ordered, modifiable sequence (closes, watchlist). Tuple: fixed sequence, ideal for a record such as an OHLC bar.
- Assigning a list to another name does not copy it: both names point to the same list. To copy,
.copy(). - Dictionary: key → value (ticker → shares).
d[clave]fails if the key does not exist;d.get(clave, 0)returns a default value. - Set: unique elements with no order. The difference between the universes at two dates gives the additions and deletions; ignoring the deletions is survivorship bias.
- A comprehension builds a collection in one line:
[c1 / c0 - 1 for c0, c1 in zip(cierres, cierres[1:])]gives the daily returns.