Skip to content

Observations & coverage

Observations are append-only point-in-time balance snapshots — “at time T, this holding’s balance was B, per source S”. Every sync appends one. Every screenshot appends one. They are the anchor points that make historical balance reconstruction robust against incomplete transaction history. The coverage table is a per-holding cache that records first/last observation, first/last transaction, the set of ingester sources that have contributed, and the synthesised opening balance (if any). The opening-balance reconciliation flow runs nightly to keep the ledger in sync with the current holdings.balance.

Transaction history is frequently incomplete. Helius’s parsed-tx index has retention limits. Etherscan caps how far back it’ll return events for some addresses. Exchange CSV exports start at the first export date, not the account-creation date. A wallet imported mid-life has no events before the import.

Without observations, balance-at-time-T reconstruction would be a pure walk over transactions starting from zero — which produces negative reconstructed balances the moment the first event we know about is a sell. (You can’t have negatively held an asset you can’t short.)

Observations break that dependency: every sync writes “as of now, the balance is B”, giving the reconstructor a forward floor even when the historical ledger is sparse.

holding_balance_observations:

ColumnMeaning
iduuid PK.
userIduuid → users.id.
holdingIduuid → holdings.id. Migration 0054 dropped redundant (account_id, token_id) columns.
balanceDecimal string — the observed balance.
observedAtWhen the observation was true.
sourceSee Sources.
sourceMetadata (jsonb)Provider-specific context.
createdAtWhen the row was written.

Uniqueness: (holdingId, observedAt, source). Indexes on (holdingId, observedAt desc) and (userId, observedAt desc).

SourceWhen it’s written
sync-captureEvery successful balance-fetching sync run (exchange API, blockchain RPC).
statement-closeA closing balance read from an uploaded statement (PDF / CSV).
screenshotExtracted via OpenAI Vision from a portfolio screenshot.
user-enteredUser typed it in.
manual-correctionUser edited the holding’s balance directly; the new value is captured here.

holding_coverage is a per-holding metadata cache:

ColumnMeaning
holdingIdPK, FK to holdings.id.
firstTxAt / lastTxAtThe transaction history window.
firstObservationAt / lastObservationAtThe observation window.
txSourcesArray of ingester source names that have contributed — e.g. ['etherscan', 'binance-api'].
hasCompleteTxHistoryTrue when the ingester signalled it has paged everything reachable.
lastReconciledAtWhen the opening-balance reconciliation last ran.
openingBalanceQuantityThe synthesised opening balance (Decimal string, positive or negative) — see below. NULL if reconciliation hasn’t run or sum(txs) matched the observation.
reconciliationNotesFree-text notes for the data-quality panel.
updatedAt

Coverage drives:

  • The data-quality panel in the SPA.
  • The set of (token × date) pairs the historical price backfill needs to fetch.
  • The coverageQuality column on the rollup, which the chart uses to render dashed/gap segments.

OpeningBalanceReconciliationService runs nightly. For each holding:

  1. Sum all real transactions (every kind except opening_balance).
  2. Compare to current holdings.balance.
  3. If they disagree by more than an epsilon (1e-12), synthesise a kind='opening_balance' transaction at the start of known history with source = 'reconciliation-opening'. The synthesised quantity is whatever makes sum(transactions) == holdings.balance.
  4. Update holding_coverage.openingBalanceQuantity to record what was synthesised.

The operation is idempotent per holding — re-running won’t insert a second opening balance. If the holding’s balance later drifts again, the synthesised row is updated in place (the only ledger row Scani ever updates, by design exception, because there is at most one opening-balance row per holding per cycle).

The nightly portfolio-value rollup writes one of four coverageQuality values per row:

ValueMeaning
fullAll holdings priced, all transactions known.
partialSome holdings priced, some missing.
estimatedUsed a stale or hub-routed price.
unknownNo anchor available.

These bucket sizes drive how the chart renders that point (solid / dashed / gap) and feed into the data-quality summary.