Files
sure/test/models/holding/portfolio_cache_test.rb
StalkerSea e4cfee0267 Fix income trades (interest/dividend) wiping security holdings to $0 (#2673)
Income trades created via Trade::CreateForm#create_income_trade set
price: 0 on the Trade record. This zero price was ingested by
PortfolioCache#load_prices as a valid price source, causing
ForwardCalculator#build_holdings to compute holding amount = qty * 0 = $0,
and overriding the security's market price on that date.

Additionally, Balance::BaseCalculator#flows_for_date misclassified income
trades (qty=0) as sells, producing spurious non_cash_outflows.

Fixes:
- PortfolioCache#load_prices: filter out zero-price trades from trade
  price sources using .select { |t| t.entryable.price&.positive? }
- Balance::BaseCalculator#flows_for_date: separate income trades from
  regular trades using their qty (income trades have qty=0), so they
  contribute only to cash flows, not non-cash flows
- Balance::SyncCache#converted_entries: preserve the entryable association
  target on duped entries so that e.entryable.qty access doesn't N+1

Fixes #2672
2026-07-14 01:33:35 +02:00

123 lines
3.4 KiB
Ruby

require "test_helper"
class Holding::PortfolioCacheTest < ActiveSupport::TestCase
include EntriesTestHelper, ProviderTestHelper
setup do
@provider = mock
Security.stubs(:provider).returns(@provider)
@account = families(:empty).accounts.create!(
name: "Test Brokerage",
balance: 10000,
currency: "USD",
accountable: Investment.new
)
@security = Security.create!(name: "Test Security", ticker: "TEST", exchange_operating_mic: "TEST")
@trade = create_trade(@security, account: @account, qty: 1, date: 2.days.ago.to_date, price: 210.23).trade
end
test "gets price from DB if available" do
db_price = 210
Security::Price.create!(
security: @security,
date: Date.current,
price: db_price
)
cache = Holding::PortfolioCache.new(@account)
assert_equal db_price, cache.get_price(@security.id, Date.current).price
end
test "if no price from db, try getting the price from trades" do
Security::Price.destroy_all
cache = Holding::PortfolioCache.new(@account)
assert_equal @trade.price, cache.get_price(@security.id, @trade.entry.date).price
end
test "if no price from db or trades, search holdings" do
Security::Price.delete_all
Entry.delete_all
holding = Holding.create!(
security: @security,
account: @account,
date: Date.current,
qty: 1,
price: 250,
amount: 250 * 1,
currency: "USD"
)
cache = Holding::PortfolioCache.new(@account, use_holdings: true)
assert_equal holding.price, cache.get_price(@security.id, holding.date).price
end
test "excludes income trades with zero price from trade price sources" do
Security::Price.destroy_all
@account.entries.create!(
name: "Interest: TEST",
date: Date.current,
amount: -50,
currency: "USD",
entryable: Trade.new(
qty: 0,
security: @security,
price: 0,
currency: "USD",
investment_activity_label: "Interest"
)
)
cache = Holding::PortfolioCache.new(@account)
# Income trade price=0 should be excluded; with no DB price for today,
# get_price returns nil instead of 0.
assert_nil cache.get_price(@security.id, Date.current)
# The buy trade's price is still available on its own date.
assert_equal @trade.price, cache.get_price(@security.id, @trade.entry.date).price
end
test "converts historical prices using the requested date exchange rate" do
account = families(:empty).accounts.create!(
name: "CHF Brokerage",
balance: 10000,
currency: "CHF",
accountable: Investment.new
)
holding_date = 2.days.ago.to_date
ExchangeRate.create!(from_currency: "USD", to_currency: "CHF", date: holding_date, rate: 0.80)
ExchangeRate.create!(from_currency: "USD", to_currency: "CHF", date: Date.current, rate: 0.95)
Holding.create!(
security: @security,
account: account,
date: holding_date,
qty: 1,
price: 100,
amount: 100,
currency: "USD"
)
Security::Price.create!(
security: @security,
date: holding_date,
price: 100,
currency: "USD"
)
cache = Holding::PortfolioCache.new(account, use_holdings: true)
converted_price = cache.get_price(@security.id, holding_date)
assert_equal BigDecimal("80.0"), converted_price.price
assert_equal "CHF", converted_price.currency
end
end