Lesson 0.2.4 · 40 min
Basic terminal, paths, environment variables and .env files
A trading system is started from a terminal, reads data from a path and receives its keys through the environment. This lesson covers all three fronts and ends with a hand-written .env reader that never reveals a secret.
- Module
- 0.2 · Modern working environment
- Duration
- 40 min
- Prerequisites
- 0.2.2 · Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles
- 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
The terminal in five minutes
The terminal is a program that reads typed commands and runs them. You have already used it to install uv (lesson 0.2.1) and create the project (lesson 0.2.2). In systematic trading it is the everyday tool: it starts the backtest, launches the system on a server, reads its logs and stops it.
Inside the terminal runs a command interpreter (shell): bash or zsh on Linux and macOS, PowerShell on
Windows. Each has its own syntax, but the basic commands almost always match, because PowerShell defines aliases with
the Unix names:
| Action | Linux and macOS | Windows (PowerShell) |
|---|---|---|
| Which folder am I in? | pwd | pwd |
| List the contents | ls, ls -la (hidden ones too) | ls, ls -Force |
| Enter a folder / go up one | cd datos, cd .. | cd datos, cd .. |
| Create a folder | mkdir -p datos/brutos | mkdir datos\brutos |
| View a text file | cat .gitignore, head -5 sint.csv | cat .gitignore, Get-Content sint.csv -Head 5 |
| Copy / delete | cp a b, rm a | cp a b, rm a |
| Stop the running program | Ctrl+C | Ctrl+C |
A typical session in the course project:
cd ~/ppt-curso # ~ is your home folder
ls # pyproject.toml, src, datos...
head -3 datos/brutos/sint_diario.csv
uv run python -m ppt.backtest # runs a module of your packageThree habits save hours: the Tab key completes file and command names; the up arrow brings back previous commands; and Ctrl+C interrupts the running program, which is the normal way to stop a backtest that takes too long or a paper trading system.
Paths: absolute and relative
A path says where a file is. It is absolute if it starts at the root of the disk (/home/ana/ppt-curso/… on
Linux, C:\Users\ana\ppt-curso\… on Windows) and relative if it is interpreted from the current folder
(datos/sint_diario.csv). Python has a module to handle them without fighting with slashes and separators,
pathlib:
from pathlib import Path
ruta = Path("datos") / "brutos" / "sint_diario.csv"
print(ruta)
print(ruta.name, "|", ruta.stem, "|", ruta.suffix)
print(ruta.parent)
print(ruta.parts)datos/brutos/sint_diario.csv
sint_diario.csv | sint_diario | .csv
datos/brutos
('datos', 'brutos', 'sint_diario.csv')The / operator joins pieces of a path with the system separator: / on Linux and macOS, \ on Windows. That is
why it is better than writing paths by hand as text. On Windows, moreover, the backslash has a trap: inside a Python
string, \n is a newline and \t a tab.
from pathlib import PureWindowsPath
mal = "C:\nuevos\ticks.csv" # \n and \t are characters
bien = r"C:\nuevos\ticks.csv" # r"" = literal backslashes
print(len(mal), len(bien))
print(PureWindowsPath(bien).name)
print(PureWindowsPath(bien).as_posix())17 19
ticks.csv
C:/nuevos/ticks.csvThe first string has two fewer characters: \n and \t have turned into a newline and a tab, and the file
ticks.csv will never show up. With the r prefix ("raw" string) or with pathlib the problem disappears.
PureWindowsPath lets you handle Windows paths from any system; in your code, Path is enough.
The most frequent problem with paths is not the slash, but the current folder. A relative path is resolved against the folder the program was launched from, not against the folder of the code file. The course code runs in a folder that contains the data files loose, so:
print(Path("sint_diario.csv").exists())
print(Path("datos/sint_diario.csv").exists())
print(Path("sint_diario.csv").is_absolute())
print(Path("sint_diario.csv").resolve().is_absolute())True
False
False
TrueThe same script, launched from another folder, by cron or by a service on the server, would not find the file. The
solution is to anchor paths to something fixed. In a .py file, the __file__ variable is the path of the file
itself; from it you go up to the project root:
from pathlib import Path
# src/ppt/rutas.py -> up two levels = project root
RAIZ = Path(__file__).resolve().parents[2]
DATOS_BRUTOS = RAIZ / "datos" / "brutos"
DATOS_PROCESADOS = RAIZ / "datos" / "procesados"The rest of the code imports DATOS_BRUTOS and never writes a path again. If tomorrow the data moves to another
disk, one line changes. CSV, Parquet and JSON files with pathlib are covered in lesson 0.4.4.
Environment variables
Every program receives at startup a set of NAME=value pairs: its environment variables. Some are set by the
system (PATH, the list of folders where the terminal looks for programs; that is why after installing uv you had to
reopen the terminal), and others you set yourself. They are the standard way to pass configuration to a program
without touching its code: the operating mode, the capital, the broker key.
They are defined in the terminal before starting the program:
export PPT_MODO=paper # Linux and macOS, for the session
$env:PPT_MODO = "paper" # PowerShell, for the session
PPT_MODO=live uv run python sistema.py # Linux and macOS, that run onlyIn Python they are read with os.environ, which works like a dictionary. Here the variable is created from Python so
that the cell is reproducible; in your system it would come from the terminal:
import os
os.environ["PPT_MODO"] = "paper" # in real life: export
print(os.environ.get("PPT_MODO"))
print(os.environ.get("PPT_CAPITAL")) # does not exist
print(os.environ.get("PPT_CAPITAL", "25000")) # defaultpaper
None
25000os.environ["PPT_CAPITAL"], with square brackets, would raise a KeyError if the variable does not exist; .get()
returns None or the default value. Use square brackets for what is mandatory (so it fails early) and .get() with a
safe default value for what is optional. In trading, safe means conservative: if the mode is missing, paper, not
live.
Environment variables are always text. A number has to be converted, and a yes/no has a trap:
os.environ["PPT_REAL"] = "false"
print(bool(os.environ["PPT_REAL"])) # careful
def leer_bool(nombre: str, defecto: bool = False) -> bool:
valor = os.environ.get(nombre)
if valor is None:
return defecto
return valor.strip().lower() in {"1", "true", "si", "sí", "yes"}
print(leer_bool("PPT_REAL"))
capital = float(os.environ.get("PPT_CAPITAL", "25000"))
print(capital * 0.02) # maximum risk of 2%True
False
500.0bool("false") is True, because any non-empty string is truthy. A system that decides whether to trade live with
bool(os.environ[...]) would trade live with PPT_REAL=false. leer_bool only accepts an explicit "yes"; everything
else, including a typo, counts as no.
.env files
Defining ten variables by hand every time you open the terminal is not practical. The convention is to keep them in a
.env file at the project root, one per line, which the program reads at startup. The .env belongs to your
machine: it has your keys and your configuration, it is in .gitignore from the first commit (lesson 0.1.3) and it
is not shared. What does get versioned is a .env.example with the names and without the values.
There are libraries to read it (python-dotenv, and Pydantic in lesson 0.4.2), but the format is so simple that it is
worth writing the reader by hand to understand what they do. First, an example .env with made-up keys:
Path(".env").write_text("""\
# Broker (paper account)
BROKER_API_KEY=pk_demo_8f3a91c2e7b4
BROKER_API_SECRET="sk_demo_41d0c9e2a7f35b18"
export PPT_MODO=paper
PPT_CAPITAL=25000
PPT_RIESGO_MAX=0.02
""");The reader: it ignores empty lines and comments, accepts the export prefix (so the same file works in bash),
splits on the first = and removes the quotes around the value:
def leer_env(ruta: Path) -> dict[str, str]:
valores = {}
for linea in ruta.read_text().splitlines():
linea = linea.strip()
if not linea or linea.startswith("#"):
continue
linea = linea.removeprefix("export ")
clave, igual, valor = linea.partition("=")
if not igual:
continue # line without '=': ignored
valor = valor.strip()
comillas = valor[:1] in ("'", '"')
if comillas and len(valor) >= 2 and valor[-1] == valor[0]:
valor = valor[1:-1]
valores[clave.strip()] = valor
return valores
env = leer_env(Path(".env"))
print(list(env))['BROKER_API_KEY', 'BROKER_API_SECRET', 'PPT_MODO', 'PPT_CAPITAL', 'PPT_RIESGO_MAX']The cell prints only the names, not the values. It is deliberate: a printed value ends up in the notebook output, in a log or in a screenshot. When the configuration needs to be shown (for example, in the system's startup log), secrets are masked:
SENSIBLES = ("KEY", "SECRET", "TOKEN", "PASSWORD")
def enmascarar(clave: str, valor: str) -> str:
if any(s in clave.upper() for s in SENSIBLES):
return valor[:3] + "***" if len(valor) > 12 else "***"
return valor
for clave, valor in env.items():
print(f"{clave:<18} {enmascarar(clave, valor)}")BROKER_API_KEY pk_***
BROKER_API_SECRET sk_***
PPT_MODO paper
PPT_CAPITAL 25000
PPT_RIESGO_MAX 0.02Three characters are enough to tell a paper key (pk_) from another one without revealing anything useful.
Who wins: environment, .env and default values
The same program runs on your laptop, on a server and in the tests. Each place needs to adjust something without
touching the code or the others' .env. The convention, followed by python-dotenv and uv run --env-file, is a
precedence chain in which each layer overrides the previous one:
Mermaid source
flowchart TB D[Defaults] --> E[.env file] E --> V[Environment] V --> C[Configuration]
Default values live in the code and are conservative; the .env adjusts them for your machine; an environment
variable changes them for a specific run. With the reader above, the full load looks like this, noting where each
value comes from:
DEFECTO = {"PPT_MODO": "paper", "PPT_CAPITAL": "10000",
"PPT_RIESGO_MAX": "0.01", "PPT_LOG": "INFO"}
def cargar_config(ruta: Path) -> dict[str, tuple[str, str]]:
config = {k: (v, "default") for k, v in DEFECTO.items()}
for k, v in leer_env(ruta).items():
config[k] = (v, ".env")
for k in config:
if k in os.environ:
config[k] = (os.environ[k], "environment")
return config
os.environ["PPT_CAPITAL"] = "50000" # export PPT_CAPITAL=50000
for clave, (valor, origen) in cargar_config(Path(".env")).items():
print(f"{clave:<18} {enmascarar(clave, valor):<10} {origen}")PPT_MODO paper environment
PPT_CAPITAL 50000 environment
PPT_RIESGO_MAX 0.02 .env
PPT_LOG INFO default
BROKER_API_KEY pk_*** .env
BROKER_API_SECRET sk_*** .envEach value carries its origin: when a system does something unexpected, knowing where the capital or the mode came
from is half the diagnosis. PPT_MODO comes from the environment because an earlier cell defined it with
os.environ; it is the same effect a forgotten export in the terminal would have.
In your scripts there is no need to call a reader of your own: uv loads the .env when it starts the program if you
ask it to.
uv run --env-file .env python -m ppt.backtest
uv run --env-file .env --env-file .env.servidor python -m ppt.sistemaThe variables in the file reach os.environ and, as in cargar_config, a variable that already existed in the
environment is not overwritten. With several files, each one overrides the previous one: a common .env and another
with what is specific to the server. What uv does not do is validate the values or mask the secrets when showing
them; that is still up to your code, as in the second exercise.
Exercises
- In the terminal, without using the file explorer: inside
ppt-curso, create the foldersdatos/brutosanddatos/procesados, copy thesint_diario.csvfile from the resources intodatos/brutosand show its first three lines.
Solution
On Linux and macOS: cd ~/ppt-curso, mkdir -p datos/brutos datos/procesados, cp ~/Downloads/sint_diario.csv datos/brutos/ (the downloads folder depends on your system) and head -3 datos/brutos/sint_diario.csv. In
PowerShell: mkdir datos\brutos, mkdir datos\procesados, cp $HOME\Downloads\sint_diario.csv datos\brutos\ and
Get-Content datos\brutos\sint_diario.csv -Head 3. The first line is the header:
fecha,apertura,maximo,minimo,cierre,volumen.
- Extend
cargar_configso that it fails with a clear message if a mandatory variable is missing (for example,BROKER_API_KEY) and so that it convertsPPT_CAPITALandPPT_RIESGO_MAXtofloat, checking that the risk is between 0 and 0.05.
Solution
OBLIGATORIAS = ("BROKER_API_KEY", "BROKER_API_SECRET")
def config_validada(ruta: Path) -> dict[str, object]:
crudo = {k: v for k, (v, _) in cargar_config(ruta).items()}
faltan = [k for k in OBLIGATORIAS if not crudo.get(k)]
if faltan:
raise RuntimeError(f"Missing variables: {', '.join(faltan)}")
riesgo = float(crudo["PPT_RIESGO_MAX"])
if not 0 < riesgo <= 0.05:
raise ValueError(f"PPT_RIESGO_MAX out of range: {riesgo}")
return crudo | {"PPT_CAPITAL": float(crudo["PPT_CAPITAL"]),
"PPT_RIESGO_MAX": riesgo}
cfg = config_validada(Path(".env"))
print(cfg["PPT_CAPITAL"], cfg["PPT_RIESGO_MAX"])50000.0 0.02The message names the missing variables, but never shows values. Failing at startup is much better than finding out in the middle of a session that the capital was a string or that the risk was 20%.
- Write the
.env.examplethat would accompany the lesson's.env. What is the difference between the two and which one goes to Git?
Solution
# Copy this file to .env and fill in the values
BROKER_API_KEY=
BROKER_API_SECRET=
PPT_MODO=paper
PPT_CAPITAL=25000
PPT_RIESGO_MAX=0.02The .env.example has the same names, with the secrets empty and example values for what is not secret. It goes to
Git: it documents what the program needs. The .env, with the real keys, is never versioned.
Each answer is folded under its question
Self-assessment
Your script reads
datos/sint_diario.csvand works when you launch it from the project folder. Scheduled with cron, it fails withFileNotFoundError. Why?- ABecause the path is relative and is resolved against the current folder, which under cron is not the project's
- BBecause cron does not have permission to read CSV files
- CBecause pandas does not work outside the terminal
- DBecause the file was deleted
Show answer
Answer A. Because the path is relative and is resolved against the current folder, which under cron is not the project's
A relative path is interpreted from the process's working folder. Cron, a service or an editor may start the program from another folder. The solution is to build the path from the location of the code file itself or from a known root.
What does
bool(os.environ.get("PPT_REAL", "false"))return if the variable does not exist?- A
False - B
None - C
True, because"false"is a non-empty string - DAn error
Show answer
Answer C.
True, because"false"is a non-empty stringEnvironment variables are always text, and any non-empty string is truthy. To read a yes/no you have to compare the text with specific values, as
leer_booldoes in the lesson. A mistake like this can put a system into live trading.- A
The variable
PPT_CAPITALis10000in the environment and25000in the.env. With the precedence from the lesson, which capital does the system use?- A25000, because the
.envbelongs to the project - B10000, because the environment takes priority over the
.env - CThe sum, 35000
- DNeither: the program fails because of the conflict
Show answer
Answer B. 10000, because the environment takes priority over the
.envThe
.envprovides default values for your machine; the environment lets you change them for a specific run (a server, a test) without touching files. It is the same rule followed by uv with--env-fileand by thepython-dotenvlibrary.- A25000, because the
You accidentally pushed a
.envwith your broker key to a public repository and deleted it with another commit five minutes later. What do you do?- ANothing: it is already deleted
- BMake the repository private
- CRename the file
- DRevoke the key at the broker and create another; the old one is still in the history and may already have been copied
Show answer
Answer D. Revoke the key at the broker and create another; the old one is still in the history and may already have been copied
There are programs that continuously crawl public repositories looking for keys, and the commit is still in the history. A key that has been in Git is considered compromised: it is revoked, another is created and the account activity is reviewed.
Key points
- The terminal is the way to start, stop (Ctrl+C) and automate programs. A few commands are enough:
pwd,ls,cd,mkdir,cat. - A relative path depends on the current folder. Build paths with
pathlibfrom a known root. - Environment variables are always text: convert numbers and yes/no explicitly and give a safe default value.
- Precedence: default values, then
.env, then the environment. The environment wins. - Secrets go in
.env, which is in.gitignore; they are never printed, not in logs and not in notebooks.