SimpleFIN Holdings Import#
The holdings import pipeline ingests investment position snapshots from the SimpleFIN API and persists them as Holding rows in Sure's database. It is triggered as part of the per-account processing step for Investment and Crypto account types only .
Entry points:
SimplefinAccount::Investments::HoldingsProcessor#process(app/models/simplefin_account/investments/holdings_processor.rb) — iterates raw holdings fromsimplefin_account.raw_holdings_payload, resolves each to aSecurity, and callsAccount::ProviderImportAdapter#import_holding.SimplefinHoldingsApplyJob(app/jobs/simplefin_holdings_apply_job.rb) — thin orchestration job that instantiatesHoldingsProcessorand is a safe no-op when the account is missing, unlinked, or has no payload . Errors are caught and logged as warnings rather than raised.Account::ProviderImportAdapter#import_holding(app/models/account/provider_import_adapter.rb) — generic upsert layer shared with Plaid; handles external-ID matching, cost basis reconciliation, and cross-provider collision guards.
Data source: Holdings are read from simplefin_accounts.raw_holdings_payload (a jsonb column). Each element in the array is a SimpleFIN holding object from the /accounts API response .
Date handling: Holdings always use Date.current as the snapshot date regardless of the created timestamp in the provider payload, because SimpleFIN's created reflects when the holding was first seen, not when it was observed by Sure .
Field Parsing and Fallback Chains#
Because SimpleFIN brokerages vary in which fields they populate, the processor uses multi-key fallback lookups for every numeric field :
| Field | Keys tried (in order) |
|---|---|
| Quantity | shares, quantity, qty, units |
| Market value | market_value, current_value |
| Cost basis | cost_basis, basis, total_cost, value |
| Fallback price | purchase_price, price, unit_price, average_cost, avg_cost |
Price derivation: When both qty > 0 and market_value > 0, price is computed as market_value / qty. Otherwise the fallback price field is used .
value field exclusion from market value: value is intentionally excluded from the market_value fallback chain because some brokerages (e.g. Vanguard, Fidelity) use it to mean cost basis — using it as current price would display average cost as market value .
Zero-position filtering: Holdings where both qty and computed_amount are zero are skipped to avoid invisible rows .
Decimal parsing: All numeric values are parsed via BigDecimal. Invalid strings log an error and fall back to 0 rather than raising .
Security Resolution and Ticker Ambiguity#
Security resolution is the most complex step. The processor calls resolve_security(symbol, description), which applies several normalizations before passing the ticker to Security::Resolver.
Ambiguous tickers (ETH and others): Symbols like ETH, BTC, SOL, DOGE, LTC, BCH could be either a cryptocurrency or an equity ticker. The processor resolves the ambiguity by checking three conditions :
accountable_type == "Crypto"on the linked account- The symbol itself is in the crypto allowlist (
BTC,ETH,SOL,DOGE,LTC,BCH) - The description contains the word "crypto"
If any condition is true, the ticker is prefixed with CRYPTO: (e.g., ETH → CRYPTO:ETH), routing it to the crypto security namespace rather than colliding with any equity using the same ticker. An ETH holding on a standard Investment account without a crypto-related description will not receive the CRYPTO: prefix and will be resolved as an equity — this is a known ambiguity gap.
Missing symbols → synthetic tickers: If a holding has no symbol but has a description, a CUSTOM: ticker is generated: the description is normalized to alphanumeric, truncated to 24 chars, and appended with a 5-char MD5 hex suffix for uniqueness . These CUSTOM: securities bypass Security::Resolver entirely and are created directly as offline securities .
Resolver fallback: For all non-custom tickers, Security::Resolver tries an exact DB match → provider search → offline security creation. If the resolver raises for any reason, the processor catches the exception, logs a warning, and falls back to Security.find_or_initialize_by(ticker: sym) with offline: true .
Cost Basis Normalization#
Sure stores cost_basis as per-share average cost, but SimpleFIN brokerages are inconsistent about whether their fields report per-share or total-position cost .
The normalize_cost_basis method applies this logic:
total_cost/value— always treated as total position cost; divided byqtyunconditionally.cost_basis/basis— treated as per-share by default (spec-compliant behavior).- Exception allowlist — Vanguard and Fidelity are known to populate
cost_basis/basiswith total position cost in violation of the spec (issues #1718, #1182). For connections whereorg_data.nameororg_data.domaincontains"vanguard"or"fidelity"(case-insensitive), these fields are also divided byqty.
A prior magnitude-heuristic approach was withdrawn because a holding with a large unrealized loss (e.g., 100 shares at basis $100, now worth $5) would have its per-share basis incorrectly divided to $1/share, corrupting compliant providers . The allowlist trades manual maintenance for correctness.
Cost basis is then reconciled through Holding::CostBasisReconciler inside import_holding, which enforces the priority hierarchy: manual > calculated > provider — locked values are never overwritten by sync .
Currency Handling and Validation Gaps#
Currency assignment: The processor passes simplefin_holding["currency"].presence || "USD" to import_holding . There is no ISO 4217 validation at this layer — the Holding model validates only presence, not code validity .
Invalid currency codes: If a brokerage sends a non-standard string (e.g., "ETH" or "CUSTOM" as a currency field), it is accepted and stored as-is. Downstream effects:
Holding#amount_in_account_currencycallsMoney#exchange_to, which wrapsMoney::Currency.new(currency)and can raise aConversionErrorfor unknown codes . The method rescues this and falls back to the rawamount— so the holding saves, but currency conversion silently returns an unconverted value.- Weight calculations and balance materialization that depend on currency conversion will produce incorrect results for holdings with invalid currency codes.
No validation stage: Unlike transaction import (which enforces external_id and source), import_holding has no pre-save currency guard. Invalid currency codes will pass Holding model validations as long as the field is non-blank.
Known ambiguity — ETH as currency: Some brokerage payloads set "currency": "ETH" on Ethereum holdings, treating the asset as a denomination unit rather than a security. The pipeline has no logic to detect or reclassify this pattern; such a holding will be stored with currency = "ETH" and security resolved as a crypto security, which may produce duplicate representation of the same asset.
Error Handling and Resilience#
The processor wraps each holding in a begin/rescue block — a per-holding error is logged and the loop continues, so one malformed holding does not abort the rest of the batch .
Skip conditions (a holding is silently skipped, not errored):
idfield is missing- Both
symbolanddescriptionare blank resolve_securityreturns nil (rare; the resolver always falls back to offline)qtyandamountare both zero
Logging: All skip events are logged at debug level with a structured JSON payload including sfa_id, account_id, holding id, and symbol. The holding processor's rescue clause logs at error level with the symbol context when available .
SimplefinHoldingsApplyJob safety: The job catches all errors from process and logs them as warnings, making the job safe to retry without side effects .
Key known gaps:
- No validation of
currencyagainst ISO 4217 — invalid codes silently corrupt currency-dependent calculations. ETH(and similar) is classified as a security or a currency based on account type and description heuristics, with no definitive signal from the SimpleFIN spec itself.TOTAL_BASIS_INSTITUTIONSallowlist for cost basis normalization must be manually maintained as new brokerages are discovered .