MarketMind: The Self-Hosted Portfolio Tracker Built to Get Dividends and FX Tax Right

9 min read
MarketMind: The Self-Hosted Portfolio Tracker Built to Get Dividends and FX Tax Right

The Problem With Spreadsheet Portfolios

If you invest across more than one broker, one currency, and more than a handful of tickers, you already know the spreadsheet stops working. Dividend reinvestment yield calculations quietly assume every stock pays quarterly. Currency conversions get mixed into the same P&L column as the actual stock gain, so a strong euro can make a winning trade look like a loss. And when tax season arrives, “how much did I actually gain, in my own currency, separate from what the exchange rate did to me” turns into an afternoon of manual reconciliation.

MarketMind is a self-hosted Django application built to answer those questions properly: portfolio tracking, dividend automation, and tax-ready gain/loss reports with real FIFO lot matching and full multi-currency support — not approximations.


What MarketMind Tracks

Everything lives inside per-user portfolios, each holding a full transaction book: buys, sells, dividends, interest, spin-offs, FX exchanges, deposits, and withdrawals. Positions, valuations, and dividend income all roll up from that ledger — nothing is entered twice.

Feature overview: portfolio tracking, dividend automation, FIFO tax reports, and multi-currency FX
FeatureWhat it does
Portfolio managementMultiple portfolios, live valuations, broker-level breakdown
Transaction bookBUY, SELL, DIV, INT, SPOF, EXC, deposits & withdrawals
Live price feedyfinance, market-aware caching, shared across every view
Dividend automationAuto-synced ledger entries, frequency-aware Buy Yield
Tax reportingPer-year FIFO reports, stock and FX gains kept separate
Withholding taxPer-user, per-country, entity-aware (MLP/REIT) estimates
Multi-currency FXReal + virtual currency lots, FIFO-consumed for tax
REST APIFull JSON API with JWT auth

The three sections below are the parts worth explaining in detail — not because they’re big, but because each one is solving a genuinely easy-to-get-wrong problem.


Buy Yield: The Dividend Yield You Actually Locked In

Every BUY transaction gets a buy_yield — the forward dividend yield that purchase locked in:

buy_yield=annualised dividend per shareeffective cost per share\text{buy\_yield} = \frac{\text{annualised dividend per share}}{\text{effective cost per share}}

The effective cost includes commission; the interesting part is the numerator, which depends on two things most tools get wrong: how often the stock actually pays, and which dividend to annualise from.

Payment frequency isn’t always 4. infer_dividend_frequency takes the median gap between a stock’s actual ex-dividend dates over the trailing ~5 years and buckets it, rather than assuming quarterly:

Median gapInferred frequency
≤ 45 days12 / year
≤ 135 days4 / year
≤ 275 days2 / year
otherwise1 / year

Using the median — not the average or a raw payment count — matters: BHP is a genuine semi-annual payer, but a couple of its recent years threw in a third special payment. Before this fix, MarketMind assumed quarterly for every stock and computed a nonsensical 24.18% Buy Yield on a BHP purchase. The median of [2,2,2,2,3,2,2,2] is still 2, so infer_dividend_frequency correctly resolves it to a semi-annual payer regardless of the outlier years.

Which dividend counts as the reference matters just as much. The naive rule — “last dividend with an ex-date before the buy” — under-counts recent buys: a purchase made the day before a newly-raised dividend goes ex-date is entitled to the new rate, not the stale one the ex-date rule would pick. MarketMind instead prefers the dividend with the latest declaration_date <= buy_date, since a publicly declared rate is locked in the moment it’s declared — regardless of whether it’s gone ex yet — and only falls back to the ex-date rule when no declaration data exists at all.

That reference is then sanity-checked against the median of the trailing 2 × frequency actual payments. If it’s more than 1.75× that median, it’s treated as a special-dividend outlier and the median is substituted instead — a real dividend raise never jumps that much in one step. For BHP, the naive reference dividend was a special-dividend-inflated $3.5682 payment (~2.8× the stock’s own recent median); swapping in the median drops the computed yield from 24.18% down to a sane 4.32%.

ETFs get a different treatment entirely: Stock.is_etf (auto-detected from yfinance) switches to summing the last frequency distributions rather than extrapolating from any single one, since ETF payouts vary shipment-to-shipment with no board-declared rate to lock in.


Two Ledgers, One Tax Report

Most tax authorities want stock gains and currency gains reported separately — and mixing them, as a plain spreadsheet does, produces a P&L that’s technically wrong even if the total happens to net out correctly. MarketMind keeps them apart by running two independent FIFO systems side by side.

How MarketMind splits every transaction into a stock P&L stream and an FX P&L stream, each matched via its own FIFO ledger

Stock P&L is the familiar one: for a SELL, walk the portfolio’s BUY/SPOF history for that symbol oldest-first, consume shares FIFO, and the cost basis includes commission amortised per share. This produces the gain or loss in the stock’s own currency, which is then converted to the portfolio’s native currency using the FX rate on the sell date.

FX P&L is the part most trackers skip entirely. Every foreign-currency event creates an FXLot in the currency book:

