Module 1 · Day 3 of 80

Functions, Modules & Virtual Environments

About 2.5 hours
Video lesson coming soon
Day 3 · Functions, Modules & Virtual Environments
Module 1: Python for Macroeconomics · The recording will appear here as soon as it is published. The written lesson below is available now.

What you'll learn today

  • I can write functions with parameters, defaults, docstrings and type hints
  • I understand why functions should return rather than print
  • I can import functions from my own module
  • I can use assert for simple self-tests
  • I can create, activate, and rebuild a .venv from requirements.txt
  • I fetched live data from the Bank of Canada Valet API

Today's plan

Timed blocks for the day. Hover a bar for details.

LessonHands-on projectBreak2:30 total
Written lesson
TimeBlock
0:00 – 0:05Warm-up
0:05 – 0:403.1 Why functions · 3.2 Parameters and defaults · 3.3 Docstrings and type hints
0:40 – 0:48Break
0:48 – 1:153.4 Return values · 3.5 Functions on time series · 3.6 Scope
1:15 – 1:383.7 Modules: your own macrotools.py
1:38 – 1:45Break
1:45 – 2:083.8 Virtual environments (.venv) and packages
2:08 – 2:253.9 Capstone: live Bank of Canada data
2:25 – 2:303.10 Commit and checklist

Warm-up (5 min)

Yesterday you wrote the stance classification three times. Copy-pasting code means that if the neutral band changes, you must fix it everywhere. Functions solve this.

mkdir day03

3.1 Why Functions? (10 min)

A function is a named, reusable piece of logic — like f(x) in economics. It takes inputs (arguments) and gives back an output (return value).

Example 1 — Your first function

def real_rate(nominal, inflation):
    return nominal - inflation

print(real_rate(2.25, 3.0))     # -0.75
print(real_rate(5.00, 3.8))     # 1.2

Anatomy: def + name + (parameters) + colon; indented body; return sends the result back.

Example 2 — Replacing repeated code

def classify_stance(rate):
    if rate < 2.25:
        return "Stimulative"
    elif rate <= 2.75:
        return "Neutral"
    else:
        return "Restrictive"

for r in [0.25, 2.50, 5.00]:
    print(f"{r:.2f}% → {classify_stance(r)}")

3.2 Parameters and Defaults (13 min)

Example 1 — Default values

Parameters can have defaults, used when the caller doesn't supply a value.

def real_rate(nominal, inflation, method="approx"):
    if method == "exact":
        return ((1 + nominal / 100) / (1 + inflation / 100) - 1) * 100
    return nominal - inflation

print(real_rate(2.25, 3.0))                  # -0.75 (default)
print(real_rate(2.25, 3.0, method="exact"))  # -0.728...

Example 2 — Keyword arguments make calls readable

def annualise(growth, periods_per_year=4, compound=True):
    if compound:
        return ((1 + growth / 100) ** periods_per_year - 1) * 100
    return growth * periods_per_year

print(annualise(0.8))                         # quarterly, compounded
print(annualise(0.8, compound=False))         # the notebook's ×4 shortcut
print(annualise(0.25, periods_per_year=12))   # monthly growth

Example 3 — Making the neutral band adjustable

def classify_stance(rate, neutral_low=2.25, neutral_high=2.75):
    if rate < neutral_low:
        return "Stimulative"
    elif rate <= neutral_high:
        return "Neutral"
    return "Restrictive"

print(classify_stance(2.50))                                    # Neutral
print(classify_stance(2.50, neutral_low=2.75, neutral_high=3.25))  # Stimulative

Now if the Bank of Canada revises its neutral-rate estimate, you change one argument.


3.3 Docstrings and Type Hints (13 min)

Professional code explains itself. A docstring (triple-quoted text right after def) describes the function; type hints state expected input and output types.

def pct_change(new: float, old: float) -> float:
    """Percentage change from old to new.

    Args:
        new: The later value (e.g. this month's CPI level).
        old: The earlier value (e.g. last year's CPI level).

    Returns:
        Percentage change, e.g. 3.0 for a 3% increase.
    """
    return (new / old - 1) * 100


print(pct_change(169.0, 164.1))
help(pct_change)        # shows your docstring

In VS Code, hover over pct_change anywhere — your docstring pops up. Type hints don't enforce anything at runtime, but they help VS Code catch mistakes and help readers understand your code.

