Investment Holdings Management#
Overview#
Investment holdings are app-level materialized snapshots of an account's security positions — not computed on demand. Each Holding row is keyed by (account_id, security_id, date, currency) and stores qty, price, amount, cost_basis, cost_basis_source, and cost_basis_locked .
Holding::Materializer is the entry point for all holding production. It delegates to one of two calculators based on account type :
| Account type | Calculator | Strategy |
|---|---|---|
| Manual (no provider) | Holding::ForwardCalculator | Accumulate chronologically from trades |
| Linked / synced | Holding::ReverseCalculator | Work backward from the latest provider snapshot |
Cost basis has a strict priority hierarchy enforced by Holding::CostBasisReconciler : manual > calculated > provider. Locked values (cost_basis_locked: true) are never overwritten by any sync or import path .
After persisting, Holding::Materializer explicitly reloads account.holdings to clear the ActiveRecord association cache before balance calculation begins .
ForwardCalculator — Manual Accounts#
Holding::ForwardCalculator is used for all accounts without a provider link (strategy :forward). It:
- Initializes an empty portfolio keyed by
security_id → qty. - Iterates day-by-day from
account.start_datetoDate.current. - Calls
update_cost_basis_trackerfor each day's trades, accumulating a weighted-average cost from buy trades only (qty > 0) . Trade prices are converted to account currency viaMoney#exchange_to; aConversionErrorfalls back to the raw price . - Builds
Holdingobjects viabuild_holdings, which looks up prices throughHolding::PortfolioCache#get_price. - Calls
Holding.gapfillon the full series to fill date gaps .
Multi-currency limitation: PortfolioCache#get_price currently converts prices to account.currency and stamps that as the holding's currency — so all manual holdings are materialized in account currency regardless of the trade's original price currency. Provider-imported holdings do not have this constraint. See the known issue below .
ReverseCalculator — Linked Accounts#
Holding::ReverseCalculator (strategy :reverse) is used for accounts linked to a provider (Plaid, SimpleFIN, SnapTrade, etc.). It works backward from the latest provider snapshot, reversing the effect of trades on each past date to reconstruct historical positions. It precomputes cost-basis snapshots from all trades using a running weighted-average method and uses binary search to retrieve the applicable cost basis for any historical date .
Provider snapshot authority: Holding rows with account_provider_id set are authoritative. Holding::Materializer#persist_holdings skips any calculated row that would overwrite a provider-sourced row on the same key . Two additional cleanup passes run after persist:
cleanup_shadowed_calculated_holdings— deletes calculated rows where a provider snapshot exists for the exact same(date, security, currency).cleanup_stale_calculated_rows_on_latest_provider_snapshot— on the provider's latest snapshot date, removes all non-provider rows for securities that appear in that snapshot (even if in a different currency than the calculated rows).
Provider cost-basis carry-forward: For calculated rows that exist between provider snapshots, carry_forward_provider_cost_basis looks up the most recent provider-supplied cost_basis on or before the holding date, with FX conversion when currencies differ .
Provider Holding Import: Account::ProviderImportAdapter#import_holding#
Account::ProviderImportAdapter#import_holding is the shared upsert layer called by all provider processors (SimpleFIN, Plaid, etc.) when ingesting a position snapshot from a broker. Key fields: security, quantity, amount, currency, date, price, cost_basis, external_id, source, and account_provider_id .
Matching strategy (applied in order when external_id is present):
- Exact
external_idmatch. - Fallback by
provider_securityfield for remapped securities, scoped bydate,currency, andaccount_provider_id. - Fallback by provider-security ticker, also scoped by
account_provider_id. - Composite key
(security, date, currency), optionally filtered byaccount_provider_id.
When no external_id is provided, find_or_initialize_by on the composite key is used .
Cross-provider collision guards: Before writing, if the composite key already belongs to a different account_provider_id, the method logs a warning and returns the existing holding without modification. The same check applies inside the RecordNotUnique rescue handler — a row owned by a different provider is never claimed .
Cost basis: Holding::CostBasisReconciler.reconcile is called with incoming_source: "provider" before save and again after a uniqueness collision, ensuring the priority hierarchy (manual > calculated > provider) is respected in all cases .
Bulk Import via Family::DataImporter (SureImport NDJSON)#
Family::DataImporter#import_holdings handles Holding records in the SureImport NDJSON format, which is used for full data portability (export/re-import). It runs inside a single Import.transaction alongside accounts, trades, valuations, and other entity types — holdings are imported after accounts and trades to satisfy referential dependencies .
Security resolution: Each holding record is resolved to a Security via find_or_create_security, which checks a session-scoped in-memory cache first, then looks up by (ticker, exchange_operating_mic), falling back to a best-effort ticker-only match or a new unsaved Security object .
Preserved fields: The importer passes through cost_basis, cost_basis_source, cost_basis_locked, and security_locked directly from the NDJSON payload , so exported locked or manually-set cost basis values survive a round-trip.
Upsert helper: upsert_imported_holding! uses find_or_initialize_by(security:, date:, currency:) and wraps the save in a requires_new: true transaction to catch RecordNotUnique races — on collision it falls back to a direct update! on the existing row.
Supported NDJSON entity types include Account, Trade, Holding, Valuation, and all common financial record types .
Known Issue: Multi-Currency Manual Holdings#
Bug (#2782): Manual investment holdings in multi-currency accounts are normalized to account currency at materialization time, while provider-imported holdings retain their native currency — creating an inconsistency in how multi-currency positions are reported.
Root cause: Holding::PortfolioCache#get_price converts prices to account.currency and returns a Security::Price stamped with currency: account.currency. ForwardCalculator#build_holdings then constructs Holding records using that converted price/currency .
History: The inconsistency accumulated across several PRs — account-currency normalization was introduced in #1531 (Dec 2024) and hardened in a Mar 2025 commit, while the provider snapshot path was later updated (#1722, May 2026) to handle multi-currency holdings correctly without back-porting the fix to the manual path .
Affected code paths to audit for a fix:
Holding::PortfolioCache#get_priceHolding::ForwardCalculator#build_holdingsAccount#current_holdings(currently filters manual holdings bycurrency: account.currency)- Cost basis conversion logic (some paths explicitly convert to account currency)