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.

Fig. — The 13 direct dependencies of the course project and how many packages each one pulls in: 37 in total in its uv.lock
02/06
Module
0.2 · Modern working environment
Duration
40 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

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:

IN [01]python
import sys
from pathlib import Path

print("Virtual environment:", sys.prefix != sys.base_prefix)
print("Environment folder:", Path(sys.prefix).name)
OUT [01]Inside a virtual environment?
Virtual environment: True
Environment folder: .venv

The 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):

IN [02]python
import pandas as pd

ruta = Path(pd.__file__).relative_to(sys.prefix)
print(ruta.parent)
OUT [02]Where pandas is installed
lib/python3.14/site-packages/pandas

Python 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:

FileWhat it isTo Git?
pyproject.tomlWhat the project declares: name, Python version, dependenciesYes
.python-versionThe Python version uv will use in this folder (3.14)Yes
uv.lockExact versions and hashes of every package, direct and indirectYes
.venv/The virtual environment: the installed librariesNo
src/ppt/__init__.pyYour packageYes
.gitignoreWhat Git should ignore; it already includes .venvYes

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:

IN [03]python
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"])
OUT [03]Reading a pyproject.toml
>=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:

IN [04]python
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'}")
OUT [04]Requested versus installed
numpy  wants >= 2.5.4  installed 2.5.4  ok
pandas wants >= 3.0.6  installed 3.0.6  ok

como_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:

IN [05]python
from importlib.metadata import requires

for req in requires("pandas"):
    if "extra ==" not in req:
        print(req)
OUT [05]The dependencies of pandas
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:

pyproject.tomluv resolveruv.lockuv sync.venv
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):

IN [06]uv.lock (excerpt)toml
[[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:

IN [07]python
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])
OUT [07]A hash changes with a single byte
f7cf610c97e34f7f
ec6f2c6ad27ed154

A 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.

IN [08]python
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%}")
OUT [08]Same volatility, two results
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 n−1n-1 (sample standard deviation) and NumPy by nn (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:

IN [09]python
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])
OUT [09]Environment fingerprint
163 packages installed
Fingerprint: e758dd2c21d8

If 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 python outside uv run: depending on the terminal, it may be the system Python, with other libraries. If a script fails with ModuleNotFoundError and it worked in the notebook, start here.
  • Installing with pip inside the project: the package never reaches pyproject.toml or uv.lock.
  • Editing pyproject.toml by hand and not locking again: the lock falls out of date. uv lock (or any uv run) brings it up to date; uv sync --locked detects it.
  • Not committing uv.lock: the repository stores what you ask for, but not what was installed.

Exercises

  1. Create the course project with the commands from the lesson, add pandas, numpy and, as a development dependency, pytest. Open uv.lock in 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.

  1. 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
IN [10]python
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"}))
OUT [10]Backtest versions versus the current environment
[]
['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.

  1. A colleague hands you a notebook that "works" and a list of imports, but neither pyproject.toml nor 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

  1. Your pyproject.toml says pandas>=3.0.6. Which version of pandas gets installed on the computer of someone else who clones your repository and runs uv 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 repository

    pyproject.toml declares what the project accepts (a range); uv.lock stores the exact version that was resolved. uv sync installs 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.

  2. Which file or folder is NOT pushed to Git?

    • Apyproject.toml
    • Buv.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 with uv sync from the other three. uv init already adds it to .gitignore.

  3. In a cell, sys.prefix != sys.base_prefix returns False. 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.prefix points to the environment's folder and sys.base_prefix to the Python it was created from. If they match, you are in the base Python: any pip install would end up there.

  4. 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 runs

    With 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.toml declares what you ask for (ranges); uv.lock pins what was resolved (exact versions and hashes of every package, including indirect ones).
  • uv add writes to both files; uv sync rebuilds .venv from the lock; uv run runs inside the environment.
  • pyproject.toml, uv.lock and .python-version go 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.

Resources