Try it 3.3

Write a documented, type-hinted function spread_bp(long_yield, short_yield) that returns the spread in basis points. Test it with 3.75 and 2.27.

Solution
def spread_bp(long_yield: float, short_yield: float) -> float:
    """Term spread in basis points.

    Args:
        long_yield: Long-maturity yield in % (e.g. 10-year).
        short_yield: Short-maturity yield in % (e.g. 2-year).

    Returns:
        Spread in basis points (positive = normal curve).
    """
    return (long_yield - short_yield) * 100


print(f"{spread_bp(3.75, 2.27):.0f} bp")    # 148 bp

3.4 Return Values: Common Pitfalls and Multiple Outputs (10 min)

Pitfall — print is not return

def bad_real_rate(nominal, inflation):
    print(nominal - inflation)          # displays, but returns nothing

result = bad_real_rate(2.25, 3.0)       # prints -0.75
print(result)                            # None!  Can't use it in further math

Rule: functions should return values. Let the caller decide whether to print.

Returning several values

def summarise(values):
    """Return min, max and mean of a list of numbers."""
    mean = sum(values) / len(values)
    return min(values), max(values), mean

low, high, avg = summarise([2.9, 2.6, 2.4, 2.1, 1.9])
print(f"Range {low}–{high}, mean {avg:.2f}")

Returning a dictionary is often clearer:

def summarise(values):
    return {
        "n": len(values),
        "min": min(values),
        "max": max(values),
        "mean": sum(values) / len(values),
    }

stats = summarise([2.9, 2.6, 2.4, 2.1, 1.9])
print(stats["mean"])

3.5 Functions on Time Series (13 min)

Let's wrap Day 2's loops in functions.

Example 1 — Year-over-year changes

def yoy_changes(levels, lag=12):
    """Percentage change versus `lag` periods earlier.

    With monthly data and lag=12 this is year-over-year inflation.
    The first `lag` periods have no comparison and are skipped.
    """
    changes = []
    for i in range(lag, len(levels)):
        changes.append(pct_change(levels[i], levels[i - lag]))
    return changes


# Illustrative: 15 months of CPI levels
cpi = [158.9, 159.5, 160.1, 160.4, 160.8, 161.2, 161.5, 161.6,
       161.4, 161.9, 162.0, 162.1, 163.6, 164.2, 164.9]

print(yoy_changes(cpi))            # 3 YoY values
print(yoy_changes(cpi, lag=1))     # month-over-month instead

Notice yoy_changes calls pct_change — small functions combine into bigger ones.

Example 2 — A moving average

def moving_average(values, window=3):
    """Simple trailing moving average."""
    result = []
    for i in range(window - 1, len(values)):
        chunk = values[i - window + 1 : i + 1]
        result.append(sum(chunk) / window)
    return result

housing = [240, 255, 230, 262, 270, 248]    # illustrative, thousands
print(moving_average(housing, window=4))

This is what the notebook's .rolling(4).mean() does for housing starts.


3.6 Scope: Where Variables Live (5 min)

Variables created inside a function exist only inside it.

def compute():
    temp = 42
    return temp * 2

print(compute())      # 84
print(temp)           # NameError: name 'temp' is not defined

Good practice: pass everything a function needs as arguments and get results back via return. Avoid relying on variables defined outside the function.


3.7 Modules: Your Own macrotools.py (23 min)

A module is simply a .py file whose functions you can import into other files. This is how the course notebook's code will eventually become a real project.

Step 1 — Create day03/macrotools.py

"""macrotools — small, reusable macroeconomic helper functions."""


def pct_change(new: float, old: float) -> float:
    """Percentage change from old to new."""
    return (new / old - 1) * 100


def real_rate(nominal: float, inflation: float, method: str = "approx") -> float:
    """Real interest rate in %. method='approx' (i − π) or 'exact' (Fisher)."""
    if method == "exact":
        return ((1 + nominal / 100) / (1 + inflation / 100) - 1) * 100
    return nominal - inflation


def annualise(growth: float, periods_per_year: int = 4, compound: bool = True) -> float:
    """Annualise a per-period growth rate given in %."""
    if compound:
        return ((1 + growth / 100) ** periods_per_year - 1) * 100
    return growth * periods_per_year


def spread_bp(long_yield: float, short_yield: float) -> float:
    """Term spread in basis points."""
    return (long_yield - short_yield) * 100


def classify_stance(rate: float, neutral_low: float = 2.25,
                    neutral_high: float = 2.75) -> str:
    """Classify the policy rate relative to a neutral band."""
    if rate < neutral_low:
        return "Stimulative"
    elif rate <= neutral_high:
        return "Neutral"
    return "Restrictive"


def yoy_changes(levels: list, lag: int = 12) -> list:
    """Percentage change versus `lag` periods earlier."""
    return [pct_change(levels[i], levels[i - lag]) for i in range(lag, len(levels))]


def clean_observations(observations: list, series: str) -> tuple:
    """Turn raw BoC Valet observations into (dates, values) lists.

    Unusable values ('', 'NA', 'Bank holiday', missing keys) are skipped.
    """
    dates, values = [], []
    for obs in observations:
        raw = obs.get(series, {}).get("v", "")
        try:
            value = float(raw)
        except ValueError:
            continue
        dates.append(obs["d"])
        values.append(value)
    return dates, values


def summarise(values: list) -> dict:
    """Basic descriptive statistics."""
    return {
        "n": len(values),
        "min": min(values),
        "max": max(values),
        "mean": sum(values) / len(values),
    }


# ── Self-tests: run only when this file is executed directly ──────────────
if __name__ == "__main__":
    assert real_rate(2.25, 3.0) == -0.75
    assert classify_stance(2.50) == "Neutral"
    assert classify_stance(5.00) == "Restrictive"
    assert round(spread_bp(3.75, 2.27)) == 148
    assert round(annualise(0.8, compound=False), 1) == 3.2

    sample = [{"d": "2026-01-05", "V39079": {"v": "2.25"}},
              {"d": "2026-01-06", "V39079": {"v": "NA"}}]
    assert clean_observations(sample, "V39079") == (["2026-01-05"], [2.25])

    print("✓ All macrotools self-tests passed")

Run it:

python day03/macrotools.py

Two new ideas:

  • assert condition crashes loudly if the condition is False. It's the simplest form of testing — a first taste of the data-quality contracts you'll build with Great Expectations on Days 41–50.
  • if __name__ == "__main__": means "run this block only when the file is executed directly, not when imported."

Step 2 — Use it from another file: day03/analysis.py

from macrotools import real_rate, classify_stance, spread_bp, summarise

overnight = 2.25
cpi_yoy = 3.0

print(f"Real rate (approx): {real_rate(overnight, cpi_yoy):+.2f}%")
print(f"Real rate (exact):  {real_rate(overnight, cpi_yoy, method='exact'):+.2f}%")
print(f"Stance: {classify_stance(overnight)}")
print(f"10y-2y spread: {spread_bp(3.75, 2.27):.0f} bp")

rates_2024 = [4.75, 4.50, 4.25, 3.75, 3.25]
print(summarise(rates_2024))

Run from inside the folder so Python can find macrotools.py:

cd day03
python analysis.py
cd ..

Note that the self-tests did not run — that's __name__ == "__main__" doing its job.

Try it 3.7

Add a function fisher_gap(nominal, inflation) to macrotools.py that returns the difference between the approximate and exact real rates. Add an assert for it, then use it in analysis.py.

Solution
# In macrotools.py
def fisher_gap(nominal: float, inflation: float) -> float:
    """Approximate minus exact real rate, in percentage points."""
    return real_rate(nominal, inflation) - real_rate(nominal, inflation, method="exact")

# In the self-test block
    assert abs(fisher_gap(2.25, 3.0)) < 0.1

# In analysis.py
from macrotools import fisher_gap
print(f"Fisher gap: {fisher_gap(2.25, 3.0):.3f} pp")

3.8 Virtual Environments and Packages (23 min)

Why?

Python's power comes from packages others have written: requests (web APIs), pandas (data frames), dlt, dbt, prefect… Different projects need different versions. A virtual environment is a private, project-specific set of packages so projects don't interfere with each other — and so your colleagues can recreate your exact setup.

Think of it as a replication package for your code environment.

Step 1 — Create the environment (at the repository root)

cd /workspaces/macro-data-course
python -m venv .venv

This creates a hidden .venv folder containing its own copy of Python.

