mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-02 20:15:22 +02:00
Basic Portfolio Views (#1000)
* Add holdings tab to account view * Basic portfolio UI * Cleanup * Handle missing holding data * Remove synced at (implemented in separate pr) * translations * Tweak post sync streams * Remove stale methods from merge conflict
This commit is contained in:
parent
ef4be7948a
commit
7c2091b343
37 changed files with 582 additions and 86 deletions
20
test/controllers/account/holdings_controller_test.rb
Normal file
20
test/controllers/account/holdings_controller_test.rb
Normal file
|
@ -0,0 +1,20 @@
|
|||
require "test_helper"
|
||||
|
||||
class Account::HoldingsControllerTest < ActionDispatch::IntegrationTest
|
||||
setup do
|
||||
sign_in users(:family_admin)
|
||||
@account = accounts(:investment)
|
||||
@holding = @account.holdings.current.first
|
||||
end
|
||||
|
||||
test "gets holdings" do
|
||||
get account_holdings_url(@account)
|
||||
assert_response :success
|
||||
end
|
||||
|
||||
test "gets holding" do
|
||||
get account_holding_path(@account, @holding)
|
||||
|
||||
assert_response :success
|
||||
end
|
||||
end
|
2
test/fixtures/account/holdings.yml
vendored
2
test/fixtures/account/holdings.yml
vendored
|
@ -3,6 +3,7 @@ one:
|
|||
security: aapl
|
||||
date: <%= Date.current %>
|
||||
qty: 10
|
||||
price: 215
|
||||
amount: 2150 # 10 * $215
|
||||
currency: USD
|
||||
|
||||
|
@ -11,5 +12,6 @@ two:
|
|||
security: aapl
|
||||
date: <%= 1.day.ago.to_date %>
|
||||
qty: 10
|
||||
price: 214
|
||||
amount: 2140 # 10 * $214
|
||||
currency: USD
|
||||
|
|
|
@ -37,11 +37,11 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase
|
|||
|
||||
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)
|
||||
create_trade(aapl, account: @investment_account, date: 1.day.ago.to_date, qty: 10)
|
||||
|
||||
run_sync_for @investment_account
|
||||
|
||||
assert_equal [ 52000, 50000, 50000 ], @investment_account.balances.chronological.map(&:balance)
|
||||
assert_equal [ 52140, 50000, 50000 ], @investment_account.balances.chronological.map(&:balance)
|
||||
end
|
||||
|
||||
test "syncs account with valuations and transactions" do
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
require "test_helper"
|
||||
|
||||
class Account::Holding::SyncerTest < ActiveSupport::TestCase
|
||||
include Account::EntriesTestHelper
|
||||
include Account::EntriesTestHelper, SecuritiesTestHelper
|
||||
|
||||
setup do
|
||||
@account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 20000, currency: "USD", accountable: Investment.new)
|
||||
|
@ -25,12 +25,12 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase
|
|||
{ date: Date.current, price: 124 }
|
||||
])
|
||||
|
||||
create_trade(security1, qty: 10, date: 2.days.ago.to_date) # buy 10 shares of AMZN
|
||||
create_trade(security1, account: @account, 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, 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, qty: -10, date: Date.current) # sell 10 shares of AMZN
|
||||
create_trade(security1, account: @account, 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 },
|
||||
|
@ -45,6 +45,27 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase
|
|||
assert_holdings(expected)
|
||||
end
|
||||
|
||||
test "generates all holdings even when missing security prices" do
|
||||
aapl = create_security("AMZN", prices: [
|
||||
{ date: 1.day.ago.to_date, price: 215 }
|
||||
])
|
||||
|
||||
create_trade(aapl, 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 = [
|
||||
{ symbol: "AMZN", qty: 10, price: 210, amount: 10 * 210, date: 2.days.ago.to_date },
|
||||
{ symbol: "AMZN", qty: 10, price: 215, amount: 10 * 215, date: 1.day.ago.to_date },
|
||||
{ symbol: "AMZN", qty: 10, price: nil, amount: nil, date: Date.current }
|
||||
]
|
||||
|
||||
run_sync_for(@account)
|
||||
|
||||
assert_holdings(expected)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assert_holdings(expected_holdings)
|
||||
|
@ -64,37 +85,6 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase
|
|||
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
|
||||
|
|
|
@ -1,7 +1,71 @@
|
|||
require "test_helper"
|
||||
require "ostruct"
|
||||
|
||||
class Account::HoldingTest < ActiveSupport::TestCase
|
||||
# test "the truth" do
|
||||
# assert true
|
||||
# end
|
||||
include Account::EntriesTestHelper, SecuritiesTestHelper
|
||||
|
||||
setup do
|
||||
@account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 20000, 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
|
||||
|
||||
assert_in_delta expected_amzn_weight, @amzn.weight, 0.001
|
||||
assert_in_delta expected_nvda_weight, @nvda.weight, 0.001
|
||||
end
|
||||
|
||||
test "calculates simple average cost basis" do
|
||||
assert_equal Money.new((212.0 + 216.0) / 2), @amzn.avg_cost
|
||||
assert_equal Money.new((128.0 + 124.0) / 2), @nvda.avg_cost
|
||||
end
|
||||
|
||||
test "calculates total return trend" do
|
||||
# Gained $30, or 0.93%
|
||||
assert_equal Money.new(30), @amzn.trend.value
|
||||
assert_in_delta 0.9, @amzn.trend.percent, 0.001
|
||||
|
||||
# Lost $60, or -1.59%
|
||||
assert_equal Money.new(-60), @nvda.trend.value
|
||||
assert_in_delta -1.6, @nvda.trend.percent, 0.001
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_holdings
|
||||
security1 = create_security("AMZN", prices: [
|
||||
{ date: 1.day.ago.to_date, price: 212.00 },
|
||||
{ date: Date.current, price: 216.00 }
|
||||
])
|
||||
|
||||
security2 = create_security("NVDA", prices: [
|
||||
{ date: 1.day.ago.to_date, price: 128.00 },
|
||||
{ date: Date.current, price: 124.00 }
|
||||
])
|
||||
|
||||
create_holding(security1, 1.day.ago.to_date, 10)
|
||||
amzn = create_holding(security1, Date.current, 15)
|
||||
|
||||
create_holding(security2, 1.day.ago.to_date, 5)
|
||||
nvda = create_holding(security2, Date.current, 30)
|
||||
|
||||
[ amzn, nvda ]
|
||||
end
|
||||
|
||||
def create_holding(security, date, qty)
|
||||
price = Security::Price.find_by(date: date, isin: security.isin).price
|
||||
|
||||
@account.holdings.create! \
|
||||
date: date,
|
||||
security: security,
|
||||
qty: qty,
|
||||
price: price,
|
||||
amount: qty * price,
|
||||
currency: "USD"
|
||||
end
|
||||
end
|
||||
|
|
|
@ -28,12 +28,19 @@ module Account::EntriesTestHelper
|
|||
Account::Entry.create! entry_defaults.merge(attributes)
|
||||
end
|
||||
|
||||
def create_trade(account:, security:, qty:, price:, date:)
|
||||
def create_trade(security, account:, qty:, date:, price: nil)
|
||||
trade_price = price || Security::Price.find_by!(isin: security.isin, date: date).price
|
||||
|
||||
trade = Account::Trade.new \
|
||||
qty: qty,
|
||||
security: security,
|
||||
price: trade_price
|
||||
|
||||
account.entries.create! \
|
||||
date: date,
|
||||
amount: qty * price,
|
||||
currency: "USD",
|
||||
name: "Trade",
|
||||
entryable: Account::Trade.new(qty: qty, price: price, security: security)
|
||||
date: date,
|
||||
amount: qty * trade_price,
|
||||
currency: "USD",
|
||||
entryable: trade
|
||||
end
|
||||
end
|
||||
|
|
16
test/support/securities_test_helper.rb
Normal file
16
test/support/securities_test_helper.rb
Normal file
|
@ -0,0 +1,16 @@
|
|||
module SecuritiesTestHelper
|
||||
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
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue