DEV Community

Diego
Diego

Posted on

Modelando três mercados (BR / US / Cripto) com um só motor de rebalanceamento

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

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

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

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

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

É 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)