mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-05 13:35:21 +02:00
Account:: namespace simplifications and cleanup (#2110)
* Flatten Holding model * Flatten balance model * Entries domain renames * Fix valuations reference * Fix trades stream * Fix brakeman warnings * Fix tests * Replace existing entryable type references in DB
This commit is contained in:
parent
f181ba941f
commit
e657c40d19
172 changed files with 1297 additions and 1258 deletions
146
test/models/holding/forward_calculator_test.rb
Normal file
146
test/models/holding/forward_calculator_test.rb
Normal file
|
@ -0,0 +1,146 @@
|
|||
require "test_helper"
|
||||
|
||||
class Holding::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||
include 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
|
||||
calculated = Holding::ForwardCalculator.new(@account).calculate
|
||||
assert_equal [], calculated
|
||||
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
|
||||
Holding.new(security: @voo, date: 4.days.ago.to_date, qty: 0, price: 460, amount: 0),
|
||||
Holding.new(security: @wmt, date: 4.days.ago.to_date, qty: 0, price: 100, amount: 0),
|
||||
Holding.new(security: @amzn, date: 4.days.ago.to_date, qty: 0, price: 200, amount: 0),
|
||||
|
||||
# 3 days ago
|
||||
Holding.new(security: @voo, date: 3.days.ago.to_date, qty: 20, price: 470, amount: 9400),
|
||||
Holding.new(security: @wmt, date: 3.days.ago.to_date, qty: 0, price: 100, amount: 0),
|
||||
Holding.new(security: @amzn, date: 3.days.ago.to_date, qty: 0, price: 200, amount: 0),
|
||||
|
||||
# 2 days ago
|
||||
Holding.new(security: @voo, date: 2.days.ago.to_date, qty: 5, price: 480, amount: 2400),
|
||||
Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0),
|
||||
Holding.new(security: @amzn, date: 2.days.ago.to_date, qty: 1, price: 200, amount: 200),
|
||||
|
||||
# 1 day ago
|
||||
Holding.new(security: @voo, date: 1.day.ago.to_date, qty: 10, price: 490, amount: 4900),
|
||||
Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000),
|
||||
Holding.new(security: @amzn, date: 1.day.ago.to_date, qty: 0, price: 200, amount: 0),
|
||||
|
||||
# Today
|
||||
Holding.new(security: @voo, date: Date.current, qty: 10, price: 500, amount: 5000),
|
||||
Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000),
|
||||
Holding.new(security: @amzn, date: Date.current, qty: 0, price: 200, amount: 0)
|
||||
]
|
||||
|
||||
calculated = Holding::ForwardCalculator.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 = [
|
||||
Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0),
|
||||
Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000),
|
||||
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 = Holding::ForwardCalculator.new(@account).calculate
|
||||
|
||||
assert_equal expected.length, calculated.length
|
||||
assert_holdings(expected, calculated)
|
||||
end
|
||||
|
||||
test "offline tickers sync holdings based on most recent trade price" do
|
||||
offline_security = Security.create!(ticker: "OFFLINE", name: "Offline Ticker")
|
||||
|
||||
create_trade(offline_security, qty: 1, date: 3.days.ago.to_date, price: 90, account: @account)
|
||||
create_trade(offline_security, qty: 1, date: 1.day.ago.to_date, price: 100, account: @account)
|
||||
|
||||
expected = [
|
||||
Holding.new(security: offline_security, date: 3.days.ago.to_date, qty: 1, price: 90, amount: 90),
|
||||
Holding.new(security: offline_security, date: 2.days.ago.to_date, qty: 1, price: 90, amount: 90),
|
||||
Holding.new(security: offline_security, date: 1.day.ago.to_date, qty: 2, price: 100, amount: 200),
|
||||
Holding.new(security: offline_security, date: Date.current, qty: 2, price: 100, amount: 200)
|
||||
]
|
||||
|
||||
calculated = Holding::ForwardCalculator.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
|
||||
end
|
87
test/models/holding/portfolio_cache_test.rb
Normal file
87
test/models/holding/portfolio_cache_test.rb
Normal file
|
@ -0,0 +1,87 @@
|
|||
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
|
||||
)
|
||||
|
||||
expect_provider_prices([], start_date: @account.start_date)
|
||||
|
||||
cache = Holding::PortfolioCache.new(@account)
|
||||
assert_equal db_price, cache.get_price(@security.id, Date.current).price
|
||||
end
|
||||
|
||||
test "if no price in DB, try fetching from provider" do
|
||||
Security::Price.delete_all
|
||||
|
||||
provider_price = Security::Price.new(
|
||||
security: @security,
|
||||
date: Date.current,
|
||||
price: 220,
|
||||
currency: "USD"
|
||||
)
|
||||
|
||||
expect_provider_prices([ provider_price ], start_date: @account.start_date)
|
||||
|
||||
cache = Holding::PortfolioCache.new(@account)
|
||||
assert_equal provider_price.price, cache.get_price(@security.id, Date.current).price
|
||||
end
|
||||
|
||||
test "if no price from db or provider, try getting the price from trades" do
|
||||
Security::Price.destroy_all
|
||||
expect_provider_prices([], start_date: @account.start_date)
|
||||
|
||||
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, provider, 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"
|
||||
)
|
||||
|
||||
expect_provider_prices([], start_date: @account.start_date)
|
||||
|
||||
cache = Holding::PortfolioCache.new(@account, use_holdings: true)
|
||||
assert_equal holding.price, cache.get_price(@security.id, holding.date).price
|
||||
end
|
||||
|
||||
private
|
||||
def expect_provider_prices(prices, start_date:, end_date: Date.current)
|
||||
@provider.expects(:fetch_security_prices)
|
||||
.with(@security, start_date: start_date, end_date: end_date)
|
||||
.returns(provider_success_response(prices))
|
||||
end
|
||||
end
|
155
test/models/holding/reverse_calculator_test.rb
Normal file
155
test/models/holding/reverse_calculator_test.rb
Normal file
|
@ -0,0 +1,155 @@
|
|||
require "test_helper"
|
||||
|
||||
class Holding::ReverseCalculatorTest < ActiveSupport::TestCase
|
||||
include 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
|
||||
calculated = Holding::ReverseCalculator.new(@account).calculate
|
||||
assert_equal [], calculated
|
||||
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 = Holding::ReverseCalculator.new(@account).calculate
|
||||
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
|
||||
Holding.new(security: @voo, date: 4.days.ago.to_date, qty: 0, price: 460, amount: 0),
|
||||
Holding.new(security: @wmt, date: 4.days.ago.to_date, qty: 0, price: 100, amount: 0),
|
||||
Holding.new(security: @amzn, date: 4.days.ago.to_date, qty: 0, price: 200, amount: 0),
|
||||
|
||||
# 3 days ago
|
||||
Holding.new(security: @voo, date: 3.days.ago.to_date, qty: 20, price: 470, amount: 9400),
|
||||
Holding.new(security: @wmt, date: 3.days.ago.to_date, qty: 0, price: 100, amount: 0),
|
||||
Holding.new(security: @amzn, date: 3.days.ago.to_date, qty: 0, price: 200, amount: 0),
|
||||
|
||||
# 2 days ago
|
||||
Holding.new(security: @voo, date: 2.days.ago.to_date, qty: 5, price: 480, amount: 2400),
|
||||
Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0),
|
||||
Holding.new(security: @amzn, date: 2.days.ago.to_date, qty: 1, price: 200, amount: 200),
|
||||
|
||||
# 1 day ago
|
||||
Holding.new(security: @voo, date: 1.day.ago.to_date, qty: 10, price: 490, amount: 4900),
|
||||
Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000),
|
||||
Holding.new(security: @amzn, date: 1.day.ago.to_date, qty: 0, price: 200, amount: 0),
|
||||
|
||||
# Today
|
||||
Holding.new(security: @voo, date: Date.current, qty: 10, price: 500, amount: 5000),
|
||||
Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000),
|
||||
Holding.new(security: @amzn, date: Date.current, qty: 0, price: 200, amount: 0)
|
||||
]
|
||||
|
||||
calculated = Holding::ReverseCalculator.new(@account).calculate
|
||||
|
||||
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
|
||||
|
||||
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
|
29
test/models/holding/syncer_test.rb
Normal file
29
test/models/holding/syncer_test.rb
Normal file
|
@ -0,0 +1,29 @@
|
|||
require "test_helper"
|
||||
|
||||
class Holding::SyncerTest < ActiveSupport::TestCase
|
||||
include EntriesTestHelper
|
||||
|
||||
setup do
|
||||
@family = families(:empty)
|
||||
@account = @family.accounts.create!(name: "Test", balance: 20000, cash_balance: 20000, currency: "USD", accountable: Investment.new)
|
||||
@aapl = securities(:aapl)
|
||||
end
|
||||
|
||||
test "syncs holdings" do
|
||||
create_trade(@aapl, account: @account, qty: 1, price: 200, date: Date.current)
|
||||
|
||||
# Should have yesterday's and today's holdings
|
||||
assert_difference "@account.holdings.count", 2 do
|
||||
Holding::Syncer.new(@account, strategy: :forward).sync_holdings
|
||||
end
|
||||
end
|
||||
|
||||
test "purges stale holdings for unlinked accounts" do
|
||||
# Since the account has no entries, there should be no holdings
|
||||
Holding.create!(account: @account, security: @aapl, qty: 1, price: 100, amount: 100, currency: "USD", date: Date.current)
|
||||
|
||||
assert_difference "Holding.count", -1 do
|
||||
Holding::Syncer.new(@account, strategy: :forward).sync_holdings
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue