Holdings
Summary
Section titled “Summary”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.
Schema
Section titled “Schema”The Drizzle table is holdings in
packages/infra/db/src/schema/holdings.ts. The relevant columns:
Composite indexes cover the common lookups: (user, account, token),
(user, token), (user, createdAt desc), plus boolean indexes on
isHidden and isActive.
Hidden vs inactive
Section titled “Hidden vs inactive”These two flags do different things and matter for both the UI and the rollup:
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).
Lifecycle
Section titled “Lifecycle”A holding is created in one of four ways:
- 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 toFeedIngestServiceas 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. - A screenshot is parsed via OpenAI Vision and the resulting rows are written under a synthetic “manual” institution.
- A CSV / file import writes both holdings and the matching ledger entries.
- The user creates one manually (
manual-holdings-createjob).
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-payoutsjob appends akind='interest'transaction and bumps the balance. - The opening-balance reconciliation — when
sum(transactions) ≠ holdings.balance, a synthetickind='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.