---
title: "0.2.3 Editors and notebooks: VS Code, Jupyter and marimo"
description: "Which editor to choose (VS Code, Cursor or PyCharm), when to use Jupyter, JupyterLab or marimo, notebook hidden state and when to move code to src/."
url: "https://pythonparatrading.com/en/python-trading-course/level-0/code-editors-jupyter-marimo-notebooks/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-0/code-editors-jupyter-marimo-notebooks.png"
---

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

- Course: [Level 0: Python from scratch and a data mindset](https://pythonparatrading.com/en/python-trading-course/level-0/)
- 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](https://pythonparatrading.com/en/python-trading-course/level-0/virtual-environments-dependencies-with-uv/)

## By the end of this lesson

- Choose an editor and a type of notebook according to the job, and connect them to the project's virtual environment created with uv.
- Explain what the hidden state of a notebook is and reproduce how the execution order changes the result of a backtest.
- Check whether a notebook was run in order and run it again from start to finish before trusting a number.
- Decide when a function should leave the notebook and move to `src/` with its tests.

## 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 interface[^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.toml` and the `.venv` when 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`:

```python skip title="comprobar_entorno.py"
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:

```bash
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 # marimo
```

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

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

```python
for orden in ([1, 2, 3, 4], [1, 2, 3, 4, 3, 4], [1, 2, 4]):
    print(f"{str(orden):<20} {ejecutar(orden):+.1%}")
```
```output
[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"] / 2` to 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:

```python
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")
```
```output
[1, 2, 7, 4] out of order
```

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

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

An `.ipynb` stores the outputs inside the file: tables, charts and anything you printed. If a cell displayed an API
key, an account balance or licensed data, that travels with the notebook to Git. Clear the outputs before committing,
or never print anything sensitive (lesson 0.2.4).

</aside>

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:

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

```python
# 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%}")
```
```output
 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:

```python skip
%load_ext autoreload
%autoreload 2
```

## Exercises

1. Install an editor, open it on the `ppt-curso` folder from lesson 0.2.2 and select the `.venv` interpreter. Create
   `notebooks/explorar-sint.ipynb`, load `sint_diario.csv` and 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).

2. With the `ejecutar` function 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.**

```python
print(f"{ejecutar([1, 2, 3, 3, 3, 4]):+.1%}")
```
```output
-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.

3. 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/`.

[^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).

## Self-assessment

1. You run a second time a cell that does `posicion = posicion.shift(1)`. What happens to the backtest?

   - A. Nothing: running a cell twice gives the same as running it once
   - B. Python raises an error because `posicion` already exists
   - C. The notebook restarts on its own
   - D. The position ends up shifted two days and the result changes, even though the notebook's code is the same

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

2. What does marimo do when you change a cell that other cells depend on?

   - A. Nothing; you have to rerun the following cells by hand
   - B. It reruns the cells that depend on it (or marks them as stale), so the state always matches the code
   - C. It deletes all the variables
   - D. It saves the notebook in `.ipynb` format

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

3. Which of these is NOT a good reason to move a function from the notebook to `src/`?

   - A. You use it in two different notebooks
   - B. It needs tests
   - C. It is longer than three lines
   - D. It is going to run in the system that trades live

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

4. An `.ipynb` has cells with execution counts 1, 2, 7, 4. What does it indicate?

   - 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
   - B. That the notebook has four cells with errors
   - C. That it was run with four cores
   - D. Nothing: the counters are random

   *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 `.venv` interpreter.
- 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.

## Resources

### Research

- [The Working Environment - 1](https://pythonparatrading.com/en/docker-for-trading/) · Research-0013 · 4 min

### Documentation

- [VS Code: Python in Visual Studio Code](https://code.visualstudio.com/docs/python/python-tutorial) · code.visualstudio.com
- [VS Code: Jupyter notebooks](https://code.visualstudio.com/docs/datascience/jupyter-notebooks) · code.visualstudio.com
- [Unified PyCharm: what is free and what is Pro](https://www.jetbrains.com/help/pycharm/unified-pycharm.html) · jetbrains.com
- [uv: using Jupyter within a project](https://docs.astral.sh/uv/guides/integration/jupyter/) · docs.astral.sh
- [uv: using marimo](https://docs.astral.sh/uv/guides/integration/marimo/) · docs.astral.sh

Previous lesson: [0.2.2 · Virtual environments and dependencies: uv init, uv add, pyproject.toml and lockfiles](https://pythonparatrading.com/en/python-trading-course/level-0/virtual-environments-dependencies-with-uv/)

Next lesson: [0.2.4 · Basic terminal, paths, environment variables and .env files](https://pythonparatrading.com/en/python-trading-course/level-0/terminal-paths-environment-variables/)
