DEV Community

Diego
Diego

Posted on

Imposto de renda sobre ações como um service em Django

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')
Enter fullscreen mode Exit fullscreen mode

É 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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': ...,
    }
Enter fullscreen mode Exit fullscreen mode

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)