Skip to main content

Asset types

TypeExamplesPrice sourceNotes
StockAAPL, MSFT, RELIANCE.NSYahoo Finance (yfinance)Suffix determines exchange
CryptoBTC, ETH, SOLCoinGecko APIAlways priced in USD
ETFSPY, QQQ, CSPX.L, ES3.SIYahoo Finance
REITCICT.SI, PLDYahoo Finance
CommodityGLD, SLVYahoo FinanceTraded as ETFs, not spot
BondManual (avg price)No live pricing
Fixed DepositCalculatedInterest accrual formula
Real EstateManualNo live pricing

Multi-currency architecture

Every holding has a currency field (the currency it’s traded in). The app displays everything in your chosen display currency (SGD, INR, or USD) using live exchange rates.

The effectiveCurrency pattern

Crypto is the edge case: CoinGecko always returns prices in USD regardless of what’s in the currency field. So the provider overrides it:
final effectiveCurrency = h.holding.assetType == AssetType.crypto
    ? 'USD'
    : h.holding.currency;

// Build the forex pair key for lookup
final rateKey = '$effectiveCurrency$displayCurrency'; // e.g. "USDSGD"
final fxRate = fxRates[rateKey] ?? 1.0;
final convertedValue = marketValue * fxRate;
If displayCurrency == effectiveCurrency, the rate key resolves to e.g. "SGDSGD" which returns 1.0 — no conversion needed.

Shared forex rates provider

All portfolio providers share a single forex fetch per render cycle:
final portfolioFxRatesProvider = FutureProvider<Map<String, double>>((ref) async {
    final holdings = await ref.watch(holdingsProvider.future);
    final displayCurrency = ref.watch(currencyProvider);

    // Collect unique foreign currencies in this portfolio
    final needed = <String>{};
    for (final h in holdings) {
        final eff = h.assetType == AssetType.crypto ? 'USD' : h.currency;
        if (eff != displayCurrency) needed.add('$eff$displayCurrency');
    }
    // Always include USD if portfolio has crypto and display != USD
    if (holdings.any((h) => h.assetType == AssetType.crypto) && displayCurrency != 'USD') {
        needed.add('USD$displayCurrency');
    }

    return marketService.getForexRates(needed.toList());
});
This results in exactly one forex HTTP call per portfolio screen load, regardless of how many different currencies are in the portfolio.

Format helpers

// Use formatConverted when the value needs FX conversion
// (individual holding tiles, cost basis per holding)
formatConverted(value, displayCurrency, rates, baseCurrency: effectiveCurrency)
// → applies fxRates['USDSGD'] conversion

// Use format when value is already in displayCurrency
// (hero total, pre-summed market value)
format(value, displayCurrency)
// → no conversion, just number formatting
Mixing these up is the most common bug in portfolio display code — hero totals that run their own FX-summed loop use format(), per-holding tiles use formatConverted().

Live price fetching

Caching and deduplication

The MarketDataService has two layers of deduplication: 1. TTL cache (60 seconds)
final _stockCache = <String, _CacheEntry<Map<String, StockPriceData>>>{};
// Cache key: sorted symbols joined with commas → "AAPL,MSFT,TSLA"
2. In-flight Completer deduplication
final _inflightStock = <String, Completer<Map<String, StockPriceData>>>{};
// If holdingsProvider and heatmapProvider both fire for same symbols at the same time,
// the second call gets the first call's future — zero duplicate HTTP requests.

Batch endpoint

The /market/batch endpoint combines stocks + crypto + forex into a single call:
Future<BatchMarketData> getBatchData({
    List<String> stockSymbols = const [],
    List<String> cryptoIds = const [],
    List<String> forexPairs = const [],
})
Used by holdingsWithPricesProvider to hydrate the full portfolio in one round trip.

Asset-specific price logic

Fixed Deposits — accrual formula:
double _calculateFDCurrentValue(Holding h) {
    // Rate parsed from notes field via regex: r'\d+\.?\d*%'
    // e.g. notes = "3.5% p.a." → rate = 3.5
    final rateMatch = RegExp(r'(\d+\.?\d*)%').firstMatch(h.notes ?? '');
    final rate = double.tryParse(rateMatch?.group(1) ?? '') ?? 0.0;
    final years = DateTime.now().difference(h.purchaseDate!).inDays / 365.0;
    return h.quantity * h.avgPrice * (1 + rate / 100 * years);
}
Metals — ETF proxies: GLD, SLV, PDBC are fetched as normal yfinance tickers (they’re ETFs, not spot commodity prices). getMetalPrice() maps semantic names:
// gold → XAU=F (futures), silver → SI=F, etc.
// Used only if user enters the metal name directly rather than the ETF ticker
Crypto — CoinGecko ID normalisation: CoinGecko IDs are lowercase (bitcoin, ethereum, solana). The holding’s symbol field is stored in uppercase by convention. The service normalises:
final coinId = h.symbol.toLowerCase();

Portfolio calculations

Cost basis

costBasis = Σ (quantity × avgPrice × fxRate)
avgPrice is stored in the holding’s original currency. FX conversion applied at display time.

Market value

marketValue = Σ (quantity × currentPrice × fxRate)
currentPrice comes from the live price fetch. Falls back to avgPrice on fetch failure.

Return

totalReturn% = ((marketValue - costBasis) / costBasis) × 100

Unrealised P&L per holding

pnl = quantity × (currentPrice - avgPrice)
pnl% = ((currentPrice - avgPrice) / avgPrice) × 100

Historical charts

The asset detail screen fetches OHLCV history for period selector tabs (1W / 1M / 3M / 6M / 1Y / ALL):
// assetType differentiates the backend endpoint called
ref.watch(assetHistoryProvider((symbol: 'AAPL', period: '1mo', assetType: 'stock')))
Backend maps period strings to yfinance parameters:
Period labelyfinance periodyfinance interval
1W5d1h
1M1mo1d
3M3mo1d
6M6mo1d
1Y1y1wk
ALLmax1mo
Crypto history uses CoinGecko’s /coins/{id}/market_chart endpoint with equivalent day ranges.

Portfolio sparkline (14-day)

The dashboard hero sparkline aggregates the last 14 days of portfolio value:
final portfolioSparklineProvider = FutureProvider<List<double>>((ref) async {
    // Splits holdings into two groups:
    // market: stocks/crypto that have yfinance/CoinGecko history
    // non-market: FD/bonds/real estate (use avgPrice as flat line)
    //
    // For market holdings: fetch 14d history per symbol, multiply by quantity, sum daily
    // For non-market: add flat contribution to every day
    // Returns 14 data points
});

Heatmap

The heatmap widget sizes each tile by portfolio allocation weight and colours by day change %:
Day changeColour
> +2%#10B981 bright
+0.5% to +2%#10B981 muted
-0.5% to +0.5%#F59E0B amber
-0.5% to -2%#EF4444 muted
< -2%#EF4444 bright
Tile size is proportional to currentValue / totalPortfolioValue.