Investment Account Data Pipeline#
Investment accounts maintain two parallel materialized datasets that must remain in sync after every sync cycle:
| System | Table | Purpose |
|---|---|---|
| Holdings | holdings | Per-security snapshots (qty, price, amount, cost_basis) β drives returns tracking |
| Balances | balances | Daily balance rows (end_balance, net_market_flows, etc.) β drives trends and charts |
These systems are tightly coupled: balance calculation reads live holdings data to split cash vs. non-cash balances and to compute net_market_flows. When the holdings materialization step is incomplete or stale, both systems diverge.
The full pipeline is orchestrated by Balance::Materializer#materialize_balances, which runs all steps inside a single Balance.transaction. The entry point from the sync side is Account::Syncer#perform_sync.
Holdings System (Returns Tracking)#
Holdings are app-level materialized snapshots of an account's security positions, produced on every sync by Holding::Materializer.
Holding model β keyed by (account_id, security_id, date, currency). Key fields:
qty,price,amountβ position size and valuationcost_basis,cost_basis_source,cost_basis_lockedβ source priority ismanual>calculated>provider; locked values are never overwrittenavg_costβ returns the storedcost_basisif present and positive; falls back to a weighted-average calculation from tradestrendβ computes unrealized gain/loss as(current_amount) β (qty Γ avg_cost)
Holding::Materializer delegates to one of two calculators :
Holding::ForwardCalculatorβ manual accounts; accumulates weighted-average cost chronologicallyHolding::ReverseCalculatorβ linked/synced accounts; works backward from the latest provider snapshot
After persisting, Holding::Materializer explicitly reloads account.holdings to clear the ActiveRecord association cache before the balance calculator initializes its own SyncCache.
Provider-sourced holdings (account_provider_id present) are preserved and never overwritten by calculated rows . Calculated rows that collide with a provider snapshot on the same (date, security, currency) key are cleaned up .
Balance System (Trends Tracking)#
Balance rows are keyed by (account_id, date, currency) and store the full daily snapshot needed for charts and return calculations.
Key columns on the balances table:
| Column | Description |
|---|---|
end_balance, end_cash_balance, end_non_cash_balance | End-of-day total, cash, and holdings values |
start_balance, start_cash_balance, start_non_cash_balance | Start-of-day values (for chart deltas) |
net_market_flows | Market-driven value change β excludes buys/sells |
cash_inflows, cash_outflows, non_cash_inflows, non_cash_outflows | Flow components |
flows_factor | +1 for assets, -1 for liabilities (sign convention) |
net_market_flows is calculated in BaseCalculator#market_value_change_on_date as:
net_market_flows = (end_of_day_holdings_value β start_of_day_holdings_value) β (non_cash_inflows β non_cash_outflows)
This isolates price appreciation/depreciation from trade activity.
Balance::SyncCache memoizes holdings values by date in a single pass over account.holdings at the start of balance calculation. This is why the holdings reload in Holding::Materializer is essential β the cache reads holdings exactly once, so any stale association would propagate through the entire balance series.
For investment accounts, end_non_cash_balance is taken directly from holdings_value_for_date() rather than being derived from flows , making the holdings table the authoritative source for that column.
Balance::ChartSeriesBuilder queries end_balance, end_cash_balance, and end_non_cash_balance from the balances table via a lateral-join SQL query with FX conversion.
Pipeline Ordering and Atomicity#
Balance::Materializer#materialize_balances runs all steps in a single Balance.transaction in strict order:
materialize_holdingsβHolding::Materializer(holdings table updated, AR cache reloaded)calculate_balancesβReverseCalculatororForwardCalculator(reads fresh holdings viaSyncCache)persist_balancesβ bulk upsert keyed on(account_id, date, currency)purge_stale_balancesβ delete rows outside the computed date windowupdate_account_infoβ (forward strategy only) write-back toaccount.balance/account.cash_balance
The transaction guarantees atomicity: a failure in Holding::Materializer rolls back everything, preventing a partial state where balances reflect stale holdings .
Incremental forward sync edge case: Balance::ForwardCalculator supports incremental recalculation seeded from the most recent persisted Balance row instead of replaying from the opening anchor. However, it falls back to full recalculation when the prior balance has a non-zero non-cash component β because Holding::Materializer always performs a full recalculation, so the persisted start_non_cash_balance seed would be stale relative to freshly computed holding prices . The incremental? flag reflects whether the calculator actually ran incrementally versus falling back.
Divergence Scenarios#
The two systems can diverge when materialization is incomplete or bypassed:
1. Stale SyncCache on interrupted sync
If Holding::Materializer is bypassed without raising (e.g., a code path that skips holdings for a non-investment account type incorrectly classified), Balance::SyncCache will initialize holdings_value_by_date from old holdings data . The resulting net_market_flows and end_non_cash_balance on every Balance row will be wrong for that sync cycle.
2. Incremental balance with stale non-cash seed
Without the fallback safeguard in ForwardCalculator, an incremental sync would seed start_non_cash_balance from a persisted balance row that no longer reflects current holding prices, producing incorrect cash/non-cash splits going forward .
3. Goals progress basis divergence
Investment-backed goals with progress_basis = contributions compute current value as balance β Ξ£ net_market_flows from the balances table . If balance rows have stale or missing net_market_flows, goal progress will misrepresent contributions vs. market gains.
Diagnostic signals:
- If
end_non_cash_balanceon a balance row doesn't match the sum ofholding.amountvalues for that date, holdings and balances have diverged. net_market_flows = 0consistently across all days on an investment account (with no trades) may indicate thatholdings_value_by_datewas empty when the balance was calculated.
Key Consumers#
| Consumer | Data Source | What It Reads |
|---|---|---|
InvestmentStatement#period_return_trend | balances | SUM(net_market_flows) for period absolute return; start value from most recent pre-period end_balance |
InvestmentStatement#unrealized_gains_trend | holdings | current_holdings with avg_cost for unrealized gain/loss across all securities |
InvestmentStatement#current_holdings | holdings | Latest holding per (account_id, security_id) with non-zero qty |
Balance::ChartSeriesBuilder | balances | end_balance, end_cash_balance, end_non_cash_balance for balance/cash/holdings chart series |
Goal contributions progress basis | balances | Ξ£ net_market_flows to subtract market gains from current balance |
Related files:
app/models/balance/materializer.rbβ pipeline orchestratorapp/models/holding/materializer.rbβ holdings materializationapp/models/balance/base_calculator.rbβ shared balance calculation logic includingnet_market_flowsapp/models/balance/sync_cache.rbβ holdings value lookup used during balance calculationapp/models/holding.rbβHoldingmodel with cost basis logicapp/models/investment_statement.rbβ investment-level aggregations