Skip to content

Mental model

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.

┌───────────────────────────────────────────────────────────────────────┐
│ │
│ ┌──────────────┐ │
│ │ accounts │── one per (user, institution, name)│
│ └──────┬───────┘ │
│ │ │
│ ▼ │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ holdings │────────▶│ tokens │ │
│ │ (positions) │ │ (assets) │ │
│ └──────┬───────┘ └──────┬───────┘ │
│ │ │ │
│ ┌────────────┴──────────────┐ ▼ │
│ ▼ ▼ ┌──────────────┐ │
│ ┌────────────────────┐ ┌────────────────│ token_prices │ │
│ │ holding_ │ │ holding_ └──────────────┘ │
│ │ transactions │ │ balance_ │
│ │ (append-only │ │ observations │
│ │ ledger) │ │ (append-only │
│ └────────────────────┘ │ anchors) │
│ └────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────────┘
PrimitiveRoleMutability
accountsContainer at one institution (one Kraken account, one Metamask wallet)Mutable metadata; never deleted while holdings reference it.
holdingsA single (account, token) position with a balance stringCurrent state — the only mutable balance.
tokensTradeable asset: fiat, crypto, equity, privateMutable metadata; deduplicated globally.
holding_transactionsImmutable ledger row per economic event (buy, sell, deposit, transfer, fee, …)Append-only. Never updated, never deleted in normal operation.
holding_balance_observations”At time T, this holding’s balance was B, per source S”Append-only. Live syncs, statement closes, screenshots, user entry, manual corrections.
DerivedSourceComputed by
Balance at past time Tobservations + transactions + current holdingBalanceAtTimeService
FX/price conversiontoken_prices graphPriceGraphService, hub-routed
Daily portfolio totalsthe four above, per scopeportfolio_value_daily, nightly
Holding coverage qualityper-holding tx + observation timestampsholding_coverage, per-ingest

The rollup is purely a cache — drop it and the nightly job rebuilds it. The ledger and observations are the load-bearing truth.

ConceptRole
VaultsSavings goals. Allocate percentage splits of holdings to each goal (25% of BTC → house deposit, 75% → retirement). Compute progress toward a target.
GroupsFree-form tags (Crypto, Retirement, Side projects). Many-to-many to holdings and accounts. Pure UI labels — they don’t change calculations.
APY configsPer-holding yield rules. A nightly cron appends kind='interest' transactions according to the schedule.
SourceExamples
Exchange syncsBinance, Kraken, Bybit, … — credentialed reads on a schedule.
Brokerage syncsInteractive Brokers Flex Web Service, Wise.
On-chain syncsEtherscan (EVM), Helius (Solana), Bitcoin RPC, Tron, TON, ENS.
AI parsingScreenshot → structured holdings via OpenAI Vision.
Manual entryTyped in by the user — stored as a synthetic “manual” institution.
CSV / statement importBank statements, brokerage exports.

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.

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 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.

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.