A Year of Spending, from Your Own Statements
A small local tool that turns bank exports into the one number a plan starts with — the files it reads, the rules it follows, and the prompts to use once you have the answer
The funds plan starts with one instruction: measure what a year costs you. Not an estimate — most people are wrong by 20–30% on their first guess — but twelve months of actual spending. Everything downstream is a multiple of that number.
It turns out to be surprisingly hard to get. Not because the arithmetic is difficult, but because a bank statement records every dollar that left an account, and only some of those dollars were spent. This article describes a small tool built to close that gap, what it reads and writes, and what building it against real accounts taught about the problem. Everything runs on your own machine. Nothing is uploaded.
The tool runs at collegica.org/owl — the same engine, in your own browser, with nothing uploaded. Drop your CSV exports, press run. Or start with the invented household it ships with.
Every figure in this article is invented, chosen to match the worked example in the planning article: a person whose year costs $X = $60,000. The tool was developed on real accounts; those numbers stay where they belong.
Only one of six kinds of money movement is spending
The insight that shaped the whole tool. Money leaves an account for at least six reasons, and a bank feed shows them all identically:
| Movement | Why it is not spending |
|---|---|
| Transfer between your own accounts | The credit-card payment is not spending — the card purchases already are. Count both and every dollar on the card is doubled. |
| Lending and its repayment | Money lent to a friend is an asset you are holding, not spending. When it comes back, it is not income. |
| Savings contribution | Moving money into an RRSP or a TFSA does not consume it. |
| Pass-through | A rebate that arrives and is forwarded the same day is neither income nor spending. |
| Income | Named explicitly, because an e-transfer from an employer looks like every other e-transfer. |
| Spending | What is left. This is the number. |
During development, every one of the first five was counted as spending at some point. The recurring figure fell by about 7% between the first run and the last, and almost the whole gap was classification, not arithmetic. That is the general lesson: a budget tool’s hard problem is taxonomy, not summation.
What it reads
The statements
Export the last several months as CSV from online banking, for every account you spend from — chequing, each credit card, any line of credit — and drop the files in a folder. Filenames do not matter; the tool works out each file’s delimiter, encoding, date convention and column layout on its own.
Export the cards, not just the chequing account. Chequing alone shows you a handful of card payments and nothing about where the money went.
If a bank offers only PDF statements, a companion importer converts them to the same shape — and refuses any statement it cannot reconcile against the totals printed on the statement itself. That check is what makes the numbers trustworthy: a parsing bug and a real transaction look identical until you have a figure the bank itself asserts to compare against. Thirty-two statements across four institutions reconciled before the tool was allowed to draw a conclusion from any of them.
The normalised shape
Whatever the source, every file is reduced to three columns. You can write one by hand if a bank offers nothing exportable at all:
Date,Description,Amount
2026-03-02,COSTCO WHOLESALE #1234 SOMEWHERE ON,-241.18
2026-03-03,PAYROLL DEPOSIT ACME LTD,3850.00
2026-03-05,MB-Transfer to Credit Card,-1200.00
2026-03-05,Withdrawal Free Interac E-Transfer,-950.00
Negative is money out. Dates are ISO. That is the whole contract.
The rules
Everything personal lives in one YAML file, rules.yml, which is yours and never leaves your machine. It maps description patterns to what they mean, first match wins:
# --- Money moving between your own accounts: never spending ---------------
transfers:
- 'payment.*(thank you|received)'
- '(mb-)?transfer to\s+credit\s*card'
- 'online banking transfer'
- 'cash advance to\s*-' # a line-of-credit advance ...
- 'payment from\s*-' # ... and its repayment a few days later
# --- Income: inflows that are not refunds --------------------------------
income:
- 'payroll|salary|direct deposit.*pay'
- 'acme ltd' # the employer, named
- 'tax refund'
# --- Spending, by budget line ---------------------------------------------
categories:
Groceries: ['loblaw', 'metro\b', 'costco whsl', 'no ?frills']
Restaurants: ['restaurant', 'uber\s?eats', 'tim hortons']
Gas: ['petro[- ]?canada', 'esso', 'shell\b']
Mortgage: ['mortgage payment']
Mobile: ['fido', 'koodo', 'telus mobil']
Registered investments: ['rrsp contribution', 'mutual funds'] # savings, not spending
# --- A monthly obligation whose payment date wobbles across month-end -----
level:
- match: 'withdrawal.*interac'
note: support payment — made on the last day of the month
# --- Things only you can classify, pinned to date and amount --------------
dated:
- date: 2026-02-10
amount: 1500
line: Gifts
note: a wedding
one_off:
- date: 2026-05-20
amount: 3800
note: transmission — a repair, not a service intervalThree of those sections exist because of specific failures:
level— a payment made on the last day of the month sometimes clears on the 1st of the next. Bucketed by clearing date, some months looked empty and others double, and the monthly spread read 57%–134% of the median when the spending was steady. An obligation listed here is charged at its monthly rate — what was actually paid, divided by the months observed. The tool refuses to level anything with fewer than three payments, because this is a fix for a calendar artefact, not a way to smooth real variation.dated— a cheque shows up as a branch withdrawal like any other. Only you know what it was for, and pinning it to a date and amount means no other withdrawal is swept up with it.one_off— an irregular item you have looked at and judged one-time. It stays out of the recurring baseline either way; recording it just stops the tool asking again, and keeps the reason on the record.
Loans get their own file, because a statement cannot tell you who an e-transfer went to:
lent:
- to: Friend A
principal: 5000
repaid: 2000
disbursements:
- date: 2026-01-20
amount: 5000
to: Friend A
note: cheque
receipts:
- date: 2026-04-05
amount: 2000
frm: Friend A
note: mobile cheque depositAnd the yearly items you know about but did not observe — insurance renewals, property tax, the annual trip — go in known-annual.yml, stated rather than inferred. A short window cannot see them, and twelve months only means you got luckier.
What it writes
files 4 transactions 512 duplicates dropped 3
categorised 97% of spending
observed 2026-01-01 to 2026-08-31 (8 complete month(s))
LEVELLED $950/month — support payment — made on the last day of the month
$7,600 over 9 transfer(s) in 8 months, charged monthly
instead of on the dates they cleared
RECURRING $4,650/month -> $55,800/year
months ranged $4,190-$5,070 (90%-109% of median); steady
ONE-OFF 1 item(s) you have judged one-time, held out of the average:
2026-05-20 $ 3,800 MAIN STREET AUTO
transmission — a repair, not a service interval
IRREGULAR 1 transaction(s) at or above $1,000, not yet decided:
2026-07-14 $ 2,400 AIR CANADA
Decide for each: one-off, yearly, or the start of a monthly
payment. Averaging them is what makes a short window lie.
LENDING not spending, and not income when it comes back
Friend A $ 3,000 outstanding of $5,000
SAVINGS $500/month -> $6,000/year
not spending — it moves money, it does not consume it
INCOME $9,600/month (median) -> $115,200/year
savings rate 5.2% of income
against $55,800 of recurring spending, a surplus of $53,400/year
KNOWN YEARLY from known-annual.yml, not from your statements:
Home insurance $ 1,450
Municipal taxes $ 2,750
PLANNING FIGURE $60,000/year = recurring $55,800 + known yearly $4,200
Read the structure, not the numbers. Three things are doing the work:
Recurring is a median of complete months. Only months fully inside the observed window count — a partial month understates. The median, not the mean, so one strange month cannot drag the year. And the spread is printed: 90%–109% is a number you can plan on; 57%–134% is a warning, and the tool says so rather than averaging through it.
Irregular items are decisions, not averages. Anything large that does not repeat is held out and listed. In the real sample, one car repair was close to a full month of everything else; averaged in, it would have added a month and a half of imaginary spending to the year. Ignored, it would have hidden a real expense. So it is neither — it is put in front of you.
A measurement is labelled as one. The planning figure is recurring plus what you have stated. Nothing is extrapolated from seven weeks and printed in the same typeface as a measurement. The first version of the tool did exactly that, and the correction — “is that an estimate? you don’t have the whole year” — is the single most important line in its history.
How the number is computed
The whole engine is under a thousand lines of Python. The part that matters fits here:
def complete_months(lo, hi):
"""Only months fully inside the observed window."""
m, out = date(lo.year, lo.month, 1), []
while m <= hi:
nxt = (m.replace(day=28) + timedelta(days=4)).replace(day=1)
if m >= lo and nxt - timedelta(days=1) <= hi:
out.append(f"{m.year}-{m.month:02d}")
m = nxt
return out
def signature(desc):
"""Same standing payment month to month, reference numbers stripped:
'Mortgage payment #498539-3' == 'Mortgage payment Term Life ...'"""
d = re.sub(r'[#\d]{3,}', '', desc.lower())
return ' '.join(re.sub(r'[^a-z ]', ' ', d).split()[:2])
# Size alone is the wrong test: a mortgage is large AND perfectly regular.
# Something is irregular only if it is large and does NOT repeat.
months_seen = defaultdict(set)
for d, desc, out in spending:
months_seen[signature(desc)].add((d.year, d.month))
recurring = {k for k, v in months_seen.items() if len(v) >= 3}
lumpy = [t for t in spending
if t.out >= LUMPY and signature(t.desc) not in recurring]
per_month = defaultdict(float)
for d, desc, out in spending:
if is_levelled(desc):
continue # charged at its monthly rate below
if out < LUMPY or signature(desc) in recurring:
per_month[f"{d.year}-{d.month:02d}"] += out
for total in levelled_totals.values():
for m in whole:
per_month[m] += total / len(whole)
vals = sorted(per_month[m] for m in whole)
median = vals[len(vals) // 2]
baseline = median * 12
steady = max(vals) / median <= 1.35 and min(vals) / median >= 0.75Note what is not there: no machine learning, no bank API, no cloud. A regular expression, a median, and a rule that says a large payment is only irregular if it does not come back. The intelligence is in the rules file, and the rules file is yours.
What building it taught
The tool was developed against one person’s real accounts over a few days, and every wrong number it produced along the way came from the same place.
Almost nothing that mattered was in the statements. That a large cheque was a loan to a friend; that a series of e-transfers was a family obligation; that a monthly fund purchase was savings — none of that is inferable, and every attempt to infer it was wrong. Lending inferred from an e-transfer pattern came to more than twice what had actually been lent. So the tool’s skill has to be asking well, not guessing well: surface what it cannot classify, ranked by dollars, and turn each answer into a durable rule with the reason attached.
Reconciliation is what separates parsing from knowing. Without a figure the bank itself asserts, there is no way to tell a regex bug from a real transaction. There were several: a stripped leading minus sign that turned a refund into a charge; column offsets read once instead of per page that overstated one month’s withdrawals threefold. Each was caught only because a statement refused to reconcile.
Variance in the bookkeeping looks exactly like variance in the life. A tool that reports a swing has to be able to say whether the world moved or the calendar did. False alarms are why people stop using budgeting tools.
Load everything before concluding anything. With only the credit cards imported, card payments looked like spending with no income behind them, and the loans looked like a large unexplained hole. Partial data does not give partial conclusions; it gives confident wrong ones.
Then: the prompts
Once you have the planning figure, an AI is genuinely useful — for interpreting it, not for producing it. Two prompts, in the order you will need them.
Turning merchant names into rules
The tool’s feedback loop is a file of descriptions it could not match, largest first. A model is good at writing the regular expressions, and merchant names are not identifiers — but strip the amounts, dates and account fragments first, and only paste names you would say aloud.
I am categorising bank transactions with case-insensitive regular
expressions, first match wins. The budget lines available are:
Groceries, Restaurants, Gas, Public transit, Parking, Pharmacy, Mobile,
Internet, Electricity, Clothing, Gifts, Sports, Travel, Other personal
Here are merchant descriptions that did not match any rule. For each,
give a pattern that matches it and its likely variants (store numbers,
city suffixes) without matching unrelated merchants, and the budget line.
If a description could be two things, say so rather than guessing.
- COSTCO WHOLESALE #1234
- SQ *THE DAILY GRIND
- PRESTO AUTOLOAD
- SP LULULEMON ONLINE
- AMZN Mktp CAAsk it to flag ambiguity explicitly. AMZN Mktp is groceries, a gift, and a garden hose, and the right answer is “Other personal, and look at the big ones by hand”, not a confident wrong category.
Interpreting the result
Translate to ratios first — the planning article explains how — and then give the model the shape of the year rather than the year itself. The tool’s output is already aggregated, with no account numbers in it, but it still carries your absolute income and the names of the people you lend to. Those stay home.
My annual spending is $X = $60,000, measured from eight complete months
of statements (monthly spread 90%-109% of median, so I trust it).
Of that, 0.19X is a fixed family obligation and 0.69X is the mortgage
plus everything else recurring. Savings are currently 0.10X a year.
Income is 1.9X. One irregular item this year, 0.06X, was a car repair I
have judged one-time. I hold 0.05X in loans to a friend, unsecured.
Known yearly items not yet in a monthly figure: home insurance 0.024X,
municipal tax 0.046X.
1. Is the emergency floor of 0.25-0.5X reasonable given the fixed
obligation, or should it be larger?
2. My savings rate is low for my income. Show me what 0.15X and 0.25X a
year would each do over ten years at 4% real, and what I would have to
give up to get there.
3. What questions should I be asking a fee-only planner about the loan?
State every assumption. Show the arithmetic. Do not give me a product.The model can reason well about a profile like this. What it cannot do is verify any limit, rate or rule against this year’s reality — do that against the tax authority’s own site before acting.
Where it lives
The tool is OWL Planner, MIT licensed: two Python files, an expense taxonomy modelled on a planner’s intake form, and three example configuration files to copy. Clone it, drop your exports in statements/, and pixi run budget — or run it in your browser, where the same budget.py runs under Pyodide with every asset served from this site. Your files stay in the tab; your rules stay in your browser’s storage, exportable as plain YAML and deletable in one click. PDFs still need the local tool. The personal files it creates — rules.yml, loans.yml, known-annual.yml — are ignored by version control, because they describe your money. So are the statements and the budget it writes.
It is Canadian in its reflexes: the $3,000 e-transfer cap, Interac’s description formats, the banks it can reconcile PDFs from. The engine does not care. If your bank exports a CSV, it will read it, and the rules file is where you teach it your own country.