Parte de uma série sobre construir o Balance, um app de rebalanceamento de carteira,
como dev solo. O código aqui é simplificado em relação ao service real.
Calcular IR de renda variável no Brasil é o tipo de tarefa que parece simples até você tentar automatizar. Três regras, cada uma com uma pegadinha, e um histórico de transações que precisa ser reconstruído na ordem certa. Foi exatamente isso que virou o IRReportService do Balance.
As regras que confundem todo investidor
| Tipo de ativo | Alíquota | Isenção mensal |
|---|---|---|
| Ações comuns (ACAO) | 15% | Vendas até R$ 20.000/mês |
| FII | 20% | Nenhuma |
| ETF de ações | 15% | Nenhuma |
A pegadinha número um: a isenção de R$ 20 mil só vale para ações. Vendeu R$ 5 mil de FII com lucro? Paga DARF sobre o lucro, do primeiro real. Muita gente acha que está isenta e descobre a multa depois.
Como identificar um FII ou ETF programaticamente? Pela convenção da B3: tickers terminados em 11.
def _is_always_taxed(ticker: str) -> bool:
"""FIIs e ETFs (terminados em '11') são sempre tributados."""
return ticker.strip().upper().endswith('11')
É uma heurística, não uma verdade universal — mas cobre a esmagadora maioria dos casos reais de uma carteira brasileira, e é a mesma regra que o investidor usa de cabeça.
Custo médio: reconstruir, não armazenar
Para saber o lucro de uma venda, você precisa do custo médio de aquisição. E custo médio muda a cada compra. A forma robusta de calcular é não confiar num campo cacheado, e sim reprocessar todas as transações do ticker em ordem cronológica:
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:
# venda reduz a quantidade pelo custo médio atual (não altera o custo médio)
avg = total_cost / qty
total_cost -= t.quantidade * avg
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)
O detalhe que pega: uma venda não muda o custo médio — só reduz a quantidade. Quem implementa "ingênuo" às vezes recalcula o custo médio na venda e erra todo o histórico seguinte.
Outra armadilha sutil: ordenar por ('data', 'created_at'), não só por data. Importações em lote (ex.: extrato da B3) podem inserir uma venda antes da compra do mesmo dia; sem o desempate por created_at, a venda é descartada pelo guard qty > 0 e o cálculo quebra silenciosamente.
Apuração mensal e o pool de prejuízo
Com lucro por venda em mãos, a apuração mensal agrupa tudo e aplica isenção, alíquota e compensação de prejuízo:
def monthly_summary(self, year: int) -> list[dict]:
months = []
loss_pool_acao = Decimal('0') # prejuízo de ações
loss_pool_fii_etf = Decimal('0') # prejuízo de FII/ETF (pool separado!)
for month in range(1, 13):
sales = self._sales_in(year, month)
# Ações: isentas se o volume vendido no mês <= R$ 20.000
acao_sales = [s for s in sales if not _is_always_taxed(s.ticker)]
acao_volume = sum(s.proceeds for s in acao_sales)
acao_gain = sum(s.gain_loss for s in acao_sales)
if acao_volume <= Decimal('20000'):
acao_taxable = Decimal('0') # isento
else:
acao_taxable, loss_pool_acao = self._apply_loss(acao_gain, loss_pool_acao)
# FII/ETF: sem isenção, pool de prejuízo PRÓPRIO
fii_gain = sum(s.gain_loss for s in sales if _is_always_taxed(s.ticker))
fii_taxable, loss_pool_fii_etf = self._apply_loss(fii_gain, loss_pool_fii_etf)
darf = (acao_taxable * Decimal('0.15') + fii_taxable * Decimal('0.20'))
months.append({'month': month, 'darf': darf.quantize(Decimal('0.01')), ...})
return months
A regra mais esquecida está aqui: prejuízo de ações não compensa lucro de FII, e vice-versa. São dois pools separados que carregam de um mês para o outro (carryforward) de forma independente. No Balance isso é configurável por carteira via IRSettings, para o usuário registrar prejuízos acumulados de anos anteriores.
O relatório anual: Bens & Direitos
Além da apuração mensal, a declaração anual exige a posição em 31/12 pelo custo de aquisição (não pelo valor de mercado) — a famosa ficha de Bens e Direitos:
def annual_report(self, year: int) -> dict:
return {
'monthly': self.monthly_summary(year),
'bens_e_direitos': self._position_at_year_end(year), # custo, não mercado
'proventos_isentos': self._exempt_dividends(year),
'renda_fixa_isenta': self._exempt_fixed_income(year), # LCI, LCA, CRI, CRA, LIG
'total_darf': ...,
}
E o relatório vira um XLSX com três abas formatadas, pronto para consultar na hora de declarar.
O que aprendi traduzindo lei em código
Regra fiscal é cheia de "exceto se". A tentação é tratar cada exceção como um if solto; o que funcionou foi modelar os conceitos (pool de prejuízo, ativo sempre tributado, custo médio reconstruído) e deixar as regras emergirem deles.
E uma ressalva honesta que vai em todo relatório do app: isso não substitui um contador. Automatiza a parte mecânica e propensa a erro — somar, separar ações de FII, lembrar do limite de R$ 20k — mas a responsabilidade da declaração continua sendo do investidor.
O Balance calcula isso automaticamente a partir do seu histórico de compras e vendas — apuração mensal, DARF estimado, Bens & Direitos e export em XLSX. Link no perfil. Já caiu na pegadinha do "11"? Conta nos comentários.
Top comments (0)