Files
sure/test/models/investment_statement_test.rb
ghost 894d705e83 perf(dashboard): streamline investment activity totals (#2257)
* perf(dashboard): streamline investment activity totals

* fix(dashboard): skip empty investment totals cache writes

Short-circuit empty investment account totals before cache fetch and move the new SQL query capture regression helper into test/support.

* refactor(dashboard): address review on investment totals query

- Drop defensive ArgumentError guards in InvestmentStatement::Totals#initialize.
  The sole caller (totals_query) always passes correct types, and the
  empty_result early-return already handles the zero-accounts case.
- Remove the redundant accounts JOIN and its family_id/status filters from the
  aggregation SQL. account_ids is already scoped to the family's visible
  (draft/active) investment accounts, so the join back to accounts is
  unnecessary work in the query plan. Drop the now-unused family_id param.
- Add a regression assertion that the aggregate no longer joins accounts.
2026-06-18 20:18:18 +02:00

295 lines
11 KiB
Ruby

require "test_helper"
class InvestmentStatementTest < ActiveSupport::TestCase
setup do
@family = families(:empty)
# families(:empty) defaults to currency "USD"
@statement = InvestmentStatement.new(@family, user: nil)
end
test "portfolio_value and cash_balance with a single-currency family" do
create_investment_account(balance: 1000, cash_balance: 100)
assert_equal 1000, @statement.portfolio_value
assert_equal 100, @statement.cash_balance
assert_equal 900, @statement.holdings_value
end
test "portfolio_value converts foreign-currency accounts to family currency" do
create_investment_account(balance: 1921.92, cash_balance: -162, currency: "USD")
create_investment_account(balance: 1000, cash_balance: 1000, currency: "EUR")
ExchangeRate.create!(
from_currency: "EUR",
to_currency: "USD",
date: Date.current,
rate: 1.1
)
# 1921.92 + 1000 * 1.1 = 3021.92
assert_in_delta 3021.92, @statement.portfolio_value, 0.001
# -162 + 1000 * 1.1 = 938
assert_in_delta 938, @statement.cash_balance, 0.001
# 3021.92 - 938 = 2083.92
assert_in_delta 2083.92, @statement.holdings_value, 0.001
end
test "portfolio_value falls back to 1:1 when FX rate is missing" do
create_investment_account(balance: 1921.92, currency: "USD")
create_investment_account(balance: 1000, currency: "EUR")
# No ExchangeRate row → rates_for defaults to 1
assert_in_delta 2921.92, @statement.portfolio_value, 0.001
end
test "current_holdings includes holdings from every investment account regardless of currency" do
usd_account = create_investment_account(balance: 2100, currency: "USD")
eur_account = create_investment_account(balance: 2000, currency: "EUR")
usd_security = Security.create!(ticker: "AAPL", name: "Apple")
eur_security = Security.create!(ticker: "ASML", name: "ASML")
Holding.create!(
account: usd_account, security: usd_security, date: Date.current,
qty: 10, price: 210, amount: 2100, currency: "USD"
)
Holding.create!(
account: eur_account, security: eur_security, date: Date.current,
qty: 4, price: 500, amount: 2000, currency: "EUR"
)
assert_equal 2, @statement.current_holdings.count
end
test "top_holdings ranks by family-currency value across currencies" do
usd_account = create_investment_account(balance: 2100, currency: "USD")
eur_account = create_investment_account(balance: 2000, currency: "EUR")
usd_security = Security.create!(ticker: "AAPL", name: "Apple")
eur_security = Security.create!(ticker: "ASML", name: "ASML")
Holding.create!(
account: usd_account, security: usd_security, date: Date.current,
qty: 10, price: 210, amount: 2100, currency: "USD"
)
Holding.create!(
account: eur_account, security: eur_security, date: Date.current,
qty: 4, price: 500, amount: 2000, currency: "EUR"
)
ExchangeRate.create!(
from_currency: "EUR", to_currency: "USD",
date: Date.current, rate: 1.1
)
# 2000 EUR = 2200 USD > 2100 USD, so ASML outranks AAPL in family currency
top = @statement.top_holdings(limit: 2)
assert_equal %w[ASML AAPL], top.map(&:ticker)
end
test "allocation weights sum to 100% with mixed currencies" do
usd_account = create_investment_account(balance: 2100, currency: "USD")
eur_account = create_investment_account(balance: 2000, currency: "EUR")
usd_security = Security.create!(ticker: "AAPL", name: "Apple")
eur_security = Security.create!(ticker: "ASML", name: "ASML")
Holding.create!(
account: usd_account, security: usd_security, date: Date.current,
qty: 10, price: 210, amount: 2100, currency: "USD"
)
Holding.create!(
account: eur_account, security: eur_security, date: Date.current,
qty: 4, price: 500, amount: 2000, currency: "EUR"
)
ExchangeRate.create!(
from_currency: "EUR", to_currency: "USD",
date: Date.current, rate: 1.1
)
allocation = @statement.allocation
assert_equal 2, allocation.size
assert_in_delta 100.0, allocation.sum(&:weight), 0.01
# Every row is labeled in family currency
assert allocation.all? { |a| a.amount.currency.iso_code == "USD" }
end
test "unrealized_gains sums in family currency with mixed-currency holdings" do
usd_account = create_investment_account(balance: 2100, currency: "USD")
eur_account = create_investment_account(balance: 2000, currency: "EUR")
usd_security = Security.create!(ticker: "AAPL", name: "Apple")
eur_security = Security.create!(ticker: "ASML", name: "ASML")
Holding.create!(
account: usd_account, security: usd_security, date: Date.current,
qty: 10, price: 210, amount: 2100, currency: "USD",
cost_basis: 200, cost_basis_locked: true
)
Holding.create!(
account: eur_account, security: eur_security, date: Date.current,
qty: 4, price: 500, amount: 2000, currency: "EUR",
cost_basis: 450, cost_basis_locked: true
)
ExchangeRate.create!(
from_currency: "EUR", to_currency: "USD",
date: Date.current, rate: 1.1
)
# AAPL unrealized = 2100 - (10 * 200) = 100 USD
# ASML unrealized = 2000 - (4 * 450) = 200 EUR → 220 USD @ 1.1
# Total = 320 USD
assert_in_delta 320, @statement.unrealized_gains, 0.001
assert_equal "USD", @statement.unrealized_gains_money.currency.iso_code
end
test "unrealized_gains_trend is denominated in family currency" do
usd_account = create_investment_account(balance: 2100, currency: "USD")
eur_account = create_investment_account(balance: 2000, currency: "EUR")
usd_security = Security.create!(ticker: "AAPL", name: "Apple")
eur_security = Security.create!(ticker: "ASML", name: "ASML")
Holding.create!(
account: usd_account, security: usd_security, date: Date.current,
qty: 10, price: 210, amount: 2100, currency: "USD",
cost_basis: 200, cost_basis_locked: true
)
Holding.create!(
account: eur_account, security: eur_security, date: Date.current,
qty: 4, price: 500, amount: 2000, currency: "EUR",
cost_basis: 450, cost_basis_locked: true
)
ExchangeRate.create!(
from_currency: "EUR", to_currency: "USD",
date: Date.current, rate: 1.1
)
trend = @statement.unrealized_gains_trend
assert_equal "USD", trend.current.currency.iso_code
assert_equal "USD", trend.previous.currency.iso_code
# current = 2100 USD + (2000 EUR * 1.1) = 4300 USD
assert_in_delta 4300, trend.current.amount, 0.001
# previous (cost basis) = (10 * 200) USD + (4 * 450 * 1.1) EUR→USD = 2000 + 1980 = 3980 USD
assert_in_delta 3980, trend.previous.amount, 0.001
end
test "period_return_trend returns nil when no balance data in period" do
period = Period.custom(start_date: 10.years.ago.to_date, end_date: 9.years.ago.to_date)
assert_nil @statement.period_return_trend(period: period)
end
test "period_return_trend returns nil when start portfolio value is zero" do
account = create_investment_account(balance: 5000)
period = Period.custom(start_date: Date.current.beginning_of_month, end_date: Date.current)
# Balance only inside the period — nothing strictly before period_start means start_value = 0
account.balances.create!(
date: period.date_range.begin,
balance: 5000,
currency: @family.currency,
net_market_flows: 200
)
assert_nil @statement.period_return_trend(period: period)
end
test "period_return_trend returns Trend with correct absolute and percent return" do
account = create_investment_account(balance: 10_500)
period = Period.custom(start_date: Date.current.beginning_of_month, end_date: Date.current)
# Pre-period row: start_non_cash_balance drives end_balance (virtual stored column)
account.balances.create!(
date: period.date_range.begin - 1.day,
balance: 10_000,
currency: @family.currency,
start_non_cash_balance: 10_000,
net_market_flows: 0
)
# In-period row: 500 of market gains
account.balances.create!(
date: period.date_range.begin,
balance: 10_500,
currency: @family.currency,
start_non_cash_balance: 10_000,
net_market_flows: 500
)
trend = @statement.period_return_trend(period: period)
assert_not_nil trend
assert_in_delta 500, trend.value.amount, 1
assert_in_delta 5.0, trend.percent, 0.1
end
test "totals skips cache when there are no investment accounts" do
Rails.cache.expects(:fetch).never
totals = @statement.totals(period: Period.current_month)
assert_equal Money.new(0, "USD"), totals.contributions
assert_equal Money.new(0, "USD"), totals.withdrawals
assert_equal Money.new(0, "USD"), totals.dividends
assert_equal Money.new(0, "USD"), totals.interest
assert_equal 0, totals.trades_count
end
test "totals aggregate directly from trade entries" do
period = Period.custom(start_date: Date.current.beginning_of_month, end_date: Date.current)
shared_user = users(:new_email)
investment_account = create_investment_account(balance: 500)
hidden_account = create_investment_account(balance: 500)
investment_account.share_with!(shared_user, permission: "read_only", include_in_finances: true)
create_trade(account: investment_account, qty: 2, amount: 120, date: period.start_date)
create_trade(account: investment_account, qty: -1, amount: -40, date: period.start_date + 1.day)
create_trade(account: investment_account, qty: 1, amount: 999, date: period.start_date - 1.day)
create_trade(account: hidden_account, qty: 1, amount: 9999, date: period.start_date)
statement = InvestmentStatement.new(@family, user: shared_user)
totals = nil
queries = capture_sql_queries { totals = statement.totals(period: period) }
assert_equal Money.new(120, "USD"), totals.contributions
assert_equal Money.new(40, "USD"), totals.withdrawals
assert_equal 2, totals.trades_count
aggregate_queries = queries.grep(/SUM\(CASE WHEN trades\.qty > 0/)
assert_equal 1, aggregate_queries.size
assert_includes aggregate_queries.first, "FROM entries JOIN trades"
assert_includes aggregate_queries.first, "entries.entryable_type = 'Trade'"
assert_includes aggregate_queries.first, "entries.account_id IN"
assert_includes aggregate_queries.first, "entries.excluded = false"
assert_no_match(/FROM \(SELECT "trades"\.\*/, aggregate_queries.first)
# account_ids is pre-scoped to the family's visible accounts, so the
# aggregate trusts that input and no longer joins back to accounts.
assert_no_match(/JOIN accounts/, aggregate_queries.first)
end
private
def create_investment_account(balance:, cash_balance: 0, currency: "USD")
@family.accounts.create!(
name: "Investment #{SecureRandom.hex(3)}",
balance: balance,
cash_balance: cash_balance,
currency: currency,
accountable: Investment.new
)
end
def create_trade(account:, qty:, amount:, date:)
account.entries.create!(
name: "Trade #{SecureRandom.hex(3)}",
amount: amount,
date: date,
currency: account.currency,
entryable: Trade.new(
security: Security.create!(ticker: "T#{SecureRandom.hex(2)}", name: "Test Security"),
qty: qty,
price: amount.to_d.abs / qty.to_d.abs,
currency: account.currency
)
)
end
end