---
title: "0.2.2 Entornos virtuales y dependencias con uv"
description: "Qué es un entorno virtual, cómo crear un proyecto con uv init y uv add, qué guardan pyproject.toml y uv.lock y por qué fijan el resultado de un backtest."
url: "https://pythonparatrading.com/curso-python-trading/nivel-0/entornos-virtuales-y-dependencias-con-uv/"
lang: "es"
image: "https://pythonparatrading.com/og/curso-python-trading/nivel-0/entornos-virtuales-y-dependencias-con-uv.png"
---

# 0.2.2 · 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.

- Curso: [Nivel 0: Python desde cero y mentalidad de datos](https://pythonparatrading.com/curso-python-trading/nivel-0/)
- Módulo: 0.2 · Entorno de trabajo moderno
- Duración: 40 min
- Requisitos: [0.2.1 · Instalación de Python con uv: versiones y ciclo de soporte](https://pythonparatrading.com/curso-python-trading/nivel-0/instalar-python-con-uv/)

## Al terminar esta lección

- Crear un proyecto con `uv init`, añadir dependencias con `uv add` y ejecutar código con `uv run`.
- Explicar qué guardan `pyproject.toml`, `uv.lock` y `.venv`, y cuáles de ellos se versionan en Git.
- Comprobar desde Python si el código se ejecuta en un entorno virtual y qué versión de cada librería tiene instalada.
- Relacionar el lockfile con la reproducibilidad de un backtest y detectar cuándo un resultado ha cambiado por el entorno.

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

```python
import sys
from pathlib import Path

print("Entorno virtual:", sys.prefix != sys.base_prefix)
print("Carpeta del entorno:", Path(sys.prefix).name)
```
```output
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`):

```python
import pandas as pd

ruta = Path(pd.__file__).relative_to(sys.prefix)
print(ruta.parent)
```
```output
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:

```bash
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:

| Fichero | Qué es | ¿A Git? |
|---|---|---|
| `pyproject.toml` | Lo que el proyecto declara: nombre, versión de Python, dependencias | Sí |
| `.python-version` | La versión de Python que uv usará en esta carpeta (`3.14`) | Sí |
| `uv.lock` | Versiones exactas y hashes de todos los paquetes, directos e indirectos | Sí |
| `.venv/` | El entorno virtual: las librerías instaladas | No |
| `src/ppt/__init__.py` | Tu paquete | Sí |
| `.gitignore` | Lo que Git debe ignorar; ya incluye `.venv` | Sí |

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.

<aside class="callout" data-kind="aviso">

No mezcles `pip install` con un proyecto de uv. Lo que instala pip no queda en `pyproject.toml` ni en `uv.lock`:
funciona en tu máquina y falla en la de cualquier otra persona. Todo lo que el proyecto necesita entra con `uv add`.

</aside>

## 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`:

```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"])
```
```output
>=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:

```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'}")
```
```output
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:

```python
from importlib.metadata import requires

for req in requires("pandas"):
    if "extra ==" not in req:
        print(req)
```
```output
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:

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

```toml title="uv.lock (extracto)"
[[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:

```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])
```
```output
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.

<aside class="callout" data-kind="nota">

Para actualizar a propósito: `uv lock --upgrade-package pandas` (solo pandas) o `uv lock --upgrade` (todo). El lock
cambia, lo revisas, ejecutas los tests y haces commit del lock con un mensaje que diga qué se actualizó. Nunca se
actualiza solo.

</aside>

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:

```bash
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.

```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%}")
```
```output
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-1$ (desviación típica muestral) y NumPy por $n$
(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:

```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])
```
```output
42 paquetes instalados
Huella: 85d837279e0d
```

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.

2. 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.**

```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"}))
```
```output
[]
['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.

3. 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).

## 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`?

   - A. La última que exista ese día, porque `>=` no pone techo
   - B. La que fije `uv.lock`, si el lockfile está en el repositorio
   - C. La 3.0.6 exactamente, porque es el mínimo
   - D. La que tenga instalada en el Python del sistema

   *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?

   - A. `pyproject.toml`
   - B. `uv.lock`
   - C. `.python-version`
   - D. `.venv/`

   *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?

   - A. Que el código se está ejecutando con el Python base, fuera de cualquier entorno virtual
   - B. Que pandas no está instalado
   - C. Que el proyecto no tiene `pyproject.toml`
   - D. Que la versión de Python es antigua

   *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?

   - A. Que la estrategia ha dejado de funcionar
   - B. Si ha cambiado el entorno: compara `uv.lock` (o las versiones guardadas con el resultado) entre las dos ejecuciones
   - C. La velocidad del ordenador
   - D. Nada: es ruido aleatorio normal

   *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

### Research

- [El entorno de trabajo - 1](https://pythonparatrading.com/docker-para-trading/) · Research-0013 · 4 min

### Documentación

- [uv: trabajar con proyectos (documentación oficial)](https://docs.astral.sh/uv/guides/projects/) · docs.astral.sh
- [uv: bloquear y sincronizar (locking and syncing)](https://docs.astral.sh/uv/concepts/projects/sync/) · docs.astral.sh
- [Python Packaging User Guide: especificación de pyproject.toml](https://packaging.python.org/en/latest/specifications/pyproject-toml/) · packaging.python.org
- [Documentación de Python: venv, creación de entornos virtuales](https://docs.python.org/es/3/library/venv.html) · docs.python.org
- [Documentación de Python: importlib.metadata](https://docs.python.org/3/library/importlib.metadata.html) · docs.python.org

Lección anterior: [0.2.1 · Instalación de Python con uv: versiones y ciclo de soporte](https://pythonparatrading.com/curso-python-trading/nivel-0/instalar-python-con-uv/)

Lección siguiente: [0.2.3 · Editores (VS Code, Cursor, PyCharm) y notebooks (Jupyter, JupyterLab, marimo)](https://pythonparatrading.com/curso-python-trading/nivel-0/editores-y-notebooks-jupyter-marimo/)
