Mental model
Summary
Section titled “Summary”Scani tracks a portfolio as two concurrent records of truth: an append-only ledger of every economic event, and an append-only log of observed balances at points in time. Current state (holdings) is a denormalised cache. Past state is reconstructed by walking the ledger between observation anchors. The headline portfolio number is a sum of holdings × prices through a price graph; the chart is a daily-grain cache (rollup) that rebuilds from the same primitives.
The five primitives
Section titled “The five primitives”┌───────────────────────────────────────────────────────────────────────┐│ ││ ┌──────────────┐ ││ │ accounts │── one per (user, institution, name)││ └──────┬───────┘ ││ │ ││ ▼ ││ ┌──────────────┐ ┌──────────────┐ ││ │ holdings │────────▶│ tokens │ ││ │ (positions) │ │ (assets) │ ││ └──────┬───────┘ └──────┬───────┘ ││ │ │ ││ ┌────────────┴──────────────┐ ▼ ││ ▼ ▼ ┌──────────────┐ ││ ┌────────────────────┐ ┌────────────────│ token_prices │ ││ │ holding_ │ │ holding_ └──────────────┘ ││ │ transactions │ │ balance_ ││ │ (append-only │ │ observations ││ │ ledger) │ │ (append-only ││ └────────────────────┘ │ anchors) ││ └────────────────┘ ││ │└───────────────────────────────────────────────────────────────────────┘Derived data
Section titled “Derived data”The rollup is purely a cache — drop it and the nightly job rebuilds it. The ledger and observations are the load-bearing truth.
User-defined organisation
Section titled “User-defined organisation”Where the data comes from
Section titled “Where the data comes from”Each source produces both transactions (with a deduped external_id)
and observations (source: 'sync-capture'). Reconciliation
(OpeningBalanceReconciliationService)
fills the gap when the ledger doesn’t fully explain the current
holding balance — it synthesises an opening_balance transaction at
the start of known history.
Pricing is its own graph
Section titled “Pricing is its own graph”There is no USD-canonical column. Every price is stored in its
native quote (a Kraken BTC/EUR trade has priceNativeTokenId = EUR,
not USD). Conversions walk the implicit graph implied by
token_prices rows: direct, then reverse direct, then one-hop via
USD / USDT / EUR. See Pricing & the price graph
for the routing rules and the staleness contract.
The headline reconciles with the chart
Section titled “The headline reconciles with the chart”The dashboard’s headline portfolio total and the chart’s latest point must agree by construction. Both apply the same holding-inclusion rule — hidden holdings, inactive holdings, and scam-flagged tokens are excluded from both. The rule lives twice (in TypeScript for the dashboard read path, in SQL for the chart) but in two places that are tested to stay in sync.
Three deployment tiers, one binary
Section titled “Three deployment tiers, one binary”The same four services run three ways. Two env vars switch tiers:
SCANI_CLOUD_URL— where to send outbound third-party calls. Tier 1:http://data-provider:8082(same machine). Tier 2/3: a hosted data-provider endpoint.SCANI_CLOUD_API_KEY— the bearer token the api + worker present.
See Tier model.