Lección 0.2.2 · 40 min

Entornos virtuales y dependencias: uv init, uv add, pyproject.toml y lockfiles

Un backtest depende del código, de los datos y de las librerías con las que se ejecuta. Esta lección crea el proyecto del curso con uv, explica qué guarda cada fichero y enseña a fijar las versiones exactas para que el mismo código dé el mismo resultado en cualquier máquina.

Fig. — Las 13 dependencias directas del proyecto del curso y cuántos paquetes arrastra cada una: 37 en total en su uv.lock
02/06
Módulo
0.2 · Entorno de trabajo moderno
Duración
40 min
Lecciones del nivel 0
  1. 0.1 · Bienvenida y expectativas

    1. 0.1.1 Trading sistemático, algorítmico y cuantitativo frente al discrecional
    2. 0.1.2 Expectativas realistas: costes, tiempo y riesgo de pérdida
    3. 0.1.3 Estructura del curso, tests de entrada, repositorio personal y aprendizaje con IA
  2. 0.2 · Entorno de trabajo moderno

    1. 0.2.1 Instalación de Python con uv: versiones y ciclo de soporte
    2. 0.2.2 Entornos virtuales y dependencias: uv init, uv add, pyproject.toml y lockfiles
    3. 0.2.3 Editores (VS Code, Cursor, PyCharm) y notebooks (Jupyter, JupyterLab, marimo)
    4. 0.2.4 Terminal básica, rutas, variables de entorno y ficheros .env
    5. 0.2.5 Git y GitHub: commits, ramas, pull requests y .gitignore
    6. 0.2.6 Introducción a los asistentes de código con IA: prompts, revisión y tests
  3. 0.3 · Fundamentos del lenguaje

    1. 0.3.1 Variables, tipos y operadores; coma flotante y decimal para precios y ticks
    2. 0.3.2 Cadenas, f-strings y formato de números y fechas
    3. 0.3.3 Listas, tuplas, diccionarios, conjuntos y comprensiones
    4. 0.3.4 Control de flujo: if, elif, else, for, while y match-case
    5. 0.3.5 Funciones: argumentos, *args y **kwargs, lambdas y ámbito
    6. 0.3.6 Excepciones; módulos y paquetes, con los imports perezosos de Python 3.15
  4. 0.4 · Python intermedio

    1. 0.4.1 Programación orientada a objetos: clases, herencia, composición y dataclasses
    2. 0.4.2 Tipado: type hints, mypy o ty y Pydantic para configuraciones
    3. 0.4.3 Iteradores, generadores, decoradores y context managers
    4. 0.4.4 Ficheros (CSV, JSON, YAML, TOML) y fechas con zonas horarias
    5. 0.4.5 Logging profesional y testing con pytest
    6. 0.4.6 Concurrencia: threading, multiprocessing, asyncio y free-threading
  5. 0.5 · Computación numérica y datos

    1. 0.5.1 NumPy 2: arrays, broadcasting, vectorización, álgebra lineal y generadores aleatorios
    2. 0.5.2 pandas 3.0: Series, DataFrame, índices temporales, Copy-on-Write, tipo str y pd.col()
    3. 0.5.3 Selección, filtrado, groupby, merge, pivot, resample y ventanas móviles
    4. 0.5.4 Polars: API lazy frente a eager, expresiones, rendimiento y cuándo elegirlo frente a pandas
    5. 0.5.5 DuckDB y SQL esencial: SELECT, JOIN, GROUP BY y funciones de ventana sobre Parquet
  6. 0.6 · Visualización

    1. 0.6.1 Matplotlib orientado a objetos, Seaborn y Plotly: gráficos de precios, drawdown y velas
    2. 0.6.2 Dashboards rápidos con Streamlit: un panel de seguimiento en un solo script
  7. 0.7 · Matemáticas y estadística mínimas

    1. 0.7.1 Rentabilidades simples y logarítmicas, interés compuesto y estadística descriptiva
    2. 0.7.2 Probabilidad y distribuciones, correlación frente a causalidad y regresión simple
    3. 0.7.3 Álgebra lineal intuitiva: vectores y matrices como preparación para carteras

Un Python para cada proyecto

En la lección 0.2.1 instalaste Python 3.14 con uv. Ese intérprete es compartido: si instalas pandas «en él», todos los programas que lo usen verán la misma versión de pandas. Funciona hasta el día en que dos proyectos necesitan cosas distintas:

  • Un sistema que opera en real se probó con pandas 2.2 y no quieres tocarlo hasta revisarlo con calma.
  • El proyecto del curso usa pandas 3.0, que cambia el tipo de las columnas de texto y la semántica de las copias (lección 0.5.2).

Con un único Python, actualizar pandas para el curso cambia también el sistema en real, sin que nadie lo haya probado. La solución es que cada proyecto tenga su propio conjunto de librerías: un entorno virtual.

Un entorno virtual es una carpeta (por convención, .venv dentro del proyecto) con tres cosas: un enlace al intérprete base, una carpeta site-packages donde se instalan las librerías del proyecto y un fichero de configuración. No copia Python entero: pesa lo que pesen las librerías. Cuando ejecutas código «dentro» del entorno, Python busca los paquetes primero en esa carpeta.

Desde el propio código se puede saber si estás dentro de uno. sys.prefix es la carpeta del entorno activo; sys.base_prefix, la del Python del que se creó. Fuera de un entorno virtual, las dos coinciden. El código de este curso se ejecuta con uv run dentro de un proyecto, así que aquí son distintas:

IN [01]python
import sys
from pathlib import Path

print("Entorno virtual:", sys.prefix != sys.base_prefix)
print("Carpeta del entorno:", Path(sys.prefix).name)
OUT [01]¿Dentro de un entorno virtual?
Entorno virtual: True
Carpeta del entorno: .venv

Las librerías del proyecto viven dentro de esa carpeta. La ruta relativa al entorno es la misma en Linux y macOS (en Windows es Lib/site-packages):

IN [02]python
import pandas as pd

ruta = Path(pd.__file__).relative_to(sys.prefix)
print(ruta.parent)
OUT [02]Dónde está instalado pandas
lib/python3.14/site-packages/pandas

Python tiene un módulo para crear entornos a mano (python -m venv .venv) y pip para instalar en ellos. Funciona, pero obliga a recordar qué se instaló, en qué orden y con qué versiones. uv hace todo eso a partir de dos ficheros de texto.

Un proyecto con uv

El proyecto del curso se crea con uv init. La opción --name ppt da al paquete el nombre que usaste en la lección 0.1.3 (src/ppt/); sin ella, se llamaría como la carpeta:

uv init ppt-curso --name ppt --python 3.14
cd ppt-curso
uv add pandas numpy          # dependencias del proyecto
uv add --dev pytest          # solo para desarrollar (tests)
uv run python -c "import pandas; print(pandas.__version__)"

uv init crea la estructura mínima y, si no estás ya dentro de un repositorio, inicializa uno de Git (lección 0.2.5). El primer uv add resuelve las versiones, escribe el lockfile y crea el entorno. El resultado:

FicheroQué es¿A Git?
pyproject.tomlLo que el proyecto declara: nombre, versión de Python, dependenciasSí
.python-versionLa versión de Python que uv usará en esta carpeta (3.14)Sí
uv.lockVersiones exactas y hashes de todos los paquetes, directos e indirectosSí
.venv/El entorno virtual: las librerías instaladasNo
src/ppt/__init__.pyTu paqueteSí
.gitignoreLo que Git debe ignorar; ya incluye .venvSí

A partir de aquí casi nunca escribirás python a secas. uv run comprueba que el entorno coincide con el lock (y lo actualiza si hace falta) antes de ejecutar: uv run python script.py, uv run pytest, uv run jupyter lab. No hace falta «activar» el entorno.

pyproject.toml: lo que pides

pyproject.toml es el fichero estándar de configuración de un proyecto de Python. Está en formato TOML, que Python lee sin librerías externas desde la versión 3.11 con el módulo tomllib. Este es, recortado, el que deja uv init después de los dos uv add:

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]Leer un pyproject.toml
>=3.14
['numpy>=2.5.4', 'pandas>=3.0.6']
['pytest>=9.1.1']

