1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-24 07:39:39 +02:00

Plaid portfolio sync algorithm and calculation improvements (#1526)

* Start tests rework

* Cash balance on schema

* Add reverse syncer

* Reverse balance sync with holdings

* Reverse holdings sync

* Reverse holdings sync should work with only trade entries

* Consolidate brokerage cash

* Add forward sync option

* Update new balance info after syncs

* Intraday balance calculator and sync fixes

* Show only balance for trade entries

* Tests passing

* Update Gemfile.lock

* Cleanup, performance improvements

* Remove account reloads for reliable sync outputs

* Simplify valuation view logic

* Special handling for Plaid cash holding
This commit is contained in:
Zach Gollwitzer 2024-12-10 17:41:20 -05:00 committed by GitHub
parent a59ca5b7c6
commit 49c353e10c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 1152 additions and 1046 deletions

View file

@ -43,6 +43,7 @@ investment:
family: dylan_family
name: Robinhood Brokerage Account
balance: 10000
cash_balance: 5000
currency: USD
accountable_type: Investment
accountable: one

View file

@ -1 +1 @@
one: { }
one: {}

View file

@ -1,153 +0,0 @@
require "test_helper"
class Account::Balance::SyncerTest < ActiveSupport::TestCase
include Account::EntriesTestHelper
setup do
@account = families(:empty).accounts.create!(name: "Test", balance: 20000, currency: "USD", accountable: Depository.new)
@investment_account = families(:empty).accounts.create!(name: "Test Investment", balance: 50000, currency: "USD", accountable: Investment.new)
end
test "syncs account with no entries" do
assert_equal 0, @account.balances.count
run_sync_for @account
assert_equal [ @account.balance ], @account.balances.chronological.map(&:balance)
end
test "syncs account with valuations only" do
create_valuation(account: @account, date: 2.days.ago.to_date, amount: 22000)
run_sync_for @account
assert_equal 22000, @account.balance
assert_equal [ 22000, 22000, 22000 ], @account.balances.chronological.map(&:balance)
end
test "syncs account with transactions only" do
create_transaction(account: @account, date: 4.days.ago.to_date, amount: 100)
create_transaction(account: @account, date: 2.days.ago.to_date, amount: -500)
run_sync_for @account
assert_equal 20000, @account.balance
assert_equal [ 19600, 19500, 19500, 20000, 20000, 20000 ], @account.balances.chronological.map(&:balance)
end
test "syncs account with valuations and transactions when valuation starts" do
create_valuation(account: @account, date: 5.days.ago.to_date, amount: 20000)
create_transaction(account: @account, date: 3.days.ago.to_date, amount: -500)
create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100)
create_valuation(account: @account, date: 1.day.ago.to_date, amount: 25000)
run_sync_for(@account)
assert_equal 25000, @account.balance
assert_equal [ 20000, 20000, 20500, 20400, 25000, 25000 ], @account.balances.chronological.map(&:balance)
end
test "syncs account with valuations and transactions when transaction starts" do
new_account = families(:empty).accounts.create!(name: "Test Account", balance: 1000, currency: "USD", accountable: Depository.new)
create_transaction(account: new_account, date: 2.days.ago.to_date, amount: 250)
create_valuation(account: new_account, date: Date.current, amount: 1000)
run_sync_for(new_account)
assert_equal 1000, new_account.balance
assert_equal [ 1250, 1000, 1000, 1000 ], new_account.balances.chronological.map(&:balance)
end
test "syncs account with transactions in multiple currencies" do
ExchangeRate.create! date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2
create_transaction(account: @account, date: 3.days.ago.to_date, amount: 100, currency: "USD")
create_transaction(account: @account, date: 2.days.ago.to_date, amount: 300, currency: "USD")
create_transaction(account: @account, date: 1.day.ago.to_date, amount: 500, currency: "EUR") # €500 * 1.2 = $600
run_sync_for(@account)
assert_equal 20000, @account.balance
assert_equal [ 21000, 20900, 20600, 20000, 20000 ], @account.balances.chronological.map(&:balance)
end
test "converts foreign account balances to family currency" do
@account.update! currency: "EUR"
create_transaction(date: 1.day.ago.to_date, amount: 1000, account: @account, currency: "EUR")
create_exchange_rate(2.days.ago.to_date, from: "EUR", to: "USD", rate: 2)
create_exchange_rate(1.day.ago.to_date, from: "EUR", to: "USD", rate: 2)
create_exchange_rate(Date.current, from: "EUR", to: "USD", rate: 2)
with_env_overrides SYNTH_API_KEY: ENV["SYNTH_API_KEY"] || "fookey" do
run_sync_for(@account)
end
usd_balances = @account.balances.where(currency: "USD").chronological.map(&:balance)
eur_balances = @account.balances.where(currency: "EUR").chronological.map(&:balance)
assert_equal 20000, @account.balance
assert_equal [ 21000, 20000, 20000 ], eur_balances # native account balances
assert_equal [ 42000, 40000, 40000 ], usd_balances # converted balances at rate of 2:1
end
test "raises issue if missing exchange rates" do
create_transaction(date: Date.current, account: @account, currency: "EUR")
ExchangeRate.expects(:find_rate).with(from: "EUR", to: "USD", date: Date.current).returns(nil)
@account.expects(:observe_missing_exchange_rates).with(from: "EUR", to: "USD", dates: [ Date.current ])
syncer = Account::Balance::Syncer.new(@account)
syncer.run
end
# Account is able to calculate balances in its own currency (i.e. can still show a historical graph), but
# doesn't have exchange rates available to convert those calculated balances to the family currency
test "observes issue if exchange rate provider is not configured" do
@account.update! currency: "EUR"
syncer = Account::Balance::Syncer.new(@account)
@account.expects(:observe_missing_exchange_rate_provider)
with_env_overrides SYNTH_API_KEY: nil do
syncer.run
end
end
test "overwrites existing balances and purges stale balances" do
assert_equal 0, @account.balances.size
@account.balances.create! date: Date.current, currency: "USD", balance: 30000 # incorrect balance, will be updated
@account.balances.create! date: 10.years.ago.to_date, currency: "USD", balance: 35000 # Out of range balance, will be deleted
assert_equal 2, @account.balances.size
run_sync_for(@account)
assert_equal [ @account.balance ], @account.balances.chronological.map(&:balance)
end
test "partial sync does not affect balances prior to sync start date" do
existing_balance = @account.balances.create! date: 2.days.ago.to_date, currency: "USD", balance: 30000
transaction = create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100, currency: "USD")
run_sync_for(@account, start_date: 1.day.ago.to_date)
assert_equal [ existing_balance.balance, existing_balance.balance - transaction.amount, @account.balance ], @account.balances.chronological.map(&:balance)
end
private
def run_sync_for(account, start_date: nil)
syncer = Account::Balance::Syncer.new(account, start_date: start_date)
syncer.run
end
def create_exchange_rate(date, from:, to:, rate:)
ExchangeRate.create! date: date, from_currency: from, to_currency: to, rate: rate
end
end

View file

@ -0,0 +1,156 @@
require "test_helper"
class Account::BalanceCalculatorTest < ActiveSupport::TestCase
include Account::EntriesTestHelper
setup do
@account = families(:empty).accounts.create!(
name: "Test",
balance: 20000,
cash_balance: 20000,
currency: "USD",
accountable: Investment.new
)
end
# When syncing backwards, we start with the account balance and generate everything from there.
test "reverse no entries sync" do
assert_equal 0, @account.balances.count
expected = [ @account.balance ]
calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true)
assert_equal expected, calculated.map(&:balance)
end
# When syncing forwards, we don't care about the account balance. We generate everything based on entries, starting from 0.
test "forward no entries sync" do
assert_equal 0, @account.balances.count
expected = [ 0 ]
calculated = Account::BalanceCalculator.new(@account).calculate
assert_equal expected, calculated.map(&:balance)
end
test "forward valuations sync" do
create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000)
create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000)
expected = [ 0, 17000, 17000, 19000, 19000, 19000 ]
calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
assert_equal expected, calculated
end
test "reverse valuations sync" do
create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000)
create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000)
expected = [ 17000, 17000, 19000, 19000, 20000, 20000 ]
calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true).sort_by(&:date).map(&:balance)
assert_equal expected, calculated
end
test "forward transactions sync" do
create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income
create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense
expected = [ 0, 500, 500, 400, 400, 400 ]
calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
assert_equal expected, calculated
end
test "reverse transactions sync" do
create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income
create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense
expected = [ 19600, 20100, 20100, 20000, 20000, 20000 ]
calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true).sort_by(&:date).map(&:balance)
assert_equal expected, calculated
end
test "reverse multi-entry sync" do
create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000)
create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000)
create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500)
create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500)
create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000)
create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100)
expected = [ 12000, 17000, 17000, 17000, 16500, 17000, 17000, 20100, 20000, 20000 ]
calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true) .sort_by(&:date).map(&:balance)
assert_equal expected, calculated
end
test "forward multi-entry sync" do
create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000)
create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000)
create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500)
create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500)
create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000)
create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100)
expected = [ 0, 5000, 5000, 17000, 17000, 17500, 17000, 17000, 16900, 16900 ]
calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
assert_equal expected, calculated
end
test "investment balance sync" do
@account.update!(cash_balance: 18000)
# Transactions represent deposits / withdrawals from the brokerage account
# Ex: We deposit $20,000 into the brokerage account
create_transaction(account: @account, date: 2.days.ago.to_date, amount: -20000)
# Trades either consume cash (buy) or generate cash (sell). They do NOT change total balance, but do affect composition of cash/holdings.
# Ex: We buy 20 shares of MSFT at $100 for a total of $2000
create_trade(securities(:msft), account: @account, date: 1.day.ago.to_date, qty: 20, price: 100)
holdings = [
Account::Holding.new(date: Date.current, security: securities(:msft), amount: 2000),
Account::Holding.new(date: 1.day.ago.to_date, security: securities(:msft), amount: 2000),
Account::Holding.new(date: 2.days.ago.to_date, security: securities(:msft), amount: 0)
]
expected = [ 0, 20000, 20000, 20000 ]
calculated_backwards = Account::BalanceCalculator.new(@account, holdings: holdings).calculate(reverse: true).sort_by(&:date).map(&:balance)
calculated_forwards = Account::BalanceCalculator.new(@account, holdings: holdings).calculate.sort_by(&:date).map(&:balance)
assert_equal calculated_forwards, calculated_backwards
assert_equal expected, calculated_forwards
end
test "multi-currency sync" do
ExchangeRate.create! date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2
create_transaction(account: @account, date: 3.days.ago.to_date, amount: -100, currency: "USD")
create_transaction(account: @account, date: 2.days.ago.to_date, amount: -300, currency: "USD")
# Transaction in different currency than the account's main currency
create_transaction(account: @account, date: 1.day.ago.to_date, amount: -500, currency: "EUR") # €500 * 1.2 = $600
expected = [ 0, 100, 400, 1000, 1000 ]
calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
assert_equal expected, calculated
end
private
def create_holding(date:, security:, amount:)
Account::Holding.create!(
account: @account,
security: security,
date: date,
qty: 0, # not used
price: 0, # not used
amount: amount,
currency: @account.currency
)
end
end

View file

@ -43,13 +43,10 @@ class Account::EntryTest < ActiveSupport::TestCase
end
test "triggers sync with correct start date when transaction deleted" do
current_entry = create_transaction(date: 1.day.ago.to_date)
prior_entry = create_transaction(date: current_entry.date - 1.day)
@entry.destroy!
current_entry.destroy!
current_entry.account.expects(:sync_later).with(start_date: prior_entry.date)
current_entry.sync_account_later
@entry.account.expects(:sync_later).with(start_date: nil)
@entry.sync_account_later
end
test "can search entries" do
@ -99,26 +96,4 @@ class Account::EntryTest < ActiveSupport::TestCase
assert create_transaction(amount: -10).inflow?
assert create_transaction(amount: 10).outflow?
end
test "balance_after_entry skips account valuations" do
family = families(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
new_valuation = create_valuation(account: account, amount: 1)
transaction = create_transaction(date: new_valuation.date, account: account, amount: -100)
assert_equal Money.new(100), transaction.balance_after_entry
end
test "prior_entry_balance returns last transaction entry balance" do
family = families(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
new_valuation = create_valuation(account: account, amount: 1)
transaction = create_transaction(date: new_valuation.date, account: account, amount: -100)
assert_equal Money.new(100), transaction.prior_entry_balance
end
end

View file

@ -1,145 +0,0 @@
require "test_helper"
class Account::Holding::SyncerTest < ActiveSupport::TestCase
include Account::EntriesTestHelper, SecuritiesTestHelper
setup do
@account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 20000, currency: "USD", accountable: Investment.new)
end
test "account with no trades has no holdings" do
run_sync_for(@account)
assert_equal [], @account.holdings
end
test "can buy and sell securities" do
# First create securities with their prices
security1 = create_security("AMZN", prices: [
{ date: 2.days.ago.to_date, price: 214 },
{ date: 1.day.ago.to_date, price: 215 },
{ date: Date.current, price: 216 }
])
security2 = create_security("NVDA", prices: [
{ date: 1.day.ago.to_date, price: 122 },
{ date: Date.current, price: 124 }
])
# Then create trades after prices exist
create_trade(security1, account: @account, qty: 10, date: 2.days.ago.to_date) # buy 10 shares of AMZN
create_trade(security1, account: @account, qty: 2, date: 1.day.ago.to_date) # buy 2 shares of AMZN
create_trade(security2, account: @account, qty: 20, date: 1.day.ago.to_date) # buy 20 shares of NVDA
create_trade(security1, account: @account, qty: -10, date: Date.current) # sell 10 shares of AMZN
expected = [
{ security: security1, qty: 10, price: 214, amount: 10 * 214, date: 2.days.ago.to_date },
{ security: security1, qty: 12, price: 215, amount: 12 * 215, date: 1.day.ago.to_date },
{ security: security1, qty: 2, price: 216, amount: 2 * 216, date: Date.current },
{ security: security2, qty: 20, price: 122, amount: 20 * 122, date: 1.day.ago.to_date },
{ security: security2, qty: 20, price: 124, amount: 20 * 124, date: Date.current }
]
run_sync_for(@account)
assert_holdings(expected)
end
test "generates holdings with prices" do
provider = mock
Security::Price.stubs(:security_prices_provider).returns(provider)
provider.expects(:fetch_security_prices).never
amzn = create_security("AMZN", prices: [ { date: Date.current, price: 215 } ])
create_trade(amzn, account: @account, qty: 10, date: Date.current, price: 215)
expected = [
{ security: amzn, qty: 10, price: 215, amount: 10 * 215, date: Date.current }
]
run_sync_for(@account)
assert_holdings(expected)
end
test "generates all holdings even when missing security prices" do
amzn = create_security("AMZN", prices: [])
create_trade(amzn, account: @account, qty: 10, date: 2.days.ago.to_date, price: 210)
# 2 days ago — no daily price found, but since this is day of entry, we fall back to entry price
# 1 day ago — finds daily price, uses it
# Today — no daily price, no entry, so price and amount are `nil`
expected = [
{ security: amzn, qty: 10, price: 210, amount: 10 * 210, date: 2.days.ago.to_date },
{ security: amzn, qty: 10, price: 215, amount: 10 * 215, date: 1.day.ago.to_date },
{ security: amzn, qty: 10, price: nil, amount: nil, date: Date.current }
]
fetched_prices = [ Security::Price.new(ticker: "AMZN", date: 1.day.ago.to_date, price: 215) ]
Gapfiller.any_instance.expects(:run).returns(fetched_prices)
Security::Price.expects(:find_prices)
.with(security: amzn, start_date: 2.days.ago.to_date, end_date: Date.current)
.once
.returns(fetched_prices)
run_sync_for(@account)
assert_holdings(expected)
end
# It is common for data providers to not provide prices for weekends, so we need to carry the last observation forward
test "uses locf gapfilling when price is missing" do
friday = Date.new(2024, 9, 27) # A known Friday
saturday = friday + 1.day # weekend
sunday = saturday + 1.day # weekend
monday = sunday + 1.day # known Monday
# Prices should be gapfilled like this: 210, 210, 210, 220
tm = create_security("TM", prices: [
{ date: friday, price: 210 },
{ date: monday, price: 220 }
])
create_trade(tm, account: @account, qty: 10, date: friday, price: 210)
expected = [
{ security: tm, qty: 10, price: 210, amount: 10 * 210, date: friday },
{ security: tm, qty: 10, price: 210, amount: 10 * 210, date: saturday },
{ security: tm, qty: 10, price: 210, amount: 10 * 210, date: sunday },
{ security: tm, qty: 10, price: 220, amount: 10 * 220, date: monday }
]
run_sync_for(@account)
assert_holdings(expected)
end
private
def assert_holdings(expected_holdings)
holdings = @account.holdings.includes(:security).to_a
expected_holdings.each do |expected_holding|
actual_holding = holdings.find { |holding|
holding.security == expected_holding[:security] &&
holding.date == expected_holding[:date]
}
date = expected_holding[:date]
expected_price = expected_holding[:price]
expected_qty = expected_holding[:qty]
expected_amount = expected_holding[:amount]
ticker = expected_holding[:security].ticker
assert actual_holding, "expected #{ticker} holding on date: #{date}"
assert_equal expected_holding[:qty], actual_holding.qty, "expected #{expected_qty} qty for holding #{ticker} on date: #{date}"
assert_equal expected_holding[:amount].to_d, actual_holding.amount.to_d, "expected #{expected_amount} amount for holding #{ticker} on date: #{date}"
assert_equal expected_holding[:price].to_d, actual_holding.price.to_d, "expected #{expected_price} price for holding #{ticker} on date: #{date}"
end
end
def run_sync_for(account)
Account::Holding::Syncer.new(account).run
end
end

View file

@ -0,0 +1,231 @@
require "test_helper"
class Account::HoldingCalculatorTest < ActiveSupport::TestCase
include Account::EntriesTestHelper
setup do
@account = families(:empty).accounts.create!(
name: "Test",
balance: 20000,
cash_balance: 20000,
currency: "USD",
accountable: Investment.new
)
end
test "no holdings" do
forward = Account::HoldingCalculator.new(@account).calculate
reverse = Account::HoldingCalculator.new(@account).calculate(reverse: true)
assert_equal forward, reverse
assert_equal [], forward
end
# Should be able to handle this case, although we should not be reverse-syncing an account without provided current day holdings
test "reverse portfolio with trades but without current day holdings" do
voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF")
Security::Price.create!(security: voo, date: Date.current, price: 470)
Security::Price.create!(security: voo, date: 1.day.ago.to_date, price: 470)
create_trade(voo, qty: -10, date: Date.current, price: 470, account: @account)
calculated = Account::HoldingCalculator.new(@account).calculate(reverse: true)
assert_equal 2, calculated.length
end
test "reverse portfolio calculation" do
load_today_portfolio
# Build up to 10 shares of VOO (current value $5000)
create_trade(@voo, qty: 20, date: 3.days.ago.to_date, price: 470, account: @account)
create_trade(@voo, qty: -15, date: 2.days.ago.to_date, price: 480, account: @account)
create_trade(@voo, qty: 5, date: 1.day.ago.to_date, price: 490, account: @account)
# Amazon won't exist in current holdings because qty is zero, but should show up in historical portfolio
create_trade(@amzn, qty: 1, date: 2.days.ago.to_date, price: 200, account: @account)
create_trade(@amzn, qty: -1, date: 1.day.ago.to_date, price: 200, account: @account)
# Build up to 100 shares of WMT (current value $10000)
create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account)
expected = [
# 4 days ago
Account::Holding.new(security: @voo, date: 4.days.ago.to_date, qty: 0, price: 460, amount: 0),
Account::Holding.new(security: @wmt, date: 4.days.ago.to_date, qty: 0, price: 100, amount: 0),
Account::Holding.new(security: @amzn, date: 4.days.ago.to_date, qty: 0, price: 200, amount: 0),
# 3 days ago
Account::Holding.new(security: @voo, date: 3.days.ago.to_date, qty: 20, price: 470, amount: 9400),
Account::Holding.new(security: @wmt, date: 3.days.ago.to_date, qty: 0, price: 100, amount: 0),
Account::Holding.new(security: @amzn, date: 3.days.ago.to_date, qty: 0, price: 200, amount: 0),
# 2 days ago
Account::Holding.new(security: @voo, date: 2.days.ago.to_date, qty: 5, price: 480, amount: 2400),
Account::Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0),
Account::Holding.new(security: @amzn, date: 2.days.ago.to_date, qty: 1, price: 200, amount: 200),
# 1 day ago
Account::Holding.new(security: @voo, date: 1.day.ago.to_date, qty: 10, price: 490, amount: 4900),
Account::Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000),
Account::Holding.new(security: @amzn, date: 1.day.ago.to_date, qty: 0, price: 200, amount: 0),
# Today
Account::Holding.new(security: @voo, date: Date.current, qty: 10, price: 500, amount: 5000),
Account::Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000),
Account::Holding.new(security: @amzn, date: Date.current, qty: 0, price: 200, amount: 0)
]
calculated = Account::HoldingCalculator.new(@account).calculate(reverse: true)
assert_equal expected.length, calculated.length
expected.each do |expected_entry|
calculated_entry = calculated.find { |c| c.security == expected_entry.security && c.date == expected_entry.date }
assert_equal expected_entry.qty, calculated_entry.qty, "Qty mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}"
assert_equal expected_entry.price, calculated_entry.price, "Price mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}"
assert_equal expected_entry.amount, calculated_entry.amount, "Amount mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}"
end
end
test "forward portfolio calculation" do
load_prices
# Build up to 10 shares of VOO (current value $5000)
create_trade(@voo, qty: 20, date: 3.days.ago.to_date, price: 470, account: @account)
create_trade(@voo, qty: -15, date: 2.days.ago.to_date, price: 480, account: @account)
create_trade(@voo, qty: 5, date: 1.day.ago.to_date, price: 490, account: @account)
# Amazon won't exist in current holdings because qty is zero, but should show up in historical portfolio
create_trade(@amzn, qty: 1, date: 2.days.ago.to_date, price: 200, account: @account)
create_trade(@amzn, qty: -1, date: 1.day.ago.to_date, price: 200, account: @account)
# Build up to 100 shares of WMT (current value $10000)
create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account)
expected = [
# 4 days ago
Account::Holding.new(security: @voo, date: 4.days.ago.to_date, qty: 0, price: 460, amount: 0),
Account::Holding.new(security: @wmt, date: 4.days.ago.to_date, qty: 0, price: 100, amount: 0),
Account::Holding.new(security: @amzn, date: 4.days.ago.to_date, qty: 0, price: 200, amount: 0),
# 3 days ago
Account::Holding.new(security: @voo, date: 3.days.ago.to_date, qty: 20, price: 470, amount: 9400),
Account::Holding.new(security: @wmt, date: 3.days.ago.to_date, qty: 0, price: 100, amount: 0),
Account::Holding.new(security: @amzn, date: 3.days.ago.to_date, qty: 0, price: 200, amount: 0),
# 2 days ago
Account::Holding.new(security: @voo, date: 2.days.ago.to_date, qty: 5, price: 480, amount: 2400),
Account::Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0),
Account::Holding.new(security: @amzn, date: 2.days.ago.to_date, qty: 1, price: 200, amount: 200),
# 1 day ago
Account::Holding.new(security: @voo, date: 1.day.ago.to_date, qty: 10, price: 490, amount: 4900),
Account::Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000),
Account::Holding.new(security: @amzn, date: 1.day.ago.to_date, qty: 0, price: 200, amount: 0),
# Today
Account::Holding.new(security: @voo, date: Date.current, qty: 10, price: 500, amount: 5000),
Account::Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000),
Account::Holding.new(security: @amzn, date: Date.current, qty: 0, price: 200, amount: 0)
]
calculated = Account::HoldingCalculator.new(@account).calculate
assert_equal expected.length, calculated.length
assert_holdings(expected, calculated)
end
# Carries the previous record forward if no holding exists for a date
# to ensure that net worth historical rollups have a value for every date
test "uses locf to fill missing holdings" do
load_prices
create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account)
expected = [
Account::Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0),
Account::Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000),
Account::Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000)
]
# Price missing today, so we should carry forward the holding from 1 day ago
Security.stubs(:find).returns(@wmt)
Security::Price.stubs(:find_price).with(security: @wmt, date: 2.days.ago.to_date).returns(Security::Price.new(price: 100))
Security::Price.stubs(:find_price).with(security: @wmt, date: 1.day.ago.to_date).returns(Security::Price.new(price: 100))
Security::Price.stubs(:find_price).with(security: @wmt, date: Date.current).returns(nil)
calculated = Account::HoldingCalculator.new(@account).calculate
assert_equal expected.length, calculated.length
assert_holdings(expected, calculated)
end
private
def assert_holdings(expected, calculated)
expected.each do |expected_entry|
calculated_entry = calculated.find { |c| c.security == expected_entry.security && c.date == expected_entry.date }
assert_equal expected_entry.qty, calculated_entry.qty, "Qty mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}"
assert_equal expected_entry.price, calculated_entry.price, "Price mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}"
assert_equal expected_entry.amount, calculated_entry.amount, "Amount mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}"
end
end
def load_prices
@voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF")
Security::Price.create!(security: @voo, date: 4.days.ago.to_date, price: 460)
Security::Price.create!(security: @voo, date: 3.days.ago.to_date, price: 470)
Security::Price.create!(security: @voo, date: 2.days.ago.to_date, price: 480)
Security::Price.create!(security: @voo, date: 1.day.ago.to_date, price: 490)
Security::Price.create!(security: @voo, date: Date.current, price: 500)
@wmt = Security.create!(ticker: "WMT", name: "Walmart Inc.")
Security::Price.create!(security: @wmt, date: 4.days.ago.to_date, price: 100)
Security::Price.create!(security: @wmt, date: 3.days.ago.to_date, price: 100)
Security::Price.create!(security: @wmt, date: 2.days.ago.to_date, price: 100)
Security::Price.create!(security: @wmt, date: 1.day.ago.to_date, price: 100)
Security::Price.create!(security: @wmt, date: Date.current, price: 100)
@amzn = Security.create!(ticker: "AMZN", name: "Amazon.com Inc.")
Security::Price.create!(security: @amzn, date: 4.days.ago.to_date, price: 200)
Security::Price.create!(security: @amzn, date: 3.days.ago.to_date, price: 200)
Security::Price.create!(security: @amzn, date: 2.days.ago.to_date, price: 200)
Security::Price.create!(security: @amzn, date: 1.day.ago.to_date, price: 200)
Security::Price.create!(security: @amzn, date: Date.current, price: 200)
end
# Portfolio holdings:
# +--------+-----+--------+---------+
# | Ticker | Qty | Price | Amount |
# +--------+-----+--------+---------+
# | VOO | 10 | $500 | $5,000 |
# | WMT | 100 | $100 | $10,000 |
# +--------+-----+--------+---------+
# Brokerage Cash: $5,000
# Holdings Value: $15,000
# Total Balance: $20,000
def load_today_portfolio
@account.update!(cash_balance: 5000)
load_prices
@account.holdings.create!(
date: Date.current,
price: 500,
qty: 10,
amount: 5000,
currency: "USD",
security: @voo
)
@account.holdings.create!(
date: Date.current,
price: 100,
qty: 100,
amount: 10000,
currency: "USD",
security: @wmt
)
end
end

