DEV Community

Diego
Diego

Posted on

Modeling three markets (BR / US / Crypto) with one rebalancing engine

Part of a series on building Balance, a portfolio rebalancing app, as a solo developer.

Balance started as a Brazilian-only app: B3 tickers, prices in reais, Brazilian income tax. Then came US stocks. Then crypto. The naïve path would have been three apps glued together — three price fetchers, three sets of forms, three rebalancing engines.

Instead, the whole thing pivots on a single field:

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 is the one axis of variation. Everything market-specific branches on it — and crucially, the rebalancing engine itself doesn't. Let me show where the branches live and why the core stays untouched.

Where the code actually differs

Three markets differ in four concrete places:

Concern BR US Crypto
Price source BRAPI → Yahoo (.SA) Yahoo (direct) Binance
Currency symbol R$ $ $
Tax rules IR (20k exemption) Capital Gains (FIFO) IR cripto (35k)
Quantity precision whole shares whole shares 8 decimals

Notice what's not on that list: the rebalancing math, the category/target model, the deposit flow, the snapshots. That's ~90% of the domain, shared verbatim.

Branch 1: price routing

The price service picks a source from the portfolio's market. The trick is keeping the interface identical — every path returns a Decimal | None:

def _fetch_price(self, ticker: str, market: str) -> Decimal | None:
    if market == 'CRYPTO':
        return fetch_crypto_price(ticker)          # Binance, no .SA
    if market == 'US':
        return self._fetch_yahoo(ticker)           # AAPL, not AAPL.SA
    # BR: BRAPI first, Yahoo with .SA as fallback
    return self._fetch_brapi(ticker) or self._fetch_yahoo(f'{ticker}.SA')
Enter fullscreen mode Exit fullscreen mode

Callers never know which source answered. They get a price or a None, and the cache (MarketPrice) sits in front of all three. New market = new branch here, nothing else in the pipeline changes.

Branch 2: market-aware forms

A US ticker is AAPL. A Brazilian one is PETR4. A crypto one is BTC. The form adapts its validation, asset-class choices and currency labels from the portfolio's market:

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 = 'Price ($)'
        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

Same ModelForm, three personalities. The view just passes market=portfolio.market.

Branch 3: precision (the sharp edge)

This is the one that bites. A stock quantity of 2 is fine in decimal_places=2. A crypto quantity of 0.00123456 silently rounds to 0.00. The fix was to widen the precision everywhere money or quantity flows — Asset.quantity, current_price, avg_cost, MarketPrice.price, Transaction fields — all to max_digits=20, decimal_places=8.

But that would over-format stocks ("2.00000000 shares"). So the display branches, while the storage doesn't:

{% if is_crypto %}
  {{ asset.quantity|floatformat:"-8" }}   {# 0.00123456 #}
{% else %}
  {{ asset.quantity|floatformat:"0" }}    {# 2 #}
{% endif %}
Enter fullscreen mode Exit fullscreen mode

floatformat:"-8" strips trailing zeros, so 1.50000000 shows as 1.5. Store wide, render narrow.

Branch 4: the rebalancing engine almost doesn't branch

I said the engine is shared — that's 95% true. The one place it peeks at market is where whole-share assumptions break:

if self.portfolio.market == 'CRYPTO':
    quantity = (budget / price).quantize(Decimal('0.00000001'))  # fraction
else:
    quantity = int(budget / price)                                # whole units
Enter fullscreen mode Exit fullscreen mode

That's it. Two or three of these guards across the whole service. Everything else — gap calculation, budget distribution, leftover spending — is market-agnostic because it works in money, and money is the same shape in every market.

The trade-off I'd flag

The honest downside: if market == 'CRYPTO' sprinkled across the codebase is a smell that's tolerable at three markets and would rot at ten. If a fourth market with genuinely different mechanics showed up (say, options), I'd refactor those branches into a strategy object — market.quantize(budget, price) — rather than keep growing the conditionals.

But premature abstraction has its own cost. At three markets, the branches are few, local, and obvious. Designing a pluggable "market backend" architecture on day one would have been more code to serve a flexibility I didn't yet need.

The principle that held up: model the axis of variation explicitly (market), branch where behavior genuinely differs, and keep the shared core working in the most general currency you have — money.


Balance runs Brazilian, US and crypto portfolios through one rebalancing engine, with a consolidated dashboard that converts everything to a single currency. Link in profile. How do you handle multi-tenant-style variation in your apps — branching or strategy objects? Curious in the comments.

Top comments (0)