Fíjate en el >=. uv add pandas no fija la versión: escribe la que acaba de instalar como mínimo. El proyecto declara «necesito al menos esto», que es lo razonable para una lista de requisitos: no impide que otra persona use una versión con correcciones posteriores.

Comprobar si lo instalado cumple lo pedido es fácil con importlib.metadata, el módulo que lee los metadatos de los paquetes instalados. Para comparar las versiones se usa la misma idea de tuplas de la lección 0.2.1:

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} pide >= {minima}  instalada {instalada}"
          f"  {'ok' if cumple else 'NO'}")
OUT [04]Lo pedido frente a lo instalado
numpy  pide >= 2.5.4  instalada 2.5.4  ok
pandas pide >= 3.0.6  instalada 3.0.6  ok

como_tupla solo entiende versiones de números separados por puntos. Hay versiones como 2.9.0.post0 (la de python-dateutil que usa pandas) que la romperían; para eso existe la librería packaging, la que usan uv y pip. Aquí basta para numpy y pandas.

uv.lock: lo que se resolvió

Un rango no basta para reproducir un resultado. pandas>=3.0.6 admite la 3.0.6 hoy y la 3.1 dentro de seis meses. Además, pandas tiene sus propias dependencias, también con rangos. Los metadatos instalados lo muestran:

IN [05]python
from importlib.metadata import requires

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

Las líneas que no se imprimen son las dependencias opcionales (extras: para Excel, para gráficos…), que solo se instalan si se piden. Lo que queda dice: NumPy 2.3.3 o superior en Python 3.14, python-dateutil 2.8.2 o superior y tzdata solo en Windows y en el navegador (emscripten). Cada una puede tener a su vez dependencias propias.

Encontrar un conjunto de versiones que cumpla a la vez todos los rangos es el trabajo del resolver. uv lo hace una vez y guarda la solución en uv.lock. Desde ese momento, instalar ya no es resolver, sino copiar:

pyproject.tomlResolver de uvuv.lockuv sync.venv
Fuente Mermaid
flowchart TB
  P[pyproject.toml] --> R[Resolver de uv]
  R --> L[uv.lock]
  L --> S[uv sync]
  S --> V[.venv]

uv add y uv lock recorren el camino entero; uv sync y uv run parten del lock. El lockfile es también TOML y tiene una entrada por paquete. Esta es la de pandas en el proyecto con el que se ha ejecutado el código del curso, recortada (sin la lista de ficheros descargables y con el hash abreviado):

IN [06]uv.lock (extracto)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…", … }

La versión ya no es un rango sino un número exacto, y cada fichero descargable lleva su hash: una huella criptográfica del contenido. Si el fichero que llega del servidor no tiene esa huella exacta, uv se niega a instalarlo. Es la misma técnica que sirve para comprobar que un fichero de datos no ha cambiado:

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]Un hash cambia con un solo byte
f7cf610c97e34f7f
ec6f2c6ad27ed154

Un céntimo de diferencia en el cierre del 29 de diciembre de 2025 produce una huella sin nada en común con la original. Volverás a usar esta idea para versionar datos de mercado en el nivel 1.

El lock es universal: incluye los paquetes de todas las plataformas, con su condición (marker). El del curso tiene 13 dependencias directas y 37 paquetes en total (la figura de la cabecera muestra cuántos arrastra cada una; muchos se repiten, como NumPy). uv sync instala en cada máquina solo los que le corresponden: en Linux, ni tzdata ni colorama, que son de Windows.

En otra máquina —el portátil de un compañero, el servidor donde operará tu sistema, el servicio de integración continua que ejecuta los tests— el proyecto se reconstruye con dos comandos:

git clone https://github.com/<usuario>/ppt-curso.git
cd ppt-curso
uv sync --locked      # instala exactamente lo que dice uv.lock
uv run pytest

--locked hace que uv falle si uv.lock no está al día con pyproject.toml, en lugar de resolver de nuevo en silencio. Es la opción correcta en un servidor: si alguien añadió una dependencia y olvidó hacer commit del lock, prefieres un error al desplegar a un sistema que opera con versiones que nadie ha probado. uv descarga también la versión de Python de .python-version si la máquina no la tiene.

Mismas versiones, mismos resultados

¿Por qué tanto cuidado con las versiones? Porque las librerías cambian, y con ellas pueden cambiar las cifras de un backtest. A veces el cambio es una mejora documentada (pandas 3.0 cambió el tipo por defecto del texto, lección 0.5.2); otras, un valor por defecto que nadie mira. Un ejemplo con dos librerías que el curso usa a diario: la volatilidad anualizada de SINT calculada con pandas y con 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 años: {vol_anual(rent):.3%}")
print(f"NumPy,  5 años: {vol_anual(rent.to_numpy()):.3%}")
dic = rent.loc["2025-12"]
print(f"pandas, dic 2025: {vol_anual(dic):.2%}")
print(f"NumPy,  dic 2025: {vol_anual(dic.to_numpy()):.2%}")
OUT [08]Misma volatilidad, dos resultados
pandas, 5 años: 18.096%
NumPy,  5 años: 18.089%
pandas, dic 2025: 13.97%
NumPy,  dic 2025: 13.67%

El mismo método, .std(), da números distintos: pandas divide por n−1n-1 (desviación típica muestral) y NumPy por nn (poblacional), salvo que se le pida otra cosa con ddof. Con cinco años de datos la diferencia es despreciable; con una ventana de un mes, como la que usaría un sistema que ajusta su tamaño a la volatilidad reciente, cambia el tamaño de la posición. Si una actualización cambiara un valor por defecto así, el backtest daría otra cifra sin que el código cambiara una línea.

Por eso un resultado se acompaña de su entorno. En la lección 0.2.1 guardaste las versiones de Python, NumPy y pandas; con importlib.metadata se puede resumir el entorno entero en una huella corta:

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), "paquetes instalados")
print("Huella:", huella[:12])
OUT [09]Huella del entorno
35 paquetes instalados
Huella: ebf7b3c8e431

Si guardas esa huella junto al Sharpe y la curva de capital de cada backtest, sabrás de un vistazo si dos resultados salieron del mismo entorno. En tu proyecto, el propio uv.lock versionado en Git cumple esa función: el commit que produjo un resultado identifica el código y el entorno a la vez.

Cuatro errores frecuentes rompen esa garantía sin que se note:

  • Ejecutar python a secas fuera de uv run: según la terminal, puede ser el Python del sistema, con otras librerías. Si un script falla con ModuleNotFoundError y en el notebook funcionaba, empieza por aquí.
  • Instalar con pip dentro del proyecto: el paquete no llega a pyproject.toml ni a uv.lock.
  • Editar pyproject.toml a mano y no volver a bloquear: el lock queda desfasado. uv lock (o cualquier uv run) lo pone al día; uv sync --locked lo detecta.
  • No hacer commit de uv.lock: el repositorio guarda lo que pides, pero no lo que se instaló.

Ejercicios

  1. Crea el proyecto del curso con los comandos de la lección, añade pandas, numpy y, como dependencia de desarrollo, pytest. Abre uv.lock con un editor y cuenta cuántas entradas [[package]] tiene. ¿Por qué hay más de tres?
Solución

Tendrá en torno a una docena: el propio proyecto, numpy, pandas, python-dateutil, six y tzdata (las de pandas), y pytest con las suyas (iniconfig, packaging, pluggy, pygments y colorama, esta solo para Windows). Hay más de tres porque el lock fija también las dependencias indirectas, que son las que más a menudo cambian sin que nadie se entere. El número exacto depende de las versiones del día en que lo hagas.

  1. Escribe una función comprobar_exactas(fijadas) que reciba un diccionario {"pandas": "3.0.6", "numpy": "2.5.4"} con las versiones con las que se hizo un backtest y devuelva una lista con las librerías cuya versión instalada no coincide. Pruébala con una versión inventada para ver que la detecta.
Solución
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]Versiones del backtest frente al entorno actual
[]
['pandas: 2.2.3 -> 3.0.6']

