Skip to content

Holdings

A holding is the atomic unit of portfolio tracking: one position of one token inside one account. Each holding has a current balance (a decimal string for precision), flags for isHidden / isActive, and a source indicating whether it came from a synced provider or manual entry. The balance field is the only mutable balance in the system; every change to it is also appended to the immutable ledger and observed at the moment it changed.

The Drizzle table is holdings in packages/infra/db/src/schema/holdings.ts. The relevant columns:

ColumnTypeMeaning
iduuid PK
userIduuid → users.idCascade-deletes with the user.
accountIduuid → accounts.idCascade-deletes with the account.
tokenIduuid → tokens.idON DELETE RESTRICT — a token with holdings cannot be deleted.
balancetextDecimal string (uses Decimal.js for precision).
sourcetext'manual' or 'blockchain' (or an exchange-specific value like 'binance-api'). Tracks how the holding was created.
externalIdtext | nullProvider-specific identifier (e.g. 'BTC' for Binance) used as a sync-matching key. NULL for manual holdings.
isHiddenboolHidden holdings are excluded from the UI but still synced by cron jobs.
isActiveboolInactive holdings stay visible but are excluded from total calculations.
lastUpdatedtimestamptzWhen the balance was last updated by any source. Not used to reconstruct a past balance.
createdAttimestamptz

Composite indexes cover the common lookups: (user, account, token), (user, token), (user, createdAt desc), plus boolean indexes on isHidden and isActive.

These two flags do different things and matter for both the UI and the rollup:

FlagUI behaviourSynced by cron?Counts toward totals?
isHidden = trueHidden from dashboards and lists.Yes — still kept up to date.No, if you hid it. Yes, if Scani hid it as a closed position (hiddenBy = 'auto'): it stays in value history, PnL, returns and flows.
isActive = falseVisible (greyed).No — left alone.No.

A holding Scani hid as a closed position is shown again the next time anything writes a non-zero balance to it: a sync, an edit or a transfer. One you hid yourself is not shown by a sync, an edit or a transfer. An integration import shows either kind when it reports a balance for it.

The holding-inclusion rule combines these with the token’s isScamProbability to decide whether a holding contributes to a portfolio total. The rule is enforced identically in the TypeScript read path (PortfolioValuationService) and the SQL chart read path (PortfolioValueDailyRepository).

A holding is created in one of four ways:

  1. A sync discovers a position the user didn’t have before. The connect-time imports (exchange-import, wallet-import), which create the account if needed, and the balance syncs each hand one account’s answer to FeedIngestService as a snapshot batch. It creates the holding at the reported balance and records that balance as a 'sync-capture' observation. A snapshot batch carries no ledger entry.
  2. A screenshot is parsed via OpenAI Vision and the resulting rows are written under a synthetic “manual” institution.
  3. A CSV / file import writes both holdings and the matching ledger entries.
  4. The user creates one manually (manual-holdings-create job).

A holding’s balance then changes only via:

  • A sync run that observes a different balance — the new balance is written, a 'sync-capture' observation is appended, and any new transactions discovered since the last sync are inserted into the ledger.
  • A user edit — the change is written and an 'manual-correction' observation is recorded.
  • A yield payout — the nightly apy-payouts job appends a kind='interest' transaction and bumps the balance.
  • The opening-balance reconciliation — when sum(transactions) ≠ holdings.balance, a synthetic kind='opening_balance' transaction is appended so the ledger fully explains the current balance.

Multiple holdings of the same token in the same account

Section titled “Multiple holdings of the same token in the same account”

The schema allows it. Multi-lot scenarios (a user with two separately tracked BTC positions on the same exchange — e.g. one for trading, one for long-term cost basis) work without schema gymnastics. The holding_transactions table keys on holdingId rather than (account_id, token_id) precisely so a second position can be materialised without breaking the ledger.

Migration 0054 was the schema change that made this possible: it dropped the old (account_id, token_id) composite key on holding_transactions and re-keyed everything on holding_id.