Lesson 0.2.3 · 35 min
Editors (VS Code, Cursor, PyCharm) and notebooks (Jupyter, JupyterLab, marimo)
The editor is where you write the system; the notebook, where you explore it. This lesson picks one of each, connects them to the project environment and shows the most expensive notebook mistake: a result that depends on the order in which you ran the cells.
- Module
- 0.2 · Modern working environment
- Duration
- 35 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
Two tools for two jobs
A trading project has two kinds of code. The exploratory kind: load some data, look at it, try an idea, plot an equity curve. And the kind that stays: the functions that compute signals, positions and costs, which are reused, tested and will one day trade live. Each kind has its tool:
| Editor | Notebook | |
|---|---|---|
| What for | Code that stays: src/, tests, scripts | Exploring data, trying ideas, telling an analysis |
| Unit | .py file | Cell (code + output) |
| Execution | From start to finish, always the same | Cell by cell, in the order you choose |
| Example | src/ppt/senales.py with its test | notebooks/explorar-sint.ipynb |
In practice both are used at the same time, and modern editors also open notebooks. What matters is that both use the
same environment of the project (.venv, lesson 0.2.2): if the notebook imports pandas from the system Python
and your tests from the project environment, you are not testing the same thing you are exploring.
The editor: VS Code, Cursor or PyCharm
Any of the three works for the whole course. The differences are in the licensing model and in how they integrate AI (lesson 0.2.6):
| Editor | Who and how | Notebooks | AI |
|---|---|---|---|
| VS Code | Microsoft; free; extensions for Python and Jupyter | .ipynb with the Jupyter extension | GitHub Copilot, with a limited free plan |
| Cursor | Based on the VS Code codebase; free plan with limited AI and paid plans | Those of VS Code | It is the core of the product: chat, autocomplete and agent |
| PyCharm | JetBrains; since 2025.1, a single product with the basic features free and a Pro subscription | Built-in .ipynb, also in the free part | Agents (Junie, Claude, Codex) with a JetBrains subscription or with your own keys |
If you do not know which one to choose, start with VS Code: it is free, most tutorials use it and what you learn carries over to Cursor, which shares its interface1Note 1 · Sources for the table, accessed on October 21, 2026: Cursor Docs, "VS Code Migration" ("Cursor is based upon the VS Code codebase") and its pricing page (free plan with limited AI usage); GitHub Docs, Copilot plans (Copilot Free); JetBrains, "Unified PyCharm overview" (basic features free, Jupyter included, since 2025.1).. PyCharm ships with more refactoring and debugging tools out of the box, in exchange for a busier interface.
Whichever it is, you have to tell it which interpreter to use. With the project created in lesson 0.2.2, open the
ppt-curso folder (not a single file) and choose the Python in .venv:
- VS Code and Cursor: command palette (Ctrl+Shift+P or Cmd+Shift+P), "Python: Select Interpreter" and the entry
that points to
.venv. It usually appears first, marked as recommended. - PyCharm: it usually detects the
pyproject.tomland the.venvwhen you open the folder; if not, in the project settings, "Python Interpreter", add the existing interpreter from.venv.
To check it, create a file with these two lines and run it from the editor. The output should mention .venv:
import sys
print(sys.prefix)Jupyter, JupyterLab or marimo
A notebook mixes code, outputs, charts and text in a single document, run cell by cell against a Python process that stays alive (the kernel). It is the natural way to explore: you load five years of prices once and try twenty variants without reading the file again.
| Jupyter Notebook / JupyterLab | marimo | |
|---|---|---|
| File format | .ipynb (JSON with code and outputs) | .py (plain Python) |
| Execution order | Whatever you choose, cell by cell | Reactive: changing a cell runs the ones that depend on it |
| In Git | Noisy diffs; outputs travel inside the file | Clean diffs, like any .py |
| As a script | It has to be converted | uv run notebook.py |
| Ecosystem | The standard: VS Code, PyCharm, Colab… open it | Younger; its own editor in the browser |
JupyterLab is the modern Jupyter interface (tabs, file browser, terminal); the classic Jupyter Notebook is the minimal version. Both use the same format. To start them with the project environment:
uv add --dev ipykernel # for notebooks in VS Code or PyCharm
uv run --with jupyter jupyter lab # JupyterLab in the browser
uv add --dev marimo
uv run marimo edit notebooks/explorar.py # marimouv run --with jupyter adds Jupyter for that run only, without putting it in the project dependencies. That way the
notebook sees exactly the libraries in the lock.
Hidden state
In a classic notebook, the kernel remembers everything you have run, in the order you ran it. The document shows something else: the code in the order it is written. When the two do not match, the notebook lies without looking like it.
An example with the most expensive mistake in systematic trading, the signal lag (lesson 0.1.1). This four-cell
notebook computes a strategy that is long when SINT's close is above its 50-session average. Cell 3 shifts the
position one day, to trade the day after the signal. To simulate the kernel, each cell is a string run with exec on
a shared dictionary; there is no need to understand exec now, only that it does the same as pressing "run" on a
cell:
import pandas as pd
celdas = {
1: """precios = pd.read_csv("sint_diario.csv",
parse_dates=["fecha"],
index_col="fecha")["cierre"]""",
2: """media = precios.rolling(50).mean()
posicion = (precios > media).astype(int)""",
3: "posicion = posicion.shift(1).fillna(0)",
4: """rent = precios.pct_change().fillna(0)
resultado = (1 + posicion * rent).prod() - 1""",
}
def ejecutar(orden: list[int]) -> float:
kernel = {"pd": pd} # kernel memory, empty
for n in orden:
exec(celdas[n], kernel)
return kernel["resultado"]Now, the same notebook run in three orders that in real life go unnoticed: top to bottom; top to bottom and then the last two cells again (to "refresh the result"); and skipping cell 3 because cell 4 was run directly after tweaking cell 2.
for orden in ([1, 2, 3, 4], [1, 2, 3, 4, 3, 4], [1, 2, 4]):
print(f"{str(orden):<20} {ejecutar(orden):+.1%}")[1, 2, 3, 4] -2.3%
[1, 2, 3, 4, 3, 4] -6.9%
[1, 2, 4] +280.8%The code is identical in all three cases; the figure in the header plots the three results. Repeating cell 3 leaves the position shifted two days and the result gets worse. Skipping it, the strategy trades with information from the same close it claims to anticipate: look-ahead bias, and a return that does not exist. In a real notebook, the output of cell 4 would show any of the three numbers, and nothing on screen would tell you which.
Other forms of hidden state, just as common:
- Orphan variables. You delete the cell that defined
coste = 0.001, but the variable is still in memory and everything works... until someone else opens the notebook. - Data modified in place. A cell does
df["cierre"] = df["cierre"] / 2to adjust for a split. Every run divides again. - Imports that change. You edit a function in
src/and the kernel keeps using the old version until you restart it.
The defense is a rule: before believing a number from a notebook, restart the kernel and run everything ("Restart
Kernel and Run All Cells" in JupyterLab, "Restart" and "Run All" in VS Code). It is exactly what ejecutar does with
[1, 2, 3, 4]: empty memory and cells in order. If the number changes, the one you had was false.
Reading the order in an .ipynb
An .ipynb is a JSON file. Each code cell stores its execution_count: the number you see as In [7]. If the
counters do not increase from top to bottom, the cells were not run in a single pass. This is a minimal notebook,
trimmed to the essentials, with a suspicious order:
import json
nb = json.loads("""{"cells": [
{"cell_type": "markdown", "source": ["# SINT"]},
{"cell_type": "code", "execution_count": 1, "source": ["..."]},
{"cell_type": "code", "execution_count": 2, "source": ["..."]},
{"cell_type": "code", "execution_count": 7, "source": ["..."]},
{"cell_type": "code", "execution_count": 4, "source": ["..."]}
]}""")
contadores = [c["execution_count"] for c in nb["cells"]
if c["cell_type"] == "code"]
en_orden = contadores == list(range(1, len(contadores) + 1))
print(contadores, "in order" if en_orden else "out of order")[1, 2, 7, 4] out of orderWith json.load(open("explorar.ipynb")) you would do the same with a real notebook. It is a cheap check before
sharing a result or committing a notebook.
marimo solves the problem at the root: it analyzes which variables each cell defines and uses and builds a graph. When you change a cell, it runs (or marks as stale) all the ones that depend on it. For the previous example, the graph is a chain:
Mermaid source
flowchart TB C1[precios] --> C2[media, posicion] C2 --> C3[posicion shift] C3 --> C4[resultado]
That graph demands one thing: each variable is defined in a single cell. Cell 3 (posicion = posicion.shift(1))
is not valid in marimo, because it redefines a variable from another cell; you have to write posicion_ejecutada = posicion.shift(1). It is a restriction, and it is precisely the one that prevents the mistake in the figure.
Clearing outputs has a second advantage: the diffs Git shows (lesson 0.2.5) are reduced to the code. In JupyterLab, "Edit", "Clear Outputs of All Cells" before saving; in VS Code, the "Clear All Outputs" button in the notebook toolbar. A notebook that is going to be shared is also saved freshly run from start to finish: that way its counters are in order and its outputs correspond to the code you see.
When to move code to src/
The notebook is the draft. When a function meets any of these conditions, it leaves the notebook for a file in
src/ppt/:
- It is reused: you use it in a second notebook. Copying it creates two versions that will diverge.
- It needs a test: any calculation a money decision depends on (signals, positions, costs, position sizing).
Tests are written against
src/, not against a notebook (lesson 0.4.5). - It goes to production: the system that trades live does not run notebooks.
In the example, the logic of cells 2 to 4 deserves to leave. As a function, the lag no longer depends on how many times you run something:
def rentabilidad_media_movil(precios: pd.Series,
ventana: int = 50) -> float:
"""Total return of being long above the moving average.
The signal from the close is traded in the next session.
"""
media = precios.rolling(ventana).mean()
senal = (precios > media).astype(int)
posicion = senal.shift(1).fillna(0)
rent = precios.pct_change().fillna(0)
return float((1 + posicion * rent).prod() - 1)From the notebook it is imported instead of copied. Here the function is already defined in the previous cell; in your project, the first line would be the import:
# in your notebook: from ppt.backtest import rentabilidad_media_movil
precios = pd.read_csv("sint_diario.csv", parse_dates=["fecha"],
index_col="fecha")["cierre"]
for ventana in (20, 50, 100):
r = rentabilidad_media_movil(precios, ventana)
print(f"{ventana:>3}-session average: {r:+.1%}") 20-session average: +34.7%
50-session average: -2.3%
100-session average: -17.0%With 50 sessions you get the −2.3% of the notebook run in order: the function and the correct notebook say the same thing. That the 20-session average gives +34.7% does not make it the right one: choosing the parameter that did best in the past is overfitting, and level 2 explains how to avoid it.
So that the kernel picks up the changes you make in src/ without restarting it, Jupyter has an IPython extension
that reloads modules before each cell. It is enabled in the first cell of the notebook:
%load_ext autoreload
%autoreload 2Exercises
- Install an editor, open it on the
ppt-cursofolder from lesson 0.2.2 and select the.venvinterpreter. Createnotebooks/explorar-sint.ipynb, loadsint_diario.csvand plot the close. How do you check, from the notebook itself, that the kernel is the project's?
Solution
With a cell that prints sys.prefix: it should end in ppt-curso/.venv. Another way: import pandas; pandas.__version__ should give the version in uv.lock. If the notebook says it cannot find pandas, the kernel is
another Python: choose the interpreter again (in VS Code, the kernel picker at the top right of the notebook).
- With the
ejecutarfunction from the lesson, compute the result of the order[1, 2, 3, 3, 3, 4]. Before running it, predict whether it will be better or worse than that of[1, 2, 3, 4]and why.
Solution
print(f"{ejecutar([1, 2, 3, 3, 3, 4]):+.1%}")-17.5%The position ends up shifted three days: the strategy enters and exits every move three sessions late. Here it gets worse (−17.5% versus −2.3%), but there is no general rule: with other data it could improve. What matters is that it is not the strategy you thought you were testing.
- Rewrite the four cells of the example so that they are valid in marimo (each variable defined in a single cell). What has changed in cell 3?
Solution
Cell 1: precios = …. Cell 2: media = precios.rolling(50).mean() and senal = (precios > media).astype(int).
Cell 3: posicion = senal.shift(1).fillna(0), which now defines a new variable from another one instead of modifying
the existing one. Cell 4: rent = … and resultado = …. Since nothing is redefined, running a cell twice always
gives the same result: the notebook is idempotent, just like the function in src/.
Footnotes
-
Sources for the table, accessed on October 21, 2026: Cursor Docs, "VS Code Migration" ("Cursor is based upon the VS Code codebase") and its pricing page (free plan with limited AI usage); GitHub Docs, Copilot plans (Copilot Free); JetBrains, "Unified PyCharm overview" (basic features free, Jupyter included, since 2025.1). ↩
Each answer is folded under its question
Self-assessment
You run a second time a cell that does
posicion = posicion.shift(1). What happens to the backtest?- ANothing: running a cell twice gives the same as running it once
- BPython raises an error because
posicionalready exists - CThe notebook restarts on its own
- DThe position ends up shifted two days and the result changes, even though the notebook's code is the same
Show answer
Answer D. The position ends up shifted two days and the result changes, even though the notebook's code is the same
The cell modifies a variable that already exists in memory: each run shifts it one more day. The code you see has not changed, but the state has. That is hidden state.
What does marimo do when you change a cell that other cells depend on?
- ANothing; you have to rerun the following cells by hand
- BIt reruns the cells that depend on it (or marks them as stale), so the state always matches the code
- CIt deletes all the variables
- DIt saves the notebook in
.ipynbformat
Show answer
Answer B. It reruns the cells that depend on it (or marks them as stale), so the state always matches the code
marimo analyzes which variables each cell defines and uses and builds a dependency graph. That is why it does not allow defining the same variable in two cells: it would break the graph.
Which of these is NOT a good reason to move a function from the notebook to
src/?- AYou use it in two different notebooks
- BIt needs tests
- CIt is longer than three lines
- DIt is going to run in the system that trades live
Show answer
Answer C. It is longer than three lines
Size is not the criterion. Reuse, the need to test it and going to production are. A three-line function that computes position size belongs in
src/with its test.An
.ipynbhas cells with execution counts 1, 2, 7, 4. What does it indicate?- AThat the cells were not run top to bottom in a single pass: the outputs may not correspond to the code as it stands
- BThat the notebook has four cells with errors
- CThat it was run with four cores
- DNothing: the counters are random
Show answer
Answer A. That the cells were not run top to bottom in a single pass: the outputs may not correspond to the code as it stands
The counter shows the order in which each cell was run in the session. If it does not increase from top to bottom, someone ran cells out of order or repeated one. Restart and run everything before believing the result.
Key points
- Editor for the code that stays (
src/, tests); notebook for exploring. Both with the project's.venvinterpreter. - A classic notebook keeps state in memory: the result depends on the order in which you ran the cells, not on the order in which they are written.
- Before believing a number from a notebook: restart the kernel and run everything from top to bottom.
- marimo avoids hidden state with a dependency graph and saves notebooks as
.py, readable in Git. - Whatever is reused, tested or goes to production leaves the notebook for
src/, with its test.