Parte de uma série sobre construir o Balance, um app de rebalanceamento de carteira
para investidores BR/US/Cripto, como dev solo. O código aqui é simplificado em relação ao real.
Todo investidor que faz aportes recorrentes esbarra na mesma parede:
"Tenho R$ 1.000 pra investir esse mês. O que eu compro pra minha carteira ficar perto da meta?"
A resposta de manual — "venda o que está acima, compre o que está abaixo" — tem custo: IR, corretagem e a disciplina emocional de vender o que está subindo. Existe um caminho mais gentil, que quase ninguém formaliza: rebalancear usando só o novo aporte.
É esse o algoritmo no coração do Balance. Vamos construir.
A ideia em um exemplo
Digamos que sua meta seja:
| Categoria | Meta |
|---|---|
| Ações | 40% |
| Renda fixa | 25% |
| FIIs | 20% |
| Internacional | 15% |
Esse mês, depois de algumas oscilações de preço, você está em 46% de ações e 16% de FIIs. Ações acima, FIIs abaixo.
Em vez de vender ações (e gerar um fato gerador de IR), você direciona o aporte do mês inteiro para as categorias abaixo da meta. A carteira converge para o alvo sem uma única venda.
O algoritmo
A coisa toda é: descobrir o quanto cada categoria está longe de onde deveria estar depois do aporte, e distribuir o aporte proporcionalmente a esses gaps.
from collections import defaultdict
from decimal import Decimal
def calculate(self, deposit_amount: Decimal) -> dict:
deposit = Decimal(deposit_amount).quantize(Decimal('0.01'))
investments = self._category_investments() # {category_id: [(kind, item), ...]}
current_total = self.portfolio.total_value
new_total = current_total + deposit
# 1. Valor atual em cada categoria
cat_value = defaultdict(lambda: Decimal('0'))
for cat_id, rows in investments.items():
for kind, item in rows:
cat_value[cat_id] += item.current_value
# 2. Gap = o quanto cada categoria está ABAIXO da meta pós-aporte
gaps = {}
for cat_id, rows in investments.items():
category = rows[0][1].category
target_value = new_total * category.target_percentage / Decimal('100')
gaps[cat_id] = max(Decimal('0'), target_value - cat_value[cat_id])
total_gap = sum(gaps.values())
# 3. Divide o aporte entre as categorias, proporcional a cada gap
cat_budget = {
cat_id: (deposit * gaps[cat_id] / total_gap).quantize(Decimal('0.01'))
for cat_id in investments
}
...
Três passos:
- Valor por categoria — soma o que você tem hoje em cada uma.
-
Gap por categoria —
max(0, meta − atual). Categorias já na meta ou acima têm gap zero; não recebem nada. - Orçamento por categoria — distribui o aporte proporcionalmente aos gaps.
O max(Decimal('0'), ...) é o pulo do gato: categorias acima da meta simplesmente não competem pelo aporte. O dinheiro flui para onde falta.
Caso de borda: se todas as categorias já estão na meta ou acima (
total_gap == 0), não há déficit a preencher. Caímos num fallback que divide o aporte por peso de meta, mantendo a alocação atual em vez de dividir por zero.
Do orçamento para as ordens
Orçamento por categoria ainda não é uma ordem — você não compra 3,7 ações. Então o orçamento de cada categoria é dividido entre os ativos e vira quantidade inteira:
def _buy_suggestion(self, kind, item, budget: Decimal):
if budget <= 0:
return None
if self.portfolio.market == 'CRYPTO':
quantity = (budget / item.current_price).quantize(Decimal('0.00000001'))
else:
quantity = int(budget / item.current_price) # só ações inteiras
if quantity <= 0:
return None
cost = (quantity * item.current_price).quantize(Decimal('0.01'))
return self._asset_suggestion_row(item, quantity, cost)
Repare no branch por mercado: ações compram unidades inteiras (int()), cripto compra frações de até 8 casas decimais. Esse único if é o que permite o mesmo motor servir uma carteira de ações e uma de Bitcoin. (Tem um artigo só sobre essa decisão mais pra frente na série.)
O problema do troco
Arredondar para ações inteiras deixa dinheiro na mesa. Compre int(500 / 140) = 3 ações a R$140 e você gastou R$420 — sobram R$80 do aporte sem alocação.
Por isso há uma segunda passada que gasta o restante, de forma gananciosa, na categoria que ainda tem o maior déficit:
while remaining > 0:
# pega a categoria mais abaixo da meta que ainda dá pra comprar
best_item = max(
affordable_items,
key=lambda it: target(it) - simulated_value[it.category_id],
default=None,
)
if best_item is None:
break
# compra mais uma ação (ou, pra cripto, gasta todo o resto como fração)
...
Para ações isso compra uma ação extra por vez até não sobrar nada comprável. Para cripto, joga o restante no ativo mais abaixo da meta como fração. De qualquer jeito, o aporte é totalmente deployado em vez de deixar caixa parado.
Opcional: rebalancear com vendas (driblando o IR)
Às vezes o aporte não basta — uma categoria está tão acima que nenhum aporte realista resolve. Para esses casos, o serviço pode sugerir vendas também, com uma sacada específica da lei brasileira:
if avoid_ir_sells and _is_always_taxed(ticker):
continue # nunca sugere vender um ativo sempre tributado (ETF/FII)
No Brasil, vendas de ações até R$20k/mês são isentas, mas ETFs e FIIs (tickers terminados em 11) são sempre tributados. A flag avoid_ir_sells manda o motor rebalancear vendendo só o que é isento, deixando os ativos tributáveis intocados. O mesmo cálculo, agora consciente do IR.
Por que isso virou o produto
A matemática aqui não é sofisticada — é distribuição proporcional com alguns guardas. Mas embrulhada numa UI que busca cotação ao vivo, sabe suas metas e respeita as regras de IR, ela responde uma pergunta que investidores reais fazem todo mês e hoje resolvem com planilha e chute.
É a lição que eu fico reaprendendo construindo o Balance: a parte valiosa raramente é o algoritmo. É remover o atrito em volta dele.
O Balance é uma ferramenta de rebalanceamento para investidores BR/US/Cripto — diz o que comprar com o próximo aporte pra ficar na meta. Se você faz aportes recorrentes e quer testar, o link está no perfil. Dúvidas sobre a abordagem? Comenta aí.
Top comments (0)