Improve handling of cost_basis during holding materialization and display (#619)

- Refactored `persist_holdings` to separate and conditionally upsert holdings with and without cost_basis.
- Updated `avg_cost` logic to treat 0 cost_basis as unknown and return nil when cost_basis cannot be determined.
- Modified trend and investment calculation to exclude holdings with unknown cost_basis.
- Adjusted `average_cost` formatting to handle nil values in API responses and views.
- Added comprehensive tests to ensure cost_basis preservation and fallback behavior.
- Localized `unknown` label for display when cost_basis is unavailable.

Co-authored-by: Josh Waldrep <joshua.waldrep5+github@gmail.com>
This commit is contained in:
LPW
2026-01-11 17:58:51 -05:00
committed by GitHub
parent 9aa9b3a1b0
commit fa78e1d292
8 changed files with 141 additions and 22 deletions

View File

@@ -105,8 +105,8 @@ class Assistant::Function::GetHoldings < Assistant::Function
amount: holding.amount.to_f,
formatted_amount: holding.amount_money.format,
weight: holding.weight&.round(2),
average_cost: holding.avg_cost.to_f,
formatted_average_cost: holding.avg_cost.format,
average_cost: holding.avg_cost&.to_f,
formatted_average_cost: holding.avg_cost&.format,
account: holding.account.name,
date: holding.date
}

View File

@@ -27,12 +27,16 @@ class Holding < ApplicationRecord
account.balance.zero? ? 1 : amount / account.balance * 100
end
# Basic approximation of cost-basis
# Returns average cost per share, or nil if unknown.
#
# Uses pre-computed cost_basis if available (set during materialization),
# otherwise falls back to calculating from trades
# otherwise falls back to calculating from trades. Returns nil when cost
# basis cannot be determined (no trades and no provider cost_basis).
def avg_cost
# Use stored cost_basis if available (eliminates N+1 queries)
return Money.new(cost_basis, currency) if cost_basis.present?
# Use stored cost_basis if available and positive (eliminates N+1 queries)
# Note: cost_basis of 0 is treated as "unknown" since providers sometimes
# return 0 when they don't have the data
return Money.new(cost_basis, currency) if cost_basis.present? && cost_basis.positive?
# Fallback to calculation for holdings without pre-computed cost_basis
calculate_avg_cost
@@ -75,6 +79,7 @@ class Holding < ApplicationRecord
private
def calculate_trend
return nil unless amount_money
return nil unless avg_cost # Can't calculate trend without cost basis
start_amount = qty * avg_cost
@@ -83,6 +88,8 @@ class Holding < ApplicationRecord
previous: start_amount
end
# Calculates weighted average cost from buy trades.
# Returns nil if no trades exist (cost basis is unknown).
def calculate_avg_cost
trades = account.trades
.with_entry
@@ -101,13 +108,10 @@ class Holding < ApplicationRecord
Arel.sql("SUM(trades.qty)")
)
weighted_avg =
if total_qty && total_qty > 0
total_cost / total_qty
else
price
end
# Return nil when no trades exist - cost basis is genuinely unknown
# Previously this fell back to current market price, which was misleading
return nil unless total_qty && total_qty > 0
Money.new(weighted_avg || price, currency)
Money.new(total_cost / total_qty, currency)
end
end

View File

@@ -27,14 +27,38 @@ class Holding::Materializer
end
def persist_holdings
return if @holdings.empty?
current_time = Time.now
account.holdings.upsert_all(
@holdings.map { |h| h.attributes
.slice("date", "currency", "qty", "price", "amount", "security_id", "cost_basis")
.merge("account_id" => account.id, "updated_at" => current_time) },
unique_by: %i[account_id security_id date currency]
)
# Separate holdings into those with and without computed cost_basis
holdings_with_cost_basis, holdings_without_cost_basis = @holdings.partition { |h| h.cost_basis.present? }
# Upsert holdings that have computed cost_basis (from trades)
# These will overwrite any existing provider cost_basis with the trade-derived value
if holdings_with_cost_basis.any?
account.holdings.upsert_all(
holdings_with_cost_basis.map { |h|
h.attributes
.slice("date", "currency", "qty", "price", "amount", "security_id", "cost_basis")
.merge("account_id" => account.id, "updated_at" => current_time)
},
unique_by: %i[account_id security_id date currency]
)
end
# Upsert holdings WITHOUT cost_basis column - preserves existing provider cost_basis
# This handles securities that have no trades (e.g., SimpleFIN-only holdings)
if holdings_without_cost_basis.any?
account.holdings.upsert_all(
holdings_without_cost_basis.map { |h|
h.attributes
.slice("date", "currency", "qty", "price", "amount", "security_id")
.merge("account_id" => account.id, "updated_at" => current_time)
},
unique_by: %i[account_id security_id date currency]
)
end
end
def purge_stale_holdings

View File

@@ -133,8 +133,12 @@ class InvestmentStatement
holdings = current_holdings.to_a
return nil if holdings.empty?
current = holdings.sum(&:amount)
previous = holdings.sum { |h| h.qty * h.avg_cost.amount }
# Only include holdings with known cost basis in the calculation
holdings_with_cost_basis = holdings.select(&:avg_cost)
return nil if holdings_with_cost_basis.empty?
current = holdings_with_cost_basis.sum(&:amount)
previous = holdings_with_cost_basis.sum { |h| h.qty * h.avg_cost.amount }
Trend.new(current: current, previous: previous)
end