Lesson 0.2.2 · 40 min
Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles
A backtest depends on the code, on the data and on the libraries it runs with. This lesson creates the course project with uv, explains what each file stores and shows how to pin exact versions so that the same code gives the same result on any machine.
- Module
- 0.2 · Modern working environment
- Duration
- 40 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
One Python per project
In lesson 0.2.1 you installed Python 3.14 with uv. That interpreter is shared: if you install pandas "into it", every program that uses it will see the same version of pandas. That works until the day two projects need different things:
- A system that trades live was tested with pandas 2.2 and you do not want to touch it until you have reviewed it carefully.
- The course project uses pandas 3.0, which changes the type of text columns and the semantics of copies (lesson 0.5.2).
With a single Python, upgrading pandas for the course also changes the live system, without anyone having tested it. The solution is for each project to have its own set of libraries: a virtual environment.
A virtual environment is a folder (by convention, .venv inside the project) with three things: a link to the base
interpreter, a site-packages folder where the project's libraries are installed and a configuration file. It does
not copy the whole of Python: it weighs as much as the libraries do. When you run code "inside" the environment,
Python looks for packages in that folder first.
You can tell from the code itself whether you are inside one. sys.prefix is the folder of the active environment;
sys.base_prefix, that of the Python it was created from. Outside a virtual environment, the two match. The code in
this course runs with uv run inside a project, so here they differ:
import sys
from pathlib import Path
print("Virtual environment:", sys.prefix != sys.base_prefix)
print("Environment folder:", Path(sys.prefix).name)Virtual environment: True
Environment folder: .venvThe project's libraries live inside that folder. The path relative to the environment is the same on Linux and macOS
(on Windows it is Lib/site-packages):
import pandas as pd
ruta = Path(pd.__file__).relative_to(sys.prefix)
print(ruta.parent)lib/python3.14/site-packages/pandasPython has a module to create environments by hand (python -m venv .venv) and pip to install into them. It works,
but it forces you to remember what was installed, in what order and with which versions. uv does all of that from two
text files.
A project with uv
The course project is created with uv init. The --name ppt option gives the package the name you used in
lesson 0.1.3 (src/ppt/); without it, it would be named after the folder:
uv init ppt-curso --name ppt --python 3.14
cd ppt-curso
uv add pandas numpy # project dependencies
uv add --dev pytest # for development only (tests)
uv run python -c "import pandas; print(pandas.__version__)"uv init creates the minimal structure and, if you are not already inside a repository, initializes a Git one
(lesson 0.2.5). The first uv add resolves the versions, writes the lockfile and creates the environment. The
result:
| File | What it is | To Git? |
|---|---|---|
pyproject.toml | What the project declares: name, Python version, dependencies | Yes |
.python-version | The Python version uv will use in this folder (3.14) | Yes |
uv.lock | Exact versions and hashes of every package, direct and indirect | Yes |
.venv/ | The virtual environment: the installed libraries | No |
src/ppt/__init__.py | Your package | Yes |
.gitignore | What Git should ignore; it already includes .venv | Yes |
From here on you will almost never type plain python. uv run checks that the environment matches the lock (and
updates it if needed) before running: uv run python script.py, uv run pytest, uv run jupyter lab. There is no
need to "activate" the environment.
pyproject.toml: what you ask for
pyproject.toml is the standard configuration file of a Python project. It is in TOML format, which Python reads
without external libraries since version 3.11 with the tomllib module. This is, trimmed, the one uv init leaves
after the two uv add commands:
import tomllib
texto = """
[project]
name = "ppt"
version = "0.1.0"
requires-python = ">=3.14"
dependencies = [
"numpy>=2.5.4",
"pandas>=3.0.6",
]
[dependency-groups]
dev = ["pytest>=9.1.1"]
"""
proyecto = tomllib.loads(texto)
print(proyecto["project"]["requires-python"])
print(proyecto["project"]["dependencies"])
print(proyecto["dependency-groups"]["dev"])>=3.14
['numpy>=2.5.4', 'pandas>=3.0.6']
['pytest>=9.1.1']Notice the >=. uv add pandas does not pin the version: it writes the one it has just installed as a minimum.
The project declares "I need at least this", which is reasonable for a list of requirements: it does not stop someone
else from using a version with later fixes.
Checking whether what is installed meets what was asked for is easy with importlib.metadata, the module that reads
the metadata of installed packages. To compare versions, the same tuple idea from lesson 0.2.1 is used:
from importlib.metadata import version
def como_tupla(v: str) -> tuple[int, ...]:
return tuple(int(parte) for parte in v.split("."))
for requisito in proyecto["project"]["dependencies"]:
nombre, minima = requisito.split(">=")
instalada = version(nombre)
cumple = como_tupla(instalada) >= como_tupla(minima)
print(f"{nombre:<6} wants >= {minima} installed {instalada}"
f" {'ok' if cumple else 'NO'}")numpy wants >= 2.5.4 installed 2.5.4 ok
pandas wants >= 3.0.6 installed 3.0.6 okcomo_tupla only understands versions made of numbers separated by dots. There are versions such as 2.9.0.post0
(the one of python-dateutil that pandas uses) that would break it; that is what the packaging library, the one uv
and pip use, is for. Here it is enough for numpy and pandas.
uv.lock: what was resolved
A range is not enough to reproduce a result. pandas>=3.0.6 accepts 3.0.6 today and 3.1 six months from now. On top
of that, pandas has its own dependencies, also with ranges. The installed metadata shows them:
from importlib.metadata import requires
for req in requires("pandas"):
if "extra ==" not in req:
print(req)numpy>=1.26.0; python_version < "3.14"
numpy>=2.3.3; python_version >= "3.14"
python-dateutil>=2.8.2
tzdata; sys_platform == "win32"
tzdata; sys_platform == "emscripten"The lines that are not printed are the optional dependencies (extras: for Excel, for plotting…), which are only
installed if requested. What remains says: NumPy 2.3.3 or higher on Python 3.14, python-dateutil 2.8.2 or higher and
tzdata only on Windows and in the browser (emscripten). Each of them may in turn have dependencies of its own.
Finding a set of versions that satisfies all the ranges at once is the job of the resolver. uv does it once and
saves the solution in uv.lock. From that moment on, installing is no longer resolving, but copying:
Mermaid source
flowchart TB P[pyproject.toml] --> R[uv resolver] R --> L[uv.lock] L --> S[uv sync] S --> V[.venv]
uv add and uv lock walk the whole path; uv sync and uv run start from the lock. The lockfile is also TOML and
has one entry per package. This is the one for pandas in the project the course code was run with, trimmed (without
the list of downloadable files and with the hash abbreviated):
[[package]]
name = "pandas"
version = "3.0.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "numpy" },
{ name = "python-dateutil" },
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/…/pandas-3.0.6.tar.gz", hash = "sha256:66b07ef7…", … }The version is no longer a range but an exact number, and each downloadable file carries its hash: a cryptographic fingerprint of the content. If the file that arrives from the server does not have that exact fingerprint, uv refuses to install it. It is the same technique used to check that a data file has not changed:
import hashlib
datos = Path("sint_diario.csv").read_bytes()
print(hashlib.sha256(datos).hexdigest()[:16])
alterado = datos.replace(b"139.56", b"139.57", 1)
print(hashlib.sha256(alterado).hexdigest()[:16])f7cf610c97e34f7f
ec6f2c6ad27ed154A one-cent difference in the close of December 29, 2025 produces a fingerprint with nothing in common with the original. You will use this idea again to version market data in level 1.
The lock is universal: it includes the packages for every platform, with their condition (marker). The course's
lock started with 13 direct dependencies and 37 packages in total (the figure in the header shows how many each one
pulled in; many repeat, like NumPy); each level adds its own, and the fingerprint further down counts today's. uv sync
installs on each machine only the ones that apply to it: on Linux, neither tzdata nor colorama, which are for
Windows.
On another machine —a colleague's laptop, the server where your system will trade, the continuous integration service that runs the tests— the project is rebuilt with two commands:
git clone https://github.com/<user>/ppt-curso.git
cd ppt-curso
uv sync --locked # installs exactly what uv.lock says
uv run pytest--locked makes uv fail if uv.lock is not up to date with pyproject.toml, instead of silently resolving again. It
is the right option on a server: if someone added a dependency and forgot to commit the lock, you would rather have an
error at deploy time than a system trading with versions nobody has tested. uv also downloads the Python version in
.python-version if the machine does not have it.
Same versions, same results
Why so much care with versions? Because libraries change, and with them the figures of a backtest can change. Sometimes the change is a documented improvement (pandas 3.0 changed the default type for text, lesson 0.5.2); other times, a default value nobody looks at. An example with two libraries the course uses every day: the annualized volatility of SINT computed with pandas and with NumPy.
import numpy as np
precios = pd.read_csv("sint_diario.csv", parse_dates=["fecha"],
index_col="fecha")
rent = precios["cierre"].pct_change().dropna()
def vol_anual(r) -> float:
return float(r.std() * 252 ** 0.5)
print(f"pandas, 5 years: {vol_anual(rent):.3%}")
print(f"NumPy, 5 years: {vol_anual(rent.to_numpy()):.3%}")
dic = rent.loc["2025-12"]
print(f"pandas, Dec 2025: {vol_anual(dic):.2%}")
print(f"NumPy, Dec 2025: {vol_anual(dic.to_numpy()):.2%}")pandas, 5 years: 18.096%
NumPy, 5 years: 18.089%
pandas, Dec 2025: 13.97%
NumPy, Dec 2025: 13.67%The same method, .std(), gives different numbers: pandas divides by (sample standard deviation) and NumPy by
(population), unless asked otherwise with ddof. With five years of data the difference is negligible; with a
one-month window, like the one a system that scales its size to recent volatility would use, it changes the position
size. If an upgrade changed a default value like that, the backtest would give a different figure without a single
line of code changing.
That is why a result travels with its environment. In lesson 0.2.1 you saved the versions of Python, NumPy and
pandas; with importlib.metadata the whole environment can be summarized in a short fingerprint:
from importlib.metadata import distributions
paquetes = sorted(f"{d.metadata['Name'].lower()}=={d.version}"
for d in distributions())
huella = hashlib.sha256("\n".join(paquetes).encode()).hexdigest()
print(len(paquetes), "packages installed")
print("Fingerprint:", huella[:12])163 packages installed
Fingerprint: e758dd2c21d8If you save that fingerprint next to the Sharpe and the equity curve of each backtest, you will know at a glance
whether two results came from the same environment. In your project, the uv.lock versioned in Git does that job
itself: the commit that produced a result identifies the code and the environment at the same time.
Four common mistakes break that guarantee without anyone noticing:
- Running plain
pythonoutsideuv run: depending on the terminal, it may be the system Python, with other libraries. If a script fails withModuleNotFoundErrorand it worked in the notebook, start here. - Installing with
pipinside the project: the package never reachespyproject.tomloruv.lock. - Editing
pyproject.tomlby hand and not locking again: the lock falls out of date.uv lock(or anyuv run) brings it up to date;uv sync --lockeddetects it. - Not committing
uv.lock: the repository stores what you ask for, but not what was installed.
Exercises
- Create the course project with the commands from the lesson, add
pandas,numpyand, as a development dependency,pytest. Openuv.lockin an editor and count how many[[package]]entries it has. Why are there more than three?
Solution
It will have about a dozen: the project itself, numpy, pandas, python-dateutil, six and tzdata (those of pandas), and pytest with its own (iniconfig, packaging, pluggy, pygments and colorama, the latter only for Windows). There are more than three because the lock also pins the indirect dependencies, which are the ones that most often change without anyone noticing. The exact number depends on the versions on the day you do it.
- Write a function
comprobar_exactas(fijadas)that receives a dictionary{"pandas": "3.0.6", "numpy": "2.5.4"}with the versions a backtest was run with and returns a list of the libraries whose installed version does not match. Try it with a made-up version to see that it detects it.
Solution
def comprobar_exactas(fijadas: dict[str, str]) -> list[str]:
distintas = []
for nombre, esperada in fijadas.items():
actual = version(nombre)
if actual != esperada:
distintas.append(f"{nombre}: {esperada} -> {actual}")
return distintas
print(comprobar_exactas({"pandas": "3.0.6", "numpy": "2.5.4"}))
print(comprobar_exactas({"pandas": "2.2.3", "numpy": "2.5.4"}))[]
['pandas: 2.2.3 -> 3.0.6']An empty list means the environment matches. In a live system, a check like this at startup avoids trading with libraries the strategy has not been tested on.
- A colleague hands you a notebook that "works" and a list of imports, but neither
pyproject.tomlnor a lockfile. What steps do you follow to turn it into a reproducible project with uv?
Solution
uv init in a new folder; uv add for each library the notebook imports (if you know the versions it worked with,
pin them: uv add "pandas==2.2.3"); copy the notebook; run it from start to finish with uv run jupyter lab or
uv run --with jupyter jupyter lab and compare the results with theirs; if they match, commit pyproject.toml,
uv.lock, .python-version and the notebook. If they do not match, the difference lies in the versions or in the
notebook's hidden state (lesson 0.2.3).
Each answer is folded under its question
Self-assessment
Your
pyproject.tomlsayspandas>=3.0.6. Which version of pandas gets installed on the computer of someone else who clones your repository and runsuv sync?- AThe latest one that exists that day, because
>=sets no ceiling - BThe one pinned in
uv.lock, if the lockfile is in the repository - CExactly 3.0.6, because it is the minimum
- DWhatever is installed in the system Python
Show answer
Answer B. The one pinned in
uv.lock, if the lockfile is in the repositorypyproject.tomldeclares what the project accepts (a range);uv.lockstores the exact version that was resolved.uv syncinstalls what the lock says. Without a lock in the repository, each machine would resolve the range on its own and could end up with different versions.- AThe latest one that exists that day, because
Which file or folder is NOT pushed to Git?
- A
pyproject.toml - B
uv.lock - C
.python-version - D
.venv/
Show answer
Answer D.
.venv/.venv/is the result of installing: it weighs hundreds of megabytes, contains binaries for your operating system and is regenerated withuv syncfrom the other three.uv initalready adds it to.gitignore.- A
In a cell,
sys.prefix != sys.base_prefixreturnsFalse. What does it mean?- AThe code is running with the base Python, outside any virtual environment
- Bpandas is not installed
- CThe project has no
pyproject.toml - DThe Python version is old
Show answer
Answer A. The code is running with the base Python, outside any virtual environment
Inside a virtual environment,
sys.prefixpoints to the environment's folder andsys.base_prefixto the Python it was created from. If they match, you are in the base Python: anypip installwould end up there.A backtest gave a Sharpe of 0.84 in March and 0.81 in October, with the same code and the same data. What do you check first?
- AThat the strategy has stopped working
- BWhether the environment has changed: compare
uv.lock(or the versions saved with the result) between the two runs - CThe speed of the computer
- DNothing: it is normal random noise
Show answer
Answer B. Whether the environment has changed: compare
uv.lock(or the versions saved with the result) between the two runsWith the same code and the same data, a deterministic result only changes if the environment changes. If the versions are identical, the next suspect is an unset random seed.
Key points
- A virtual environment is a folder (
.venv) with its own libraries and a link to a base Python. Each project gets its own. pyproject.tomldeclares what you ask for (ranges);uv.lockpins what was resolved (exact versions and hashes of every package, including indirect ones).uv addwrites to both files;uv syncrebuilds.venvfrom the lock;uv runruns inside the environment.pyproject.toml,uv.lockand.python-versiongo to Git;.venv/never does.- Same code, same data and same lock = same result. A change of version or of a default value can move the figures of a backtest.