Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support multi-currency investment accounts #1088

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/models/account/entry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ def create_trend
end

def trade_valid?
if account_trade.currency != currency
# i18n-tasks-use t('activerecord.errors.models.account/entry.attributes.base.currency_mismatch')
errors.add(:base, :currency_mismatch)
end

if account_trade.sell?
current_qty = account.holding_qty(account_trade.security)

Expand Down
1 change: 1 addition & 0 deletions config/locales/models/account/entry/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ en:
account/entry:
attributes:
base:
currency_mismatch: Entry currency must match trade currency
invalid_sell_quantity: cannot sell %{sell_qty} shares of %{ticker} because
you only own %{current_qty} shares
15 changes: 14 additions & 1 deletion test/models/account/entry_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -110,9 +110,22 @@ class Account::EntryTest < ActiveSupport::TestCase
amount: 100,
currency: "USD",
name: "Sell 10 shares of AMZN",
entryable: Account::Trade.new(qty: -10, price: 200, security: security)
entryable: Account::Trade.new(qty: -10, price: 200, currency: "USD", security: security)
end

assert_match /cannot sell 10.0 shares of AAPL because you only own 0.0 shares/, error.message
end

# Trade has a denormalized currency field that must match its parent Entry currency
test "trade must have same currency as entry" do
account = accounts(:investment)
assert_raises ActiveRecord::RecordInvalid do
account.entries.create! \
date: Date.current,
amount: 100,
currency: "USD",
name: "Test",
entryable: Account::Trade.new(qty: 10, price: 10, currency: "EUR", security: securities(:aapl))
end
end
end
97 changes: 78 additions & 19 deletions test/models/account/holding/syncer_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -33,16 +33,16 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase
create_trade(security1, account: @account, qty: -10, date: Date.current) # sell 10 shares of AMZN

expected = [
{ ticker: "AMZN", qty: 10, price: 214, amount: 10 * 214, date: 2.days.ago.to_date },
{ ticker: "AMZN", qty: 12, price: 215, amount: 12 * 215, date: 1.day.ago.to_date },
{ ticker: "AMZN", qty: 2, price: 216, amount: 2 * 216, date: Date.current },
{ ticker: "NVDA", qty: 20, price: 122, amount: 20 * 122, date: 1.day.ago.to_date },
{ ticker: "NVDA", qty: 20, price: 124, amount: 20 * 124, date: Date.current }
{ ticker: "AMZN", qty: 10, price: 214, amount: 10 * 214, date: 2.days.ago.to_date, currency: "USD" },
{ ticker: "AMZN", qty: 12, price: 215, amount: 12 * 215, date: 1.day.ago.to_date, currency: "USD" },
{ ticker: "AMZN", qty: 2, price: 216, amount: 2 * 216, date: Date.current, currency: "USD" },
{ ticker: "NVDA", qty: 20, price: 122, amount: 20 * 122, date: 1.day.ago.to_date, currency: "USD" },
{ ticker: "NVDA", qty: 20, price: 124, amount: 20 * 124, date: Date.current, currency: "USD" }
]

run_sync_for(@account)

assert_holdings(expected)
assert_holdings(expected, @account)
end

test "generates holdings with prices" do
Expand All @@ -55,12 +55,12 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase
create_trade(amzn, account: @account, qty: 10, date: Date.current, price: 215)

expected = [
{ ticker: "AMZN", qty: 10, price: 215, amount: 10 * 215, date: Date.current }
{ ticker: "AMZN", qty: 10, price: 215, amount: 10 * 215, date: Date.current, currency: "USD" }
]

run_sync_for(@account)

assert_holdings(expected)
assert_holdings(expected, @account)
end

test "generates all holdings even when missing security prices" do
Expand All @@ -72,9 +72,9 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase
# 1 day ago — finds daily price, uses it
# Today — no daily price, no entry, so price and amount are `nil`
expected = [
{ ticker: "AMZN", qty: 10, price: 210, amount: 10 * 210, date: 2.days.ago.to_date },
{ ticker: "AMZN", qty: 10, price: 215, amount: 10 * 215, date: 1.day.ago.to_date },
{ ticker: "AMZN", qty: 10, price: nil, amount: nil, date: Date.current }
{ ticker: "AMZN", qty: 10, price: 210, amount: 10 * 210, date: 2.days.ago.to_date, currency: "USD" },
{ ticker: "AMZN", qty: 10, price: 215, amount: 10 * 215, date: 1.day.ago.to_date, currency: "USD" },
{ ticker: "AMZN", qty: 10, price: nil, amount: nil, date: Date.current, currency: "USD" }
]