EventLot type
EXC (currency exchange)REAL — actual foreign currency purchased
SELL with profit vs. FIFO costVIRTUAL_SELL — proceeds “received” in stock currency
SELL with loss vs. FIFO costconsumes existing lots instead of creating one
DIV / INTVIRTUAL_DIV / VIRTUAL_INT — income received in its own currency

When foreign currency “leaves” the book — spending it, or a SELL landing below cost basis — it’s matched against the oldest open lots first, exactly like the stock-side FIFO. Each match creates an FXLotConsumption record: the amount consumed, the FX rate at the original lot’s creation versus the rate now, and the resulting gain or loss in native currency. A euro-denominated investor who bought USD at 1.05, held it through a stock sale, and converted back at 1.12 has a real, separately-reportable FX gain — captured automatically, with a full audit trail of exactly which lot funded which transaction.

The result: TaxReportService.calculate returns a stock_total and an fx_total as genuinely independent numbers for a given year, not a single blended figure that happens to add up.


Withholding Tax, Per User, Per Entity Type

Dividend withholding is personal — two people holding the same stock can owe completely different origin-country rates depending on residency, treaty status, and paperwork on file. get_withholding_tax_rate(user, stock) resolves it through three layers, most specific first: a rule for that exact stock, then a rule for the stock’s country + entity type, then a country + REGULAR fallback. Nothing matches → the rate is None and tax stays 0 rather than guessing.

The entity-type layer exists because not all dividends are alike. Stock.entity_type is auto-classified once, the first time a stock is seen (never touched again, so a manual correction sticks): a name containing “L.P.” is an MLP (effectively-connected income, not treaty-reducible — a much higher US withholding rate than a regular dividend), a “Real Estate” sector is a REIT (genuinely treaty/case-specific), everything else is REGULAR. A confirmed test case makes the difference concrete: a 20grossEPD(MLP)dividendwithholds20 gross EPD (MLP) dividend withholds **7.40** at 37%, while a 5grossMSFTdividendwithholds5 gross MSFT dividend withholds **0.75** at 15% — same mechanism, very different outcomes depending purely on entity type.

The whole lookup also runs client-side in the transaction form: a small JSON blob of every tracked stock’s {country, entity_type} plus the user’s own rules is embedded in the page, so the “Tax Withheld” field auto-fills as you type — no round trip, and still just an editable preview for the odd case (a partially-franked dividend) that needs a manual override.


Dividend Data That Heals Itself

Dividend history comes from two routed sources — FMP for US-listed stocks, Alpha Vantage for everything else — falling back to yfinance when both are unavailable. Both give full history including declaration_date in a single request per symbol, which is what Buy Yield’s declaration-date preference depends on. But FMP premium-gates a rotating, unpredictable subset of well-known US tickers (CAT, HON, MO, WDS — no way to know which in advance short of calling live), and Alpha Vantage’s free tier caps out at 25 requests/day.

Rather than leaving gaps, a daily cron (backfill_dividend_declaration_dates, run right after the nightly DB backup) closes them automatically: it retries any FMP failure through Alpha Vantage before giving up, processes non-US stocks first so that fallback spending never starves the symbols that genuinely depend on Alpha Vantage, and tracks a declaration_date_checked flag per dividend so a source that genuinely has nothing doesn’t get re-queried — and burn quota — every single day forever. recompute_buy_yields then reruns behind it, so any yield computed while data was still incomplete gets silently corrected within a day or two, with no manual intervention.


Live Prices Without Hammering Yahoo Finance

PriceCacheService fronts every price lookup with a market-aware TTL: a 15-minute cache during NYSE regular hours (matching yfinance’s own 15-minute delay), stretching to 2 hours once the market’s closed. Every page shares the same per-symbol cache key, so two views can never disagree on a price, and the day’s OHLC is lazily persisted to HistoricalPrice in a background thread after close — turning the live feed into next season’s historical data with no separate ETL step.


Under the Hood

LayerTechnology
BackendDjango 5.0.1, Django REST Framework
DatabasePostgreSQL 15
Market datayfinance, FMP + Alpha Vantage (dividend history)
FX ratesSelf-hosted Frankfurter v2 instance, reached over Tailscale
Authenticationdjango-allauth (Google OAuth) + djangorestframework-simplejwt
FrontendHTML5, CSS3, vanilla JavaScript
Static filesWhiteNoise
ContainerisationDocker + Docker Compose, image published to GHCR

Production deployment sits behind a Cloudflare Tunnel with no extra infrastructure — settings.py already trusts X-Forwarded-Proto/Host, and the only required step is pointing CSRF_TRUSTED_ORIGINS at the tunnel domain (skip it and every POST comes back a 403, Django’s CSRF layer rejecting an origin it doesn’t recognise). FX rates come from a self-hosted instance of the author’s own Frankfurter v2 fork — one project’s infrastructure quietly powering another’s.


Try It

The live instance is available at market-mind.box2overtake.com. The source is on GitHub.

Your portfolio already has all this data in it. MarketMind is just the part that does the arithmetic properly.

Suggest an edit

Last modified: 22 Aug 2026