Skip to content

Balance reconstruction

BalanceAtTimeService.getBalance(holdingId, at) returns the reconstructed balance of one holding at any past time. It walks the ledger backward (or forward) from the most trustworthy anchor available — typically the nearest balance observation, falling back to the current state of the holding when no observation is near. The result is clamped non-negative because early imported history is frequently incomplete; you can’t have negatively held an asset you can’t short.

For a given (holdingId, at), the service picks the first available anchor from this ordered list:

  1. Nearest observation at or after at. Highest confidence. Subtract the sum of transaction quantities between at and the observation timestamp from the observed balance — that’s the balance at at.
  2. Current holdings.balance at holdings.lastUpdated. Fallback when no observation is at or after the target time. Subtract the sum of transactions between at and holdings.lastUpdated.
  3. Nearest observation before at — last-ditch anchor. Walk forward by adding the sum of transactions between observation.observedAt and at.

If no anchor of any kind is reachable (no observation, no current holding, no transactions), the service returns { balance: null } — honest “unknown” rather than a fabricated zero.

// Simplified shape of the return:
interface BalanceAtTimeResult {
balance: Decimal | null;
anchor: 'holdings' | 'observation-after' | 'observation-before' | null;
anchorAt: Date | null;
txApplied: number;
beforeRecords: boolean;
}

anchor is reported back so callers can fold confidence into the displayed coverageQuality rather than blindly trust every point.

Every anchor above walks the ledger between at and the anchor’s timestamp. Below a holding’s earliest evidence that range covers the entire ledger, so all three reduce to the same expression:

current balance − sum(every transaction we know about)

That is zero only if the ledger is complete. As the clamp section below explains, imported histories frequently are not — so what the expression actually returns is the unexplained opening balance, and without a bound it is reported for every date before the ledger starts, forever. Production held 586.94 on a 2025 date for a holding whose first transaction is in 2026: the magnitude of one real withdrawal, attributed to all of time.

Earliest evidence is the oldest of three timestamps:

  1. the holding’s first transaction (occurredAt),
  2. its first balance observation (observedAt),
  3. holdings.created_at — the moment the row itself appeared.

Oldest, not newest, so an imported wallet whose transactions reach back years is still answered from its first transaction rather than from the day Scani happened to learn of it. And it is resolved once, above the anchor ladder, because anchor 1 is tried first: bounding only the holdings anchor would leave every holding carrying observations answering exactly as before.

Below that point balance is still returned. Propagating a balance backward is what the history chart is built on, and withholding the number would empty the chart for anyone who onboarded this week. What changes is the claim attached to it: beforeRecords: true travels with the figure, and the rollup downgrades the day’s coverage_quality from full to partial rather than asserting full confidence about a period nothing in our data describes.

The service applies clampNonNegative to every reconstructed past balance — if the math produces a negative number, it’s clamped to zero.

Why: blockchain indexers, exchange CSVs, and manual entry frequently start partway through a holding’s life. Helius’s parsed-tx index has retention limits. An exchange CSV starts at export date, not account creation. A wallet imported mid-life has no events before the import. When the first transaction Scani knows about is a sell, naïve forward-walking math produces:

0 (assumed starting balance)
- 0.5 BTC (the sell we have)
= -0.5 BTC

That’s nonsense; you can’t have negatively held BTC. Clamping at zero keeps the chart sensible without rewriting the underlying ledger, which preserves signed quantities for cost-basis math.

The opening-balance reconciliation flow later synthesises the implied opening balance so the ledger becomes self-consistent.

The nightly portfolio-value rollup calls getBalance once per holding per snapshot date in the backfill window. To avoid a per-holding round-trip per day, callers can pre-load the per-user data and hand it in:

interface BalanceAtTimeCaches {
holdings?: ReadonlyMap<string, Holding>;
observations?: ReadonlyMap<string, ReadonlyArray<HoldingBalanceObservation>>;
transactions?: ReadonlyMap<string, ReadonlyArray<HoldingTransaction>>;
}

When a cache key is present, the service uses it; when absent (or undefined for that holding), it falls through to the repository. This lets the rollup hot-path do one bulk fetch of per-user data and feed millions of getBalance calls without further DB round-trips, while ad-hoc callers (the chart endpoint, an interactive valuation) just call getBalance(holdingId, at) and let the service handle the I/O.

  • It does not mutate the ledger. Pure read.
  • It does not mutate observations. Pure read.
  • It does not return PnL — that’s a separate concern computed by the rollup against the reconstructed balance and the price graph.
  • It does not convert currencies. The returned balance is in the token’s units (BTC, AAPL, EUR, …); pricing happens above.