mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-10 07:55:21 +02:00
Separate forward and reverse calculators for holdings and balances
This commit is contained in:
parent
9edf67d1a0
commit
45187d855e
17 changed files with 640 additions and 353 deletions
|
@ -122,12 +122,4 @@ class Account < ApplicationRecord
|
|||
entryable: Account::Valuation.new
|
||||
end
|
||||
end
|
||||
|
||||
def sparkline_series
|
||||
cache_key = family.build_cache_key("#{id}_sparkline")
|
||||
|
||||
Rails.cache.fetch(cache_key) do
|
||||
balance_series
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -107,4 +107,22 @@ module Account::Chartable
|
|||
favorable_direction: favorable_direction
|
||||
)
|
||||
end
|
||||
|
||||
def sparkline_series
|
||||
cache_key = family.build_cache_key("#{id}_sparkline")
|
||||
|
||||
Rails.cache.fetch(cache_key) do
|
||||
balance_series
|
||||
end
|
||||
end
|
||||
|
||||
# Plaid accounts provide us current-day data, so we start there and work backwards
|
||||
# Manual, "offline" accounts must be calculated from the origin, forwards based on entries entirely
|
||||
def series_calculator
|
||||
if plaid_account_id.present?
|
||||
Account::ReverseSeriesCalculator.new(self)
|
||||
else
|
||||
Account::ForwardSeriesCalculator.new(self)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
85
app/models/account/forward_series_calculator.rb
Normal file
85
app/models/account/forward_series_calculator.rb
Normal file
|
@ -0,0 +1,85 @@
|
|||
class Account::ForwardSeriesCalculator < Account::SeriesCalculator
|
||||
def calculate
|
||||
Rails.logger.tagged("Account::BalanceCalculator") do
|
||||
Rails.logger.info("Calculating cash balances with strategy: forward sync")
|
||||
cash_balances = calculate_balances
|
||||
|
||||
cash_balances.map do |balance|
|
||||
holdings_value = converted_holdings.select { |h| h.date == balance.date }.sum(&:amount)
|
||||
balance.balance = balance.balance + holdings_value
|
||||
balance
|
||||
end.compact
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def calculate_balances
|
||||
prior_balance = 0
|
||||
current_balance = nil
|
||||
|
||||
oldest_date.upto(Date.current).map do |date|
|
||||
entries_for_date = converted_entries.select { |e| e.date == date }
|
||||
holdings_for_date = converted_holdings.select { |h| h.date == date }
|
||||
|
||||
valuation = entries_for_date.find { |e| e.account_valuation? }
|
||||
|
||||
current_balance = if valuation
|
||||
# To get this to a cash valuation, we back out holdings value on day
|
||||
valuation.amount - holdings_for_date.sum(&:amount)
|
||||
else
|
||||
transactions = entries_for_date.select { |e| e.account_transaction? || e.account_trade? }
|
||||
|
||||
calculate_balance(prior_balance, transactions, inverse: true)
|
||||
end
|
||||
|
||||
balance_record = Account::Balance.new(
|
||||
account: account,
|
||||
date: date,
|
||||
balance: current_balance,
|
||||
cash_balance: current_balance,
|
||||
currency: account.currency
|
||||
)
|
||||
|
||||
prior_balance = current_balance
|
||||
|
||||
balance_record
|
||||
end
|
||||
end
|
||||
|
||||
def oldest_date
|
||||
converted_entries.first ? converted_entries.first.date - 1.day : Date.current
|
||||
end
|
||||
|
||||
def converted_entries
|
||||
@converted_entries ||= @account.entries.order(:date).to_a.map do |e|
|
||||
converted_entry = e.dup
|
||||
converted_entry.amount = converted_entry.amount_money.exchange_to(
|
||||
account.currency,
|
||||
date: e.date,
|
||||
fallback_rate: 1
|
||||
).amount
|
||||
converted_entry.currency = account.currency
|
||||
converted_entry
|
||||
end
|
||||
end
|
||||
|
||||
def converted_holdings
|
||||
@converted_holdings ||= holdings.map do |h|
|
||||
converted_holding = h.dup
|
||||
converted_holding.amount = converted_holding.amount_money.exchange_to(
|
||||
account.currency,
|
||||
date: h.date,
|
||||
fallback_rate: 1
|
||||
).amount
|
||||
converted_holding.currency = account.currency
|
||||
converted_holding
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_balance(prior_balance, transactions, inverse: false)
|
||||
flows = transactions.sum(&:amount)
|
||||
negated = inverse ? account.asset? : account.liability?
|
||||
flows *= -1 if negated
|
||||
prior_balance + flows
|
||||
end
|
||||
end
|
13
app/models/account/holding/calculator.rb
Normal file
13
app/models/account/holding/calculator.rb
Normal file
|
@ -0,0 +1,13 @@
|
|||
class Account::Holding::Calculator
|
||||
def initialize(account)
|
||||
@account = account
|
||||
@securities_cache = {}
|
||||
end
|
||||
|
||||
def calculate
|
||||
raise NotImplementedError, "Subclasses must implement this method"
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account, :securities_cache
|
||||
end
|
|
@ -1,40 +1,18 @@
|
|||
class Account::HoldingCalculator
|
||||
def initialize(account)
|
||||
@account = account
|
||||
@securities_cache = {}
|
||||
end
|
||||
|
||||
def calculate(reverse: false)
|
||||
class Account::Holding::ForwardCalculator < Account::Holding::Calculator
|
||||
def calculate
|
||||
Rails.logger.tagged("Account::HoldingCalculator") do
|
||||
@securities_cache = {}
|
||||
preload_securities
|
||||
|
||||
Rails.logger.info("Calculating holdings with strategy: #{reverse ? "reverse sync" : "forward sync"}")
|
||||
calculated_holdings = reverse ? reverse_holdings : forward_holdings
|
||||
Rails.logger.info("Calculating holdings with strategy: forward sync")
|
||||
calculated_holdings = calculate_holdings
|
||||
|
||||
gapfill_holdings(calculated_holdings)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account, :securities_cache
|
||||
|
||||
def reverse_holdings
|
||||
current_holding_quantities = load_current_holding_quantities
|
||||
prior_holding_quantities = {}
|
||||
|
||||
holdings = []
|
||||
|
||||
Date.current.downto(portfolio_start_date).map do |date|
|
||||
today_trades = trades.select { |t| t.date == date }
|
||||
prior_holding_quantities = calculate_portfolio(current_holding_quantities, today_trades)
|
||||
holdings += generate_holding_records(current_holding_quantities, date)
|
||||
current_holding_quantities = prior_holding_quantities
|
||||
end
|
||||
|
||||
holdings
|
||||
end
|
||||
|
||||
def forward_holdings
|
||||
def calculate_holdings
|
||||
prior_holding_quantities = load_empty_holding_quantities
|
||||
current_holding_quantities = {}
|
||||
|
5
app/models/account/holding/portfolio_price_cache.rb
Normal file
5
app/models/account/holding/portfolio_price_cache.rb
Normal file
|
@ -0,0 +1,5 @@
|
|||
class Account::Holding::PortfolioPriceCache
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
end
|
167
app/models/account/holding/reverse_calculator.rb
Normal file
167
app/models/account/holding/reverse_calculator.rb
Normal file
|
@ -0,0 +1,167 @@
|
|||
class Account::Holding::ReverseCalculator < Account::Holding::Calculator
|
||||
def calculate
|
||||
Rails.logger.tagged("Account::HoldingCalculator") do
|
||||
preload_securities
|
||||
|
||||
Rails.logger.info("Calculating holdings with strategy: reverse sync")
|
||||
calculated_holdings = calculate_holdings
|
||||
|
||||
gapfill_holdings(calculated_holdings)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def calculate_holdings
|
||||
current_holding_quantities = load_current_holding_quantities
|
||||
prior_holding_quantities = {}
|
||||
|
||||
holdings = []
|
||||
|
||||
Date.current.downto(portfolio_start_date).map do |date|
|
||||
today_trades = trades.select { |t| t.date == date }
|
||||
prior_holding_quantities = calculate_portfolio(current_holding_quantities, today_trades)
|
||||
holdings += generate_holding_records(current_holding_quantities, date)
|
||||
current_holding_quantities = prior_holding_quantities
|
||||
end
|
||||
|
||||
holdings
|
||||
end
|
||||
|
||||
def generate_holding_records(portfolio, date)
|
||||
Rails.logger.info "Generating holdings for #{portfolio.size} securities on #{date}"
|
||||
|
||||
portfolio.map do |security_id, qty|
|
||||
security = securities_cache[security_id]
|
||||
|
||||
price = security.dig(:prices)&.find { |p| p.date == date }
|
||||
|
||||
# We prefer to use prices from our data provider. But if the provider doesn't have an EOD price
|
||||
# for this security, we search through the account's trades and use the "spot" price at the time of
|
||||
# the most recent trade for that day's holding. This is not as accurate, but it allows users to define
|
||||
# what we call "offline" securities (which is essential given we cannot get prices for all securities globally)
|
||||
if price.blank?
|
||||
converted_price = most_recent_trade_price(security_id, date)
|
||||
else
|
||||
converted_price = Money.new(price.price, price.currency).exchange_to(account.currency, fallback_rate: 1).amount
|
||||
end
|
||||
|
||||
account.holdings.build(
|
||||
security: security.dig(:security),
|
||||
date: date,
|
||||
qty: qty,
|
||||
price: converted_price,
|
||||
currency: account.currency,
|
||||
amount: qty * converted_price
|
||||
)
|
||||
end.compact
|
||||
end
|
||||
|
||||
def gapfill_holdings(holdings)
|
||||
filled_holdings = []
|
||||
|
||||
holdings.group_by { |h| h.security_id }.each do |security_id, security_holdings|
|
||||
next if security_holdings.empty?
|
||||
|
||||
sorted = security_holdings.sort_by(&:date)
|
||||
previous_holding = sorted.first
|
||||
|
||||
sorted.first.date.upto(Date.current) do |date|
|
||||
holding = security_holdings.find { |h| h.date == date }
|
||||
|
||||
if holding
|
||||
filled_holdings << holding
|
||||
previous_holding = holding
|
||||
else
|
||||
# Create a new holding based on the previous day's data
|
||||
filled_holdings << account.holdings.build(
|
||||
security: previous_holding.security,
|
||||
date: date,
|
||||
qty: previous_holding.qty,
|
||||
price: previous_holding.price,
|
||||
currency: previous_holding.currency,
|
||||
amount: previous_holding.amount
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
filled_holdings
|
||||
end
|
||||
|
||||
def trades
|
||||
@trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a
|
||||
end
|
||||
|
||||
def portfolio_start_date
|
||||
trades.first ? trades.first.date - 1.day : Date.current
|
||||
end
|
||||
|
||||
def preload_securities
|
||||
# Get securities from trades and current holdings
|
||||
securities = trades.map(&:entryable).map(&:security).uniq
|
||||
securities += account.holdings.where(date: Date.current).map(&:security)
|
||||
securities.uniq!
|
||||
|
||||
Rails.logger.info "Preloading #{securities.size} securities for account #{account.id}"
|
||||
|
||||
securities.each do |security|
|
||||
Rails.logger.info "Loading security: ID=#{security.id} Ticker=#{security.ticker}"
|
||||
|
||||
prices = Security::Price.find_prices(
|
||||
security: security,
|
||||
start_date: portfolio_start_date,
|
||||
end_date: Date.current
|
||||
)
|
||||
|
||||
Rails.logger.info "Found #{prices.size} prices for security #{security.id}"
|
||||
|
||||
@securities_cache[security.id] = {
|
||||
security: security,
|
||||
prices: prices
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_portfolio(holding_quantities, today_trades, inverse: false)
|
||||
new_quantities = holding_quantities.dup
|
||||
|
||||
today_trades.each do |trade|
|
||||
security_id = trade.entryable.security_id
|
||||
qty_change = inverse ? trade.entryable.qty : -trade.entryable.qty
|
||||
new_quantities[security_id] = (new_quantities[security_id] || 0) + qty_change
|
||||
end
|
||||
|
||||
new_quantities
|
||||
end
|
||||
|
||||
def load_empty_holding_quantities
|
||||
holding_quantities = {}
|
||||
|
||||
trades.map { |t| t.entryable.security_id }.uniq.each do |security_id|
|
||||
holding_quantities[security_id] = 0
|
||||
end
|
||||
|
||||
holding_quantities
|
||||
end
|
||||
|
||||
def load_current_holding_quantities
|
||||
holding_quantities = load_empty_holding_quantities
|
||||
|
||||
account.holdings.where(date: Date.current, currency: account.currency).map do |holding|
|
||||
holding_quantities[holding.security_id] = holding.qty
|
||||
end
|
||||
|
||||
holding_quantities
|
||||
end
|
||||
|
||||
def most_recent_trade_price(security_id, date)
|
||||
first_trade = trades.select { |t| t.entryable.security_id == security_id }.min_by(&:date)
|
||||
most_recent_trade = trades.select { |t| t.entryable.security_id == security_id && t.date <= date }.max_by(&:date)
|
||||
|
||||
if most_recent_trade
|
||||
most_recent_trade.entryable.price
|
||||
else
|
||||
first_trade.entryable.price
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,13 +1,8 @@
|
|||
class Account::BalanceCalculator
|
||||
def initialize(account, holdings: nil)
|
||||
@account = account
|
||||
@holdings = holdings || []
|
||||
end
|
||||
|
||||
def calculate(reverse: false, start_date: nil)
|
||||
class Account::ReverseSeriesCalculator < Account::SeriesCalculator
|
||||
def calculate
|
||||
Rails.logger.tagged("Account::BalanceCalculator") do
|
||||
Rails.logger.info("Calculating cash balances with strategy: #{reverse ? "reverse sync" : "forward sync"}")
|
||||
cash_balances = reverse ? reverse_cash_balances : forward_cash_balances
|
||||
Rails.logger.info("Calculating cash balances with strategy: reverse sync")
|
||||
cash_balances = calculate_balances
|
||||
|
||||
cash_balances.map do |balance|
|
||||
holdings_value = converted_holdings.select { |h| h.date == balance.date }.sum(&:amount)
|
||||
|
@ -18,13 +13,11 @@ class Account::BalanceCalculator
|
|||
end
|
||||
|
||||
private
|
||||
attr_reader :account, :holdings
|
||||
|
||||
def oldest_date
|
||||
converted_entries.first ? converted_entries.first.date - 1.day : Date.current
|
||||
end
|
||||
|
||||
def reverse_cash_balances
|
||||
def calculate_balances
|
||||
prior_balance = account.cash_balance
|
||||
|
||||
Date.current.downto(oldest_date).map do |date|
|
||||
|
@ -56,39 +49,6 @@ class Account::BalanceCalculator
|
|||
end
|
||||
end
|
||||
|
||||
def forward_cash_balances
|
||||
prior_balance = 0
|
||||
current_balance = nil
|
||||
|
||||
oldest_date.upto(Date.current).map do |date|
|
||||
entries_for_date = converted_entries.select { |e| e.date == date }
|
||||
holdings_for_date = converted_holdings.select { |h| h.date == date }
|
||||
|
||||
valuation = entries_for_date.find { |e| e.account_valuation? }
|
||||
|
||||
current_balance = if valuation
|
||||
# To get this to a cash valuation, we back out holdings value on day
|
||||
valuation.amount - holdings_for_date.sum(&:amount)
|
||||
else
|
||||
transactions = entries_for_date.select { |e| e.account_transaction? || e.account_trade? }
|
||||
|
||||
calculate_balance(prior_balance, transactions, inverse: true)
|
||||
end
|
||||
|
||||
balance_record = Account::Balance.new(
|
||||
account: account,
|
||||
date: date,
|
||||
balance: current_balance,
|
||||
cash_balance: current_balance,
|
||||
currency: account.currency
|
||||
)
|
||||
|
||||
prior_balance = current_balance
|
||||
|
||||
balance_record
|
||||
end
|
||||
end
|
||||
|
||||
def converted_entries
|
||||
@converted_entries ||= @account.entries.order(:date).to_a.map do |e|
|
||||
converted_entry = e.dup
|
13
app/models/account/series_calculator.rb
Normal file
13
app/models/account/series_calculator.rb
Normal file
|
@ -0,0 +1,13 @@
|
|||
class Account::SeriesCalculator
|
||||
def initialize(account, holdings: nil)
|
||||
@account = account
|
||||
@holdings = holdings || []
|
||||
end
|
||||
|
||||
def calculate
|
||||
raise NotImplementedError, "Subclasses must implement this method"
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account, :holdings
|
||||
end
|
|
@ -55,8 +55,8 @@ class Account::Syncer
|
|||
end
|
||||
|
||||
def sync_holdings
|
||||
calculator = Account::HoldingCalculator.new(account)
|
||||
calculated_holdings = calculator.calculate(reverse: plaid_sync?)
|
||||
calculator = plaid_sync? ? Account::Holding::ReverseCalculator.new(account) : Account::Holding::ForwardCalculator.new(account)
|
||||
calculated_holdings = calculator.calculate
|
||||
|
||||
Account.transaction do
|
||||
load_holdings(calculated_holdings)
|
||||
|
@ -67,8 +67,8 @@ class Account::Syncer
|
|||
end
|
||||
|
||||
def sync_balances(holdings)
|
||||
calculator = Account::BalanceCalculator.new(account, holdings: holdings)
|
||||
calculated_balances = calculator.calculate(reverse: plaid_sync?, start_date: start_date)
|
||||
calculator = plaid_sync? ? Account::ReverseSeriesCalculator.new(account, holdings: holdings) : Account::ForwardSeriesCalculator.new(account, holdings: holdings)
|
||||
calculated_balances = calculator.calculate
|
||||
|
||||
Account.transaction do
|
||||
load_balances(calculated_balances)
|
||||
|
|
|
@ -1,156 +0,0 @@
|
|||
require "test_helper"
|
||||
|
||||
class Account::BalanceCalculatorTest < ActiveSupport::TestCase
|
||||
include Account::EntriesTestHelper
|
||||
|
||||
setup do
|
||||
@account = families(:empty).accounts.create!(
|
||||
name: "Test",
|
||||
balance: 20000,
|
||||
cash_balance: 20000,
|
||||
currency: "USD",
|
||||
accountable: Investment.new
|
||||
)
|
||||
end
|
||||
|
||||
# When syncing backwards, we start with the account balance and generate everything from there.
|
||||
test "reverse no entries sync" do
|
||||
assert_equal 0, @account.balances.count
|
||||
|
||||
expected = [ @account.balance ]
|
||||
calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true)
|
||||
|
||||
assert_equal expected, calculated.map(&:balance)
|
||||
end
|
||||
|
||||
# When syncing forwards, we don't care about the account balance. We generate everything based on entries, starting from 0.
|
||||
test "forward no entries sync" do
|
||||
assert_equal 0, @account.balances.count
|
||||
|
||||
expected = [ 0 ]
|
||||
calculated = Account::BalanceCalculator.new(@account).calculate
|
||||
|
||||
assert_equal expected, calculated.map(&:balance)
|
||||
end
|
||||
|
||||
test "forward valuations sync" do
|
||||
create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000)
|
||||
create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000)
|
||||
|
||||
expected = [ 0, 17000, 17000, 19000, 19000, 19000 ]
|
||||
calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal expected, calculated
|
||||
end
|
||||
|
||||
test "reverse valuations sync" do
|
||||
create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000)
|
||||
create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000)
|
||||
|
||||
expected = [ 17000, 17000, 19000, 19000, 20000, 20000 ]
|
||||
calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true).sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal expected, calculated
|
||||
end
|
||||
|
||||
test "forward transactions sync" do
|
||||
create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income
|
||||
create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense
|
||||
|
||||
expected = [ 0, 500, 500, 400, 400, 400 ]
|
||||
calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal expected, calculated
|
||||
end
|
||||
|
||||
test "reverse transactions sync" do
|
||||
create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income
|
||||
create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense
|
||||
|
||||
expected = [ 19600, 20100, 20100, 20000, 20000, 20000 ]
|
||||
calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true).sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal expected, calculated
|
||||
end
|
||||
|
||||
test "reverse multi-entry sync" do
|
||||
create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000)
|
||||
create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000)
|
||||
create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500)
|
||||
create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500)
|
||||
create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000)
|
||||
create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100)
|
||||
|
||||
expected = [ 12000, 17000, 17000, 17000, 16500, 17000, 17000, 20100, 20000, 20000 ]
|
||||
calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true) .sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal expected, calculated
|
||||
end
|
||||
|
||||
test "forward multi-entry sync" do
|
||||
create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000)
|
||||
create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000)
|
||||
create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500)
|
||||
create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500)
|
||||
create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000)
|
||||
create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100)
|
||||
|
||||
expected = [ 0, 5000, 5000, 17000, 17000, 17500, 17000, 17000, 16900, 16900 ]
|
||||
calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal expected, calculated
|
||||
end
|
||||
|
||||
test "investment balance sync" do
|
||||
@account.update!(cash_balance: 18000)
|
||||
|
||||
# Transactions represent deposits / withdrawals from the brokerage account
|
||||
# Ex: We deposit $20,000 into the brokerage account
|
||||
create_transaction(account: @account, date: 2.days.ago.to_date, amount: -20000)
|
||||
|
||||
# Trades either consume cash (buy) or generate cash (sell). They do NOT change total balance, but do affect composition of cash/holdings.
|
||||
# Ex: We buy 20 shares of MSFT at $100 for a total of $2000
|
||||
create_trade(securities(:msft), account: @account, date: 1.day.ago.to_date, qty: 20, price: 100)
|
||||
|
||||
holdings = [
|
||||
Account::Holding.new(date: Date.current, security: securities(:msft), amount: 2000, currency: "USD"),
|
||||
Account::Holding.new(date: 1.day.ago.to_date, security: securities(:msft), amount: 2000, currency: "USD"),
|
||||
Account::Holding.new(date: 2.days.ago.to_date, security: securities(:msft), amount: 0, currency: "USD")
|
||||
]
|
||||
|
||||
expected = [ 0, 20000, 20000, 20000 ]
|
||||
calculated_backwards = Account::BalanceCalculator.new(@account, holdings: holdings).calculate(reverse: true).sort_by(&:date).map(&:balance)
|
||||
calculated_forwards = Account::BalanceCalculator.new(@account, holdings: holdings).calculate.sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal calculated_forwards, calculated_backwards
|
||||
assert_equal expected, calculated_forwards
|
||||
end
|
||||
|
||||
test "multi-currency sync" do
|
||||
ExchangeRate.create! date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2
|
||||
|
||||
create_transaction(account: @account, date: 3.days.ago.to_date, amount: -100, currency: "USD")
|
||||
create_transaction(account: @account, date: 2.days.ago.to_date, amount: -300, currency: "USD")
|
||||
|
||||
# Transaction in different currency than the account's main currency
|
||||
create_transaction(account: @account, date: 1.day.ago.to_date, amount: -500, currency: "EUR") # €500 * 1.2 = $600
|
||||
|
||||
expected = [ 0, 100, 400, 1000, 1000 ]
|
||||
calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal expected, calculated
|
||||
end
|
||||
|
||||
private
|
||||
def create_holding(date:, security:, amount:)
|
||||
Account::Holding.create!(
|
||||
account: @account,
|
||||
security: security,
|
||||
date: date,
|
||||
qty: 0, # not used
|
||||
price: 0, # not used
|
||||
amount: amount,
|
||||
currency: @account.currency
|
||||
)
|
||||
end
|
||||
end
|
74
test/models/account/forward_series_calculator_test.rb
Normal file
74
test/models/account/forward_series_calculator_test.rb
Normal file
|
@ -0,0 +1,74 @@
|
|||
require "test_helper"
|
||||
|
||||
class Account::ForwardSeriesCalculatorTest < ActiveSupport::TestCase
|
||||
include Account::EntriesTestHelper
|
||||
|
||||
setup do
|
||||
@account = families(:empty).accounts.create!(
|
||||
name: "Test",
|
||||
balance: 20000,
|
||||
cash_balance: 20000,
|
||||
currency: "USD",
|
||||
accountable: Investment.new
|
||||
)
|
||||
end
|
||||
|
||||
# When syncing forwards, we don't care about the account balance. We generate everything based on entries, starting from 0.
|
||||
test "no entries sync" do
|
||||
assert_equal 0, @account.balances.count
|
||||
|
||||
expected = [ 0 ]
|
||||
calculated = Account::ForwardSeriesCalculator.new(@account).calculate
|
||||
|
||||
assert_equal expected, calculated.map(&:balance)
|
||||
end
|
||||
|
||||
test "valuations sync" do
|
||||
create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000)
|
||||
create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000)
|
||||
|
||||
expected = [ 0, 17000, 17000, 19000, 19000, 19000 ]
|
||||
calculated = Account::ForwardSeriesCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal expected, calculated
|
||||
end
|
||||
|
||||
test "transactions sync" do
|
||||
create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income
|
||||
create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense
|
||||
|
||||
expected = [ 0, 500, 500, 400, 400, 400 ]
|
||||
calculated = Account::ForwardSeriesCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal expected, calculated
|
||||
end
|
||||
|
||||
test "multi-entry sync" do
|
||||
create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000)
|
||||
create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000)
|
||||
create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500)
|
||||
create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500)
|
||||
create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000)
|
||||
create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100)
|
||||
|
||||
expected = [ 0, 5000, 5000, 17000, 17000, 17500, 17000, 17000, 16900, 16900 ]
|
||||
calculated = Account::ForwardSeriesCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal expected, calculated
|
||||
end
|
||||
|
||||
test "multi-currency sync" do
|
||||
ExchangeRate.create! date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2
|
||||
|
||||
create_transaction(account: @account, date: 3.days.ago.to_date, amount: -100, currency: "USD")
|
||||
create_transaction(account: @account, date: 2.days.ago.to_date, amount: -300, currency: "USD")
|
||||
|
||||
# Transaction in different currency than the account's main currency
|
||||
create_transaction(account: @account, date: 1.day.ago.to_date, amount: -500, currency: "EUR") # €500 * 1.2 = $600
|
||||
|
||||
expected = [ 0, 100, 400, 1000, 1000 ]
|
||||
calculated = Account::ForwardSeriesCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal expected, calculated
|
||||
end
|
||||
end
|
|
@ -1,6 +1,6 @@
|
|||
require "test_helper"
|
||||
|
||||
class Account::HoldingCalculatorTest < ActiveSupport::TestCase
|
||||
class Account::Holding::ForwardCalculatorTest < ActiveSupport::TestCase
|
||||
include Account::EntriesTestHelper
|
||||
|
||||
setup do
|
||||
|
@ -14,77 +14,8 @@ class Account::HoldingCalculatorTest < ActiveSupport::TestCase
|
|||
end
|
||||
|
||||
test "no holdings" do
|
||||
forward = Account::HoldingCalculator.new(@account).calculate
|
||||
reverse = Account::HoldingCalculator.new(@account).calculate(reverse: true)
|
||||
assert_equal forward, reverse
|
||||
assert_equal [], forward
|
||||
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 = Account::HoldingCalculator.new(@account).calculate(reverse: true)
|
||||
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
|
||||
Account::Holding.new(security: @voo, date: 4.days.ago.to_date, qty: 0, price: 460, amount: 0),
|
||||
Account::Holding.new(security: @wmt, date: 4.days.ago.to_date, qty: 0, price: 100, amount: 0),
|
||||
Account::Holding.new(security: @amzn, date: 4.days.ago.to_date, qty: 0, price: 200, amount: 0),
|
||||
|
||||
# 3 days ago
|
||||
Account::Holding.new(security: @voo, date: 3.days.ago.to_date, qty: 20, price: 470, amount: 9400),
|
||||
Account::Holding.new(security: @wmt, date: 3.days.ago.to_date, qty: 0, price: 100, amount: 0),
|
||||
Account::Holding.new(security: @amzn, date: 3.days.ago.to_date, qty: 0, price: 200, amount: 0),
|
||||
|
||||
# 2 days ago
|
||||
Account::Holding.new(security: @voo, date: 2.days.ago.to_date, qty: 5, price: 480, amount: 2400),
|
||||
Account::Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0),
|
||||
Account::Holding.new(security: @amzn, date: 2.days.ago.to_date, qty: 1, price: 200, amount: 200),
|
||||
|
||||
# 1 day ago
|
||||
Account::Holding.new(security: @voo, date: 1.day.ago.to_date, qty: 10, price: 490, amount: 4900),
|
||||
Account::Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000),
|
||||
Account::Holding.new(security: @amzn, date: 1.day.ago.to_date, qty: 0, price: 200, amount: 0),
|
||||
|
||||
# Today
|
||||
Account::Holding.new(security: @voo, date: Date.current, qty: 10, price: 500, amount: 5000),
|
||||
Account::Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000),
|
||||
Account::Holding.new(security: @amzn, date: Date.current, qty: 0, price: 200, amount: 0)
|
||||
]
|
||||
|
||||
calculated = Account::HoldingCalculator.new(@account).calculate(reverse: true)
|
||||
|
||||
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
|
||||
calculated = Account::Holding::ForwardCalculator.new(@account).calculate
|
||||
assert_equal [], calculated
|
||||
end
|
||||
|
||||
test "forward portfolio calculation" do
|
||||
|
@ -129,7 +60,7 @@ class Account::HoldingCalculatorTest < ActiveSupport::TestCase
|
|||
Account::Holding.new(security: @amzn, date: Date.current, qty: 0, price: 200, amount: 0)
|
||||
]
|
||||
|
||||
calculated = Account::HoldingCalculator.new(@account).calculate
|
||||
calculated = Account::Holding::ForwardCalculator.new(@account).calculate
|
||||
|
||||
assert_equal expected.length, calculated.length
|
||||
assert_holdings(expected, calculated)
|
||||
|
@ -154,10 +85,9 @@ class Account::HoldingCalculatorTest < ActiveSupport::TestCase
|
|||
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 = Account::HoldingCalculator.new(@account).calculate
|
||||
calculated = Account::Holding::ForwardCalculator.new(@account).calculate
|
||||
|
||||
assert_equal expected.length, calculated.length
|
||||
|
||||
assert_holdings(expected, calculated)
|
||||
end
|
||||
|
||||
|
@ -175,7 +105,7 @@ class Account::HoldingCalculatorTest < ActiveSupport::TestCase
|
|||
Account::Holding.new(security: offline_security, date: Date.current, qty: 2, price: 100, amount: 200)
|
||||
]
|
||||
|
||||
calculated = Account::HoldingCalculator.new(@account).calculate
|
||||
calculated = Account::Holding::ForwardCalculator.new(@account).calculate
|
||||
|
||||
assert_equal expected.length, calculated.length
|
||||
assert_holdings(expected, calculated)
|
||||
|
@ -214,38 +144,4 @@ class Account::HoldingCalculatorTest < ActiveSupport::TestCase
|
|||
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
|
28
test/models/account/holding/portfolio_price_cache_test.rb
Normal file
28
test/models/account/holding/portfolio_price_cache_test.rb
Normal file
|
@ -0,0 +1,28 @@
|
|||
require "test_helper"
|
||||
|
||||
class Account::Holding::PortfolioPriceCacheTest < ActiveSupport::TestCase
|
||||
include Account::EntriesTestHelper
|
||||
|
||||
setup do
|
||||
@account = families(:empty).accounts.create!(
|
||||
name: "Test",
|
||||
balance: 20000,
|
||||
cash_balance: 20000,
|
||||
currency: "USD",
|
||||
accountable: Investment.new
|
||||
)
|
||||
|
||||
@voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF")
|
||||
Security::Price.create!(security: @voo, date: Date.current, price: 500)
|
||||
Security::Price.create!(security: @voo, date: 1.day.ago.to_date, price: 490)
|
||||
|
||||
@wmt = Security.create!(ticker: "WMT", name: "Walmart Inc.")
|
||||
Security::Price.create!(security: @wmt, date: Date.current, price: 100)
|
||||
Security::Price.create!(security: @wmt, date: 1.day.ago.to_date, price: 100)
|
||||
end
|
||||
|
||||
test "initializes with account" do
|
||||
cache = Account::Holding::PortfolioPriceCache.new(@account)
|
||||
assert_equal @account, cache.instance_variable_get(:@account)
|
||||
end
|
||||
end
|
155
test/models/account/holding/reverse_calculator_test.rb
Normal file
155
test/models/account/holding/reverse_calculator_test.rb
Normal file
|
@ -0,0 +1,155 @@
|
|||
require "test_helper"
|
||||
|
||||
class Account::Holding::ReverseCalculatorTest < ActiveSupport::TestCase
|
||||
include Account::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 = Account::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 = Account::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
|
||||
Account::Holding.new(security: @voo, date: 4.days.ago.to_date, qty: 0, price: 460, amount: 0),
|
||||
Account::Holding.new(security: @wmt, date: 4.days.ago.to_date, qty: 0, price: 100, amount: 0),
|
||||
Account::Holding.new(security: @amzn, date: 4.days.ago.to_date, qty: 0, price: 200, amount: 0),
|
||||
|
||||
# 3 days ago
|
||||
Account::Holding.new(security: @voo, date: 3.days.ago.to_date, qty: 20, price: 470, amount: 9400),
|
||||
Account::Holding.new(security: @wmt, date: 3.days.ago.to_date, qty: 0, price: 100, amount: 0),
|
||||
Account::Holding.new(security: @amzn, date: 3.days.ago.to_date, qty: 0, price: 200, amount: 0),
|
||||
|
||||
# 2 days ago
|
||||
Account::Holding.new(security: @voo, date: 2.days.ago.to_date, qty: 5, price: 480, amount: 2400),
|
||||
Account::Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0),
|
||||
Account::Holding.new(security: @amzn, date: 2.days.ago.to_date, qty: 1, price: 200, amount: 200),
|
||||
|
||||
# 1 day ago
|
||||
Account::Holding.new(security: @voo, date: 1.day.ago.to_date, qty: 10, price: 490, amount: 4900),
|
||||
Account::Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000),
|
||||
Account::Holding.new(security: @amzn, date: 1.day.ago.to_date, qty: 0, price: 200, amount: 0),
|
||||
|
||||
# Today
|
||||
Account::Holding.new(security: @voo, date: Date.current, qty: 10, price: 500, amount: 5000),
|
||||
Account::Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000),
|
||||
Account::Holding.new(security: @amzn, date: Date.current, qty: 0, price: 200, amount: 0)
|
||||
]
|
||||
|
||||
calculated = Account::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
|
59
test/models/account/reverse_series_calculator_test.rb
Normal file
59
test/models/account/reverse_series_calculator_test.rb
Normal file
|
@ -0,0 +1,59 @@
|
|||
require "test_helper"
|
||||
|
||||
class Account::ReverseSeriesCalculatorTest < ActiveSupport::TestCase
|
||||
include Account::EntriesTestHelper
|
||||
|
||||
setup do
|
||||
@account = families(:empty).accounts.create!(
|
||||
name: "Test",
|
||||
balance: 20000,
|
||||
cash_balance: 20000,
|
||||
currency: "USD",
|
||||
accountable: Investment.new
|
||||
)
|
||||
end
|
||||
|
||||
# When syncing backwards, we start with the account balance and generate everything from there.
|
||||
test "no entries sync" do
|
||||
assert_equal 0, @account.balances.count
|
||||
|
||||
expected = [ @account.balance ]
|
||||
calculated = Account::ReverseSeriesCalculator.new(@account).calculate
|
||||
|
||||
assert_equal expected, calculated.map(&:balance)
|
||||
end
|
||||
|
||||
test "valuations sync" do
|
||||
create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000)
|
||||
create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000)
|
||||
|
||||
expected = [ 17000, 17000, 19000, 19000, 20000, 20000 ]
|
||||
calculated = Account::ReverseSeriesCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal expected, calculated
|
||||
end
|
||||
|
||||
test "transactions sync" do
|
||||
create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income
|
||||
create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense
|
||||
|
||||
expected = [ 19600, 20100, 20100, 20000, 20000, 20000 ]
|
||||
calculated = Account::ReverseSeriesCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal expected, calculated
|
||||
end
|
||||
|
||||
test "multi-entry sync" do
|
||||
create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000)
|
||||
create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000)
|
||||
create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500)
|
||||
create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500)
|
||||
create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000)
|
||||
create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100)
|
||||
|
||||
expected = [ 12000, 17000, 17000, 17000, 16500, 17000, 17000, 20100, 20000, 20000 ]
|
||||
calculated = Account::ReverseSeriesCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||
|
||||
assert_equal expected, calculated
|
||||
end
|
||||
end
|
|
@ -22,14 +22,14 @@ class Account::SyncerTest < ActiveSupport::TestCase
|
|||
ExchangeRate.create!(date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2)
|
||||
ExchangeRate.create!(date: Date.current, from_currency: "EUR", to_currency: "USD", rate: 2)
|
||||
|
||||
Account::BalanceCalculator.any_instance.expects(:calculate).returns(
|
||||
Account::ForwardSeriesCalculator.any_instance.expects(:calculate).returns(
|
||||
[
|
||||
Account::Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "EUR"),
|
||||
Account::Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "EUR")
|
||||
]
|
||||
)
|
||||
|
||||
Account::HoldingCalculator.any_instance.expects(:calculate).returns(
|
||||
Account::Holding::ForwardCalculator.any_instance.expects(:calculate).returns(
|
||||
[
|
||||
Account::Holding.new(security: securities(:aapl), date: 1.day.ago.to_date, qty: 10, price: 50, amount: 500, currency: "EUR"),
|
||||
Account::Holding.new(security: securities(:aapl), date: Date.current, qty: 10, price: 50, amount: 500, currency: "EUR")
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue