MapleStatsMCP

Case studies

Eight crafts, one connection

What can you do with MapleStats? Eight questions, from eight kinds of work, each answered with MapleStats tool calls. See for yourself below (heads up: it's amazing).

158

microdata files for the statisticians, keepers of the weights and the margins,

A bachelor's degree, province by province

The share of adults aged 25 to 64 whose highest credential is a bachelor's degree, estimated from the 2021 Census public use microdata file with its survey weights. Each bar is a 95% confidence interval from the file's 16 replicate weights: narrowest in British Columbia and Quebec, widest in Northern Canada and Prince Edward Island, where fewer people are in the sample.

Share of adults 25 to 64 whose highest credential is a bachelor's degree, with 95% confidence intervals10.0%15.0%20.0%25.0%Newfoundland and Labrador: 13.3% (12.5% to 14.1%)Newfoundland and Labrador13.3%Prince Edward Island: 18.2% (16.2% to 20.1%)Prince Edward Island18.2%Nova Scotia: 20.1% (19.4% to 20.7%)Nova Scotia20.1%New Brunswick: 16.7% (16.0% to 17.3%)New Brunswick16.7%Quebec: 18.1% (17.9% to 18.4%)Quebec18.1%Ontario: 23.8% (23.6% to 24.1%)Ontario23.8%Manitoba: 20.3% (19.8% to 20.9%)Manitoba20.3%Saskatchewan: 18.5% (17.8% to 19.2%)Saskatchewan18.5%Alberta: 21.7% (21.3% to 22.1%)Alberta21.7%British Columbia: 22.9% (22.7% to 23.1%)British Columbia22.9%Northern Canada: 15.1% (13.7% to 16.4%)Northern Canada15.1%Share of adults 25 to 64 whose highest credential is a bachelor's degree, with 95% confidence intervals10.0%15.0%20.0%25.0%Newfoundland and Labrador: 13.3% (12.5% to 14.1%)Newfoundland and Labrador13.3%Prince Edward Island: 18.2% (16.2% to 20.1%)Prince Edward Island18.2%Nova Scotia: 20.1% (19.4% to 20.7%)Nova Scotia20.1%New Brunswick: 16.7% (16.0% to 17.3%)New Brunswick16.7%Quebec: 18.1% (17.9% to 18.4%)Quebec18.1%Ontario: 23.8% (23.6% to 24.1%)Ontario23.8%Manitoba: 20.3% (19.8% to 20.9%)Manitoba20.3%Saskatchewan: 18.5% (17.8% to 19.2%)Saskatchewan18.5%Alberta: 21.7% (21.3% to 22.1%)Alberta21.7%British Columbia: 22.9% (22.7% to 23.1%)British Columbia22.9%Northern Canada: 15.1% (13.7% to 16.4%)Northern Canada15.1%
Show the data
Share of adults 25 to 64 whose highest credential is a bachelor's degree, with 95% confidence intervals
ProvinceEstimate95% CI low95% CI high
Newfoundland and Labrador13.3%12.5%14.1%
Prince Edward Island18.2%16.2%20.1%
Nova Scotia20.1%19.4%20.7%
New Brunswick16.7%16.0%17.3%
Quebec18.1%17.9%18.4%
Ontario23.8%23.6%24.1%
Manitoba20.3%19.8%20.9%
Saskatchewan18.5%17.8%19.2%
Alberta21.7%21.3%22.1%
British Columbia22.9%22.7%23.1%
Northern Canada15.1%13.7%16.4%
Source: Statistics Canada, 2021 Census, individuals file (98M0001X), queried 27 September 2026
The calls behind this
Requestcall_tool
{"name": "statcan_pumf_tabulate", "arguments": {"url": "https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip", "rows": ["PR", "HDGREE"], "statistic": "share", "filters": {"AGEGRP": ["9", "10", "11", "12", "13", "14", "15", "16"], "HDGREE": ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13"]}}}

R

# ============================================================
# StatCan public use microdata file
# Purpose: Fetch the data behind MapleStats MCP's statcan_pumf_tabulate
#          (exact: the PUMF ZIP)
# Inputs:  https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip
# Outputs: data/raw/cen21_ind_98m0001x_part_rec21.zip; the unzipped files
# ============================================================

# 0. Setup ----

dir.create("data/raw", recursive = TRUE, showWarnings = FALSE)

# 1. Read inputs ----

download.file("https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip", "data/raw/cen21_ind_98m0001x_part_rec21.zip", mode = "wb")
zip::unzip("data/raw/cen21_ind_98m0001x_part_rec21.zip", exdir = "data/raw/cen21_ind_98m0001x_part_rec21")
file.remove("data/raw/cen21_ind_98m0001x_part_rec21.zip")

data_files <- list.files("data/raw/cen21_ind_98m0001x_part_rec21", full.names = TRUE, recursive = TRUE)

Python

# ============================================================
# StatCan public use microdata file
# Purpose: Fetch the data behind MapleStats MCP's statcan_pumf_tabulate
#          (exact: the PUMF ZIP)
# Inputs:  https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip
# Outputs: data/raw/cen21_ind_98m0001x_part_rec21.zip; the unzipped files
# ============================================================

# %% 0. Setup

import zipfile
from pathlib import Path

import httpx
import polars as pl

RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "cen21_ind_98m0001x_part_rec21.zip"

# %% 1. Read inputs

with httpx.Client(
    http2=True,
    follow_redirects=True,
    timeout=300,
    headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"},
) as client:
    response = client.get('https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip')
response.raise_for_status()
raw_path.write_bytes(response.content)

with zipfile.ZipFile(raw_path) as archive:
    archive.extractall(RAW_DIR / raw_path.stem)

Stata

* ============================================================
* StatCan public use microdata file
* Purpose: Fetch the data behind MapleStats MCP's statcan_pumf_tabulate
*          (exact: the PUMF ZIP)
* Inputs:  https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip
* Outputs: data/raw/cen21_ind_98m0001x_part_rec21.zip; the unzipped files
* ============================================================

version 18
clear all
set more off

* 0. Setup

capture mkdir "logs"
capture log close
log using "logs/statcan_pumf_tabulate.log", replace
capture mkdir "data"
capture mkdir "data/raw"

* 1. Read inputs

* Stata reads no JSON or HTML and truncates long column names, so its
* built-in Python (Stata 16+) fetches, filters and writes a CSV. Point
* Stata at a Python with these packages first: python set exec <path>.

python:
import json
import zipfile
from pathlib import Path
import httpx
import polars as pl
RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "cen21_ind_98m0001x_part_rec21.zip"
with httpx.Client(http2=True, follow_redirects=True, timeout=300, headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"}) as client: response = client.get('https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip')
response.raise_for_status()
raw_path.write_bytes(response.content)
with zipfile.ZipFile(raw_path) as archive: archive.extractall(RAW_DIR / raw_path.stem)
end

log close

Julia

# ============================================================
# StatCan public use microdata file
# Purpose: Fetch the data behind MapleStats MCP's statcan_pumf_tabulate
#          (exact: the PUMF ZIP)
# Inputs:  https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip
# Outputs: data/raw/cen21_ind_98m0001x_part_rec21.zip; the unzipped files
# ============================================================

# 0. Setup

using Downloads

mkpath("data/raw")

# 1. Read inputs

Downloads.download("https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip", "data/raw/cen21_ind_98m0001x_part_rec21.zip")

67

immigration tables for the demographers, counting who comes to stay,

Who comes to stay: Edmonton and Calgary

New permanent residents who named each city as their destination, by year, from IRCC's Monthly Updates. *2026 covers January to July only. IRCC rounds every count to a multiple of 5.

New permanent residents by intended destination, Edmonton and Calgary, by yearEdmontonCalgary010,00020,00030,00040,000Edmonton, 2015: 16,745Calgary, 2015: 21,7152015Edmonton, 2016: 17,895Calgary, 2016: 21,4302016Edmonton, 2017: 15,960Calgary, 2017: 17,8752017Edmonton, 2018: 15,750Calgary, 2018: 18,9502018Edmonton, 2019: 16,425Calgary, 2019: 19,6352019Edmonton, 2020: 8,380Calgary, 2020: 10,6652020Edmonton, 2021: 14,760Calgary, 2021: 17,8702021Edmonton, 2022: 17,360Calgary, 2022: 24,7252022Edmonton, 2023: 21,665Calgary, 2023: 27,4302023Edmonton, 2024: 24,075Calgary, 2024: 31,1652024Edmonton, 2025: 18,980Calgary, 2025: 23,5602025Edmonton, 2026*: 10,685Calgary, 2026*: 13,4002026*New permanent residents by intended destination, Edmonton and Calgary, by yearEdmontonCalgary010,00020,00030,00040,000Edmonton, 2015: 16,745Calgary, 2015: 21,7152015Edmonton, 2016: 17,895Calgary, 2016: 21,4302016Edmonton, 2017: 15,960Calgary, 2017: 17,8752017Edmonton, 2018: 15,750Calgary, 2018: 18,9502018Edmonton, 2019: 16,425Calgary, 2019: 19,6352019Edmonton, 2020: 8,380Calgary, 2020: 10,6652020Edmonton, 2021: 14,760Calgary, 2021: 17,8702021Edmonton, 2022: 17,360Calgary, 2022: 24,7252022Edmonton, 2023: 21,665Calgary, 2023: 27,4302023Edmonton, 2024: 24,075Calgary, 2024: 31,1652024Edmonton, 2025: 18,980Calgary, 2025: 23,5602025Edmonton, 2026*: 10,685Calgary, 2026*: 13,4002026*
Show the data
New permanent residents by intended destination, Edmonton and Calgary, by year
YearEdmontonCalgary
201516,74521,715
201617,89521,430
201715,96017,875
201815,75018,950
201916,42519,635
20208,38010,665
202114,76017,870
202217,36024,725
202321,66527,430
202424,07531,165
202518,98023,560
2026*10,68513,400
Source: https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv, queried 27 September 2026
The calls behind this
Requestcall_tool
{"name": "ircc_monthly_query", "arguments": {"table_id": "ODP-PR-PT_CMA", "filters": {"census_metropolitan_area": "Edmonton"}, "period": "year", "year_from": 2015}}
Requestcall_tool
{"name": "ircc_monthly_query", "arguments": {"table_id": "ODP-PR-PT_CMA", "filters": {"census_metropolitan_area": "Calgary"}, "period": "year", "year_from": 2015}}

R

# ============================================================
# IRCC monthly update: Canada - Permanent Residents by Province/Territory and CMA
# Purpose: Fetch the data behind MapleStats MCP's ircc_monthly_query
#          (exact: the source file, then the tool's filters)
# Inputs:  https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv
# Outputs: data/raw/ODP-PR-PT_CMA.csv; the prepared table as `data`
# ============================================================

# 0. Setup ----

library(dplyr)
library(janitor)
library(readr)
library(stringr)

dir.create("data/raw", recursive = TRUE, showWarnings = FALSE)

# 1. Read inputs ----

download.file("https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv", "data/raw/ODP-PR-PT_CMA.csv", mode = "wb")

data <- read_delim("data/raw/ODP-PR-PT_CMA.csv", delim = "\t", na = c("", "NA", "--"), show_col_types = FALSE)

# 2. Check inputs ----

stopifnot("https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv returned no rows" = nrow(data) > 0)

# 3. Prepare data ----

# Keep the rows the MapleStats tool kept.

data <- data |>
  filter(
    str_to_lower(str_trim(`EN_CENSUS_METROPOLITAN_AREA`)) == "edmonton",
    `EN_YEAR` >= 2015
  )

data <- data |>
  clean_names()

# Standard cleaning: trimmed text, empty strings as missing, and numbers
# stored as text converted to numbers.

data <- data |>
  mutate(across(where(is.character), \(x) na_if(str_trim(x), ""))) |>
  type_convert()

Python

# ============================================================
# IRCC monthly update: Canada - Permanent Residents by Province/Territory and CMA
# Purpose: Fetch the data behind MapleStats MCP's ircc_monthly_query
#          (exact: the source file, then the tool's filters)
# Inputs:  https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv
# Outputs: data/raw/ODP-PR-PT_CMA.csv; the prepared table as `data`
# ============================================================

# %% 0. Setup

import re
import unicodedata
from pathlib import Path

import httpx
import polars as pl

RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "ODP-PR-PT_CMA.csv"

# %% 1. Read inputs

with httpx.Client(
    http2=True,
    follow_redirects=True,
    timeout=300,
    headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"},
) as client:
    response = client.get('https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv')
response.raise_for_status()
raw_path.write_bytes(response.content)

data = pl.read_csv(raw_path, separator='\t', infer_schema_length=100_000, null_values=['--'])

# %% 2. Check inputs

assert data.height > 0, "https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv returned no rows"

# %% 3. Prepare data

# Keep the rows the MapleStats tool kept.

data = data.filter(
    pl.col('EN_CENSUS_METROPOLITAN_AREA').cast(pl.Utf8).str.strip_chars().str.to_lowercase() == 'edmonton',
    pl.col('EN_YEAR').cast(pl.Float64, strict=False) >= 2015,
)

# Standard cleaning: snake_case names without accents (PÉRIODE -> periode,
# referenceNumber -> reference_number, as janitor does in R), trimmed text,
# empty strings as missing.

data = data.rename(
    {
        column: re.sub(
            r"[^0-9a-z]+",
            "_",
            re.sub(
                r"([a-z0-9])([A-Z])",
                r"\1_\2",
                unicodedata.normalize("NFKD", column).encode("ascii", "ignore").decode(),
            ).lower(),
        ).strip("_")
        for column in data.columns
    }
)
data = data.with_columns(pl.col(pl.Utf8).str.strip_chars().replace("", None))

Stata

* ============================================================
* IRCC monthly update: Canada - Permanent Residents by Province/Territory and CMA
* Purpose: Fetch the data behind MapleStats MCP's ircc_monthly_query
*          (exact: the source file, then the tool's filters)
* Inputs:  https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv
* Outputs: data/raw/ODP-PR-PT_CMA.csv; the prepared table as `data`
* ============================================================

version 18
clear all
set more off

* 0. Setup

capture mkdir "logs"
capture log close
log using "logs/ircc_monthly_query.log", replace
capture mkdir "data"
capture mkdir "data/raw"

* 1. Read inputs

* Stata reads no JSON or HTML and truncates long column names, so its
* built-in Python (Stata 16+) fetches, filters and writes a CSV. Point
* Stata at a Python with these packages first: python set exec <path>.

python:
import json
from pathlib import Path
import httpx
import polars as pl
RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "ODP-PR-PT_CMA.csv"
with httpx.Client(http2=True, follow_redirects=True, timeout=300, headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"}) as client: response = client.get('https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv')
response.raise_for_status()
raw_path.write_bytes(response.content)
data = pl.read_csv(raw_path, separator='\t', infer_schema_length=100_000, null_values=['--'])
data = data.filter(pl.col('EN_CENSUS_METROPOLITAN_AREA').cast(pl.Utf8).str.strip_chars().str.to_lowercase() == 'edmonton', pl.col('EN_YEAR').cast(pl.Float64, strict=False) >= 2015)
nested = [name for name, dtype in data.schema.items() if dtype.is_nested()]
data = data.with_columns(pl.col(name).map_elements(lambda value: json.dumps(value.to_list() if isinstance(value, pl.Series) else value, default=str), return_dtype=pl.Utf8) for name in nested)
data.write_csv(RAW_DIR / "ODP-PR-PT_CMA_prepared.csv")
end

import delimited "data/raw/ODP-PR-PT_CMA_prepared.csv", clear varnames(1) encoding("utf-8")

* 2. Check inputs

assert _N > 0

* 3. Prepare data

* Standard cleaning: lower-case names, trimmed text, and numbers stored as
* text converted (destring leaves genuinely non-numeric text alone).

rename *, lower
quietly ds, has(type string)
local text_vars `r(varlist)'
foreach var of local text_vars {
    replace `var' = strtrim(`var')
}
destring, replace

log close

Julia

# ============================================================
# IRCC monthly update: Canada - Permanent Residents by Province/Territory and CMA
# Purpose: Fetch the data behind MapleStats MCP's ircc_monthly_query
#          (exact: the source file, then the tool's filters)
# Inputs:  https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv
# Outputs: data/raw/ODP-PR-PT_CMA.csv; the prepared table as `data`
# ============================================================

# 0. Setup

using DataFrames
using Downloads
using TidierData
using TidierFiles

mkpath("data/raw")

# 1. Read inputs

Downloads.download("https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv", "data/raw/ODP-PR-PT_CMA.csv")
data = read_csv("data/raw/ODP-PR-PT_CMA.csv", delim = "\t", missingstring = ["", "--"])

# 2. Check inputs

@assert nrow(data) > 0 "https://www.ircc.canada.ca/opendata-donneesouvertes/data/ODP-PR-PT_CMA.csv returned no rows"

# 3. Prepare data

# Keep the rows the MapleStats tool kept.

data = filter(row -> begin
        (lowercase(strip(coalesce(string(row["EN_CENSUS_METROPOLITAN_AREA"]), ""))) == "edmonton") &&
        (!ismissing(row["EN_YEAR"]) && row["EN_YEAR"] >= 2015)
    end, data)

# Standard cleaning: snake_case names, trimmed text, empty strings as missing.

data = @chain data begin
    @clean_names
end
data = mapcols(
    col -> eltype(col) <: Union{Missing, AbstractString} ?
        [ismissing(x) || isempty(strip(x)) ? missing : strip(x) for x in col] : col,
    data,
)

54

housing tables for the urban planners, who count the cranes before the keys,

Canada is building up, not out

Housing starts by year from CMHC, for single-detached homes and apartments, in centres of 10,000 people or more. In 2005, Canada started 93,994 single-detached homes and 66,404 apartments; in 2025, 41,837 and 164,839. Apartments went from 34% of all starts to 68%, and have outnumbered single-detached homes every year since 2011.

Housing starts in Canada by year, single-detached homes and apartments, centres of 10,000 people or moreSingle-detachedApartments050,000100,000150,000200,000Single-detached, 2005: 93,994Apartments, 2005: 66,4042005Single-detached, 2006: 94,110Apartments, 2006: 69,1502006Single-detached, 2007: 90,855Apartments, 2007: 69,0622007Single-detached, 2008: 74,435Apartments, 2008: 83,5652008Single-detached, 2009: 60,521Apartments, 2009: 46,3882009Single-detached, 2010: 74,244Apartments, 2010: 61,3962010Single-detached, 2011: 67,089Apartments, 2011: 77,1192011Single-detached, 2012: 67,172Apartments, 2012: 92,9512012Single-detached, 2013: 63,143Apartments, 2013: 76,0092013Single-detached, 2014: 62,380Apartments, 2014: 76,5992014Single-detached, 2015: 57,739Apartments, 2015: 92,5642015Single-detached, 2016: 60,549Apartments, 2016: 88,0072016Single-detached, 2017: 63,495Apartments, 2017: 100,3652017Single-detached, 2018: 54,180Apartments, 2018: 109,8202018Single-detached, 2019: 46,909Apartments, 2019: 115,6642019Single-detached, 2020: 49,704Apartments, 2020: 119,4282020Single-detached, 2021: 63,456Apartments, 2021: 141,6842021Single-detached, 2022: 57,515Apartments, 2022: 144,0432022Single-detached, 2023: 42,924Apartments, 2023: 147,6172023Single-detached, 2024: 44,357Apartments, 2024: 149,1132024Single-detached, 2025: 41,837Apartments, 2025: 164,8392025Housing starts in Canada by year, single-detached homes and apartments, centres of 10,000 people or moreSingle-detachedApartments050,000100,000150,000200,000Single-detached, 2005: 93,994Apartments, 2005: 66,4042005Single-detached, 2006: 94,110Apartments, 2006: 69,1502006Single-detached, 2007: 90,855Apartments, 2007: 69,0622007Single-detached, 2008: 74,435Apartments, 2008: 83,5652008Single-detached, 2009: 60,521Apartments, 2009: 46,3882009Single-detached, 2010: 74,244Apartments, 2010: 61,3962010Single-detached, 2011: 67,089Apartments, 2011: 77,1192011Single-detached, 2012: 67,172Apartments, 2012: 92,9512012Single-detached, 2013: 63,143Apartments, 2013: 76,0092013Single-detached, 2014: 62,380Apartments, 2014: 76,5992014Single-detached, 2015: 57,739Apartments, 2015: 92,5642015Single-detached, 2016: 60,549Apartments, 2016: 88,0072016Single-detached, 2017: 63,495Apartments, 2017: 100,3652017Single-detached, 2018: 54,180Apartments, 2018: 109,8202018Single-detached, 2019: 46,909Apartments, 2019: 115,6642019Single-detached, 2020: 49,704Apartments, 2020: 119,4282020Single-detached, 2021: 63,456Apartments, 2021: 141,6842021Single-detached, 2022: 57,515Apartments, 2022: 144,0432022Single-detached, 2023: 42,924Apartments, 2023: 147,6172023Single-detached, 2024: 44,357Apartments, 2024: 149,1132024Single-detached, 2025: 41,837Apartments, 2025: 164,8392025
Show the data
Housing starts in Canada by year, single-detached homes and apartments, centres of 10,000 people or more
YearSingle-detachedApartments
200593,99466,404
200694,11069,150
200790,85569,062
200874,43583,565
200960,52146,388
201074,24461,396
201167,08977,119
201267,17292,951
201363,14376,009
201462,38076,599
201557,73992,564
201660,54988,007
201763,495100,365
201854,180109,820
201946,909115,664
202049,704119,428
202163,456141,684
202257,515144,043
202342,924147,617
202444,357149,113
202541,837164,839
Source: https://www03.cmhc-schl.gc.ca/hmip-pimh/en/TableMapChart/ExportTable, queried 27 September 2026
The calls behind this
Requestcall_tool
{"name": "cmhc_get_table_data", "arguments": {"category_level_1": "New Housing Construction", "category_level_2": "Starts (Actual)", "column_field": "1", "row_field": "TIMESERIES"}}
Requestcall_tool
{"name": "cmhc_list_categories", "arguments": {}}

R

# ============================================================
# Reproduce cmhc_get_table_data
# Purpose: Fetch the data behind MapleStats MCP's cmhc_get_table_data
#          (exact: the request the tool made, recorded while it ran)
# Inputs:  https://www03.cmhc-schl.gc.ca/hmip-pimh/en/TableMapChart/ExportTable
# Outputs: data/raw/ExportTable.txt; the downloaded file
# ============================================================

# 0. Setup ----

library(httr2)

dir.create("data/raw", recursive = TRUE, showWarnings = FALSE)

# 1. Read inputs ----

request("https://www03.cmhc-schl.gc.ca/hmip-pimh/en/TableMapChart/ExportTable") |>
  req_body_form(!!!list(`TableId` = "5.6.1", `GeographyId` = "1", `GeographyTypeId` = "1", `exportType` = "csv")) |>
  req_perform(path = "data/raw/ExportTable.txt")

Python

# ============================================================
# Reproduce cmhc_get_table_data
# Purpose: Fetch the data behind MapleStats MCP's cmhc_get_table_data
#          (exact: the request the tool made, recorded while it ran)
# Inputs:  https://www03.cmhc-schl.gc.ca/hmip-pimh/en/TableMapChart/ExportTable
# Outputs: data/raw/ExportTable.txt; the downloaded file
# ============================================================

# %% 0. Setup

from pathlib import Path

import httpx
import polars as pl

RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "ExportTable.txt"

# %% 1. Read inputs

with httpx.Client(
    http2=True,
    follow_redirects=True,
    timeout=300,
    headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"},
) as client:
    response = client.post('https://www03.cmhc-schl.gc.ca/hmip-pimh/en/TableMapChart/ExportTable', data={'TableId': '5.6.1', 'GeographyId': '1', 'GeographyTypeId': '1', 'exportType': 'csv'})
response.raise_for_status()
raw_path.write_bytes(response.content)

Stata

* ============================================================
* Reproduce cmhc_get_table_data
* Purpose: Fetch the data behind MapleStats MCP's cmhc_get_table_data
*          (exact: the request the tool made, recorded while it ran)
* Inputs:  https://www03.cmhc-schl.gc.ca/hmip-pimh/en/TableMapChart/ExportTable
* Outputs: data/raw/ExportTable.txt; the downloaded file
* ============================================================

version 18
clear all
set more off

* 0. Setup

capture mkdir "logs"
capture log close
log using "logs/cmhc_get_table_data.log", replace
capture mkdir "data"
capture mkdir "data/raw"

* 1. Read inputs

* Stata reads no JSON or HTML and truncates long column names, so its
* built-in Python (Stata 16+) fetches, filters and writes a CSV. Point
* Stata at a Python with these packages first: python set exec <path>.

python:
import json
from pathlib import Path
import httpx
import polars as pl
RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "ExportTable.txt"
with httpx.Client(http2=True, follow_redirects=True, timeout=300, headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"}) as client: response = client.post('https://www03.cmhc-schl.gc.ca/hmip-pimh/en/TableMapChart/ExportTable', data={'TableId': '5.6.1', 'GeographyId': '1', 'GeographyTypeId': '1', 'exportType': 'csv'})
response.raise_for_status()
raw_path.write_bytes(response.content)
end

log close

Julia

# ============================================================
# Reproduce cmhc_get_table_data
# Purpose: Fetch the data behind MapleStats MCP's cmhc_get_table_data
#          (exact: the request the tool made, recorded while it ran)
# Inputs:  https://www03.cmhc-schl.gc.ca/hmip-pimh/en/TableMapChart/ExportTable
# Outputs: data/raw/ExportTable.txt; the downloaded file
# ============================================================

# 0. Setup

using Downloads

mkpath("data/raw")

# 1. Read inputs

Downloads.request(
    "https://www03.cmhc-schl.gc.ca/hmip-pimh/en/TableMapChart/ExportTable";
    headers = ["Content-Type" => "application/x-www-form-urlencoded"], method = "POST", input = IOBuffer("TableId=5.6.1&GeographyId=1&GeographyTypeId=1&exportType=csv"),
    output = "data/raw/ExportTable.txt",
)

962,449

census records for the microeconomists, weavers of incentives, one life at a time,

Low income across immigrant generations

The share of people below the low-income measure after tax, by immigrant generation, from the same 2021 Census microdata, with 95% confidence intervals from its replicate weights. First-generation immigrants are the most likely to be in low income (14.0%). Their children, born in Canada, are the least likely (9.1% and 8.8%), below the third generation or more (10.3%).

Share of people in low income (LIM-AT) by immigrant generation, 2021 Census, with 95% confidence intervals8.0%10.0%12.0%14.0%16.0%First generation (born abroad): 14.0% (13.8% to 14.1%)First generation (bornabroad)14.0%Second generation, both parents born abroad: 9.1% (9.0% to 9.3%)Second generation, bothparents born abroad9.1%Second generation, one parent born abroad: 8.8% (8.6% to 9.1%)Second generation, one parentborn abroad8.8%Third generation or more: 10.3% (10.2% to 10.4%)Third generation or more10.3%Share of people in low income (LIM-AT) by immigrant generation, 2021 Census, with 95% confidence intervals8.0%12.0%16.0%First generation (born abroad): 14.0% (13.8% to 14.1%)First generation (bornabroad)14.0%Second generation, both parents born abroad: 9.1% (9.0% to 9.3%)Second generation, bothparents born abroad9.1%Second generation, one parent born abroad: 8.8% (8.6% to 9.1%)Second generation, one parentborn abroad8.8%Third generation or more: 10.3% (10.2% to 10.4%)Third generation or more10.3%
Show the data
Share of people in low income (LIM-AT) by immigrant generation, 2021 Census, with 95% confidence intervals
GenerationEstimate95% CI low95% CI high
First generation (born abroad)14.0%13.8%14.1%
Second generation, both parents born abroad9.1%9.0%9.3%
Second generation, one parent born abroad8.8%8.6%9.1%
Third generation or more10.3%10.2%10.4%
Source: Statistics Canada, 2021 Census, individuals file (98M0001X), queried 27 September 2026
The calls behind this
Requestcall_tool
{"name": "statcan_pumf_tabulate", "arguments": {"url": "https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip", "rows": ["GENSTAT", "LOLIMA"], "statistic": "share", "filters": {"GENSTAT": ["1", "2", "3", "4"], "LOLIMA": ["1", "2"]}}}

R

# ============================================================
# StatCan public use microdata file
# Purpose: Fetch the data behind MapleStats MCP's statcan_pumf_tabulate
#          (exact: the PUMF ZIP)
# Inputs:  https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip
# Outputs: data/raw/cen21_ind_98m0001x_part_rec21.zip; the unzipped files
# ============================================================

# 0. Setup ----

dir.create("data/raw", recursive = TRUE, showWarnings = FALSE)

# 1. Read inputs ----

download.file("https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip", "data/raw/cen21_ind_98m0001x_part_rec21.zip", mode = "wb")
zip::unzip("data/raw/cen21_ind_98m0001x_part_rec21.zip", exdir = "data/raw/cen21_ind_98m0001x_part_rec21")
file.remove("data/raw/cen21_ind_98m0001x_part_rec21.zip")

data_files <- list.files("data/raw/cen21_ind_98m0001x_part_rec21", full.names = TRUE, recursive = TRUE)

Python

# ============================================================
# StatCan public use microdata file
# Purpose: Fetch the data behind MapleStats MCP's statcan_pumf_tabulate
#          (exact: the PUMF ZIP)
# Inputs:  https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip
# Outputs: data/raw/cen21_ind_98m0001x_part_rec21.zip; the unzipped files
# ============================================================

# %% 0. Setup

import zipfile
from pathlib import Path

import httpx
import polars as pl

RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "cen21_ind_98m0001x_part_rec21.zip"

# %% 1. Read inputs

with httpx.Client(
    http2=True,
    follow_redirects=True,
    timeout=300,
    headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"},
) as client:
    response = client.get('https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip')
response.raise_for_status()
raw_path.write_bytes(response.content)

with zipfile.ZipFile(raw_path) as archive:
    archive.extractall(RAW_DIR / raw_path.stem)

Stata

* ============================================================
* StatCan public use microdata file
* Purpose: Fetch the data behind MapleStats MCP's statcan_pumf_tabulate
*          (exact: the PUMF ZIP)
* Inputs:  https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip
* Outputs: data/raw/cen21_ind_98m0001x_part_rec21.zip; the unzipped files
* ============================================================

version 18
clear all
set more off

* 0. Setup

capture mkdir "logs"
capture log close
log using "logs/statcan_pumf_tabulate.log", replace
capture mkdir "data"
capture mkdir "data/raw"

* 1. Read inputs

* Stata reads no JSON or HTML and truncates long column names, so its
* built-in Python (Stata 16+) fetches, filters and writes a CSV. Point
* Stata at a Python with these packages first: python set exec <path>.

python:
import json
import zipfile
from pathlib import Path
import httpx
import polars as pl
RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "cen21_ind_98m0001x_part_rec21.zip"
with httpx.Client(http2=True, follow_redirects=True, timeout=300, headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"}) as client: response = client.get('https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip')
response.raise_for_status()
raw_path.write_bytes(response.content)
with zipfile.ZipFile(raw_path) as archive: archive.extractall(RAW_DIR / raw_path.stem)
end

log close

Julia

# ============================================================
# StatCan public use microdata file
# Purpose: Fetch the data behind MapleStats MCP's statcan_pumf_tabulate
#          (exact: the PUMF ZIP)
# Inputs:  https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip
# Outputs: data/raw/cen21_ind_98m0001x_part_rec21.zip; the unzipped files
# ============================================================

# 0. Setup

using Downloads

mkpath("data/raw")

# 1. Read inputs

Downloads.download("https://www150.statcan.gc.ca/n1/pub/98m0001x/2023001/cen21_ind_98m0001x_part_rec21.zip", "data/raw/cen21_ind_98m0001x_part_rec21.zip")

98

credit cards for the marketers, readers of the rivals' fine print,

What a rewards card costs

Every card listed for Alberta in the credit card comparison tool of the Financial Consumer Agency of Canada (FCAC), in Canadian dollars and without the student and secured cards, which are separate searches there: its annual fee against its purchase interest rate. The 82 cards with rewards have a median fee of $99 and a median rate of 21.99%; the 15 without rewards, $25 and 13.99%. Larger dots are several cards at the same price. One prepaid card, which charges no interest, is left out.

Credit cards listed for Alberta in FCAC's comparison tool: annual fee against purchase interest rateWith rewardsNo rewardsPurchase rate10%15%20%25%30%$0$200$400$600$800Annual fee$0, 21.99%: American Express Green Card, SimplyCash Card from American Express, BMO Blue Rewards Mastercard (Non-Quebec) (+12)$0, 20.99%: Coast Capital Collabria Cash Back Mastercard, moi RBC Visa, More Rewards‡ RBC® Visa Infinite‡ (+4)$139, 21.99%: BMO CashBack World Elite Mastercard, CIBC Aeroplan Visa Infinite Card, CIBC Aventura Gold VISA Card (+4)$120, 20.99%: Coast Capital Collabria Cash Back World Elite Mastercard, Coast Capital Collabria World Mastercard, RBC Avion Visa Infinite (+1)$599, 21.99%: American Express Aeroplan Reserve Card (Credit Card), BMO eclipse Visa Infinite Privilege Card, CIBC Aeroplan Visa Infinite Privilege Card (+1)$0, 20.90%: Desjardins Bonus Visa, Desjardins Cash Back Mastercard, Desjardins Cash Back Visa$0, 21.90%: Capital One Aspire Cash™ Platinum Mastercard®, Capital One Aspire Travel™ Platinum Mastercard®, Capital One Smart Rewards Mastercard$29, 13.99%: BMO Preferred Rate Mastercard, CIBC Select VISA Card (Low Rate), Scotiabank Value Visa card$89, 21.99%: BMO VIPorter Mastercard, TD Aeroplan Visa Platinum Card, TD Platinum Travel Visa* Card$120, 21.99%: Marriott Bonvoy American Express Card, BMO eclipse Visa Infinite Card, CIBC Dividend VISA Infinite Card$150, 21.99%: BMO Ascend World Elite Mastercard, BMO Blue Rewards World Elite Mastercard, BMO Blue Rewards World Elite Mastercard (Quebec)$0, 20.95%: Tangerine Money-Back Credit Card, Tangerine Money-Back World Mastercard$0, 29.90%: Capital One Guaranteed Mastercard®, Capital One Guaranteed Secured Mastercard$0, 10.90%: Desjardins Flexi Visa$0, 12.99%: MBNA True Line® Mastercard®$0, 19.99%: Coast Capital Collabria No Fee Cash Back Business Mastercard$0, 21.74%: Amazon.ca Rewards Mastercard®$0, 21.75%: CIBC Costco Mastercard$0, 21.99%: CIBC Classic Visa Card$20, 12.99%: RBC Visa Classic Low Rate Option$20, 13.99%: Coast Capital Collabria Classic Mastercard$25, 12.90%: TD Low Rate Visa Card$25, 12.99%: American Express Essential Credit Card$25, 13.99%: Coast Capital Collabria Low Rate Business Mastercard$30, 12.99%: Servus Personal Low Rate Mastercard$39, 10.99%: MBNA True Line® Gold Mastercard®$39, 20.99%: WestJet RBC Mastercard$39, 21.99%: MBNA Smart Cash® World Mastercard®$50, 12.99%: Coast Capital Collabria Centra Gold Mastercard$75, 20.99%: Servus Personal Gold Mastercard$99, 20.99%: RBC Cash Back Preferred World Elite Mastercard$99, 21.99%: CIBC Dividend Platinum VISA Card$100, 20.90%: Desjardins Cash Back World Elite Mastercard$110, 20.90%: Desjardins Odyssey Gold Visa$119.88, 21.99%: SimplyCash Preferred Card from American Express$120, 20.95%: Tangerine Rewards World Elite Mastercard$120, 30.00%: American Express Aeroplan Card$130, 20.90%: Desjardins Odyssey World Elite Mastercard$130, 20.99%: Coast Capital Collabria World Elite Business Mastercard$139, 20.99%: WestJet RBC World Elite Mastercard$150, 20.99%: Servus Personal World Elite Mastercard$165, 20.50%: RBC British Airways Visa Infinite$191.88, 21.99%: American Express Cobalt Card$199, 21.99%: BMO VIPorter World Elite Mastercard$250, 21.99%: American Express Gold Rewards Card (Credit Card)$250, 30.00%: American Express Gold Rewards Card (Charge Card)$395, 11.90%: Desjardins Odyssey Visa Infinite Privilege$399, 20.99%: RBC Avion® Visa Infinite Privilege$499, 21.99%: CIBC Aventura Visa Infinite Privilege Card$599, 30.00%: American Express Aeroplan Reserve Card (Charge Card)$799, 30.00%: The Platinum CardCredit cards listed for Alberta in FCAC's comparison tool: annual fee against purchase interest rateWith rewardsNo rewardsPurchase rate10%15%20%25%30%$0$200$400$600$800Annual fee$0, 21.99%: American Express Green Card, SimplyCash Card from American Express, BMO Blue Rewards Mastercard (Non-Quebec) (+12)$0, 20.99%: Coast Capital Collabria Cash Back Mastercard, moi RBC Visa, More Rewards‡ RBC® Visa Infinite‡ (+4)$139, 21.99%: BMO CashBack World Elite Mastercard, CIBC Aeroplan Visa Infinite Card, CIBC Aventura Gold VISA Card (+4)$120, 20.99%: Coast Capital Collabria Cash Back World Elite Mastercard, Coast Capital Collabria World Mastercard, RBC Avion Visa Infinite (+1)$599, 21.99%: American Express Aeroplan Reserve Card (Credit Card), BMO eclipse Visa Infinite Privilege Card, CIBC Aeroplan Visa Infinite Privilege Card (+1)$0, 20.90%: Desjardins Bonus Visa, Desjardins Cash Back Mastercard, Desjardins Cash Back Visa$0, 21.90%: Capital One Aspire Cash™ Platinum Mastercard®, Capital One Aspire Travel™ Platinum Mastercard®, Capital One Smart Rewards Mastercard$29, 13.99%: BMO Preferred Rate Mastercard, CIBC Select VISA Card (Low Rate), Scotiabank Value Visa card$89, 21.99%: BMO VIPorter Mastercard, TD Aeroplan Visa Platinum Card, TD Platinum Travel Visa* Card$120, 21.99%: Marriott Bonvoy American Express Card, BMO eclipse Visa Infinite Card, CIBC Dividend VISA Infinite Card$150, 21.99%: BMO Ascend World Elite Mastercard, BMO Blue Rewards World Elite Mastercard, BMO Blue Rewards World Elite Mastercard (Quebec)$0, 20.95%: Tangerine Money-Back Credit Card, Tangerine Money-Back World Mastercard$0, 29.90%: Capital One Guaranteed Mastercard®, Capital One Guaranteed Secured Mastercard$0, 10.90%: Desjardins Flexi Visa$0, 12.99%: MBNA True Line® Mastercard®$0, 19.99%: Coast Capital Collabria No Fee Cash Back Business Mastercard$0, 21.74%: Amazon.ca Rewards Mastercard®$0, 21.75%: CIBC Costco Mastercard$0, 21.99%: CIBC Classic Visa Card$20, 12.99%: RBC Visa Classic Low Rate Option$20, 13.99%: Coast Capital Collabria Classic Mastercard$25, 12.90%: TD Low Rate Visa Card$25, 12.99%: American Express Essential Credit Card$25, 13.99%: Coast Capital Collabria Low Rate Business Mastercard$30, 12.99%: Servus Personal Low Rate Mastercard$39, 10.99%: MBNA True Line® Gold Mastercard®$39, 20.99%: WestJet RBC Mastercard$39, 21.99%: MBNA Smart Cash® World Mastercard®$50, 12.99%: Coast Capital Collabria Centra Gold Mastercard$75, 20.99%: Servus Personal Gold Mastercard$99, 20.99%: RBC Cash Back Preferred World Elite Mastercard$99, 21.99%: CIBC Dividend Platinum VISA Card$100, 20.90%: Desjardins Cash Back World Elite Mastercard$110, 20.90%: Desjardins Odyssey Gold Visa$119.88, 21.99%: SimplyCash Preferred Card from American Express$120, 20.95%: Tangerine Rewards World Elite Mastercard$120, 30.00%: American Express Aeroplan Card$130, 20.90%: Desjardins Odyssey World Elite Mastercard$130, 20.99%: Coast Capital Collabria World Elite Business Mastercard$139, 20.99%: WestJet RBC World Elite Mastercard$150, 20.99%: Servus Personal World Elite Mastercard$165, 20.50%: RBC British Airways Visa Infinite$191.88, 21.99%: American Express Cobalt Card$199, 21.99%: BMO VIPorter World Elite Mastercard$250, 21.99%: American Express Gold Rewards Card (Credit Card)$250, 30.00%: American Express Gold Rewards Card (Charge Card)$395, 11.90%: Desjardins Odyssey Visa Infinite Privilege$399, 20.99%: RBC Avion® Visa Infinite Privilege$499, 21.99%: CIBC Aventura Visa Infinite Privilege Card$599, 30.00%: American Express Aeroplan Reserve Card (Charge Card)$799, 30.00%: The Platinum Card
Show the data
Credit cards listed for Alberta in FCAC's comparison tool: annual fee against purchase interest rate
TypeAnnual feePurchase rateCards
With rewards$019.99%1
With rewards$020.90%3
With rewards$020.95%2
With rewards$020.99%7
With rewards$021.74%1
With rewards$021.75%1
With rewards$021.90%3
With rewards$021.99%15
With rewards$3920.99%1
With rewards$3921.99%1
With rewards$5012.99%1
With rewards$7520.99%1
With rewards$8921.99%3
With rewards$9920.99%1
With rewards$9921.99%1
With rewards$10020.90%1
With rewards$11020.90%1
With rewards$119.8821.99%1
With rewards$12020.95%1
With rewards$12020.99%4
With rewards$12021.99%3
With rewards$12030.00%1
With rewards$13020.90%1
With rewards$13020.99%1
With rewards$13920.99%1
With rewards$13921.99%7
With rewards$15020.99%1
With rewards$15021.99%3
With rewards$16520.50%1
With rewards$191.8821.99%1
With rewards$19921.99%1
With rewards$25021.99%1
With rewards$25030.00%1
With rewards$39511.90%1
With rewards$39920.99%1
With rewards$49921.99%1
With rewards$59921.99%4
With rewards$59930.00%1
With rewards$79930.00%1
No rewards$010.90%1
No rewards$012.99%1
No rewards$021.99%1
No rewards$029.90%2
No rewards$2012.99%1
No rewards$2013.99%1
No rewards$2512.90%1
No rewards$2512.99%1
No rewards$2513.99%1
No rewards$2913.99%3
No rewards$3012.99%1
No rewards$3910.99%1
Source: https://itools-ioutils.fcac-acfc.gc.ca/CCCT-OCCC/SearchFilter-eng.aspx, queried 27 September 2026
The calls behind this
Requestcall_tool
{"name": "fcac_search_credit_cards", "arguments": {"province": "AB", "limit": 100}}
No script for this one. reproduce_code: This is a web page the tool parses, not a data table, so a download does not reproduce the result. Cite the URL and retrieval date, or keep the tool's output as the raw input.

6,434

days of bond yields for the macroeconomists, reading the curve before the turn,

When the yield curve turns upside down

The gap between the 10-year and the 2-year Government of Canada benchmark bond yields, every business day since 2001, from the Bank of Canada's Valet API, drawn as a monthly average. When the 2-year pays more than the 10-year, the curve is inverted: markets expect rates to fall, usually because they expect the economy to slow. It inverted in 2007, 2019 to 2020 and 2022 to 2024. The deepest month was -123 bp, in July 2023; in September 2026 it averaged 62 bp.

The yield curve: 10-year minus 2-year Government of Canada benchmark bond yields, monthly average of daily values, with inverted months shaded-200 bp-100 bp0 bp100 bp200 bp300 bpInverted200520102015202020252001-01-01: 41 bp2001-02-01: 50 bp2001-03-01: 63 bp2001-04-01: 82 bp2001-05-01: 98 bp2001-06-01: 92 bp2001-07-01: 98 bp2001-08-01: 107 bp2001-09-01: 163 bp2001-10-01: 187 bp2001-11-01: 207 bp2001-12-01: 213 bp2002-01-01: 225 bp2002-02-01: 207 bp2002-03-01: 161 bp2002-04-01: 139 bp2002-05-01: 144 bp2002-06-01: 141 bp2002-07-01: 171 bp2002-08-01: 183 bp2002-09-01: 150 bp2002-10-01: 165 bp2002-11-01: 176 bp2002-12-01: 170 bp2003-01-01: 166 bp2003-02-01: 153 bp2003-03-01: 132 bp2003-04-01: 131 bp2003-05-01: 124 bp2003-06-01: 131 bp2003-07-01: 170 bp2003-08-01: 188 bp2003-09-01: 172 bp2003-10-01: 169 bp2003-11-01: 161 bp2003-12-01: 165 bp2004-01-01: 183 bp2004-02-01: 199 bp2004-03-01: 195 bp2004-04-01: 188 bp2004-05-01: 181 bp2004-06-01: 163 bp2004-07-01: 160 bp2004-08-01: 165 bp2004-09-01: 147 bp2004-10-01: 133 bp2004-11-01: 126 bp2004-12-01: 138 bp2005-01-01: 131 bp2005-02-01: 129 bp2005-03-01: 122 bp2005-04-01: 109 bp2005-05-01: 103 bp2005-06-01: 98 bp2005-07-01: 88 bp2005-08-01: 81 bp2005-09-01: 74 bp2005-10-01: 59 bp2005-11-01: 40 bp2005-12-01: 23 bp2006-01-01: 24 bp2006-02-01: 22 bp2006-03-01: 26 bp2006-04-01: 32 bp2006-05-01: 29 bp2006-06-01: 17 bp2006-07-01: 21 bp2006-08-01: 16 bp2006-09-01: 12 bp2006-10-01: 9 bp2006-11-01: 6 bp2006-12-01: 6 bp2007-01-01: 7 bp2007-02-01: 4 bp2007-03-01: 10 bp2007-04-01: 7 bp2007-05-01: -3 bp2007-06-01: -5 bp2007-07-01: -5 bp2007-08-01: 8 bp2007-09-01: 15 bp2007-10-01: 16 bp2007-11-01: 32 bp2007-12-01: 27 bp2008-01-01: 54 bp2008-02-01: 77 bp2008-03-01: 93 bp2008-04-01: 84 bp2008-05-01: 75 bp2008-06-01: 58 bp2008-07-01: 62 bp2008-08-01: 80 bp2008-09-01: 82 bp2008-10-01: 145 bp2008-11-01: 171 bp2008-12-01: 157 bp2009-01-01: 167 bp2009-02-01: 175 bp2009-03-01: 186 bp2009-04-01: 189 bp2009-05-01: 208 bp2009-06-01: 217 bp2009-07-01: 218 bp2009-08-01: 213 bp2009-09-01: 211 bp2009-10-01: 196 bp2009-11-01: 209 bp2009-12-01: 213 bp2010-01-01: 221 bp2010-02-01: 210 bp2010-03-01: 192 bp2010-04-01: 177 bp2010-05-01: 165 bp2010-06-01: 163 bp2010-07-01: 162 bp2010-08-01: 160 bp2010-09-01: 147 bp2010-10-01: 139 bp2010-11-01: 145 bp2010-12-01: 154 bp2011-01-01: 153 bp2011-02-01: 158 bp2011-03-01: 152 bp2011-04-01: 152 bp2011-05-01: 151 bp2011-06-01: 152 bp2011-07-01: 145 bp2011-08-01: 146 bp2011-09-01: 128 bp2011-10-01: 129 bp2011-11-01: 119 bp2011-12-01: 110 bp2012-01-01: 100 bp2012-02-01: 96 bp2012-03-01: 91 bp2012-04-01: 77 bp2012-05-01: 71 bp2012-06-01: 74 bp2012-07-01: 66 bp2012-08-01: 67 bp2012-09-01: 69 bp2012-10-01: 71 bp2012-11-01: 65 bp2012-12-01: 67 bp2013-01-01: 76 bp2013-02-01: 86 bp2013-03-01: 88 bp2013-04-01: 79 bp2013-05-01: 89 bp2013-06-01: 110 bp2013-07-01: 129 bp2013-08-01: 143 bp2013-09-01: 145 bp2013-10-01: 136 bp2013-11-01: 145 bp2013-12-01: 157 bp2014-01-01: 150 bp2014-02-01: 142 bp2014-03-01: 141 bp2014-04-01: 138 bp2014-05-01: 126 bp2014-06-01: 121 bp2014-07-01: 110 bp2014-08-01: 98 bp2014-09-01: 104 bp2014-10-01: 99 bp2014-11-01: 100 bp2014-12-01: 84 bp2015-01-01: 77 bp2015-02-01: 94 bp2015-03-01: 89 bp2015-04-01: 83 bp2015-05-01: 107 bp2015-06-01: 117 bp2015-07-01: 113 bp2015-08-01: 99 bp2015-09-01: 100 bp2015-10-01: 93 bp2015-11-01: 101 bp2015-12-01: 92 bp2016-01-01: 87 bp2016-02-01: 70 bp2016-03-01: 72 bp2016-04-01: 73 bp2016-05-01: 75 bp2016-06-01: 64 bp2016-07-01: 51 bp2016-08-01: 49 bp2016-09-01: 53 bp2016-10-01: 60 bp2016-11-01: 81 bp2016-12-01: 96 bp2017-01-01: 95 bp2017-02-01: 94 bp2017-03-01: 93 bp2017-04-01: 79 bp2017-05-01: 82 bp2017-06-01: 64 bp2017-07-01: 69 bp2017-08-01: 64 bp2017-09-01: 52 bp2017-10-01: 55 bp2017-11-01: 48 bp2017-12-01: 34 bp2018-01-01: 42 bp2018-02-01: 51 bp2018-03-01: 39 bp2018-04-01: 39 bp2018-05-01: 41 bp2018-06-01: 34 bp2018-07-01: 22 bp2018-08-01: 19 bp2018-09-01: 21 bp2018-10-01: 20 bp2018-11-01: 14 bp2018-12-01: 8 bp2019-01-01: 8 bp2019-02-01: 12 bp2019-03-01: 10 bp2019-04-01: 14 bp2019-05-01: 8 bp2019-06-01: 5 bp2019-07-01: -0 bp2019-08-01: -16 bp2019-09-01: -18 bp2019-10-01: -13 bp2019-11-01: -7 bp2019-12-01: -6 bp2020-01-01: -9 bp2020-02-01: -12 bp2020-03-01: 20 bp2020-04-01: 29 bp2020-05-01: 26 bp2020-06-01: 26 bp2020-07-01: 24 bp2020-08-01: 28 bp2020-09-01: 30 bp2020-10-01: 36 bp2020-11-01: 42 bp2020-12-01: 49 bp2021-01-01: 64 bp2021-02-01: 89 bp2021-03-01: 124 bp2021-04-01: 124 bp2021-05-01: 121 bp2021-06-01: 105 bp2021-07-01: 79 bp2021-08-01: 74 bp2021-09-01: 82 bp2021-10-01: 82 bp2021-11-01: 69 bp2021-12-01: 47 bp2022-01-01: 58 bp2022-02-01: 44 bp2022-03-01: 29 bp2022-04-01: 24 bp2022-05-01: 27 bp2022-06-01: 16 bp2022-07-01: -13 bp2022-08-01: -50 bp2022-09-01: -58 bp2022-10-01: -63 bp2022-11-01: -79 bp2022-12-01: -87 bp2023-01-01: -80 bp2023-02-01: -88 bp2023-03-01: -83 bp2023-04-01: -81 bp2023-05-01: -88 bp2023-06-01: -119 bp2023-07-01: -123 bp2023-08-01: -105 bp2023-09-01: -97 bp2023-10-01: -72 bp2023-11-01: -71 bp2023-12-01: -78 bp2024-01-01: -66 bp2024-02-01: -68 bp2024-03-01: -69 bp2024-04-01: -56 bp2024-05-01: -58 bp2024-06-01: -55 bp2024-07-01: -37 bp2024-08-01: -21 bp2024-09-01: -4 bp2024-10-01: 11 bp2024-11-01: 10 bp2024-12-01: 17 bp2025-01-01: 36 bp2025-02-01: 37 bp2025-03-01: 47 bp2025-04-01: 58 bp2025-05-01: 63 bp2025-06-01: 66 bp2025-07-01: 72 bp2025-08-01: 73 bp2025-09-01: 72 bp2025-10-01: 71 bp2025-11-01: 73 bp2025-12-01: 81 bp2026-01-01: 84 bp2026-02-01: 80 bp2026-03-01: 68 bp2026-04-01: 65 bp2026-05-01: 61 bp2026-06-01: 63 bp2026-07-01: 71 bp2026-08-01: 72 bp2026-09-01: 62 bp62 bpThe yield curve: 10-year minus 2-year Government of Canada benchmark bond yields, monthly average of daily values, with inverted months shaded-200 bp-100 bp0 bp100 bp200 bp300 bpInverted200520102015202020252001-01-01: 41 bp2001-02-01: 50 bp2001-03-01: 63 bp2001-04-01: 82 bp2001-05-01: 98 bp2001-06-01: 92 bp2001-07-01: 98 bp2001-08-01: 107 bp2001-09-01: 163 bp2001-10-01: 187 bp2001-11-01: 207 bp2001-12-01: 213 bp2002-01-01: 225 bp2002-02-01: 207 bp2002-03-01: 161 bp2002-04-01: 139 bp2002-05-01: 144 bp2002-06-01: 141 bp2002-07-01: 171 bp2002-08-01: 183 bp2002-09-01: 150 bp2002-10-01: 165 bp2002-11-01: 176 bp2002-12-01: 170 bp2003-01-01: 166 bp2003-02-01: 153 bp2003-03-01: 132 bp2003-04-01: 131 bp2003-05-01: 124 bp2003-06-01: 131 bp2003-07-01: 170 bp2003-08-01: 188 bp2003-09-01: 172 bp2003-10-01: 169 bp2003-11-01: 161 bp2003-12-01: 165 bp2004-01-01: 183 bp2004-02-01: 199 bp2004-03-01: 195 bp2004-04-01: 188 bp2004-05-01: 181 bp2004-06-01: 163 bp2004-07-01: 160 bp2004-08-01: 165 bp2004-09-01: 147 bp2004-10-01: 133 bp2004-11-01: 126 bp2004-12-01: 138 bp2005-01-01: 131 bp2005-02-01: 129 bp2005-03-01: 122 bp2005-04-01: 109 bp2005-05-01: 103 bp2005-06-01: 98 bp2005-07-01: 88 bp2005-08-01: 81 bp2005-09-01: 74 bp2005-10-01: 59 bp2005-11-01: 40 bp2005-12-01: 23 bp2006-01-01: 24 bp2006-02-01: 22 bp2006-03-01: 26 bp2006-04-01: 32 bp2006-05-01: 29 bp2006-06-01: 17 bp2006-07-01: 21 bp2006-08-01: 16 bp2006-09-01: 12 bp2006-10-01: 9 bp2006-11-01: 6 bp2006-12-01: 6 bp2007-01-01: 7 bp2007-02-01: 4 bp2007-03-01: 10 bp2007-04-01: 7 bp2007-05-01: -3 bp2007-06-01: -5 bp2007-07-01: -5 bp2007-08-01: 8 bp2007-09-01: 15 bp2007-10-01: 16 bp2007-11-01: 32 bp2007-12-01: 27 bp2008-01-01: 54 bp2008-02-01: 77 bp2008-03-01: 93 bp2008-04-01: 84 bp2008-05-01: 75 bp2008-06-01: 58 bp2008-07-01: 62 bp2008-08-01: 80 bp2008-09-01: 82 bp2008-10-01: 145 bp2008-11-01: 171 bp2008-12-01: 157 bp2009-01-01: 167 bp2009-02-01: 175 bp2009-03-01: 186 bp2009-04-01: 189 bp2009-05-01: 208 bp2009-06-01: 217 bp2009-07-01: 218 bp2009-08-01: 213 bp2009-09-01: 211 bp2009-10-01: 196 bp2009-11-01: 209 bp2009-12-01: 213 bp2010-01-01: 221 bp2010-02-01: 210 bp2010-03-01: 192 bp2010-04-01: 177 bp2010-05-01: 165 bp2010-06-01: 163 bp2010-07-01: 162 bp2010-08-01: 160 bp2010-09-01: 147 bp2010-10-01: 139 bp2010-11-01: 145 bp2010-12-01: 154 bp2011-01-01: 153 bp2011-02-01: 158 bp2011-03-01: 152 bp2011-04-01: 152 bp2011-05-01: 151 bp2011-06-01: 152 bp2011-07-01: 145 bp2011-08-01: 146 bp2011-09-01: 128 bp2011-10-01: 129 bp2011-11-01: 119 bp2011-12-01: 110 bp2012-01-01: 100 bp2012-02-01: 96 bp2012-03-01: 91 bp2012-04-01: 77 bp2012-05-01: 71 bp2012-06-01: 74 bp2012-07-01: 66 bp2012-08-01: 67 bp2012-09-01: 69 bp2012-10-01: 71 bp2012-11-01: 65 bp2012-12-01: 67 bp2013-01-01: 76 bp2013-02-01: 86 bp2013-03-01: 88 bp2013-04-01: 79 bp2013-05-01: 89 bp2013-06-01: 110 bp2013-07-01: 129 bp2013-08-01: 143 bp2013-09-01: 145 bp2013-10-01: 136 bp2013-11-01: 145 bp2013-12-01: 157 bp2014-01-01: 150 bp2014-02-01: 142 bp2014-03-01: 141 bp2014-04-01: 138 bp2014-05-01: 126 bp2014-06-01: 121 bp2014-07-01: 110 bp2014-08-01: 98 bp2014-09-01: 104 bp2014-10-01: 99 bp2014-11-01: 100 bp2014-12-01: 84 bp2015-01-01: 77 bp2015-02-01: 94 bp2015-03-01: 89 bp2015-04-01: 83 bp2015-05-01: 107 bp2015-06-01: 117 bp2015-07-01: 113 bp2015-08-01: 99 bp2015-09-01: 100 bp2015-10-01: 93 bp2015-11-01: 101 bp2015-12-01: 92 bp2016-01-01: 87 bp2016-02-01: 70 bp2016-03-01: 72 bp2016-04-01: 73 bp2016-05-01: 75 bp2016-06-01: 64 bp2016-07-01: 51 bp2016-08-01: 49 bp2016-09-01: 53 bp2016-10-01: 60 bp2016-11-01: 81 bp2016-12-01: 96 bp2017-01-01: 95 bp2017-02-01: 94 bp2017-03-01: 93 bp2017-04-01: 79 bp2017-05-01: 82 bp2017-06-01: 64 bp2017-07-01: 69 bp2017-08-01: 64 bp2017-09-01: 52 bp2017-10-01: 55 bp2017-11-01: 48 bp2017-12-01: 34 bp2018-01-01: 42 bp2018-02-01: 51 bp2018-03-01: 39 bp2018-04-01: 39 bp2018-05-01: 41 bp2018-06-01: 34 bp2018-07-01: 22 bp2018-08-01: 19 bp2018-09-01: 21 bp2018-10-01: 20 bp2018-11-01: 14 bp2018-12-01: 8 bp2019-01-01: 8 bp2019-02-01: 12 bp2019-03-01: 10 bp2019-04-01: 14 bp2019-05-01: 8 bp2019-06-01: 5 bp2019-07-01: -0 bp2019-08-01: -16 bp2019-09-01: -18 bp2019-10-01: -13 bp2019-11-01: -7 bp2019-12-01: -6 bp2020-01-01: -9 bp2020-02-01: -12 bp2020-03-01: 20 bp2020-04-01: 29 bp2020-05-01: 26 bp2020-06-01: 26 bp2020-07-01: 24 bp2020-08-01: 28 bp2020-09-01: 30 bp2020-10-01: 36 bp2020-11-01: 42 bp2020-12-01: 49 bp2021-01-01: 64 bp2021-02-01: 89 bp2021-03-01: 124 bp2021-04-01: 124 bp2021-05-01: 121 bp2021-06-01: 105 bp2021-07-01: 79 bp2021-08-01: 74 bp2021-09-01: 82 bp2021-10-01: 82 bp2021-11-01: 69 bp2021-12-01: 47 bp2022-01-01: 58 bp2022-02-01: 44 bp2022-03-01: 29 bp2022-04-01: 24 bp2022-05-01: 27 bp2022-06-01: 16 bp2022-07-01: -13 bp2022-08-01: -50 bp2022-09-01: -58 bp2022-10-01: -63 bp2022-11-01: -79 bp2022-12-01: -87 bp2023-01-01: -80 bp2023-02-01: -88 bp2023-03-01: -83 bp2023-04-01: -81 bp2023-05-01: -88 bp2023-06-01: -119 bp2023-07-01: -123 bp2023-08-01: -105 bp2023-09-01: -97 bp2023-10-01: -72 bp2023-11-01: -71 bp2023-12-01: -78 bp2024-01-01: -66 bp2024-02-01: -68 bp2024-03-01: -69 bp2024-04-01: -56 bp2024-05-01: -58 bp2024-06-01: -55 bp2024-07-01: -37 bp2024-08-01: -21 bp2024-09-01: -4 bp2024-10-01: 11 bp2024-11-01: 10 bp2024-12-01: 17 bp2025-01-01: 36 bp2025-02-01: 37 bp2025-03-01: 47 bp2025-04-01: 58 bp2025-05-01: 63 bp2025-06-01: 66 bp2025-07-01: 72 bp2025-08-01: 73 bp2025-09-01: 72 bp2025-10-01: 71 bp2025-11-01: 73 bp2025-12-01: 81 bp2026-01-01: 84 bp2026-02-01: 80 bp2026-03-01: 68 bp2026-04-01: 65 bp2026-05-01: 61 bp2026-06-01: 63 bp2026-07-01: 71 bp2026-08-01: 72 bp2026-09-01: 62 bp62 bp
Show the data
The 10-year minus 2-year yield gap by year: the average, lowest and highest of that year's monthly averages
YearAverageLowest monthHighest month
2001117 bp41 bp213 bp
2002169 bp139 bp225 bp
2003155 bp124 bp188 bp
2004165 bp126 bp199 bp
200588 bp23 bp131 bp
200618 bp6 bp32 bp
20079 bp-5 bp32 bp
200895 bp54 bp171 bp
2009200 bp167 bp218 bp
2010169 bp139 bp221 bp
2011141 bp110 bp158 bp
201276 bp65 bp100 bp
2013115 bp76 bp157 bp
2014118 bp84 bp150 bp
201597 bp77 bp117 bp
201669 bp49 bp96 bp
201769 bp34 bp95 bp
201829 bp8 bp51 bp
2019-0 bp-18 bp14 bp
202024 bp-12 bp49 bp
202188 bp47 bp124 bp
2022-13 bp-87 bp58 bp
2023-91 bp-123 bp-71 bp
2024-33 bp-69 bp17 bp
202563 bp36 bp81 bp
202670 bp61 bp84 bp
Source: https://www.bankofcanada.ca/valet/observations/BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD/json, queried 27 September 2026
The calls behind this
Requestcall_tool
{"name": "boc_get_observations", "arguments": {"series_names": ["BD.CDN.2YR.DQ.YLD", "BD.CDN.10YR.DQ.YLD"], "start_date": "2001-01-01"}}

R

# ============================================================
# Bank of Canada Valet: BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD
# Purpose: Fetch the data behind MapleStats MCP's boc_get_observations
#          (exact: Valet request rebuilt from the tool's arguments)
# Inputs:  https://www.bankofcanada.ca/valet/observations/BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD/json?start_date=2001-01-01
# Outputs: data/raw/valet_observations.json; the prepared table as `data`
# ============================================================

# 0. Setup ----

library(dplyr)
library(httr2)
library(janitor)
library(jsonlite)
library(lubridate)
library(readr)
library(stringr)
library(tibble)
library(tidyr)

dir.create("data/raw", recursive = TRUE, showWarnings = FALSE)

# 1. Read inputs ----

response <- request("https://www.bankofcanada.ca/valet/observations/BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD/json?start_date=2001-01-01") |>
  req_perform()

writeLines(resp_body_string(response), "data/raw/valet_observations.json")

payload <- fromJSON("data/raw/valet_observations.json", flatten = TRUE)
data <- as_tibble(payload[["observations"]])

# 2. Check inputs ----

stopifnot("https://www.bankofcanada.ca/valet/observations/BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD/json?start_date=2001-01-01 returned no rows" = nrow(data) > 0)

# 3. Prepare data ----

data <- data |>
  clean_names()

# Valet nests each series as <series>.v; make one row per date and series.

data <- data |>
  pivot_longer(-d, names_to = "series", values_to = "value") |>
  mutate(
    series = str_remove(series, "_v$") |> str_to_upper(),
    value = as.numeric(value),
    date = ymd(d)
  ) |>
  select(date, series, value)

# Standard cleaning: trimmed text, empty strings as missing, and numbers
# stored as text converted to numbers.

data <- data |>
  mutate(across(where(is.character), \(x) na_if(str_trim(x), ""))) |>
  type_convert()

Python

# ============================================================
# Bank of Canada Valet: BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD
# Purpose: Fetch the data behind MapleStats MCP's boc_get_observations
#          (exact: Valet request rebuilt from the tool's arguments)
# Inputs:  https://www.bankofcanada.ca/valet/observations/BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD/json?start_date=2001-01-01
# Outputs: data/raw/valet_observations.json; the prepared table as `data`
# ============================================================

# %% 0. Setup

import json
import re
import unicodedata
from pathlib import Path

import httpx
import polars as pl

RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "valet_observations.json"

# %% 1. Read inputs

with httpx.Client(
    http2=True,
    follow_redirects=True,
    timeout=300,
    headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"},
) as client:
    response = client.get('https://www.bankofcanada.ca/valet/observations/BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD/json?start_date=2001-01-01')
response.raise_for_status()
raw_path.write_bytes(response.content)

payload = json.loads(raw_path.read_text(encoding="utf-8"))
records = payload['observations']
data = pl.json_normalize(records, strict=False)

# %% 2. Check inputs

assert data.height > 0, "https://www.bankofcanada.ca/valet/observations/BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD/json?start_date=2001-01-01 returned no rows"

# %% 3. Prepare data

# Valet nests each series as <series>.v; make one row per date and series.

data = data.unpivot(index="d", variable_name="series", value_name="value")
data = data.with_columns(
    pl.col("d").str.to_date().alias("date"),
    pl.col("series").str.replace(r"\.v$", ""),
    pl.col("value").cast(pl.Float64, strict=False),
).select("date", "series", "value")

# Standard cleaning: snake_case names without accents (PÉRIODE -> periode,
# referenceNumber -> reference_number, as janitor does in R), trimmed text,
# empty strings as missing. Names that clean alike are numbered as janitor
# numbers them (Indicator, indicator -> indicator, indicator_2).

clean_names = [
    re.sub(
        r"[^0-9a-z]+",
        "_",
        re.sub(
            r"([a-z0-9])([A-Z])",
            r"\1_\2",
            unicodedata.normalize("NFKD", column).encode("ascii", "ignore").decode(),
        ).lower(),
    ).strip("_")
    for column in data.columns
]
while len(set(clean_names)) < len(clean_names):
    name_counts = {}
    numbered = []
    for name in clean_names:
        name_counts[name] = name_counts.get(name, 0) + 1
        count = name_counts[name]
        numbered.append(name if count == 1 else f"{name}_{count}")
    clean_names = numbered
data = data.rename(dict(zip(data.columns, clean_names)))
data = data.with_columns(pl.col(pl.Utf8).str.strip_chars().replace("", None))

Stata

* ============================================================
* Bank of Canada Valet: BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD
* Purpose: Fetch the data behind MapleStats MCP's boc_get_observations
*          (exact: Valet request rebuilt from the tool's arguments)
* Inputs:  https://www.bankofcanada.ca/valet/observations/BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD/json?start_date=2001-01-01
* Outputs: data/raw/valet_observations.json; the prepared table as `data`
* ============================================================

version 18
clear all
set more off

* 0. Setup

capture mkdir "logs"
capture log close
log using "logs/boc_get_observations.log", replace
capture mkdir "data"
capture mkdir "data/raw"

* 1. Read inputs

* Stata reads no JSON or HTML and truncates long column names, so its
* built-in Python (Stata 16+) fetches, filters and writes a CSV. Point
* Stata at a Python with these packages first: python set exec <path>.

python:
import json
from pathlib import Path
import httpx
import polars as pl
RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "valet_observations.json"
with httpx.Client(http2=True, follow_redirects=True, timeout=300, headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"}) as client: response = client.get('https://www.bankofcanada.ca/valet/observations/BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD/json?start_date=2001-01-01')
response.raise_for_status()
raw_path.write_bytes(response.content)
payload = json.loads(raw_path.read_text(encoding="utf-8"))
records = payload['observations']
data = pl.json_normalize(records, strict=False)
nested = [name for name, dtype in data.schema.items() if dtype.is_nested()]
data = data.with_columns(pl.col(name).map_elements(lambda value: json.dumps(value.to_list() if isinstance(value, pl.Series) else value, default=str), return_dtype=pl.Utf8) for name in nested)
data.write_csv(RAW_DIR / "valet_observations_prepared.csv")
end

import delimited "data/raw/valet_observations_prepared.csv", clear varnames(1) encoding("utf-8")

* 2. Check inputs

assert _N > 0

* 3. Prepare data

* One column per series (<series>_v), one row per date.

generate date = date(d, "YMD")
format date %td
drop d

* Standard cleaning: lower-case names, trimmed text, and numbers stored as
* text converted (destring leaves genuinely non-numeric text alone).
* rename *, lower stops at a clash (Indicator next to indicator), so names
* that lower-case alike are numbered as janitor numbers them (indicator,
* indicator_2), then renamed in one group rename, which allows swaps.

local names
foreach var of varlist _all {
    local names `names' `=strlower("`var'")'
}
local dups : list dups names
while "`dups'" != "" {
    local numbered
    local before
    foreach name of local names {
        local count 1
        foreach earlier of local before {
            if "`earlier'" == "`name'" local ++count
        }
        local before `before' `name'
        if `count' > 1 {
            local name = substr("`name'", 1, 32 - strlen("_`count'")) + "_`count'"
        }
        local numbered `numbered' `name'
    }
    local names `numbered'
    local dups : list dups names
}
local old_names
local new_names
local i 0
foreach var of varlist _all {
    local ++i
    local name : word `i' of `names'
    if "`name'" != "`var'" {
        local old_names `old_names' `var'
        local new_names `new_names' `name'
    }
}
if "`old_names'" != "" {
    rename (`old_names') (`new_names')
}
quietly ds, has(type string)
local text_vars `r(varlist)'
foreach var of local text_vars {
    replace `var' = strtrim(`var')
}
destring, replace

log close

Julia

# ============================================================
# Bank of Canada Valet: BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD
# Purpose: Fetch the data behind MapleStats MCP's boc_get_observations
#          (exact: Valet request rebuilt from the tool's arguments)
# Inputs:  https://www.bankofcanada.ca/valet/observations/BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD/json?start_date=2001-01-01
# Outputs: data/raw/valet_observations.json; the prepared table as `data`
# ============================================================

# 0. Setup

using DataFrames
using Downloads
using JSON3
using Tables
using TidierData

mkpath("data/raw")

# 1. Read inputs

Downloads.download("https://www.bankofcanada.ca/valet/observations/BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD/json?start_date=2001-01-01", "data/raw/valet_observations.json")
payload = JSON3.read(read("data/raw/valet_observations.json", String))
records = payload["observations"]
data = DataFrame(Tables.dictrowtable(records))

# 2. Check inputs

@assert nrow(data) > 0 "https://www.bankofcanada.ca/valet/observations/BD.CDN.2YR.DQ.YLD,BD.CDN.10YR.DQ.YLD/json?start_date=2001-01-01 returned no rows"

# 3. Prepare data

# Standard cleaning: snake_case names, trimmed text, empty strings as missing.

data = @chain data begin
    @clean_names
end
data = mapcols(
    col -> eltype(col) <: Union{Missing, AbstractString} ?
        [ismissing(x) || isempty(strip(x)) ? missing : strip(x) for x in col] : col,
    data,
)

1,610,458

patents for the scientists, checking who got there first,

Machine learning at the patent office

Canadian patent applications in IPC class G06N, where machine learning is classified, by filing year, from ISED's IP Horizons data. From 24 in 2005 to a peak of 667 in 2021. The counts come from the newest IP Horizons bulk release when the calls were recorded, dated 11 October 2024. Applications are published about 18 months after filing, so that release holds only part of those filed from 2023 to 2025 (122 so far), and those years are left out.

Canadian patent applications in IPC class G06N (machine learning and other biological-model computing), by filing yearG06N0250500750G06N, 2005: 242005G06N, 2006: 152006G06N, 2007: 242007G06N, 2008: 392008G06N, 2009: 182009G06N, 2010: 242010G06N, 2011: 122011G06N, 2012: 262012G06N, 2013: 362013G06N, 2014: 462014G06N, 2015: 562015G06N, 2016: 772016G06N, 2017: 1782017G06N, 2018: 2562018G06N, 2019: 4782019G06N, 2020: 5292020G06N, 2021: 6672021G06N, 2022: 6542022Canadian patent applications in IPC class G06N (machine learning and other biological-model computing), by filing yearG06N0250500750G06N, 2005: 242005G06N, 2006: 152006G06N, 2007: 242007G06N, 2008: 392008G06N, 2009: 182009G06N, 2010: 242010G06N, 2011: 122011G06N, 2012: 262012G06N, 2013: 362013G06N, 2014: 462014G06N, 2015: 562015G06N, 2016: 772016G06N, 2017: 1782017G06N, 2018: 2562018G06N, 2019: 4782019G06N, 2020: 5292020G06N, 2021: 6672021G06N, 2022: 6542022
Show the data
Canadian patent applications in IPC class G06N (machine learning and other biological-model computing), by filing year
Filing yearG06N
200524
200615
200724
200839
200918
201024
201112
201226
201336
201446
201556
201677
2017178
2018256
2019478
2020529
2021667
2022654
Source: https://open.canada.ca/data/en/dataset/fe1dfbb9-0fc3-42ca-b2a9-6ca4c05dbac9, queried 27 September 2026
The calls behind this
Requestcall_tool
{"name": "ised_ip_horizons_search_patents", "arguments": {"filed_from": "1800-01-01", "limit": 1}}

21 more calls like this one, with other arguments.

R

# ============================================================
# Canadian patent search (CIPO IP Horizons)
# Purpose: Fetch the data behind MapleStats MCP's ised_ip_horizons_search_patents
#          (exact: the IP Horizons tables the search used, then the tool's filters)
# Inputs:  https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_1_to_2000000_2024-10-11.zip
# Outputs: data/raw/PT_main_1_to_2000000_2024-10-11.zip; the prepared table as `data`
# ============================================================

# 0. Setup ----

library(dplyr)
library(janitor)
library(purrr)
library(readr)
library(stringr)

dir.create("data/raw", recursive = TRUE, showWarnings = FALSE)

# 1. Read inputs ----

files <- c(
  main_1 = "https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_1_to_2000000_2024-10-11.zip",
  main_2 = "https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_2000001_to_4000000_2024-10-11.zip"
)

walk2(
  files,
  names(files),
  \(url, name) download.file(url, str_c("data/raw/", name, ".zip"), mode = "wb")
)

# Every column as text; headers are bilingual ("Patent Number - Numéro du
# brevet"), so keep the English half in snake_case.

tables <- names(files) |>
  map(
    \(name) unzip(str_c("data/raw/", name, ".zip"), exdir = "data/raw") |>
      read_delim(
        delim = "|",
        quote = "",
        na = c("", "NULL"),
        col_types = cols(.default = col_character()),
        trim_ws = TRUE
      ) |>
      rename_with(\(x) str_split_i(x, " - ", 1) |> make_clean_names())
  ) |>
  set_names(names(files))

patents <- bind_rows(tables[c("main_1", "main_2")]) |>
  filter(str_detect(filing_date, "^\\d{4}-\\d{2}-\\d{2}$"), filing_date >= "1800-01-01")
data <- patents |>
  arrange(desc(filing_date))

# 2. Check inputs ----

stopifnot("https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_1_to_2000000_2024-10-11.zip returned no rows" = nrow(data) > 0)

# 3. Prepare data ----

data <- data |>
  clean_names()

# Standard cleaning: trimmed text, empty strings as missing, and numbers
# stored as text converted to numbers.

data <- data |>
  mutate(across(where(is.character), \(x) na_if(str_trim(x), ""))) |>
  type_convert()

Python

# ============================================================
# Canadian patent search (CIPO IP Horizons)
# Purpose: Fetch the data behind MapleStats MCP's ised_ip_horizons_search_patents
#          (exact: the IP Horizons tables the search used, then the tool's filters)
# Inputs:  https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_1_to_2000000_2024-10-11.zip
# Outputs: data/raw/PT_main_1_to_2000000_2024-10-11.zip; the prepared table as `data`
# ============================================================

# %% 0. Setup

import re
import ssl
import unicodedata
import zipfile
from pathlib import Path

import certifi
import httpx
import polars as pl

RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "PT_main_1_to_2000000_2024-10-11.zip"

# %% 1. Read inputs

# CIPO's server omits its RapidSSL intermediate certificate; add it from the
# issuer on top of certifi's roots, so verification still anchors at a root.

intermediate = httpx.get("http://cacerts.rapidssl.com/RapidSSLTLSRSACAG1.crt").content
tls = ssl.create_default_context(cafile=certifi.where())
tls.load_verify_locations(cadata=ssl.DER_cert_to_PEM_cert(intermediate))

files = {
    'main_1': 'https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_1_to_2000000_2024-10-11.zip',
    'main_2': 'https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_2000001_to_4000000_2024-10-11.zip',
}
tables = {}
with httpx.Client(verify=tls, timeout=900, follow_redirects=True) as client:
    for name, url in files.items():
        zip_path = RAW_DIR / f"{name}.zip"
        with client.stream("GET", url) as response, zip_path.open("wb") as handle:
            response.raise_for_status()
            for chunk in response.iter_bytes(1 << 20):
                handle.write(chunk)
        with zipfile.ZipFile(zip_path) as archive:
            member = archive.namelist()[0]
            archive.extract(member, RAW_DIR)

        # Every column as text; headers are bilingual ("Patent Number - Numéro
        # du brevet"), so keep the English half in snake_case.

        tables[name] = (
            pl.scan_csv(
                RAW_DIR / member,
                separator="|",
                quote_char=None,
                null_values=["NULL"],
                infer_schema=False,
            )
            .rename(
                lambda column: re.sub(
                    r"[^a-z0-9]+", "_", column.split(" - ")[0].strip().lower()
                ).strip("_")
            )
            .with_columns(pl.all().str.strip_chars())
        )

patents = pl.concat([tables[name] for name in ['main_1', 'main_2']]).filter(pl.col("filing_date").str.contains(r"^\d{4}-\d{2}-\d{2}$"), pl.col("filing_date") >= "1800-01-01")
data = patents.sort("filing_date", descending=True, nulls_last=True).collect()

# %% 2. Check inputs

assert data.height > 0, "https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_1_to_2000000_2024-10-11.zip returned no rows"

# %% 3. Prepare data

# Standard cleaning: snake_case names without accents (PÉRIODE -> periode,
# referenceNumber -> reference_number, as janitor does in R), trimmed text,
# empty strings as missing. Names that clean alike are numbered as janitor
# numbers them (Indicator, indicator -> indicator, indicator_2).

clean_names = [
    re.sub(
        r"[^0-9a-z]+",
        "_",
        re.sub(
            r"([a-z0-9])([A-Z])",
            r"\1_\2",
            unicodedata.normalize("NFKD", column).encode("ascii", "ignore").decode(),
        ).lower(),
    ).strip("_")
    for column in data.columns
]
while len(set(clean_names)) < len(clean_names):
    name_counts = {}
    numbered = []
    for name in clean_names:
        name_counts[name] = name_counts.get(name, 0) + 1
        count = name_counts[name]
        numbered.append(name if count == 1 else f"{name}_{count}")
    clean_names = numbered
data = data.rename(dict(zip(data.columns, clean_names)))
data = data.with_columns(pl.col(pl.Utf8).str.strip_chars().replace("", None))

Stata

* ============================================================
* Canadian patent search (CIPO IP Horizons)
* Purpose: Fetch the data behind MapleStats MCP's ised_ip_horizons_search_patents
*          (exact: the IP Horizons tables the search used, then the tool's filters)
* Inputs:  https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_1_to_2000000_2024-10-11.zip
* Outputs: data/raw/PT_main_1_to_2000000_2024-10-11.zip; the prepared table as `data`
* ============================================================

version 18
clear all
set more off

* 0. Setup

capture mkdir "logs"
capture log close
log using "logs/ised_ip_horizons_search_patents.log", replace
capture mkdir "data"
capture mkdir "data/raw"

* 1. Read inputs

* Stata reads no JSON or HTML and truncates long column names, so its
* built-in Python (Stata 16+) fetches, filters and writes a CSV. Point
* Stata at a Python with these packages first: python set exec <path>.

python:
import json
import re
import ssl
import zipfile
from pathlib import Path
import certifi
import httpx
import polars as pl
RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "PT_main_1_to_2000000_2024-10-11.zip"
intermediate = httpx.get("http://cacerts.rapidssl.com/RapidSSLTLSRSACAG1.crt").content
tls = ssl.create_default_context(cafile=certifi.where())
tls.load_verify_locations(cadata=ssl.DER_cert_to_PEM_cert(intermediate))
files = {'main_1': 'https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_1_to_2000000_2024-10-11.zip', 'main_2': 'https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_2000001_to_4000000_2024-10-11.zip'}
tables = {}
exec('with httpx.Client(verify=tls, timeout=900, follow_redirects=True) as client:\n    for name, url in files.items():\n        zip_path = RAW_DIR / f"{name}.zip"\n        with client.stream("GET", url) as response, zip_path.open("wb") as handle:\n            response.raise_for_status()\n            for chunk in response.iter_bytes(1 << 20):\n                handle.write(chunk)\n        with zipfile.ZipFile(zip_path) as archive:\n            member = archive.namelist()[0]\n            archive.extract(member, RAW_DIR)\n        tables[name] = (pl.scan_csv(RAW_DIR / member, separator="|", quote_char=None, null_values=["NULL"], infer_schema=False) .rename(lambda column: re.sub(r"[^a-z0-9]+", "_", column.split(" - ")[0].strip().lower()).strip("_")) .with_columns(pl.all().str.strip_chars()))\n')
patents = pl.concat([tables[name] for name in ['main_1', 'main_2']]).filter(pl.col("filing_date").str.contains('^\\d{4}-\\d{2}-\\d{2}\x24'), pl.col("filing_date") >= "1800-01-01")
data = patents.sort("filing_date", descending=True, nulls_last=True).collect()
nested = [name for name, dtype in data.schema.items() if dtype.is_nested()]
data = data.with_columns(pl.col(name).map_elements(lambda value: json.dumps(value.to_list() if isinstance(value, pl.Series) else value, default=str), return_dtype=pl.Utf8) for name in nested)
data.write_csv(RAW_DIR / "PT_main_1_to_2000000_202_prepared.csv")
end

import delimited "data/raw/PT_main_1_to_2000000_202_prepared.csv", clear varnames(1) encoding("utf-8")

* 2. Check inputs

assert _N > 0

* 3. Prepare data

* Standard cleaning: lower-case names, trimmed text, and numbers stored as
* text converted (destring leaves genuinely non-numeric text alone).
* rename *, lower stops at a clash (Indicator next to indicator), so names
* that lower-case alike are numbered as janitor numbers them (indicator,
* indicator_2), then renamed in one group rename, which allows swaps.

local names
foreach var of varlist _all {
    local names `names' `=strlower("`var'")'
}
local dups : list dups names
while "`dups'" != "" {
    local numbered
    local before
    foreach name of local names {
        local count 1
        foreach earlier of local before {
            if "`earlier'" == "`name'" local ++count
        }
        local before `before' `name'
        if `count' > 1 {
            local name = substr("`name'", 1, 32 - strlen("_`count'")) + "_`count'"
        }
        local numbered `numbered' `name'
    }
    local names `numbered'
    local dups : list dups names
}
local old_names
local new_names
local i 0
foreach var of varlist _all {
    local ++i
    local name : word `i' of `names'
    if "`name'" != "`var'" {
        local old_names `old_names' `var'
        local new_names `new_names' `name'
    }
}
if "`old_names'" != "" {
    rename (`old_names') (`new_names')
}
quietly ds, has(type string)
local text_vars `r(varlist)'
foreach var of local text_vars {
    replace `var' = strtrim(`var')
}
destring, replace

log close

Julia

# ============================================================
# Canadian patent search (CIPO IP Horizons)
# Purpose: Fetch the data behind MapleStats MCP's ised_ip_horizons_search_patents
#          (exact: the IP Horizons tables the search used, then the tool's filters)
# Inputs:  https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_1_to_2000000_2024-10-11.zip
# Outputs: data/raw/PT_main_1_to_2000000_2024-10-11.zip; the prepared table as `data`
# ============================================================

# 0. Setup

using CSV
using DataFrames
using Downloads
using TidierData
using ZipFile

mkpath("data/raw")

# 1. Read inputs

files = Dict(
    "main_1" => "https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_1_to_2000000_2024-10-11.zip",
    "main_2" => "https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_2000001_to_4000000_2024-10-11.zip",
)
tables = Dict{String, DataFrame}()
for (name, url) in files
    zip_path = "data/raw/$(name).zip"
    Downloads.download(url, zip_path)
    archive = ZipFile.Reader(zip_path)
    member = archive.files[1]
    csv_path = "data/raw/$(member.name)"
    write(csv_path, read(member))
    close(archive)

    # Every column as text; keep the English half of each bilingual header.

    table = CSV.read(csv_path, DataFrame; delim = '|', quoted = false, missingstring = "NULL", types = String)
    rename!(table, [lowercase(replace(strip(split(n, " - ")[1]), r"[^A-Za-z0-9]+" => "_")) for n in names(table)])
    tables[name] = mapcols(col -> [ismissing(x) ? missing : strip(x) for x in col], table)
end

patents = vcat([tables[n] for n in ["main_1", "main_2"]]...)
patents = filter(row -> (!ismissing(row.filing_date) && occursin(r"^\d{4}-\d{2}-\d{2}$", row.filing_date) && row.filing_date >= "1800-01-01"), patents)
data = sort(patents, "filing_date"; rev = true)

# 2. Check inputs

@assert nrow(data) > 0 "https://opic-cipo.ca/cipo/client_downloads/Patent_CSV_2024_10_11/PT_main_1_to_2000000_2024-10-11.zip returned no rows"

# 3. Prepare data

# Standard cleaning: snake_case names, trimmed text, empty strings as missing.

data = @chain data begin
    @clean_names
end
data = mapcols(
    col -> eltype(col) <: Union{Missing, AbstractString} ?
        [ismissing(x) || isempty(strip(x)) ? missing : strip(x) for x in col] : col,
    data,
)

15,937

Bank of Canada series for the analysts, doomed to explain the rate,

The policy rate since 2015

The Bank of Canada's target for the overnight rate on every business day since January 2015: 29 changes, from a low of 0.25% to a high of 5.00%. On 24 September 2026 it stood at 2.25%.

Bank of Canada target for the overnight rate, daily since 20150.00%2.00%4.00%6.00%2015201620172018201920202021202220232024202520262015-01-01: 1.00%2015-01-21: 0.75%2015-07-15: 0.50%2017-07-12: 0.75%2017-09-06: 1.00%2018-01-17: 1.25%2018-07-11: 1.50%2018-10-24: 1.75%2020-03-04: 1.25%2020-03-16: 0.75%2020-03-27: 0.25%2022-03-03: 0.50%2022-04-14: 1.00%2022-06-02: 1.50%2022-07-14: 2.50%2022-09-08: 3.25%2022-10-27: 3.75%2022-12-08: 4.25%2023-01-26: 4.50%2023-06-08: 4.75%2023-07-13: 5.00%2024-06-06: 4.75%2024-07-25: 4.50%2024-09-05: 4.25%2024-10-24: 3.75%2024-12-12: 3.25%2025-01-30: 3.00%2025-03-13: 2.75%2025-09-18: 2.50%2025-10-30: 2.25%2026-09-24: 2.25%2.25%Bank of Canada target for the overnight rate, daily since 20150.00%2.00%4.00%6.00%2015201720192021202320252015-01-01: 1.00%2015-01-21: 0.75%2015-07-15: 0.50%2017-07-12: 0.75%2017-09-06: 1.00%2018-01-17: 1.25%2018-07-11: 1.50%2018-10-24: 1.75%2020-03-04: 1.25%2020-03-16: 0.75%2020-03-27: 0.25%2022-03-03: 0.50%2022-04-14: 1.00%2022-06-02: 1.50%2022-07-14: 2.50%2022-09-08: 3.25%2022-10-27: 3.75%2022-12-08: 4.25%2023-01-26: 4.50%2023-06-08: 4.75%2023-07-13: 5.00%2024-06-06: 4.75%2024-07-25: 4.50%2024-09-05: 4.25%2024-10-24: 3.75%2024-12-12: 3.25%2025-01-30: 3.00%2025-03-13: 2.75%2025-09-18: 2.50%2025-10-30: 2.25%2026-09-24: 2.25%2.25%
Show the data
Bank of Canada target for the overnight rate: the first day, each day it changed, and the last day
DateRate
1 January 20151.00%
21 January 20150.75%
15 July 20150.50%
12 July 20170.75%
6 September 20171.00%
17 January 20181.25%
11 July 20181.50%
24 October 20181.75%
4 March 20201.25%
16 March 20200.75%
27 March 20200.25%
3 March 20220.50%
14 April 20221.00%
2 June 20221.50%
14 July 20222.50%
8 September 20223.25%
27 October 20223.75%
8 December 20224.25%
26 January 20234.50%
8 June 20234.75%
13 July 20235.00%
6 June 20244.75%
25 July 20244.50%
5 September 20244.25%
24 October 20243.75%
12 December 20243.25%
30 January 20253.00%
13 March 20252.75%
18 September 20252.50%
30 October 20252.25%
24 September 20262.25%
Source: https://www.bankofcanada.ca/valet/observations/V39079/json, queried 27 September 2026
The calls behind this
Requestcall_tool
{"name": "boc_get_observations", "arguments": {"series_names": ["V39079"], "start_date": "2015-01-01"}}

R

# ============================================================
# Bank of Canada Valet: V39079
# Purpose: Fetch the data behind MapleStats MCP's boc_get_observations
#          (exact: Valet request rebuilt from the tool's arguments)
# Inputs:  https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01
# Outputs: data/raw/valet_observations.json; the prepared table as `data`
# ============================================================

# 0. Setup ----

library(dplyr)
library(httr2)
library(janitor)
library(jsonlite)
library(lubridate)
library(readr)
library(stringr)
library(tibble)
library(tidyr)

dir.create("data/raw", recursive = TRUE, showWarnings = FALSE)

# 1. Read inputs ----

response <- request("https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01") |>
  req_perform()

writeLines(resp_body_string(response), "data/raw/valet_observations.json")

payload <- fromJSON("data/raw/valet_observations.json", flatten = TRUE)
data <- as_tibble(payload[["observations"]])

# 2. Check inputs ----

stopifnot("https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01 returned no rows" = nrow(data) > 0)

# 3. Prepare data ----

data <- data |>
  clean_names()

# Valet nests each series as <series>.v; make one row per date and series.

data <- data |>
  pivot_longer(-d, names_to = "series", values_to = "value") |>
  mutate(
    series = str_remove(series, "_v$") |> str_to_upper(),
    value = as.numeric(value),
    date = ymd(d)
  ) |>
  select(date, series, value)

# Standard cleaning: trimmed text, empty strings as missing, and numbers
# stored as text converted to numbers.

data <- data |>
  mutate(across(where(is.character), \(x) na_if(str_trim(x), ""))) |>
  type_convert()

Python

# ============================================================
# Bank of Canada Valet: V39079
# Purpose: Fetch the data behind MapleStats MCP's boc_get_observations
#          (exact: Valet request rebuilt from the tool's arguments)
# Inputs:  https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01
# Outputs: data/raw/valet_observations.json; the prepared table as `data`
# ============================================================

# %% 0. Setup

import json
import re
import unicodedata
from pathlib import Path

import httpx
import polars as pl

RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "valet_observations.json"

# %% 1. Read inputs

with httpx.Client(
    http2=True,
    follow_redirects=True,
    timeout=300,
    headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"},
) as client:
    response = client.get('https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01')
response.raise_for_status()
raw_path.write_bytes(response.content)

payload = json.loads(raw_path.read_text(encoding="utf-8"))
records = payload['observations']
data = pl.json_normalize(records, strict=False)

# %% 2. Check inputs

assert data.height > 0, "https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01 returned no rows"

# %% 3. Prepare data

# Valet nests each series as <series>.v; make one row per date and series.

data = data.unpivot(index="d", variable_name="series", value_name="value")
data = data.with_columns(
    pl.col("d").str.to_date().alias("date"),
    pl.col("series").str.replace(r"\.v$", ""),
    pl.col("value").cast(pl.Float64, strict=False),
).select("date", "series", "value")

# Standard cleaning: snake_case names without accents (PÉRIODE -> periode,
# referenceNumber -> reference_number, as janitor does in R), trimmed text,
# empty strings as missing.

data = data.rename(
    {
        column: re.sub(
            r"[^0-9a-z]+",
            "_",
            re.sub(
                r"([a-z0-9])([A-Z])",
                r"\1_\2",
                unicodedata.normalize("NFKD", column).encode("ascii", "ignore").decode(),
            ).lower(),
        ).strip("_")
        for column in data.columns
    }
)
data = data.with_columns(pl.col(pl.Utf8).str.strip_chars().replace("", None))

Stata

* ============================================================
* Bank of Canada Valet: V39079
* Purpose: Fetch the data behind MapleStats MCP's boc_get_observations
*          (exact: Valet request rebuilt from the tool's arguments)
* Inputs:  https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01
* Outputs: data/raw/valet_observations.json; the prepared table as `data`
* ============================================================

version 18
clear all
set more off

* 0. Setup

capture mkdir "logs"
capture log close
log using "logs/boc_get_observations.log", replace
capture mkdir "data"
capture mkdir "data/raw"

* 1. Read inputs

* Stata reads no JSON or HTML and truncates long column names, so its
* built-in Python (Stata 16+) fetches, filters and writes a CSV. Point
* Stata at a Python with these packages first: python set exec <path>.

python:
import json
from pathlib import Path
import httpx
import polars as pl
RAW_DIR = Path("data/raw")
RAW_DIR.mkdir(parents=True, exist_ok=True)
raw_path = RAW_DIR / "valet_observations.json"
with httpx.Client(http2=True, follow_redirects=True, timeout=300, headers={"User-Agent": "Mozilla/5.0 (compatible; research script)"}) as client: response = client.get('https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01')
response.raise_for_status()
raw_path.write_bytes(response.content)
payload = json.loads(raw_path.read_text(encoding="utf-8"))
records = payload['observations']
data = pl.json_normalize(records, strict=False)
nested = [name for name, dtype in data.schema.items() if dtype.is_nested()]
data = data.with_columns(pl.col(name).map_elements(lambda value: json.dumps(value.to_list() if isinstance(value, pl.Series) else value, default=str), return_dtype=pl.Utf8) for name in nested)
data.write_csv(RAW_DIR / "valet_observations_prepared.csv")
end

import delimited "data/raw/valet_observations_prepared.csv", clear varnames(1) encoding("utf-8")

* 2. Check inputs

assert _N > 0

* 3. Prepare data

* One column per series (<series>_v), one row per date.

generate date = date(d, "YMD")
format date %td
drop d

* Standard cleaning: lower-case names, trimmed text, and numbers stored as
* text converted (destring leaves genuinely non-numeric text alone).

rename *, lower
quietly ds, has(type string)
local text_vars `r(varlist)'
foreach var of local text_vars {
    replace `var' = strtrim(`var')
}
destring, replace

log close

Julia

# ============================================================
# Bank of Canada Valet: V39079
# Purpose: Fetch the data behind MapleStats MCP's boc_get_observations
#          (exact: Valet request rebuilt from the tool's arguments)
# Inputs:  https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01
# Outputs: data/raw/valet_observations.json; the prepared table as `data`
# ============================================================

# 0. Setup

using DataFrames
using Downloads
using JSON3
using Tables
using TidierData

mkpath("data/raw")

# 1. Read inputs

Downloads.download("https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01", "data/raw/valet_observations.json")
payload = JSON3.read(read("data/raw/valet_observations.json", String))
records = payload["observations"]
data = DataFrame(Tables.dictrowtable(records))

# 2. Check inputs

@assert nrow(data) > 0 "https://www.bankofcanada.ca/valet/observations/V39079/json?start_date=2015-01-01 returned no rows"

# 3. Prepare data

# Standard cleaning: snake_case names, trimmed text, empty strings as missing.

data = @chain data begin
    @clean_names
end
data = mapcols(
    col -> eltype(col) <: Union{Missing, AbstractString} ?
        [ismissing(x) || isempty(strip(x)) ? missing : strip(x) for x in col] : col,
    data,
)

One MCP for the Agent to find them, one MCP to bring them all together.

The ring: 30 federal publishers around the outside, the provinces and cities with local sources inside, and 215 tools as arcs by subjectBank of Canada · Borealis · Canada Gazette · CanadaBuys · CDC · CER · CFIA · CGC · CIHI · CMHC · Competition Bureau · CRA · DFO tides · Earthquakes Canada · ECCC · Elections Canada · FCAC · GC InfoBase · IRCC · ISED · NRCan burned areas · NRCan energy use · NRCan places · OpenParliament · PBO · PHAC Health Infobase · Recalls · Senate · StatCan · Transport Canada · British Columbia · Surrey · Vancouver · Victoria · Alberta · Airdrie · Calgary · Edmonton · Grande Prairie · Lethbridge · Medicine Hat · Parkland · Red Deer · St. Albert · Strathcona · Sturgeon · Saskatchewan · Regina · Saskatoon · Manitoba · Winnipeg · Ontario · Aurora · Durham · Hamilton · Kitchener · London · Markham · Mississauga · Newmarket · Ottawa · Peel · Toronto · Waterloo · Windsor · York · Quebec · Montreal · New Brunswick · Nova Scotia · Halifax · Prince Edward Island · Newfoundland and Labrador · Yukon · Northwest Territories · Statistics: statistics and census (62)Provinces and cities: open-data catalogues, provincial, municipal (47)Money and business: money, prices and public finance, business, IP and competition (31)Land and energy: agriculture and food, environment and hazards, energy, geography, transport and safety (31)People: health, housing, immigration (22)Parliament: parliament, law and elections (20)Statistics 62Provinces and cities 47Money and business 31Land and energy 31People 22Parliament 20215tools,one connection
Show the data
The 215 tools by subject (the arcs); MapleStats' own tools are counted only in the total
SubjectTools
Statistics: statistics and census62
Provinces and cities: open-data catalogues, provincial, municipal47
Money and business: money, prices and public finance, business, IP and competition31
Land and energy: agriculture and food, environment and hazards, energy, geography, transport and safety31
People: health, housing, immigration22
Parliament: parliament, law and elections20

Data access and discovery are no longer the bottleneck.

Never pay for numbers Canada already publishes for free. Ask the question, get the number with its source and a script that fetches it again, and spend your time on what you actually want to do: the model, the argument, the decision.

MCP stands for Model Context Protocol, an open standard for connecting AI agents to tools and data. Think of it as a USB-C port for AI: any agent that speaks it can plug into any server that does. MapleStats is that plug for Canadian data: 215 tools from federal agencies, provinces, cities and open-data catalogues, behind one connection.

What one connection reaches

  • 1,610,458patents
  • 962,449census records
  • 15,937Bank of Canada series
  • 8,271Statistics Canada tables
  • 158microdata files
  • 98credit cards
  • 67immigration tables
  • 54housing tables