To prove a parsed brokerage statement, run two checks. First, opening cash plus every signed activity row must equal the closing cash the statement prints. Second, quantity times price must equal market value for every position. If both pass to the cent, the parse is whole. This is the same idea ZeraBooks Convert uses before it hands anyone a spreadsheet.
I work on ZeraBooks, and brokerage statements are the most annoying document type we read. A bank statement is basically one table. A brokerage statement is a summary, a holdings table, an activity table and an income summary, in whatever order and wording the broker likes, sometimes repeated per account in one PDF. Parsing it is half the job. Knowing whether you parsed it correctly is the other half, and it's the half people skip.
This post is about that second half. The code is plain Python with decimal, no dependencies.
The data model
After extraction, a monthly statement for one account reduces to a few things:
from dataclasses import dataclass
from decimal import Decimal
from typing import Literal
Kind = Literal["Deposit", "Withdrawal", "Buy", "Sell", "Dividend",
"Interest", "Reinvest", "Fee", "Transfer"]
@dataclass(frozen=True)
class Activity:
date: str # as printed in the activity section
description: str
kind: Kind
amount: Decimal # signed by cash direction: in > 0, out < 0
@dataclass(frozen=True)
class Position:
symbol: str
quantity: Decimal
price: Decimal
market_value: Decimal # as printed, not computed
@dataclass(frozen=True)
class Statement:
account: str
opening_cash: Decimal
closing_cash: Decimal # as printed
activity: list[Activity]
positions: list[Position]
Two design choices matter here.
amount is signed by cash direction, not by "type". A buy is negative because cash left. Reinvestments come through as a pair: a Dividend that's positive and a Reinvest that's negative, same date, same amount. Collapsing them into one zero row is the most common way a brokerage spreadsheet loses income.
market_value and closing_cash are stored as printed. Never compute them and then compare a value to itself.
Check 1: the cash proof
def cash_proof(s: Statement) -> Decimal:
"""Returns the difference; zero means the activity is complete."""
computed = s.opening_cash + sum((a.amount for a in s.activity), Decimal("0"))
return computed - s.closing_cash
A fictional October 2026 for a taxable account. Opening cash 3,400.00, closing cash printed as 1,743.27.
from decimal import Decimal as D
oct_2026 = Statement(
account="Individual ...7731",
opening_cash=D("3400.00"),
closing_cash=D("1743.27"),
activity=[
Activity("10/01/2026", "Electronic deposit", "Deposit", D("1000.00")),
Activity("10/02/2026", "Bought 25 shares, total market fund", "Buy", D("-3912.40")),
Activity("10/15/2026", "Cash dividend", "Dividend", D("27.55")),
Activity("10/20/2026", "Sold 40 shares, bond fund", "Sell", D("1240.00")),
Activity("10/31/2026", "Interest on cash", "Interest", D("3.12")),
Activity("10/31/2026", "Advisory fee", "Fee", D("-15.00")),
],
positions=[
Position("TMKT", D("25"), D("118.42"), D("2960.50")),
Position("INTL", D("40"), D("51.07"), D("2042.80")),
],
)
assert cash_proof(oct_2026) == 0
3,400.00 + 1,000.00 ‚àí 3,912.40 + 27.55 + 1,240.00 + 3.12 ‚àí 15.00 = 1,743.27. The difference is zero, so every activity row is present and signed correctly.
When it isn't zero, the size of the gap is a clue:
def diagnose(diff: Decimal, rows: list[Activity]) -> list[str]:
hints = []
for r in rows:
if abs(r.amount) == abs(diff):
hints.append(f"row {r.date} {r.description}: missing or doubled?")
if abs(r.amount) * 2 == abs(diff):
hints.append(f"row {r.date} {r.description}: sign flipped?")
if diff != 0 and (diff * 100) % 9 == 0:
hints.append("gap divisible by 9 in cents: transposed digits?")
return hints
A dropped Reinvest row leaves you over by exactly the dividend. A sign flip leaves you off by twice the row.
Check 2: positions
def position_errors(s: Statement, tolerance=D("0.01")) -> list[str]:
errs = []
for p in s.positions:
computed = (p.quantity * p.price).quantize(D("0.01"))
if abs(computed - p.market_value) > tolerance:
errs.append(f"{p.symbol}: {p.quantity} x {p.price} = {computed}, "
f"printed {p.market_value}")
return errs
assert position_errors(oct_2026) == []
25 √ó 118.42 = 2,960.50 and 40 √ó 51.07 = 2,042.80. Both match. The one-cent tolerance exists because some brokers print a rounded price while computing value from an unrounded one. Anything bigger is usually a misread digit in quantity or price.
What these checks don't tell you
They don't give you gains. A sell row is proceeds, and the gain needs cost basis, which lives on the broker's tax documents, not the monthly statement. Don't try to derive it from a year of statements; lots, wash sales and corporate actions will get you.
They also don't catch a row that's present but in the wrong kind. A dividend tagged as interest still proves. For that, compare your dividend and interest totals for the year against the broker's income summary and tax form.
Where this lives in ZeraBooks
ZeraBooks Convert reads brokerage and retirement statements from any broker (PDFs up to 50 MB) into Summary, Holdings and Activity sheets, and checks cash and values against the statement to the cent before you download. It isn't a cost-basis calculator. It's part of the ZeraBooks subscription, $79 a month billed annually or $149 monthly, after a 7-day trial.
If you want the accounting side of this (which statement sections become rows, and how to use twelve months of them at tax time), we wrote that up as a guide to converting a brokerage statement to Excel.
Disclosure: I'm on the ZeraBooks team.
Top comments (0)