Skip to content

Repository files navigation

CentralFolio

A self-hosted portfolio and dividend tracking app. Connect brokerage accounts through SnapTrade, see all your holdings in one place, forecast dividend income, review transactions, rebalance toward target allocations, prepare Canadian capital-gains reporting, and get alerted when something needs attention.

Features

  • Dashboard — total value, profit, passive income and an annualised money-weighted return (XIRR) across every connected brokerage, with allocation and holdings-breakdown widgets, a performance chart with benchmark overlay, and risk metrics. XIRR accounts for the size and timing of each contribution; where there is too little history to annualise, the tile falls back to a plainly-labelled simple return.
  • Holdings — a single aggregated table (by symbol) with cost basis, current value, dividends, yield, and total profit; switchable My holdings / Dividends / Returns views, search, and sortable columns.
  • Compare portfolios — put every symbol across several portfolios into one matrix: which portfolios hold it, at what size and weight, and which don't. A gap offers a buy into any trading-enabled account in the target portfolio. Filter to gaps, symbols common to all, or symbols only one portfolio owns. Amounts are converted into a single base currency, so mixed-currency portfolios total correctly.
  • Dividend tracker — three sub-views:
    • Forecast — projected annual/monthly/daily income and yield.
    • Calendar — month grid of upcoming payouts with a 12-month forecast chart, plus a list view.
    • Database — cached dividend metadata with a manual per-symbol lookup tool.
  • Watchlist — track candidates you don't own yet. Price, trailing yield, dividend growth (DGR) and growth streak are pulled from Yahoo when you add a symbol; AI ratings appear once the rating job has analysed it. Set buy criteria per symbol (price at or below, yield at or above, minimum rating, minimum growth streak) and the table shows whether they're met, plus how far price sits from your target.
  • Transactions — a ledger with Trades / Incomes / Cash / All tabs, buy/sell totals by currency, per-trade unrealised profit, search, and CSV export. Add transactions by hand or import a broker CSV for activity the connection never supplied — trades predating the connection, or transfers reported without a cost base. These feed the tax report and performance history alongside synced data.
  • Tax & T5008 — per-disposition slip data and a Schedule 3 roll-up for non-registered accounts, in CAD at each trade's own exchange rate, with superficial losses identified; carrying charges and interest expense (Schedule 4, line 22100); CSV export; and a tax-loss harvesting view of which holdings could offset this year's realised gains. See Tax reporting.
  • Rebalancing — define target allocations per portfolio and get suggested buy-only or full-rebalance trades; execute them where trading is enabled.
  • Alerts — get told when something needs attention instead of having to go looking. See Alerts.
  • Custom portfolios — group accounts from multiple brokerage connections into named, colour-labelled portfolios.
  • Brokerage connections — account ↔ portfolio link cards with last-sync time, on-demand sync, and connect/disconnect.
  • Trading — Buy and Sell sit in each holdings row, no menu to open first. Order by share count, or by a cash amount — the notional order that buys a fraction of a share where the brokerage supports it. Every buy is checked against a cash balance read live from the brokerage before anything is placed. See Trading.
  • Buy buckets — a named set of stocks bought together for one cash amount, split equally or by weights you set, into one account or several at once. See Buy buckets.
  • Performance history — the value curve is reconstructed by replaying transactions against price history, and anchored to nightly snapshots of what each account was actually worth. Snapshots are independent of the transaction ledger, so the curve holds even where the broker never reported an activity. They accumulate from first run; earlier dates stay reconstructed.
  • Background jobs — automatic dividend, holdings, transaction and price-history refresh, nightly portfolio snapshots, alert evaluation, and optional AI stock ratings — each on its own configurable schedule (Settings → Scheduler).
  • API tokens — issue revocable, non-browser tokens (Settings → Brokerage Connections → Security) for scripts and other API clients, separate from your login session.
  • Feature toggles — switch off the Compare, Dividend Tracker, Watchlist, Rebalancing and Tax pages you don't use (Settings → Features). Nothing is deleted; turning Watchlist or Rebalancing off also pauses its alert rule.
  • Live log viewer — tail the running server's logs from the browser (Settings → Logs), with level filtering, search, and pause/autoscroll.

