Part of a series on building Balance, a portfolio rebalancing app, as a solo developer.
Code here is simplified from the real service.
Computing Brazilian stock-investment tax looks simple until you try to automate it. Three rules, each with a gotcha, and a transaction history that has to be replayed in exactly the right order. That's what became Balance's IRReportService.
(Brazil-specific, but the patterns — derived-not-stored cost basis, separate loss pools, modeling concepts instead of ifs — transfer to any tax engine.)
The rules that trip everyone up
| Asset type | Rate | Monthly exemption |
|---|---|---|
| Common stocks | 15% | Sales up to R$ 20,000/month |
| REITs (FIIs) | 20% | None |
| Stock ETFs | 15% | None |
Gotcha number one: the R$20k exemption applies to stocks only. Sold R$5k of a REIT at a profit? You owe tax from the first cent. Plenty of people think they're exempt and find the penalty later.
How do you identify a REIT or ETF programmatically? By B3's convention: tickers ending in 11.
def _is_always_taxed(ticker: str) -> bool:
"""REITs and ETFs (ending in '11') are always taxed."""
return ticker.strip().upper().endswith('11')
It's a heuristic, not a universal truth — but it covers the overwhelming majority of real Brazilian-portfolio cases, and it's the same rule the investor uses in their head.
Cost basis: reconstruct, don't store
To know a sale's profit, you need the average acquisition cost. And average cost changes on every buy. The robust way to compute it is to not trust a cached field, but replay all the ticker's transactions in chronological order:
def recalculate_avg_cost(portfolio, ticker: str):
txns = (Transaction.objects
.filter(portfolio=portfolio, ticker=ticker)
.order_by('data', 'created_at'))
qty = Decimal('0')
total_cost = Decimal('0')
for t in txns:
if t.tipo == 'COMPRA':
total_cost += t.quantidade * t.preco_unitario + t.custo_operacional
qty += t.quantidade
elif t.tipo == 'VENDA' and qty > 0:
avg = total_cost / qty # a sale uses the current average...
total_cost -= t.quantidade * avg # ...and does NOT change it
qty -= t.quantidade
avg_cost = (total_cost / qty) if qty > 0 else Decimal('0')
Asset.objects.filter(portfolio=portfolio, ticker=ticker).update(avg_cost=avg_cost)
The trap: a sale doesn't change the average cost — it only reduces quantity. Naïve implementations sometimes recompute the average on a sale and corrupt the whole subsequent history.
A subtler trap: sort by ('data', 'created_at'), not just date. Bulk imports (e.g. a B3 statement) can insert a sale before a same-day buy; without the created_at tiebreaker the sale gets dropped by the qty > 0 guard and the calculation breaks silently.
Monthly assessment and the loss pool
With per-sale profit in hand, the monthly assessment groups everything and applies exemption, rate and loss offset:
def monthly_summary(self, year: int) -> list[dict]:
months = []
loss_pool_stock = Decimal('0') # stock losses
loss_pool_reit = Decimal('0') # REIT/ETF losses (separate pool!)
for month in range(1, 13):
sales = self._sales_in(year, month)
# Stocks: exempt if monthly sale volume <= R$ 20,000
stock_sales = [s for s in sales if not _is_always_taxed(s.ticker)]
stock_volume = sum(s.proceeds for s in stock_sales)
stock_gain = sum(s.gain_loss for s in stock_sales)
if stock_volume <= Decimal('20000'):
stock_taxable = Decimal('0') # exempt
else:
stock_taxable, loss_pool_stock = self._apply_loss(stock_gain, loss_pool_stock)
# REIT/ETF: no exemption, its OWN loss pool
reit_gain = sum(s.gain_loss for s in sales if _is_always_taxed(s.ticker))
reit_taxable, loss_pool_reit = self._apply_loss(reit_gain, loss_pool_reit)
darf = (stock_taxable * Decimal('0.15') + reit_taxable * Decimal('0.20'))
months.append({'month': month, 'darf': darf.quantize(Decimal('0.01')), ...})
return months
The most-forgotten rule lives here: a stock loss can't offset a REIT gain, and vice versa. They're two separate pools that carry forward independently month to month. In Balance this is configurable per portfolio via IRSettings, so the user can register accumulated losses from prior years.
The annual report: Assets & Rights
Beyond the monthly assessment, the annual return requires the position on Dec 31 at acquisition cost (not market value) — the famous "Bens e Direitos" form:
def annual_report(self, year: int) -> dict:
return {
'monthly': self.monthly_summary(year),
'bens_e_direitos': self._position_at_year_end(year), # cost, not market
'proventos_isentos': self._exempt_dividends(year),
'renda_fixa_isenta': self._exempt_fixed_income(year), # LCI, LCA, CRI, CRA, LIG
'total_darf': ...,
}
And it exports to a three-tab formatted XLSX, ready to consult at filing time.
What I learned turning law into code
Tax law is full of "except if." The temptation is to treat each exception as a loose if; what worked was modeling the concepts (loss pool, always-taxed asset, reconstructed average cost) and letting the rules emerge from them.
And an honest disclaimer that ships on every report: this does not replace an accountant. It automates the mechanical, error-prone part — summing, separating stocks from REITs, remembering the R$20k limit — but the filing responsibility stays with the investor.
Balance computes this automatically from your buy/sell history — monthly assessment, estimated tax due, year-end position, XLSX export. Link in profile. Ever fallen for the "11" gotcha? Tell me in the comments.
Top comments (0)