Security::Price.expects(:find_prices)
Expand All @@ -86,25 +86,84 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase

run_sync_for(@account)

assert_holdings(expected)
assert_holdings(expected, @account)
end

# TODO
test "syncs multi currency trade" do
price_currency = "USD" # Stock price fetched from provider is USD
trade_currency = "EUR" # Trade performed in EUR

amzn = create_security("AMZN", prices: [
{ date: 1.day.ago.to_date, price: 200, currency: price_currency },
{ date: Date.current, price: 210, currency: price_currency }
])

create_trade(amzn, account: @account, qty: 10, date: 1.day.ago.to_date, price: 180, currency: trade_currency)

# We expect holding to be generated in the account's currency (which is what shows to the user)
expected = [
{ ticker: "AMZN", qty: 10, price: 200, amount: 10 * 200, date: 1.day.ago.to_date, currency: "USD" },
{ ticker: "AMZN", qty: 10, price: 210, amount: 10 * 210, date: Date.current, currency: "USD" }
]

run_sync_for(@account)

assert_holdings(expected, @account)
end

# TODO
test "syncs foreign currency investment account" do
# Account is EUR, but family is USD. Must show holdings on account page in EUR, but aggregate holdings in USD for family views
@account.update! currency: "EUR"
assert_not_equal @account.currency, @account.family.currency

price_currency = "USD" # Stock price fetched from provider is USD
trade_currency = "EUR" # Trade performed in EUR

amzn = create_security("AMZN", prices: [
{ date: 1.day.ago.to_date, price: 200, currency: price_currency },
{ date: Date.current, price: 210, currency: price_currency }
])

create_trade(amzn, account: @account, qty: 10, date: 1.day.ago.to_date, price: 200, currency: trade_currency)

ExchangeRate.create! date: 1.day.ago.to_date, from_currency: "USD", to_currency: "EUR", rate: 0.9
ExchangeRate.create! date: Date.current, from_currency: "USD", to_currency: "EUR", rate: 0.9

expected = [
# Holdings in the account's currency for the account view
{ ticker: "AMZN", qty: 10, price: 200 * 0.9, amount: 10 * 200 * 0.9, date: 1.day.ago.to_date, currency: "EUR" },
{ ticker: "AMZN", qty: 10, price: 200 * 0.9, amount: 10 * 200 * 0.9, date: Date.current, currency: "EUR" },

# Holdings in the family's currency for aggregated calculations
{ ticker: "AMZN", qty: 10, price: 200, amount: 10 * 200, date: 1.day.ago.to_date, currency: "USD" },
{ ticker: "AMZN", qty: 10, price: 200, amount: 10 * 200, date: Date.current, currency: "USD" }
]

run_sync_for(@account)

assert_holdings(expected, @account)
end

private

def assert_holdings(expected_holdings)
holdings = @account.holdings.includes(:security).to_a
def assert_holdings(expected_holdings, account)
holdings = account.holdings.includes(:security).to_a
expected_holdings.each do |expected_holding|
actual_holding = holdings.find { |holding| holding.security.ticker == expected_holding[:ticker] && holding.date == expected_holding[:date] }
date = expected_holding[:date]
expected_price = expected_holding[:price]
expected_price = expected_holding[:price].to_d
expected_qty = expected_holding[:qty]
expected_amount = expected_holding[:amount]
expected_amount = expected_holding[:amount].to_d
expected_currency = expected_holding[:currency]
ticker = expected_holding[: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}"
assert_equal expected_qty, actual_holding.qty, "expected #{expected_qty} qty for holding #{ticker} on date: #{date}"
assert_equal expected_amount, actual_holding.amount.to_d, "expected #{expected_amount} amount for holding #{ticker} on date: #{date}"
assert_equal expected_price, actual_holding.price.to_d, "expected #{expected_price} price for holding #{ticker} on date: #{date}"
assert_equal expected_currency, actual_holding.currency, "expected #{expected_currency} price for holding #{ticker} on date: #{date}"
end
end

Expand Down
6 changes: 3 additions & 3 deletions test/support/account/entries_test_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,20 +28,20 @@ def create_valuation(attributes = {})
Account::Entry.create! entry_defaults.merge(attributes)
end

def create_trade(security, account:, qty:, date:, price: nil)
def create_trade(security, account:, qty:, date:, currency: "USD", price: nil)
trade_price = price || Security::Price.find_by!(ticker: security.ticker, date: date).price

trade = Account::Trade.new \
qty: qty,
security: security,
price: trade_price,
currency: "USD"
currency: currency

account.entries.create! \
name: "Trade",
date: date,
amount: qty * trade_price,
currency: "USD",
currency: currency,
entryable: trade
end
end
Loading