Balance History System#
The balance history system produces a daily Balance row for every account by replaying anchor valuations and transaction entries through one of two calculators. Linked accounts (e.g. SimpleFIN, Plaid) use a reverse strategy: start from today's provider-reported balance and work backwards. Manual accounts use a forward strategy: start from the opening anchor and project forward.
SimpleFIN sync
→ SimplefinAccount::Processor # sets current_anchor balance
→ Account::Syncer#perform_sync # orchestrates
→ Balance::Materializer # coordinates, persists, purges
→ Balance::ReverseCalculator (linked)
└─ Balance::ForwardCalculator (manual)
The two anchor valuations — current_anchor and opening_anchor — are the fixed points that seed the calculators. Everything else is derived from entries (transactions, trades, reconciliations) between them.
Anchor Valuations#
Both anchors are Valuation entries stored in the entries table with a special kind discriminator. The Account::Anchorable concern exposes them on every account model .
opening_anchor — the earliest known balance, used to seed forward and reverse calculations. Accessed via opening_anchor_date, opening_anchor_balance, has_opening_anchor?, delegating to Account::OpeningBalanceManager.
current_anchor — today's provider-reported balance for linked accounts only. Managed by Account::CurrentBalanceManager. Key behavior:
- Each sync, before overwriting a stale (previous-day) anchor with a fresh value, the old anchor is converted to a
reconciliationvaluation. This accumulates a chain of API-reported balance waypoints without creating extra entries per sync. current_balancereads from the anchor entry directly; falls back to the cachedaccount.balancecolumn only if no anchor exists .- Manual accounts do not use a
current_anchor— their "current balance" is maintained via reconciliations or by adjusting the opening balance with a delta .
SimpleFIN flow: SimplefinAccount::Processor#process_account! extracts the observed balance from the API payload and calls account.set_current_balance(balance), which routes through CurrentBalanceManager to create or update the current_anchor entry.
Account::Syncer#
Account::Syncer#perform_sync(sync) is the orchestration entry point. It runs three steps in order:
import_market_data— pulls exchange rates and security prices needed for historical charts. Errors are rescued and reported to Sentry so a market data failure doesn't abort the sync .materialize_balances— selects:reversestrategy for linked accounts,:forwardfor manual, then delegates toBalance::Materializer. Thewindow_start_datefrom the sync object enables incremental recalculation for forward-strategy accounts.apply_provider_balance_overrides— IBKR-specific: runsIbkrAccount::HistoricalBalancesSyncafter the main materialization .
After the sync, perform_post_sync auto-matches transfers within the family.
Balance::Materializer#
Balance::Materializer#materialize_balances wraps everything in a single DB transaction:
- Materialize holdings via
Holding::Materializer(needed to split cash vs. non-cash for investment accounts). - Calculate balances — delegates to
ReverseCalculatororForwardCalculatordepending on the strategy set at initialization . - Persist — bulk-upserts all computed
Balancerows keyed on(account_id, date, currency). - Purge stale rows — deletes
Balancerows outside the newly computed date range. In incremental forward mode, pre-anchor rows are preserved usingcalculator.calculation_start_dateas the lower bound, so prior balances aren't inadvertently deleted . - Update account cache (forward strategy only) — writes back the most-recent computed balance to
account.balance/account.cash_balance.
ReverseCalculator (Linked Accounts)#
Balance::ReverseCalculator iterates from current_anchor_date down to calculation_start_date, deriving each day's start-of-day balance from the known end-of-day value by reversing that day's flows.
Seeding: starts from current_anchor_balance, splitting into cash and non-cash components .
Special date handling within the loop :
opening_anchor_date: hard-resets toopening_anchor_balancerather than deriving — this is the authoritative floor for the history .- Reconciliation waypoints: stale current_anchors that were converted to reconciliations hard-reset the end-of-day balance to the API-reported value, absorbing drift from missing transactions .
- Opening boundary (
opening_anchor_date + 1): explicitcash_adjustments/non_cash_adjustmentsare recorded to bridge the gap between what flows predict and what the opening anchor actually says, keeping the audit trail complete .
calculation_start_date extends to the oldest entry if entries are backfilled before the opening anchor, so pre-anchor entries are still materialized .
ForwardCalculator (Manual Accounts)#
Balance::ForwardCalculator iterates from calculation_start_date up to the last entry or holding date, applying daily flows to carry balance forward.
Seeding: derives cash and non-cash components from opening_anchor_balance on opening_anchor_date. If the calculation window starts before the anchor (backfilled entries), seeds at [0, 0] and lets the first reconciliation/anchor valuation reset the absolute balance .
Valuation overrides: if a Valuation entry exists for a date (e.g. a user-entered reconciliation), that date's end-of-day balance is taken directly from the valuation rather than being derived from flows .
Incremental mode: when window_start_date is provided, the calculator tries to seed from the persisted Balance row for window_start_date - 1 rather than replaying from the opening anchor — avoiding a full history scan on every sync. Falls back to full recalculation when :
- No prior persisted balance exists for that date, or
- The prior balance has a non-zero non-cash component (investment holdings, which are always fully rematerialized), or
- The account has multi-currency entries or a foreign currency.
incremental? reflects whether the calculator actually ran incrementally (not just whether window_start_date was passed).
BaseCalculator — Shared Logic#
Balance::BaseCalculator provides the shared infrastructure used by both calculators:
| Method | Purpose |
|---|---|
calculation_start_date | min(opening_anchor_date, oldest_entry_date) — ensures backfilled entries are included |
flows_for_date | Categorizes entries into cash/non-cash inflows + outflows; handles loan and investment sign conventions (trades invert: cash outflow to buy = non-cash inflow of holdings) |
derive_cash_balance_on_date_from_total | Splits a total balance: investments → total - holdings_value; cash → total; non-cash → 0 |
cash_adjustments_for_date | Residual after subtracting flows from balance change — captures unexplained drift |
market_value_change_on_date | For investment accounts: isolates market-driven value change from buy/sell flows |
build_balance | Constructs Balance model instances with all persisted fields |
flows_factor is +1 for asset accounts and -1 for liabilities, centralizing the sign convention for all persisted balance rows .