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.

04/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

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:

ActionLinux and macOSWindows (PowerShell)
Which folder am I in?pwdpwd
List the contentsls, ls -la (hidden ones too)ls, ls -Force
Enter a folder / go up onecd datos, cd ..cd datos, cd ..
Create a foldermkdir -p datos/brutosmkdir datos\brutos
View a text filecat .gitignore, head -5 sint.csvcat .gitignore, Get-Content sint.csv -Head 5
Copy / deletecp a b, rm acp a b, rm a
Stop the running programCtrl+CCtrl+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 package

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

IN [01]python
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)
OUT [01]A path with pathlib
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.

IN [02]python
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())
OUT [02]The Windows backslash
17 19
ticks.csv
C:/nuevos/ticks.csv

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

IN [03]python
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())
OUT [03]Relative to the current folder
True
False
False
True

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

IN [04]src/ppt/rutas.pypython
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 only

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

IN [05]python
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"))    # default
OUT [05]Reading environment variables
paper
None
25000

os.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:

IN [06]python
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%
OUT [06]Always text
True
False
500.0

bool("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:

IN [07]python
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:

IN [08]python
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))
OUT [08]A .env reader
['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:

IN [09]python
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)}")
OUT [09]Showing the configuration without secrets
BROKER_API_KEY     pk_***
BROKER_API_SECRET  sk_***
PPT_MODO           paper
PPT_CAPITAL        25000
PPT_RIESGO_MAX     0.02

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

Defaults.env fileEnvironmentConfiguration
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:

IN [10]python
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}")
OUT [10]Precedence: environment > .env > defaults
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_***     .env

Each 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.sistema

The 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

  1. In the terminal, without using the file explorer: inside ppt-curso, create the folders datos/brutos and datos/procesados, copy the sint_diario.csv file from the resources into datos/brutos and 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.

  1. Extend cargar_config so that it fails with a clear message if a mandatory variable is missing (for example, BROKER_API_KEY) and so that it converts PPT_CAPITAL and PPT_RIESGO_MAX to float, checking that the risk is between 0 and 0.05.
Solution
IN [11]python
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"])
OUT [11]Validated configuration
50000.0 0.02

The 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%.

  1. Write the .env.example that would accompany the lesson's .env. What is the difference between the two and which one goes to Git?
Solution
IN [12].env.exampledotenv
# 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.02

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

  1. Your script reads datos/sint_diario.csv and works when you launch it from the project folder. Scheduled with cron, it fails with FileNotFoundError. 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.

  2. What does bool(os.environ.get("PPT_REAL", "false")) return if the variable does not exist?

    • AFalse
    • BNone
    • CTrue, because "false" is a non-empty string
    • DAn error
    Show answer

    Answer C. True, because "false" is a non-empty string

    Environment 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_bool does in the lesson. A mistake like this can put a system into live trading.

  3. The variable PPT_CAPITAL is 10000 in the environment and 25000 in the .env. With the precedence from the lesson, which capital does the system use?

    • A25000, because the .env belongs 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 .env

    The .env provides 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-file and by the python-dotenv library.

  4. You accidentally pushed a .env with 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 pathlib from 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.

Resources