Collegica Collegica Collegica
  • Subjects
    • Robotics & ROS
    • Software development
    • AI
    • Finance
    • Aging well
  • Events
  • Talks
  • About
  • Newsletter

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

Author

Behzad Samadi

Published

September 9, 2026

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.

TipRun it in your browser

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.

NoteIllustrative numbers

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 interval

Three 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 deposit

And 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.75

Note 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 CA

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

Related projects

This tool answers one question — what does a year cost — and stops. The projects below pick up on either side of it. All are open source; all but the last run on your own machine.

  • Retire, Eh? — a household retirement planner for Canada: RRSP, TFSA and CPP sequencing, with the arithmetic in Rust compiled to WebAssembly so every calculation runs in your browser and nothing leaves it. The natural next step once you have $X.
  • Retirement Planning Toolkit Canada — a Python toolkit that models RRIF wind-up, OAS clawback, the RRSP meltdown and a Monte Carlo drawdown across every province, writing Excel workbooks and HTML dashboards. Deeper than most planners will go with you.
  • FUNDerelele — the asset side of the ledger this article does not cover: a local-first, SQLite-backed portfolio tracker for Canadian account types, with importers for two brokerages. “Local-first, Canadian, private” is exactly the right creed.
  • AI Personal Finance Advisor — the other architecture: a hosted platform for newcomers to Canada, with RRSP/TFSA/FHSA guidance in ten languages and account data arriving through open banking rather than exports. Worth reading as the design this article deliberately chose not to build — convenience on one side, a folder that never leaves your machine on the other.

© 2026 Collegica

A learning companion to Mechatronics3D

  • About

  • Events

  • Talks