Requirements

  • Docker and Docker Compose
  • A SnapTrade partner account (free) for brokerage connectivity

Running

docker compose up -d   # serves on http://localhost:3000

The database is persisted to ./data on the host (DATA_DIR=/data inside the container). A prebuilt image is published at ghcr.io/rangodj/centralfolio; docker compose pulls it (or builds locally from the Dockerfile).

On first visit you'll be prompted to set a password; all later logins use it.

Everything else is done from the web UI: add your SnapTrade API credentials under Settings → Keys & Providers, then link and manage brokerage accounts under Settings → Brokerage Connections.

SnapTrade key types

SnapTrade issues two kinds of key, and the connection form asks which you have:

  • Personal — the key represents you. Its SnapTrade user is created at signup, so there is no registration step and no user secret to store; the key identifies you on every call. This is the usual choice for a self-hosted install.
  • Commercial — for an app serving other people. A SnapTrade user is registered for the connection and its secret is stored, which is why this type also asks for a user identifier.

Everything works the same either way, trading included. Picking the wrong type makes calls fail, so if registration reports that registerUser is not available for personal keys, switch that connection to Personal.

Configuration

Set in the environment: block of docker-compose.yml.

Variable Default Description
PORT 3000 HTTP port the server listens on
DATA_DIR /data Directory where snaptrade.db is stored (mapped to ./data)
LOG_LEVEL info Set to debug for verbose output
ANTHROPIC_API_KEY — Optional. Enables AI stock ratings (used by the watchlist and the rating-downgrade alert). Can be set in Settings → Keys & Providers instead.

Dividend data

Dividend metadata (frequency, ex-date, amount per share) is fetched automatically and cached in the local database. Results are cached for up to 7 days (24h for symbols with no dividend data). You can toggle automatic background sync and run manual per-symbol lookups in Settings → Keys & Providers and the Dividend Tracker → Database tab.

Alerts

Rules are evaluated in the background and surface under Settings → Alerts, with a read/unread history. When a webhook is configured (Settings → Keys & Providers) each batch is also pushed there as a single Discord-compatible message.

Rule Fires when
Dividend cut A holding paid less last complete year than the year before
Dividend due soon A forecast payout lands inside your window
Allocation drift A holding strays past your band from its rebalancing target
AI rating downgrade The stock rating gets worse since the last check
Watchlist target hit A watched symbol meets every buy criterion you set on it

All rules start disabled — this app can reach an external service, and turning that on is your decision. Enable what you want and set its thresholds in Settings.

Each situation notifies once. A dividend cut alerts on the year it happened, not on every run for as long as the cut remains true; drift is bucketed to whole percentage points so daily wobble around your threshold stays quiet. Preview evaluates every rule — including ones you haven't enabled — without sending or recording anything, so you can see what a rule would say before switching it on.

Trading

Trading is available on connections you have explicitly enabled for it. Buy and Sell appear directly in each holdings row; where a holding is spread across several tradable accounts, each gets its own pair, because an order has to name one.

An order can be sized two ways:

  • Shares — a share count, as Market or Limit, held for the Day or Good Til Cancelled.
  • Cash amount — spend a fixed sum. This is the notional order, and it is what buys a fraction of a share at brokerages that support it (Wealthsimple among them). It is Market-and-Day by definition, so order type and time in force do not apply and are hidden rather than shown as choices the brokerage would ignore.

The popup names the account the order will hit, by your own name for it where you have renamed one.

Every buy re-reads your cash balance from the brokerage first, rather than trusting the cached figure — a stale balance can block a funded account or clear an unfunded one. If the brokerage cannot be reached, nothing is placed: an unverifiable balance is not treated as a sufficient one. A share order at market is costed from the last cached close, which is an estimate; a cash-amount or limit order is costed exactly.

Orders are staged and then confirmed as two separate requests, so a live order is never the result of a single one.

Buy buckets

A bucket is a named set of stocks you buy together — managed under Settings → Buckets, or created straight from the Holdings page by ticking rows and choosing Create bucket.

A bucket stores what to buy and in what proportions, and nothing else:

  • Split equally — every stock takes the same share.
  • Split by weights — you set each stock's percentage; they must total 100%.