Step 2 — Activate it

source .venv/bin/activate

Your prompt now starts with (.venv). Confirm Python is coming from the environment:

which python
# /workspaces/macro-data-course/.venv/bin/python

You must activate the environment every time you open a new terminal. If your prompt doesn't show (.venv), you're not in it.

Step 3 — Tell VS Code to use it

Press Ctrl+Shift+P → Python: Select Interpreter → choose the one with .venv. VS Code will now use it for the Run button and new terminals.

Step 4 — Install a package

pip install requests
pip list

Step 5 — Record your dependencies

pip freeze > requirements.txt
cat requirements.txt

requirements.txt lists every package and its exact version. This file gets committed; the .venv folder does not.

Step 6 — Keep .venv out of Git

Create a file called .gitignore at the repository root:

.venv/
__pycache__/
*.pyc

Step 7 — Prove you can rebuild it

This is what a colleague (or your future self) will do:

deactivate
rm -rf .venv
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

Quick reference

CommandPurpose
python -m venv .venvCreate environment
source .venv/bin/activateActivate it
deactivateLeave it
pip install <package>Install a package
pip freeze > requirements.txtSave exact versions
pip install -r requirements.txtRecreate from the list

3.9 Capstone: Live Bank of Canada Data (18 min)

Time to replace hard-coded numbers with a live API call — using the module you wrote.

Create day03/fetch_boc.py:

"""Fetch the latest BoC overnight rate and summarise it with macrotools."""

import requests
from macrotools import clean_observations, summarise, classify_stance

SERIES = "V39079"   # BoC target for the overnight rate
URL = f"https://www.bankofcanada.ca/valet/observations/{SERIES}/json?recent=30"

response = requests.get(URL, timeout=20)
response.raise_for_status()          # stop with a clear error if the request failed

data = response.json()               # convert the JSON text into Python dicts/lists
observations = data["observations"]

print(f"Received {len(observations)} raw observations")

dates, values = clean_observations(observations, SERIES)
stats = summarise(values)

print(f"Clean observations: {stats['n']}")
print(f"Period: {dates[0]} → {dates[-1]}")
print(f"Latest overnight rate: {values[-1]:.2f}%")
print(f"Range over period: {stats['min']:.2f}% – {stats['max']:.2f}%")
print(f"Stance: {classify_stance(values[-1])}")

Run it (environment active):

cd day03
python fetch_boc.py
cd ..

Explore the raw response. Add these lines temporarily to see what the API actually sends:

print(data.keys())
print(observations[0])

You'll see the same {"d": ..., "V39079": {"v": ...}} shape you cleaned by hand on Day 2.

Try it 3.9

Change SERIES to "FXCADUSD" (the CAD/USD exchange rate). Print the latest USD/CAD (1 / value). Which function in macrotools.py would you reuse, and which line would break?

Solution

clean_observations and summarise work unchanged because they take the series code as an argument. classify_stance makes no sense for an exchange rate — remove that line and add:

print(f"Latest USD/CAD: {1 / values[-1]:.4f}")

This is the payoff of functions: the cleaning logic works for any Valet series.

Stretch: if the request fails (try disconnecting by using a misspelled URL), wrap the call in try/except requests.RequestException and print a friendly message instead of a crash.


3.10 Commit and Wrap-up (5 min)

git add .
git commit -m "Day 3: functions, macrotools module, venv, live BoC fetch"
git push

Check on GitHub that requirements.txt and .gitignore are present and .venv is not.

Day 3 checklist

  • I can write functions with parameters, defaults, docstrings and type hints
  • I understand why functions should return rather than print
  • I can import functions from my own module
  • I can use assert for simple self-tests
  • I can create, activate, and rebuild a .venv from requirements.txt
  • I fetched live data from the Bank of Canada Valet API

Looking Ahead to Days 4–7

Everything you did with lists and loops, pandas will do in single lines:

Days 1–3 (plain Python)Days 4–7 (pandas)
yoy_changes(cpi, lag=12)df["cpi"].pct_change(12) * 100
moving_average(x, window=4)df["x"].rolling(4).mean()
clean_observations(...)pd.to_numeric(..., errors="coerce").dropna()
Two lists: dates, valuesOne DataFrame with a DatetimeIndex

Because you built these by hand, you'll know exactly what pandas is doing under the hood.