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

@@ -26,4 +26,53 @@ class Holding::MaterializerTest < ActiveSupport::TestCase
Holding::Materializer.new(@account, strategy: :forward).materialize_holdings
end
end
test "preserves provider cost_basis when trade-derived cost_basis is nil" do
# Simulate a provider-imported holding with cost_basis (e.g., from SimpleFIN)
# This is the realistic scenario: linked account with provider holdings but no trades
provider_cost_basis = BigDecimal("150.00")
holding = Holding.create!(
account: @account,
security: @aapl,
qty: 10,
price: 200,
amount: 2000,
currency: "USD",
date: Date.current,
cost_basis: provider_cost_basis
)
# Use :reverse strategy (what linked accounts use) - doesn't purge holdings
# The AAPL holding has no trades, so computed cost_basis is nil
# The materializer should preserve the provider cost_basis, not overwrite with nil
Holding::Materializer.new(@account, strategy: :reverse).materialize_holdings
holding.reload
assert_equal provider_cost_basis, holding.cost_basis,
"Provider cost_basis should be preserved when no trades exist for this security"
end
test "updates cost_basis when trade-derived cost_basis is available" do
# Create a holding with provider cost_basis
Holding.create!(
account: @account,
security: @aapl,
qty: 10,
price: 200,
amount: 2000,
currency: "USD",
date: Date.current,
cost_basis: BigDecimal("150.00") # Provider says $150
)
# Create a trade that gives us a different cost basis
create_trade(@aapl, account: @account, qty: 10, price: 180, date: Date.current)
# Use :reverse strategy - with trades, it should compute cost_basis from them
Holding::Materializer.new(@account, strategy: :reverse).materialize_holdings
holding = @account.holdings.find_by(security: @aapl, date: Date.current)
assert_equal BigDecimal("180.00"), holding.cost_basis,
"Trade-derived cost_basis should override provider cost_basis when available"
end
end

View File

@@ -75,6 +75,43 @@ class HoldingTest < ActiveSupport::TestCase
assert_in_delta -1.6, @nvda.trend.percent, 0.001
end
test "avg_cost returns nil when no trades exist and no stored cost_basis" do
# Holdings created without trades should return nil for avg_cost
# This prevents displaying fake $0 gain/loss based on current market price
assert_nil @amzn.avg_cost
assert_nil @nvda.avg_cost
end
test "avg_cost uses stored cost_basis when available" do
# Simulate provider-supplied cost_basis (e.g., from SimpleFIN)
@amzn.update!(cost_basis: 200.00)
assert_equal Money.new(200.00, "USD"), @amzn.avg_cost
end
test "avg_cost treats zero cost_basis as unknown" do
# Some providers return 0 when they don't have cost basis data
# This should be treated as "unknown" (return nil), not as $0 cost
@amzn.update!(cost_basis: 0)
assert_nil @amzn.avg_cost
end
test "trend returns nil when cost basis is unknown" do
# Without cost basis, we can't calculate unrealized gain/loss
assert_nil @amzn.trend
assert_nil @nvda.trend
end
test "trend works when avg_cost is available" do
@amzn.update!(cost_basis: 214.00)
# Current price is 216, cost basis is 214
# Qty is 15, so gain = 15 * (216 - 214) = $30
assert_not_nil @amzn.trend
assert_equal Money.new(30), @amzn.trend.value
end
private
def load_holdings