The amount and the accounts are chosen each time you run it, so the same bucket serves a $50 week and a $5,000 one, into a TFSA this month and an RRSP the next. Stocks are found with a ticker search, or typed in full for a listing the search misses.

Running a bucket places one market cash-amount order per stock, which is what makes an arbitrary sum divisible across several holdings.

The amount is per account, not shared between them. Running a $250 bucket into two accounts spends $500. The preview states the grand total before you confirm.

The preview shows every order it would place — symbol, amount, estimated shares, per-account subtotal — and then:

  • Balances are re-read from the brokerage before placing. An account without the cash to cover its share blocks the run, naming the account and the shortfall. If the brokerage cannot be reached, nothing is placed.
  • Orders below the brokerage minimum (about $1) are flagged, never silently dropped. You decide whether to place them anyway.
  • A rejected order does not stop the run. Every order is attempted and the outcome reported individually, with the brokerage's reason for each failure.

Tax reporting

The tax features assume a Canadian resident filing in CAD. Amounts convert at each trade's own exchange rate, capital gains use the 50% inclusion rate, and the superficial-loss rule uses the CRA's 30-days-either-side window. They will not produce correct figures under another country's rules.

Cost base is pooled the way CRA requires: across all your non-registered accounts, and across the CAD and USD listings of the same security (so a Norbert's Gambit reads as a currency conversion rather than a disposition at zero cost). Registered accounts are excluded from dispositions — no T5008 is issued for them and including them would overstate taxable gains.

Tax-loss harvesting shows which non-registered holdings sit at a loss, how much of this year's realised gain each would offset, and what the superficial-loss rule would do to it:

  • Clear — no purchase in the last 30 days.
  • At risk — bought within 30 days, so that portion of the loss is denied and rolled into the cost base instead of claimed now. A DRIP is called out by name, since it fires inside the window without any decision from you.
  • Denied — repurchased inside an RRSP/TFSA. A registered account has no cost base to absorb the denied loss, so it is forfeited outright rather than deferred. These are excluded from the harvestable total but still listed, so you can see why.

The 30-day window counts purchases in any of your accounts, registered ones included. Setting an optional marginal rate estimates the tax saved; without it the report stops at the taxable-income reduction.

Where a disposition has no recorded purchase, the report says so rather than reporting the whole proceeds as a gain — enter the missing trade under Transactions → Add transaction to correct it.

These are estimates from your own cached data, not tax advice. Check them against your broker's slips before filing.

Development

Running from source, tests, and the layout of the codebase are covered in CONTRIBUTING.md.

Security

CentralFolio is single-user and protected by a password (bcrypt-hashed) with a JWT session secret, both stored in the local SQLite database. No secrets ever leave your server.

On login the session token is set as an httpOnly, SameSite=Strict cookie (secure when served over HTTPS), so it is not readable by injected scripts. requireAuth accepts the token from either that cookie or an Authorization: Bearer header, and POST /auth/logout clears the cookie. The bundled frontend still keeps a copy in localStorage for the bearer flow; a future hardening step is to drop the localStorage copy entirely and rely on the cookie alone.

For scripts and other non-browser clients, issue a long-lived API token under Settings → Brokerage Connections → Security instead of sharing your login session — send it as Authorization: Bearer cf_.... Tokens are shown once at creation and stored only as a SHA-256 hash; revoke one anytime from the same panel to invalidate it immediately.

The database and credentials live under the mounted ./data volume and must never be committed to source control: snaptrade.db and its WAL sidecars (snaptrade.db-shm, snaptrade.db-wal), user-credentials.json, and .env. These hold SnapTrade API keys, the password hash, and the JWT secret. Do not place DATA_DIR inside a cloud-synced folder (Dropbox, Nextcloud, iCloud, etc.) — the database holds plaintext secrets that would then be replicated to that service.

Single-instance deployment

CentralFolio is designed to run as a single process. Login rate-limiting and short-lived SSE auth tickets are held in memory, not in the database, so:

  • Running more than one replica (e.g. scaling the container horizontally) will split this state and break rate-limiting and live-update tickets.
  • A restart resets the in-memory login rate-limit counters.

For the intended single-user, single-container setup this is fine. If you ever need multiple instances, these stores must be moved to the shared SQLite database first.

About

No description, website, or topics provided.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages