Balance reconstruction
Summary
Section titled “Summary”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.
Anchor priority
Section titled “Anchor priority”For a given (holdingId, at), the service picks the first available
anchor from this ordered list:
- Nearest observation at or after
at. Highest confidence. Subtract the sum of transaction quantities betweenatand the observation timestamp from the observed balance — that’s the balance atat. - Current
holdings.balanceatholdings.lastUpdated. Fallback when no observation is at or after the target time. Subtract the sum of transactions betweenatandholdings.lastUpdated. - Nearest observation before
at— last-ditch anchor. Walk forward by adding the sum of transactions betweenobservation.observedAtandat.
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;}anchor is reported back so callers can fold confidence into the
displayed coverageQuality rather than blindly trust every point.
The non-negative clamp
Section titled “The non-negative clamp”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 BTCThat’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.
How the rollup uses it
Section titled “How the rollup uses it”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.
What it doesn’t do
Section titled “What it doesn’t do”- 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.