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:
parent
d0bc959bee
commit
47523f64c2
32 changed files with 591 additions and 56 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
101
test/models/account/holding/syncer_test.rb
Normal file
101
test/models/account/holding/syncer_test.rb
Normal 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
|
7
test/models/account/holding_test.rb
Normal file
7
test/models/account/holding_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
require "test_helper"
|
||||
|
||||
class Account::HoldingTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
|
@ -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
|
||||
|
|
4
test/models/account/trade_test.rb
Normal file
4
test/models/account/trade_test.rb
Normal file
|
@ -0,0 +1,4 @@
|
|||
require "test_helper"
|
||||
|
||||
class Account::TradeTest < ActiveSupport::TestCase
|
||||
end
|
|
@ -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
|
||||
|
|
7
test/models/security/price_test.rb
Normal file
7
test/models/security/price_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
require "test_helper"
|
||||
|
||||
class Security::PriceTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
7
test/models/security_test.rb
Normal file
7
test/models/security_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
|||
require "test_helper"
|
||||
|
||||
class SecurityTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue