Investment Account Flow Semantics#
Overview#
Investment accounts require inverted cash/non-cash flow semantics compared to regular transaction entries. The core insight: buying a security is simultaneously a cash outflow (money leaves the account) and a non-cash inflow (holdings value enters the account). The balance calculator in Balance::BaseCalculator models this dual-sided nature explicitly, keeping cash and holdings balances consistent without double-counting.
Entry Types and Sign Conventions#
All monetary events are stored as Entry rows. Investment accounts use two entryable types relevant here:
Transaction— income, expenses, deposits, withdrawals, transfers. Standard semantics:amount < 0= inflow (cash in),amount >= 0= outflow (cash out).Trade— buy/sell events. Same signed-amount storage, but interpreted inversely by the flow calculator.
Trade::CreateForm sets amount = signed_qty × price + fee, where signed_qty is positive for buys and negative for sells . This means a buy produces a positive entry amount (outflow sign), and a sell produces a negative amount (inflow sign) — consistent with how cash moves, but inverted relative to what holdings are doing.
The Inversion Logic in flows_for_date#
BaseCalculator#flows_for_date separates Trade entries from Transaction entries and applies the inversion explicitly:
cash_inflows = txn_inflow_sum.abs + trade_cash_inflow_sum.abs # both add to cash
cash_outflows = txn_outflow_sum + trade_cash_outflow_sum # both drain cash
# Trades are inverse: cash outflow to buy = non-cash (holdings) inflow
non_cash_outflows = trade_cash_inflow_sum.abs # sell → holdings leave
non_cash_inflows = trade_cash_outflow_sum # buy → holdings enter
The comment in the source is explicit: "Trades are inverse (a 'buy' is outflow of cash, but 'inflow' of non-cash, aka 'holdings')" .
Transactions are never assigned to non-cash flows; only trades move the holdings dimension.
How Balances Are Derived#
For investment accounts, end_non_cash_balance is taken directly from holdings_value_for_date() rather than being accumulated from flows . Holdings values come from the Balance::SyncCache, which snapshots the materialized holdings table at sync time .
Cash balance is then: total_balance − holdings_value .
Market value change (price appreciation, not trades) is isolated in market_value_change_on_date:
net_market_flows = Δholdings_value − net_buy_sell_value
This strips out the effect of buys/sells so only genuine price-driven changes are attributed to net_market_flows.
Asset/Liability Sign Factor#
All flow values stored in Balance rows are multiplied by flows_factor: +1 for asset accounts, -1 for liabilities . Investment accounts are always assets, so flows_factor = +1 and no additional sign flip occurs there.
Quick Reference#
| Entry type | amount < 0 (negative) | amount >= 0 (positive) |
|---|---|---|
Transaction | Cash inflow | Cash outflow |
Trade (buy) | — | Cash outflow + non-cash inflow (holdings ↑) |
Trade (sell) | Cash inflow + non-cash outflow (holdings ↓) | — |
Key Files#
| File | Purpose |
|---|---|
app/models/balance/base_calculator.rb | Core flow categorization and inversion logic |
app/models/trade/create_form.rb | Sets signed amount and qty on trade entries |
app/models/trade.rb | buy?/sell? helpers based on qty sign |