1
0
Fork 0
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:
Zach Gollwitzer 2025-03-06 13:57:45 -05:00
parent 9edf67d1a0
commit 45187d855e
17 changed files with 640 additions and 353 deletions

View file

@ -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

View file

@ -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

View 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

View 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

View file

@ -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 = {}

View file

@ -0,0 +1,5 @@
class Account::Holding::PortfolioPriceCache
def initialize(account)
@account = account
end
end

View 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

View file

@ -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

View 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

View file

@ -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)

View file

@ -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

View 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

View file

@ -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

View 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

View 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

View 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

View file

@ -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")