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; beforeRecords: boolean;}anchor is reported back so callers can fold confidence into the
displayed coverageQuality rather than blindly trust every point.
The lower bound
Section titled “The lower bound”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:
- the holding’s first transaction (
occurredAt), - its first balance observation (
observedAt), 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 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.