View file

@ -5,16 +5,15 @@ class Account::HoldingTest < ActiveSupport::TestCase
include Account::EntriesTestHelper, SecuritiesTestHelper
setup do
@account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 20000, currency: "USD", accountable: Investment.new)
@account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 20000, cash_balance: 0, currency: "USD", accountable: Investment.new)
# Current day holding instances
@amzn, @nvda = load_holdings
end
test "calculates portfolio weight" do
expected_portfolio_value = 6960.0
expected_amzn_weight = 3240.0 / expected_portfolio_value * 100
expected_nvda_weight = 3720.0 / expected_portfolio_value * 100
expected_amzn_weight = 3240.0 / @account.balance * 100
expected_nvda_weight = 3720.0 / @account.balance * 100
assert_in_delta expected_amzn_weight, @amzn.weight, 0.001
assert_in_delta expected_nvda_weight, @nvda.weight, 0.001

View file

@ -0,0 +1,54 @@
require "test_helper"
class Account::SyncerTest < ActiveSupport::TestCase
include Account::EntriesTestHelper
setup do
@account = families(:empty).accounts.create!(
name: "Test",
balance: 20000,
cash_balance: 20000,
currency: "USD",
accountable: Investment.new
)
end
test "converts foreign account balances to family currency" do
@account.family.update! currency: "USD"
@account.update! currency: "EUR"
ExchangeRate.create!(date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2)
ExchangeRate.create!(date: Date.current, from_currency: "EUR", to_currency: "USD", rate: 2)
Account::BalanceCalculator.any_instance.expects(:calculate).returns(
[
Account::Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "EUR"),
Account::Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "EUR")
]
)
Account::Syncer.new(@account).run
assert_equal [ 1000, 1000 ], @account.balances.where(currency: "EUR").chronological.map(&:balance)
assert_equal [ 1200, 2000 ], @account.balances.where(currency: "USD").chronological.map(&:balance)
end
test "purges stale balances and holdings" do
# Old, out of range holdings and balances
@account.holdings.create!(security: securities(:aapl), date: 10.years.ago.to_date, currency: "USD", qty: 100, price: 100, amount: 10000)
@account.balances.create!(date: 10.years.ago.to_date, currency: "USD", balance: 10000, cash_balance: 10000)
assert_equal 1, @account.holdings.count
assert_equal 1, @account.balances.count
Account::Syncer.new(@account).run
@account.reload
assert_equal 0, @account.holdings.count
# Balance sync always creates 1 balance if no entries present.
assert_equal 1, @account.balances.count
assert_equal 0, @account.balances.first.balance
end
end

View file

@ -8,7 +8,7 @@ class TradesTest < ApplicationSystemTestCase
@account = accounts(:investment)
visit_account_trades
visit_account_portfolio
Security.stubs(:search).returns([
Security.new(
@ -66,10 +66,7 @@ class TradesTest < ApplicationSystemTestCase
private
def open_new_trade_modal
within "[data-testid='activity-menu']" do
click_on "New"
click_on "New transaction"
end
click_on "New transaction"
end
def within_trades(&block)
@ -77,6 +74,10 @@ class TradesTest < ApplicationSystemTestCase
end
def visit_account_trades
visit account_path(@account, tab: "activity")
end
def visit_account_portfolio
visit account_path(@account)
end

View file

@ -182,7 +182,7 @@ class TransactionsTest < ApplicationSystemTestCase
investment_account = accounts(:investment)
investment_account.entries.create!(name: "Investment account", date: Date.current, amount: 1000, currency: "USD", entryable: Account::Transaction.new)
transfer_date = Date.current
visit account_url(investment_account)
visit account_url(investment_account, tab: "activity")
within "[data-testid='activity-menu']" do
click_on "New"
click_on "New transaction"