| Time | Block |
|---|---|
| 0:00 – 0:05 | Warm-up |
| 0:05 – 0:40 | 3.1 Why functions · 3.2 Parameters and defaults · 3.3 Docstrings and type hints |
| 0:40 – 0:48 | Break |
| 0:48 – 1:15 | 3.4 Return values · 3.5 Functions on time series · 3.6 Scope |
| 1:15 – 1:38 | 3.7 Modules: your own macrotools.py |
| 1:38 – 1:45 | Break |
| 1:45 – 2:08 | 3.8 Virtual environments (.venv) and packages |
| 2:08 – 2:25 | 3.9 Capstone: live Bank of Canada data |
| 2:25 – 2:30 | 3.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
returnvalues. 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 conditioncrashes loudly if the condition isFalse. 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
| Command | Purpose |
|---|---|
python -m venv .venv | Create environment |
source .venv/bin/activate | Activate it |
deactivate | Leave it |
pip install <package> | Install a package |
pip freeze > requirements.txt | Save exact versions |
pip install -r requirements.txt | Recreate 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
returnrather thanprint - I can import functions from my own module
- I can use
assertfor simple self-tests - I can create, activate, and rebuild a
.venvfromrequirements.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, values | One DataFrame with a DatetimeIndex |
Because you built these by hand, you'll know exactly what pandas is doing under the hood.