Account Balance Calculation#
Sure produces a daily Balance row for every account by routing through one of two calculators, chosen based on how the account is connected. Linked accounts (e.g. SimpleFIN, Plaid) use Balance::ReverseCalculator — starting from today's provider-reported balance and working backward. Manual accounts use Balance::ForwardCalculator — starting from the opening anchor and projecting forward. A third class, Balance::LinkedInvestmentSeriesNormalizer, post-processes investment account series to trim history to periods with stable, meaningful data.
The orchestration chain is:
Account::Syncer#perform_sync
→ materialize_balances (picks :reverse or :forward)
→ Balance::Materializer
→ Balance::ReverseCalculator (linked)
└─ Balance::ForwardCalculator (manual)
Strategy Selection and Materializer#
Account::Syncer#materialize_balances sets the strategy — :reverse for linked accounts, :forward for manual — then hands off to Balance::Materializer.
Balance::Materializer#materialize_balances runs inside a single DB transaction:
- Materialize holdings via
Holding::Materializer(needed to split cash vs. non-cash for investment accounts). - Calculate balances — delegates to the appropriate calculator .
- Persist — bulk-upserts
Balancerows keyed on(account_id, date, currency). - Purge stale rows — deletes rows outside the newly computed date range. In incremental forward mode, pre-anchor rows are preserved using
calculator.calculation_start_dateas the lower bound . - Update account cache (forward strategy only) — writes the most-recent computed balance back to
account.balance/account.cash_balance.
ReverseCalculator (Linked Accounts)#
Source: app/models/balance/reverse_calculator.rb
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, split 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_anchorvaluations 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_adjustmentsfields record the gap between what flows predict and what the opening anchor actually says, preserving the audit trail.
Known gotcha: When current_balance ≠ opening_anchor_balance + Σ(recorded transactions), the entire unreconciled difference is absorbed as a one-day balance "plug" at the opening boundary — with no transaction backing it, breaking articulation (end = start + inflows − outflows). This typically manifests when transactions are missing or the opening anchor is stale. See issue #2497 for a full reproduction and discussion.
ForwardCalculator (Manual Accounts)#
Source: app/models/balance/forward_calculator.rb
Balance::ForwardCalculator iterates from calculation_start_date up to the last entry or holding date, applying daily flows to carry the balance forward.
Seeding: derives cash and non-cash components from opening_anchor_balance on opening_anchor_date. If the 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 (e.g. a user-entered reconciliation) exists for a date, that date's end-of-day balance is taken directly from the valuation rather than derived from flows .
Incremental mode: when window_start_date is provided, the calculator seeds from the persisted Balance row for window_start_date - 1 to avoid replaying the full history. It falls back to full recalculation when :
- No prior persisted balance exists for that date
- The prior balance has a non-zero non-cash component (investment holdings are always fully rematerialized)
- 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), which Materializer uses to determine purge bounds.
LinkedInvestmentSeriesNormalizer#
Source: app/models/balance/linked_investment_series_normalizer.rb
This class is not a calculator — it post-processes a pre-built balance Series to trim it to the earliest date with meaningful, stable data. It only applies to linked investment accounts .
supported_history_start_date is the minimum of two dates :
first_provider_activity_date— earliest date of a provider-sourced non-valuation entry (a real transaction or trade) .stable_provider_holding_start_date— earliest date where the provider's holding snapshot matches the current set of held securities, found by walking backward through daily holding snapshots until the security composition changes . This prevents showing misleading balance history from periods when the portfolio held entirely different positions.
The series is trimmed to exclude dates before this computed start date . If no matching dates exist in the trimmed series, the original series is returned unchanged.
Class-level aggregate_accounts performs the same trim across multiple accounts simultaneously, using the maximum of all per-account start dates so the aggregate series only covers the period where all accounts have valid data .
Why this matters: Providers often supply historical balance data that predates actual transaction availability — creating an artificially long activity line with misleading flat balance history. See issue #2570 for an example of this manifesting as a linked account's activity chart starting two years before any real transactions.
BaseCalculator — Shared Infrastructure#
Source: app/models/balance/base_calculator.rb
Both calculators inherit from Balance::BaseCalculator , which provides:
| Method | Purpose |
|---|---|
calculation_start_date | min(opening_anchor_date, oldest_entry_date) — ensures backfilled entries before the opening anchor are included |
flows_for_date | Categorizes entries into cash/non-cash inflows + outflows; investment trades invert sign (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 accounts → total; non-cash → 0 |
cash_adjustments_for_date | Residual after subtracting flows from the 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 .