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

Investment Portfolio Sync (#974)

* Add investment portfolio models

* Add portfolio to demo data

* Setup initial tests

* Rough sketch of sync logic

* Clean up trade sync logic

* Add trade validation

* Integrate trades into sync process
This commit is contained in:
Zach Gollwitzer 2024-07-16 09:26:49 -04:00 committed by GitHub
parent d0bc959bee
commit 47523f64c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 591 additions and 56 deletions

View file

@ -5,13 +5,13 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase
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
syncer = Account::Balance::Syncer.new(@account)
syncer.run
run_sync_for @account
assert_equal [ @account.balance ], @account.balances.chronological.map(&:balance)
end
@ -19,8 +19,7 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase
test "syncs account with valuations only" do
create_valuation(account: @account, date: 2.days.ago.to_date, amount: 22000)
syncer = Account::Balance::Syncer.new(@account)
syncer.run
run_sync_for @account
assert_equal 22000, @account.balance
assert_equal [ 22000, 22000, 22000 ], @account.balances.chronological.map(&:balance)
@ -30,21 +29,28 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase
create_transaction(account: @account, date: 4.days.ago.to_date, amount: 100)
create_transaction(account: @account, date: 2.days.ago.to_date, amount: -500)
syncer = Account::Balance::Syncer.new(@account)
syncer.run
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 trades only" do
aapl = securities(:aapl)
create_trade(account: @investment_account, date: 1.day.ago.to_date, security: aapl, qty: 10, price: 200)
run_sync_for @investment_account
assert_equal [ 52000, 50000, 50000 ], @investment_account.balances.chronological.map(&:balance)
end
test "syncs account with valuations and transactions" 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)
syncer = Account::Balance::Syncer.new(@account)
syncer.run
run_sync_for(@account)
assert_equal 25000, @account.balance
assert_equal [ 20000, 20000, 20500, 20400, 25000, 25000 ], @account.balances.chronological.map(&:balance)
@ -57,8 +63,7 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase
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
syncer = Account::Balance::Syncer.new(@account)
syncer.run
run_sync_for(@account)
assert_equal 20000, @account.balance
assert_equal [ 21000, 20900, 20600, 20000, 20000 ], @account.balances.chronological.map(&:balance)
@ -73,8 +78,7 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase
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)
syncer = Account::Balance::Syncer.new(@account)
syncer.run
run_sync_for(@account)
usd_balances = @account.balances.where(currency: "USD").chronological.map(&:balance)
eur_balances = @account.balances.where(currency: "EUR").chronological.map(&:balance)
@ -113,8 +117,7 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase
assert_equal 2, @account.balances.size
syncer = Account::Balance::Syncer.new(@account)
syncer.run
run_sync_for(@account)
assert_equal [ @account.balance ], @account.balances.chronological.map(&:balance)
end
@ -124,14 +127,18 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase
transaction = create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100, currency: "USD")
syncer = Account::Balance::Syncer.new(@account, start_date: 1.day.ago.to_date)
syncer.run
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

View file

@ -12,6 +12,7 @@ class Account::EntryTest < ActiveSupport::TestCase
new_valuation = Account::Entry.new \
entryable: Account::Valuation.new,
account: existing_valuation.account,
date: existing_valuation.date, # invalid
currency: existing_valuation.currency,
amount: existing_valuation.amount
@ -92,4 +93,20 @@ class Account::EntryTest < ActiveSupport::TestCase
assert create_transaction(amount: -10).inflow?
assert create_transaction(amount: 10).outflow?
end
test "cannot sell more shares of stock than owned" do
account = families(:empty).accounts.create! name: "Test", balance: 0, accountable: Investment.new
security = securities(:aapl)
error = assert_raises ActiveRecord::RecordInvalid do
account.entries.create! \
date: Date.current,
amount: 100,
currency: "USD",
name: "Sell 10 shares of AMZN",
entryable: Account::Trade.new(qty: -10, price: 200, security: security)
end
assert_match /cannot sell 10.0 shares of aapl because you only own 0.0 shares/, error.message
end
end

View file

@ -0,0 +1,101 @@
require "test_helper"
class Account::Holding::SyncerTest < ActiveSupport::TestCase
include Account::EntriesTestHelper
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
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 }
])
create_trade(security1, qty: 10, date: 2.days.ago.to_date) # buy 10 shares of AMZN
create_trade(security1, qty: 2, date: 1.day.ago.to_date) # buy 2 shares of AMZN
create_trade(security2, qty: 20, date: 1.day.ago.to_date) # buy 20 shares of NVDA
create_trade(security1, qty: -10, date: Date.current) # sell 10 shares of AMZN
expected = [
{ symbol: "AMZN", qty: 10, price: 214, amount: 10 * 214, date: 2.days.ago.to_date },
{ symbol: "AMZN", qty: 12, price: 215, amount: 12 * 215, date: 1.day.ago.to_date },
{ symbol: "AMZN", qty: 2, price: 216, amount: 2 * 216, date: Date.current },
{ symbol: "NVDA", qty: 20, price: 122, amount: 20 * 122, date: 1.day.ago.to_date },
{ symbol: "NVDA", qty: 20, price: 124, amount: 20 * 124, date: Date.current }
]
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.symbol == expected_holding[:symbol] && holding.date == expected_holding[:date] }
date = expected_holding[:date]
expected_price = expected_holding[:price]
expected_qty = expected_holding[:qty]
expected_amount = expected_holding[:amount]
symbol = expected_holding[:symbol]
assert actual_holding, "expected #{symbol} holding on date: #{date}"
assert_equal expected_holding[:qty], actual_holding.qty, "expected #{expected_qty} qty for holding #{symbol} on date: #{date}"
assert_equal expected_holding[:amount], actual_holding.amount, "expected #{expected_amount} amount for holding #{symbol} on date: #{date}"
assert_equal expected_holding[:price], actual_holding.price, "expected #{expected_price} price for holding #{symbol} on date: #{date}"
end
end
def create_security(symbol, prices:)
isin_codes = {
"AMZN" => "US0231351067",
"NVDA" => "US67066G1040"
}
isin = isin_codes[symbol]
prices.each do |price|
Security::Price.create! isin: isin, date: price[:date], price: price[:price]
end
Security.create! isin: isin, symbol: symbol
end
def create_trade(security, qty:, date:)
price = Security::Price.find_by!(isin: security.isin, date: date).price
trade = Account::Trade.new \
qty: qty,
security: security,
price: price
@account.entries.create! \
name: "Trade",
date: date,
amount: qty * price,
currency: "USD",
entryable: trade
end
def run_sync_for(account)
Account::Holding::Syncer.new(account).run
end
end

View file

@ -0,0 +1,7 @@
require "test_helper"
class Account::HoldingTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -5,13 +5,20 @@ class Account::SyncTest < ActiveSupport::TestCase
@account = accounts(:depository)
@sync = Account::Sync.for(@account)
@balance_syncer = mock("Account::Balance::Syncer")
Account::Balance::Syncer.expects(:new).with(@account, start_date: nil).returns(@balance_syncer).once
@holding_syncer = mock("Account::Holding::Syncer")
end
test "runs sync" do
Account::Balance::Syncer.expects(:new).with(@account, start_date: nil).returns(@balance_syncer).once
Account::Holding::Syncer.expects(:new).with(@account, start_date: nil).returns(@holding_syncer).once
@balance_syncer.expects(:run).once
@balance_syncer.expects(:warnings).returns([ "test sync warning" ]).once
@balance_syncer.expects(:warnings).returns([ "test balance sync warning" ]).once
@holding_syncer.expects(:run).once
@holding_syncer.expects(:warnings).returns([ "test holding sync warning" ]).once
assert_equal "pending", @sync.status
assert_equal [], @sync.warnings
@ -20,11 +27,14 @@ class Account::SyncTest < ActiveSupport::TestCase
@sync.run
assert_equal "completed", @sync.status
assert_equal [ "test sync warning" ], @sync.warnings
assert_equal [ "test balance sync warning", "test holding sync warning" ], @sync.warnings
assert @sync.last_ran_at
end
test "handles sync errors" do
Account::Balance::Syncer.expects(:new).with(@account, start_date: nil).returns(@balance_syncer).once
Account::Holding::Syncer.expects(:new).with(@account, start_date: nil).returns(@holding_syncer).never # error from balance sync halts entire sync
@balance_syncer.expects(:run).raises(StandardError.new("test sync error"))
@sync.run

View file

@ -0,0 +1,4 @@
require "test_helper"
class Account::TradeTest < ActiveSupport::TestCase
end

View file

@ -74,4 +74,13 @@ class AccountTest < ActiveSupport::TestCase
test "generates empty series if no balances and no exchange rate" do
assert_equal 0, @account.series(currency: "NZD").values.count
end
test "calculates shares owned of holding for date" do
account = accounts(:investment)
security = securities(:aapl)
assert_equal 10, account.holding_qty(security, date: Date.current)
assert_equal 10, account.holding_qty(security, date: 1.day.ago.to_date)
assert_equal 0, account.holding_qty(security, date: 2.days.ago.to_date)
end
end

View file

@ -0,0 +1,7 @@
require "test_helper"
class Security::PriceTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -0,0 +1,7 @@
require "test_helper"
class SecurityTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end