Parte de uma série sobre construir o Balance, um app de rebalanceamento de carteira, como dev solo.
O Balance começou como um app só do Brasil: tickers da B3, preços em reais, IR brasileiro. Depois vieram ações dos EUA. Depois cripto. O caminho ingênuo teria sido três apps colados — três buscadores de preço, três conjuntos de formulários, três motores de rebalanceamento.
Em vez disso, tudo gira em torno de um único campo:
class Portfolio(BaseModel):
market = models.CharField(
max_length=6,
choices=[('BR', 'Brasil'), ('US', 'Estados Unidos'), ('CRYPTO', 'Cripto')],
default='BR',
)
market é o único eixo de variação. Tudo que é específico de mercado ramifica nele — e, crucialmente, o motor de rebalanceamento em si não ramifica. Deixa eu mostrar onde os branches vivem e por que o núcleo fica intocado.
Onde o código realmente difere
Três mercados diferem em quatro pontos concretos:
| Aspecto | BR | US | Cripto |
|---|---|---|---|
| Fonte de preço | BRAPI → Yahoo (.SA) |
Yahoo (direto) | Binance |
| Símbolo de moeda | R$ | $ | $ |
| Regras de IR | IR (isenção 20k) | Capital Gains (FIFO) | IR cripto (35k) |
| Precisão de quantidade | ações inteiras | ações inteiras | 8 casas |
Repare no que não está na lista: a matemática do rebalanceamento, o modelo de categoria/meta, o fluxo de aporte, os snapshots. Isso é ~90% do domínio, compartilhado idêntico.
Branch 1: roteamento de preço
O serviço de preço escolhe a fonte pelo mercado da carteira. O truque é manter a interface idêntica — todo caminho retorna Decimal | None:
def _fetch_price(self, ticker: str, market: str) -> Decimal | None:
if market == 'CRYPTO':
return fetch_crypto_price(ticker) # Binance, sem .SA
if market == 'US':
return self._fetch_yahoo(ticker) # AAPL, não AAPL.SA
# BR: BRAPI primeiro, Yahoo com .SA como fallback
return self._fetch_brapi(ticker) or self._fetch_yahoo(f'{ticker}.SA')
Os callers nunca sabem qual fonte respondeu. Eles recebem um preço ou um None, e o cache (MarketPrice) fica na frente das três. Novo mercado = novo branch aqui, nada mais no pipeline muda.
Branch 2: formulários conscientes do mercado
Um ticker dos EUA é AAPL. Um brasileiro é PETR4. Um de cripto é BTC. O formulário adapta validação, choices de classe e labels de moeda a partir do mercado da carteira:
class AssetForm(forms.ModelForm):
def __init__(self, *args, market='BR', **kwargs):
super().__init__(*args, **kwargs)
if market == 'US':
self._ticker_re = r'^[A-Z]{1,6}$'
self.fields['asset_class'].choices = STOCK_ETF_ONLY
self.fields['current_price'].label = 'Preço ($)'
elif market == 'CRYPTO':
self._ticker_re = r'^[A-Z]{2,10}$'
self.fields['quantity'].widget.attrs['step'] = '0.00000001'
else: # BR
self._ticker_re = r'^[A-Z]{3,6}\d{0,2}$'
O mesmo ModelForm, três personalidades. A view só passa market=portfolio.market.
Branch 3: precisão (a aresta afiada)
Esse é o que morde. Uma quantidade de ações 2 cabe em decimal_places=2. Uma quantidade de cripto 0.00123456 arredonda silenciosamente pra 0.00. A correção foi alargar a precisão em todo lugar onde dinheiro ou quantidade passa — Asset.quantity, current_price, avg_cost, MarketPrice.price, campos de Transaction — tudo para max_digits=20, decimal_places=8.
Mas isso formataria ações demais ("2.00000000 ações"). Então o display ramifica, enquanto o armazenamento não:
{% if is_crypto %}
{{ asset.quantity|floatformat:"-8" }} {# 0.00123456 #}
{% else %}
{{ asset.quantity|floatformat:"0" }} {# 2 #}
{% endif %}
floatformat:"-8" remove zeros à direita, então 1.50000000 vira 1.5. Guarda largo, mostra estreito.
Branch 4: o motor de rebalanceamento quase não ramifica
Eu disse que o motor é compartilhado — isso é 95% verdade. O único ponto em que ele olha o market é onde a suposição de "ações inteiras" quebra:
if self.portfolio.market == 'CRYPTO':
quantity = (budget / price).quantize(Decimal('0.00000001')) # fração
else:
quantity = int(budget / price) # unidades inteiras
É isso. Dois ou três desses guardas no serviço inteiro. Todo o resto — cálculo de gap, distribuição de orçamento, gasto do troco — é agnóstico ao mercado porque trabalha em dinheiro, e dinheiro tem a mesma forma em todo mercado.
O trade-off que eu sinalizo
A desvantagem honesta: if market == 'CRYPTO' espalhado pelo código é um cheiro tolerável com três mercados e que apodreceria com dez. Se um quarto mercado com mecânica genuinamente diferente aparecesse (digamos, opções), eu refatoraria esses branches num objeto de estratégia — market.quantize(budget, price) — em vez de seguir engordando os condicionais.
Mas abstração prematura tem seu próprio custo. Com três mercados, os branches são poucos, locais e óbvios. Projetar uma arquitetura plugável de "backend de mercado" no dia 1 seria mais código pra servir uma flexibilidade que eu ainda não precisava.
O princípio que se sustentou: modele o eixo de variação explicitamente (market), ramifique onde o comportamento genuinamente difere, e mantenha o núcleo compartilhado trabalhando na moeda mais geral que você tem — dinheiro.
O Balance roda carteiras brasileiras, americanas e cripto por um só motor de rebalanceamento, com dashboard consolidado que converte tudo numa moeda. Link no perfil. Como você lida com variação tipo multi-tenant nos seus apps — branch ou objeto de estratégia? Curioso nos comentários.
Top comments (0)