Una lista vacía significa que el entorno coincide. En un sistema en real, una comprobación así al arrancar evita operar con librerías en las que no se ha probado la estrategia.

  1. Un compañero te pasa un notebook que «funciona» y una lista de imports, pero ni pyproject.toml ni lockfile. ¿Qué pasos sigues para convertirlo en un proyecto reproducible con uv?
Solución

uv init en una carpeta nueva; uv add de cada librería que importa el notebook (si sabes las versiones con las que funcionaba, fíjalas: uv add "pandas==2.2.3"); copiar el notebook; ejecutarlo de principio a fin con uv run jupyter lab o uv run --with jupyter jupyter lab y comparar los resultados con los suyos; si coinciden, commit de pyproject.toml, uv.lock, .python-version y el notebook. Si no coinciden, la diferencia está en las versiones o en el estado oculto del notebook (lección 0.2.3).

Respuesta plegada en cada pregunta

Autoevaluación

  1. Tu pyproject.toml dice pandas>=3.0.6. ¿Qué versión de pandas se instala en el ordenador de otra persona que clona tu repositorio y ejecuta uv sync?

    • ALa última que exista ese día, porque >= no pone techo
    • BLa que fije uv.lock, si el lockfile está en el repositorio
    • CLa 3.0.6 exactamente, porque es el mínimo
    • DLa que tenga instalada en el Python del sistema
    Ver respuesta

    Respuesta B. La que fije uv.lock, si el lockfile está en el repositorio

    pyproject.toml declara lo que el proyecto acepta (un rango); uv.lock guarda la versión exacta que se resolvió. uv sync instala lo que dice el lock. Sin lock en el repositorio, cada máquina resolvería el rango por su cuenta y podría acabar con versiones distintas.

  2. ¿Qué fichero o carpeta NO se sube a Git?

    • Apyproject.toml
    • Buv.lock
    • C.python-version
    • D.venv/
    Ver respuesta

    Respuesta D. .venv/

    .venv/ es el resultado de instalar: pesa cientos de megas, contiene binarios de tu sistema operativo y se regenera con uv sync a partir de los otros tres. uv init ya lo añade a .gitignore.

  3. En una celda, sys.prefix != sys.base_prefix devuelve False. ¿Qué significa?

    • AQue el código se está ejecutando con el Python base, fuera de cualquier entorno virtual
    • BQue pandas no está instalado
    • CQue el proyecto no tiene pyproject.toml
    • DQue la versión de Python es antigua
    Ver respuesta

    Respuesta A. Que el código se está ejecutando con el Python base, fuera de cualquier entorno virtual

    Dentro de un entorno virtual, sys.prefix apunta a la carpeta del entorno y sys.base_prefix al Python del que se creó. Si coinciden, estás en el Python base: cualquier pip install iría a parar a él.

  4. Un backtest daba un Sharpe de 0,84 en marzo y de 0,81 en octubre, con el mismo código y los mismos datos. ¿Qué compruebas primero?

    • AQue la estrategia ha dejado de funcionar
    • BSi ha cambiado el entorno: compara uv.lock (o las versiones guardadas con el resultado) entre las dos ejecuciones
    • CLa velocidad del ordenador
    • DNada: es ruido aleatorio normal
    Ver respuesta

    Respuesta B. Si ha cambiado el entorno: compara uv.lock (o las versiones guardadas con el resultado) entre las dos ejecuciones

    Con el mismo código y los mismos datos, un resultado determinista solo cambia si cambia el entorno. Si las versiones son idénticas, el siguiente sospechoso es una semilla aleatoria sin fijar.

Ideas clave

  • Un entorno virtual es una carpeta (.venv) con sus propias librerías y un enlace a un Python base. Cada proyecto, el suyo.
  • pyproject.toml declara lo que pides (rangos); uv.lock fija lo que se resolvió (versiones exactas y hashes de todos los paquetes, también los indirectos).
  • uv add escribe en los dos ficheros; uv sync reconstruye .venv a partir del lock; uv run ejecuta dentro del entorno.
  • A Git van pyproject.toml, uv.lock y .python-version; nunca .venv/.
  • Mismo código, mismos datos y mismo lock = mismo resultado. Un cambio de versión o de valor por defecto puede mover las cifras de un backtest.

Recursos