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 | What it does |
|---|---|
| Portfolio management | Multiple portfolios, live valuations, broker-level breakdown |
| Transaction book | BUY, SELL, DIV, INT, SPOF, EXC, deposits & withdrawals |
| Live price feed | yfinance, market-aware caching, shared across every view |
| Dividend automation | Auto-synced ledger entries, frequency-aware Buy Yield |
| Tax reporting | Per-year FIFO reports, stock and FX gains kept separate |
| Withholding tax | Per-user, per-country, entity-aware (MLP/REIT) estimates |
| Multi-currency FX | Real + virtual currency lots, FIFO-consumed for tax |
| REST API | Full 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:
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 gap | Inferred frequency |
|---|---|
| ≤ 45 days | 12 / year |
| ≤ 135 days | 4 / year |
| ≤ 275 days | 2 / year |
| otherwise | 1 / 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.
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:
| Event | Lot type |
|---|---|
EXC (currency exchange) | REAL — actual foreign currency purchased |
SELL with profit vs. FIFO cost | VIRTUAL_SELL — proceeds “received” in stock currency |
SELL with loss vs. FIFO cost | consumes existing lots instead of creating one |
DIV / INT | VIRTUAL_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 7.40** at 37%, while a 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
| Layer | Technology |
|---|---|
| Backend | Django 5.0.1, Django REST Framework |
| Database | PostgreSQL 15 |
| Market data | yfinance, FMP + Alpha Vantage (dividend history) |
| FX rates | Self-hosted Frankfurter v2 instance, reached over Tailscale |
| Authentication | django-allauth (Google OAuth) + djangorestframework-simplejwt |
| Frontend | HTML5, CSS3, vanilla JavaScript |
| Static files | WhiteNoise |
| Containerisation | Docker + 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.
Last modified: 22 Aug 2026