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:
parent
a59ca5b7c6
commit
49c353e10c
72 changed files with 1152 additions and 1046 deletions
1
test/fixtures/accounts.yml
vendored
1
test/fixtures/accounts.yml
vendored
|
@ -43,6 +43,7 @@ investment:
|
|||
family: dylan_family
|
||||
name: Robinhood Brokerage Account
|
||||
balance: 10000
|
||||
cash_balance: 5000
|
||||
currency: USD
|
||||
accountable_type: Investment
|
||||
accountable: one
|
||||
|
|
2
test/fixtures/investments.yml
vendored
2
test/fixtures/investments.yml
vendored
|
@ -1 +1 @@
|
|||
one: { }
|
||||
one: {}
|
|
@ -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
|
156
test/models/account/balance_calculator_test.rb
Normal file
156
test/models/account/balance_calculator_test.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
231
test/models/account/holding_calculator_test.rb
Normal file
231
test/models/account/holding_calculator_test.rb
Normal 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
|
|
@ -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
|
||||
|
|
54
test/models/account/syncer_test.rb
Normal file
54
test/models/account/syncer_test.rb
Normal 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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue