mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 13:19:39 +02:00
Populate holdings for "offline" securities properly (#1958)
* Placeholder logic for missing prices * Generate holdings properly for "offline" securities * Separate forward and reverse calculators for holdings and balances * Remove unnecessary currency conversion during sync * Clearer sync process * Move price caching logic to dedicated model * Base holding calculator * Base calculator for balances * Finish balance calculators * Better naming * Logs cleanup * Remove stale data type * Remove stale test * Fix price lookup logic for holdings sync * Fix Plaid item sync regression * Remove temp logging * Calculate cash and holdings series * Add holdings, cash, and balance series dropdown for investments
This commit is contained in:
parent
26762477a3
commit
eac5d5e663
35 changed files with 1109 additions and 808 deletions
|
@ -18,6 +18,7 @@ class AccountsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def chart
|
def chart
|
||||||
|
@chart_view = params[:chart_view] || "balance"
|
||||||
render layout: "application"
|
render layout: "application"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,7 @@ module AccountableResource
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
@chart_view = params[:chart_view] || "balance"
|
||||||
@q = params.fetch(:q, {}).permit(:search)
|
@q = params.fetch(:q, {}).permit(:search)
|
||||||
entries = @account.entries.search(@q).reverse_chronological
|
entries = @account.entries.search(@q).reverse_chronological
|
||||||
|
|
||||||
|
|
|
@ -13,6 +13,7 @@ module AutoSync
|
||||||
|
|
||||||
def family_needs_auto_sync?
|
def family_needs_auto_sync?
|
||||||
return false unless Current.family.present?
|
return false unless Current.family.present?
|
||||||
|
return false unless Current.family.accounts.active.any?
|
||||||
|
|
||||||
(Current.family.last_synced_at&.to_date || 1.day.ago) < Date.current
|
(Current.family.last_synced_at&.to_date || 1.day.ago) < Date.current
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,11 +1,10 @@
|
||||||
class Account < ApplicationRecord
|
class Account < ApplicationRecord
|
||||||
include Syncable, Monetizable, Issuable, Chartable
|
include Syncable, Monetizable, Issuable, Chartable, Enrichable, Linkable
|
||||||
|
|
||||||
validates :name, :balance, :currency, presence: true
|
validates :name, :balance, :currency, presence: true
|
||||||
|
|
||||||
belongs_to :family
|
belongs_to :family
|
||||||
belongs_to :import, optional: true
|
belongs_to :import, optional: true
|
||||||
belongs_to :plaid_account, optional: true
|
|
||||||
|
|
||||||
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
|
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
|
||||||
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
|
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
|
||||||
|
@ -75,7 +74,16 @@ class Account < ApplicationRecord
|
||||||
def sync_data(start_date: nil)
|
def sync_data(start_date: nil)
|
||||||
update!(last_synced_at: Time.current)
|
update!(last_synced_at: Time.current)
|
||||||
|
|
||||||
Syncer.new(self, start_date: start_date).run
|
Rails.logger.info("Auto-matching transfers")
|
||||||
|
family.auto_match_transfers!
|
||||||
|
|
||||||
|
Rails.logger.info("Processing balances (#{linked? ? 'reverse' : 'forward'})")
|
||||||
|
sync_balances
|
||||||
|
|
||||||
|
if enrichable?
|
||||||
|
Rails.logger.info("Enriching transaction data")
|
||||||
|
enrich_data
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def post_sync
|
def post_sync
|
||||||
|
@ -93,10 +101,6 @@ class Account < ApplicationRecord
|
||||||
holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc)
|
holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc)
|
||||||
end
|
end
|
||||||
|
|
||||||
def enrich_data
|
|
||||||
DataEnricher.new(self).run
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_with_sync!(attributes)
|
def update_with_sync!(attributes)
|
||||||
should_update_balance = attributes[:balance] && attributes[:balance].to_d != balance
|
should_update_balance = attributes[:balance] && attributes[:balance].to_d != balance
|
||||||
|
|
||||||
|
@ -123,11 +127,14 @@ class Account < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def sparkline_series
|
def start_date
|
||||||
cache_key = family.build_cache_key("#{id}_sparkline")
|
first_entry_date = entries.minimum(:date) || Date.current
|
||||||
|
first_entry_date - 1.day
|
||||||
Rails.cache.fetch(cache_key) do
|
|
||||||
balance_series
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def sync_balances
|
||||||
|
strategy = linked? ? :reverse : :forward
|
||||||
|
Balance::Syncer.new(self, strategy: strategy).sync_balances
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
35
app/models/account/balance/base_calculator.rb
Normal file
35
app/models/account/balance/base_calculator.rb
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
class Account::Balance::BaseCalculator
|
||||||
|
attr_reader :account
|
||||||
|
|
||||||
|
def initialize(account)
|
||||||
|
@account = account
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate
|
||||||
|
Rails.logger.tagged(self.class.name) do
|
||||||
|
calculate_balances
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def sync_cache
|
||||||
|
@sync_cache ||= Account::Balance::SyncCache.new(account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_balance(date, cash_balance, holdings_value)
|
||||||
|
Account::Balance.new(
|
||||||
|
account_id: account.id,
|
||||||
|
date: date,
|
||||||
|
balance: holdings_value + cash_balance,
|
||||||
|
cash_balance: cash_balance,
|
||||||
|
currency: account.currency
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_next_balance(prior_balance, transactions, direction: :forward)
|
||||||
|
flows = transactions.sum(&:amount)
|
||||||
|
negated = direction == :forward ? account.asset? : account.liability?
|
||||||
|
flows *= -1 if negated
|
||||||
|
prior_balance + flows
|
||||||
|
end
|
||||||
|
end
|
28
app/models/account/balance/forward_calculator.rb
Normal file
28
app/models/account/balance/forward_calculator.rb
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
class Account::Balance::ForwardCalculator < Account::Balance::BaseCalculator
|
||||||
|
private
|
||||||
|
def calculate_balances
|
||||||
|
current_cash_balance = 0
|
||||||
|
next_cash_balance = nil
|
||||||
|
|
||||||
|
@balances = []
|
||||||
|
|
||||||
|
account.start_date.upto(Date.current).each do |date|
|
||||||
|
entries = sync_cache.get_entries(date)
|
||||||
|
holdings = sync_cache.get_holdings(date)
|
||||||
|
holdings_value = holdings.sum(&:amount)
|
||||||
|
valuation = sync_cache.get_valuation(date)
|
||||||
|
|
||||||
|
next_cash_balance = if valuation
|
||||||
|
valuation.amount - holdings_value
|
||||||
|
else
|
||||||
|
calculate_next_balance(current_cash_balance, entries, direction: :forward)
|
||||||
|
end
|
||||||
|
|
||||||
|
@balances << build_balance(date, next_cash_balance, holdings_value)
|
||||||
|
|
||||||
|
current_cash_balance = next_cash_balance
|
||||||
|
end
|
||||||
|
|
||||||
|
@balances
|
||||||
|
end
|
||||||
|
end
|
32
app/models/account/balance/reverse_calculator.rb
Normal file
32
app/models/account/balance/reverse_calculator.rb
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
class Account::Balance::ReverseCalculator < Account::Balance::BaseCalculator
|
||||||
|
private
|
||||||
|
def calculate_balances
|
||||||
|
current_cash_balance = account.cash_balance
|
||||||
|
previous_cash_balance = nil
|
||||||
|
|
||||||
|
@balances = []
|
||||||
|
|
||||||
|
Date.current.downto(account.start_date).map do |date|
|
||||||
|
entries = sync_cache.get_entries(date)
|
||||||
|
holdings = sync_cache.get_holdings(date)
|
||||||
|
holdings_value = holdings.sum(&:amount)
|
||||||
|
valuation = sync_cache.get_valuation(date)
|
||||||
|
|
||||||
|
previous_cash_balance = if valuation
|
||||||
|
valuation.amount - holdings_value
|
||||||
|
else
|
||||||
|
calculate_next_balance(current_cash_balance, entries, direction: :reverse)
|
||||||
|
end
|
||||||
|
|
||||||
|
if valuation.present?
|
||||||
|
@balances << build_balance(date, previous_cash_balance, holdings_value)
|
||||||
|
else
|
||||||
|
@balances << build_balance(date, current_cash_balance, holdings_value)
|
||||||
|
end
|
||||||
|
|
||||||
|
current_cash_balance = previous_cash_balance
|
||||||
|
end
|
||||||
|
|
||||||
|
@balances
|
||||||
|
end
|
||||||
|
end
|
46
app/models/account/balance/sync_cache.rb
Normal file
46
app/models/account/balance/sync_cache.rb
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
class Account::Balance::SyncCache
|
||||||
|
def initialize(account)
|
||||||
|
@account = account
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_valuation(date)
|
||||||
|
converted_entries.find { |e| e.date == date && e.account_valuation? }
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_holdings(date)
|
||||||
|
converted_holdings.select { |h| h.date == date }
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_entries(date)
|
||||||
|
converted_entries.select { |e| e.date == date && (e.account_transaction? || e.account_trade?) }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
attr_reader :account
|
||||||
|
|
||||||
|
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 ||= account.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
|
||||||
|
end
|
69
app/models/account/balance/syncer.rb
Normal file
69
app/models/account/balance/syncer.rb
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
class Account::Balance::Syncer
|
||||||
|
attr_reader :account, :strategy
|
||||||
|
|
||||||
|
def initialize(account, strategy:)
|
||||||
|
@account = account
|
||||||
|
@strategy = strategy
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_balances
|
||||||
|
Account::Balance.transaction do
|
||||||
|
sync_holdings
|
||||||
|
calculate_balances
|
||||||
|
|
||||||
|
Rails.logger.info("Persisting #{@balances.size} balances")
|
||||||
|
persist_balances
|
||||||
|
|
||||||
|
purge_stale_balances
|
||||||
|
|
||||||
|
if strategy == :forward
|
||||||
|
update_account_info
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def sync_holdings
|
||||||
|
@holdings = Account::Holding::Syncer.new(account, strategy: strategy).sync_holdings
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_account_info
|
||||||
|
calculated_balance = @balances.sort_by(&:date).last&.balance || 0
|
||||||
|
calculated_holdings_value = @holdings.select { |h| h.date == Date.current }.sum(&:amount) || 0
|
||||||
|
calculated_cash_balance = calculated_balance - calculated_holdings_value
|
||||||
|
|
||||||
|
Rails.logger.info("Balance update: cash=#{calculated_cash_balance}, total=#{calculated_balance}")
|
||||||
|
|
||||||
|
account.update!(
|
||||||
|
balance: calculated_balance,
|
||||||
|
cash_balance: calculated_cash_balance
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_balances
|
||||||
|
@balances = calculator.calculate
|
||||||
|
end
|
||||||
|
|
||||||
|
def persist_balances
|
||||||
|
current_time = Time.now
|
||||||
|
account.balances.upsert_all(
|
||||||
|
@balances.map { |b| b.attributes
|
||||||
|
.slice("date", "balance", "cash_balance", "currency")
|
||||||
|
.merge("updated_at" => current_time) },
|
||||||
|
unique_by: %i[account_id date currency]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def purge_stale_balances
|
||||||
|
deleted_count = account.balances.delete_by("date < ?", account.start_date)
|
||||||
|
Rails.logger.info("Purged #{deleted_count} stale balances") if deleted_count > 0
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculator
|
||||||
|
if strategy == :reverse
|
||||||
|
Account::Balance::ReverseCalculator.new(account)
|
||||||
|
else
|
||||||
|
Account::Balance::ForwardCalculator.new(account)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,124 +0,0 @@
|
||||||
class Account::BalanceCalculator
|
|
||||||
def initialize(account, holdings: nil)
|
|
||||||
@account = account
|
|
||||||
@holdings = holdings || []
|
|
||||||
end
|
|
||||||
|
|
||||||
def calculate(reverse: false, start_date: nil)
|
|
||||||
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
|
|
||||||
|
|
||||||
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
|
|
||||||
attr_reader :account, :holdings
|
|
||||||
|
|
||||||
def oldest_date
|
|
||||||
converted_entries.first ? converted_entries.first.date - 1.day : Date.current
|
|
||||||
end
|
|
||||||
|
|
||||||
def reverse_cash_balances
|
|
||||||
prior_balance = account.cash_balance
|
|
||||||
|
|
||||||
Date.current.downto(oldest_date).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)
|
|
||||||
end
|
|
||||||
|
|
||||||
balance_record = Account::Balance.new(
|
|
||||||
account: account,
|
|
||||||
date: date,
|
|
||||||
balance: valuation ? current_balance : prior_balance,
|
|
||||||
cash_balance: valuation ? current_balance : prior_balance,
|
|
||||||
currency: account.currency
|
|
||||||
)
|
|
||||||
|
|
||||||
prior_balance = current_balance
|
|
||||||
|
|
||||||
balance_record
|
|
||||||
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
|
|
||||||
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
|
|
|
@ -2,7 +2,9 @@ module Account::Chartable
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
class_methods do
|
class_methods do
|
||||||
def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up")
|
def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up", view: :balance)
|
||||||
|
raise ArgumentError, "Invalid view type" unless [ :balance, :cash_balance, :holdings_balance ].include?(view.to_sym)
|
||||||
|
|
||||||
balances = Account::Balance.find_by_sql([
|
balances = Account::Balance.find_by_sql([
|
||||||
balance_series_query,
|
balance_series_query,
|
||||||
{
|
{
|
||||||
|
@ -21,8 +23,8 @@ module Account::Chartable
|
||||||
date: curr.date,
|
date: curr.date,
|
||||||
date_formatted: I18n.l(curr.date, format: :long),
|
date_formatted: I18n.l(curr.date, format: :long),
|
||||||
trend: Trend.new(
|
trend: Trend.new(
|
||||||
current: Money.new(curr.balance, currency),
|
current: Money.new(balance_value_for(curr, view), currency),
|
||||||
previous: prev.nil? ? nil : Money.new(prev.balance, currency),
|
previous: prev.nil? ? nil : Money.new(balance_value_for(prev, view), currency),
|
||||||
favorable_direction: favorable_direction
|
favorable_direction: favorable_direction
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -33,8 +35,8 @@ module Account::Chartable
|
||||||
end_date: period.end_date,
|
end_date: period.end_date,
|
||||||
interval: period.interval,
|
interval: period.interval,
|
||||||
trend: Trend.new(
|
trend: Trend.new(
|
||||||
current: Money.new(balances.last&.balance || 0, currency),
|
current: Money.new(balance_value_for(balances.last, view) || 0, currency),
|
||||||
previous: Money.new(balances.first&.balance || 0, currency),
|
previous: Money.new(balance_value_for(balances.first, view) || 0, currency),
|
||||||
favorable_direction: favorable_direction
|
favorable_direction: favorable_direction
|
||||||
),
|
),
|
||||||
values: values
|
values: values
|
||||||
|
@ -52,6 +54,8 @@ module Account::Chartable
|
||||||
SELECT
|
SELECT
|
||||||
d.date,
|
d.date,
|
||||||
SUM(CASE WHEN accounts.classification = 'asset' THEN ab.balance ELSE -ab.balance END * COALESCE(er.rate, 1)) as balance,
|
SUM(CASE WHEN accounts.classification = 'asset' THEN ab.balance ELSE -ab.balance END * COALESCE(er.rate, 1)) as balance,
|
||||||
|
SUM(CASE WHEN accounts.classification = 'asset' THEN ab.cash_balance ELSE -ab.cash_balance END * COALESCE(er.rate, 1)) as cash_balance,
|
||||||
|
SUM(CASE WHEN accounts.classification = 'asset' THEN ab.balance - ab.cash_balance ELSE 0 END * COALESCE(er.rate, 1)) as holdings_balance,
|
||||||
COUNT(CASE WHEN accounts.currency <> :target_currency AND er.rate IS NULL THEN 1 END) as missing_rates
|
COUNT(CASE WHEN accounts.currency <> :target_currency AND er.rate IS NULL THEN 1 END) as missing_rates
|
||||||
FROM dates d
|
FROM dates d
|
||||||
LEFT JOIN accounts ON accounts.id IN (#{all.select(:id).to_sql})
|
LEFT JOIN accounts ON accounts.id IN (#{all.select(:id).to_sql})
|
||||||
|
@ -70,26 +74,46 @@ module Account::Chartable
|
||||||
SQL
|
SQL
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def balance_value_for(balance_record, view)
|
||||||
|
return 0 if balance_record.nil?
|
||||||
|
|
||||||
|
case view.to_sym
|
||||||
|
when :balance then balance_record.balance
|
||||||
|
when :cash_balance then balance_record.cash_balance
|
||||||
|
when :holdings_balance then balance_record.holdings_balance
|
||||||
|
else
|
||||||
|
raise ArgumentError, "Invalid view type: #{view}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def invert_balances(balances)
|
def invert_balances(balances)
|
||||||
balances.map do |balance|
|
balances.map do |balance|
|
||||||
balance.balance = -balance.balance
|
balance.balance = -balance.balance
|
||||||
|
balance.cash_balance = -balance.cash_balance
|
||||||
|
balance.holdings_balance = -balance.holdings_balance
|
||||||
balance
|
balance
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def gapfill_balances(balances)
|
def gapfill_balances(balances)
|
||||||
gapfilled = []
|
gapfilled = []
|
||||||
|
prev = nil
|
||||||
|
|
||||||
prev_balance = nil
|
balances.each do |curr|
|
||||||
|
if prev.nil?
|
||||||
[ nil, *balances ].each_cons(2).each_with_index do |(prev, curr), index|
|
# Initialize first record with zeros if nil
|
||||||
if index == 0 && curr.balance.nil?
|
curr.balance ||= 0
|
||||||
curr.balance = 0 # Ensure all series start with a non-nil balance
|
curr.cash_balance ||= 0
|
||||||
elsif curr.balance.nil?
|
curr.holdings_balance ||= 0
|
||||||
curr.balance = prev.balance
|
else
|
||||||
|
# Copy previous values for nil fields
|
||||||
|
curr.balance ||= prev.balance
|
||||||
|
curr.cash_balance ||= prev.cash_balance
|
||||||
|
curr.holdings_balance ||= prev.holdings_balance
|
||||||
end
|
end
|
||||||
|
|
||||||
gapfilled << curr
|
gapfilled << curr
|
||||||
|
prev = curr
|
||||||
end
|
end
|
||||||
|
|
||||||
gapfilled
|
gapfilled
|
||||||
|
@ -100,11 +124,20 @@ module Account::Chartable
|
||||||
classification == "asset" ? "up" : "down"
|
classification == "asset" ? "up" : "down"
|
||||||
end
|
end
|
||||||
|
|
||||||
def balance_series(period: Period.last_30_days)
|
def balance_series(period: Period.last_30_days, view: :balance)
|
||||||
self.class.where(id: self.id).balance_series(
|
self.class.where(id: self.id).balance_series(
|
||||||
currency: currency,
|
currency: currency,
|
||||||
period: period,
|
period: period,
|
||||||
|
view: view,
|
||||||
favorable_direction: favorable_direction
|
favorable_direction: favorable_direction
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def sparkline_series
|
||||||
|
cache_key = family.build_cache_key("#{id}_sparkline")
|
||||||
|
|
||||||
|
Rails.cache.fetch(cache_key) do
|
||||||
|
balance_series
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
12
app/models/account/enrichable.rb
Normal file
12
app/models/account/enrichable.rb
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
module Account::Enrichable
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
def enrich_data
|
||||||
|
DataEnricher.new(self).run
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def enrichable?
|
||||||
|
family.data_enrichment_enabled? || (linked? && Rails.application.config.app_mode.hosted?)
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,5 +1,5 @@
|
||||||
class Account::Holding < ApplicationRecord
|
class Account::Holding < ApplicationRecord
|
||||||
include Monetizable
|
include Monetizable, Gapfillable
|
||||||
|
|
||||||
monetize :amount
|
monetize :amount
|
||||||
|
|
||||||
|
|
63
app/models/account/holding/base_calculator.rb
Normal file
63
app/models/account/holding/base_calculator.rb
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
class Account::Holding::BaseCalculator
|
||||||
|
attr_reader :account
|
||||||
|
|
||||||
|
def initialize(account)
|
||||||
|
@account = account
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate
|
||||||
|
Rails.logger.tagged(self.class.name) do
|
||||||
|
holdings = calculate_holdings
|
||||||
|
Account::Holding.gapfill(holdings)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
def portfolio_cache
|
||||||
|
@portfolio_cache ||= Account::Holding::PortfolioCache.new(account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def empty_portfolio
|
||||||
|
securities = portfolio_cache.get_securities
|
||||||
|
securities.each_with_object({}) { |security, hash| hash[security.id] = 0 }
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_starting_portfolio
|
||||||
|
empty_portfolio
|
||||||
|
end
|
||||||
|
|
||||||
|
def transform_portfolio(previous_portfolio, trade_entries, direction: :forward)
|
||||||
|
new_quantities = previous_portfolio.dup
|
||||||
|
|
||||||
|
trade_entries.each do |trade_entry|
|
||||||
|
trade = trade_entry.entryable
|
||||||
|
security_id = trade.security_id
|
||||||
|
qty_change = trade.qty
|
||||||
|
qty_change = qty_change * -1 if direction == :reverse
|
||||||
|
new_quantities[security_id] = (new_quantities[security_id] || 0) + qty_change
|
||||||
|
end
|
||||||
|
|
||||||
|
new_quantities
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_holdings(portfolio, date)
|
||||||
|
portfolio.map do |security_id, qty|
|
||||||
|
price = portfolio_cache.get_price(security_id, date)
|
||||||
|
|
||||||
|
if price.nil?
|
||||||
|
Rails.logger.warn "No price found for security #{security_id} on #{date}"
|
||||||
|
next
|
||||||
|
end
|
||||||
|
|
||||||
|
Account::Holding.new(
|
||||||
|
account_id: account.id,
|
||||||
|
security_id: security_id,
|
||||||
|
date: date,
|
||||||
|
qty: qty,
|
||||||
|
price: price.price,
|
||||||
|
currency: price.currency,
|
||||||
|
amount: qty * price.price
|
||||||
|
)
|
||||||
|
end.compact
|
||||||
|
end
|
||||||
|
end
|
21
app/models/account/holding/forward_calculator.rb
Normal file
21
app/models/account/holding/forward_calculator.rb
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
class Account::Holding::ForwardCalculator < Account::Holding::BaseCalculator
|
||||||
|
private
|
||||||
|
def portfolio_cache
|
||||||
|
@portfolio_cache ||= Account::Holding::PortfolioCache.new(account)
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_holdings
|
||||||
|
current_portfolio = generate_starting_portfolio
|
||||||
|
next_portfolio = {}
|
||||||
|
holdings = []
|
||||||
|
|
||||||
|
account.start_date.upto(Date.current).each do |date|
|
||||||
|
trades = portfolio_cache.get_trades(date: date)
|
||||||
|
next_portfolio = transform_portfolio(current_portfolio, trades, direction: :forward)
|
||||||
|
holdings += build_holdings(next_portfolio, date)
|
||||||
|
current_portfolio = next_portfolio
|
||||||
|
end
|
||||||
|
|
||||||
|
holdings
|
||||||
|
end
|
||||||
|
end
|
38
app/models/account/holding/gapfillable.rb
Normal file
38
app/models/account/holding/gapfillable.rb
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
module Account::Holding::Gapfillable
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
class_methods do
|
||||||
|
def gapfill(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::Holding.new(
|
||||||
|
account: previous_holding.account,
|
||||||
|
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
|
||||||
|
end
|
||||||
|
end
|
132
app/models/account/holding/portfolio_cache.rb
Normal file
132
app/models/account/holding/portfolio_cache.rb
Normal file
|
@ -0,0 +1,132 @@
|
||||||
|
class Account::Holding::PortfolioCache
|
||||||
|
attr_reader :account, :use_holdings
|
||||||
|
|
||||||
|
class SecurityNotFound < StandardError
|
||||||
|
def initialize(security_id, account_id)
|
||||||
|
super("Security id=#{security_id} not found in portfolio cache for account #{account_id}. This should not happen unless securities were preloaded incorrectly.")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(account, use_holdings: false)
|
||||||
|
@account = account
|
||||||
|
@use_holdings = use_holdings
|
||||||
|
load_prices
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_trades(date: nil)
|
||||||
|
if date.blank?
|
||||||
|
trades
|
||||||
|
else
|
||||||
|
trades.select { |t| t.date == date }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_price(security_id, date)
|
||||||
|
security = @security_cache[security_id]
|
||||||
|
raise SecurityNotFound.new(security_id, account.id) unless security
|
||||||
|
|
||||||
|
price = security[:prices].select { |p| p.price.date == date }.min_by(&:priority)&.price
|
||||||
|
|
||||||
|
return nil unless price
|
||||||
|
|
||||||
|
price_money = Money.new(price.price, price.currency)
|
||||||
|
|
||||||
|
converted_amount = price_money.exchange_to(account.currency, fallback_rate: 1).amount
|
||||||
|
|
||||||
|
Security::Price.new(
|
||||||
|
security_id: security_id,
|
||||||
|
date: price.date,
|
||||||
|
price: converted_amount,
|
||||||
|
currency: price.currency
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_securities
|
||||||
|
@security_cache.map { |_, v| v[:security] }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
PriceWithPriority = Data.define(:price, :priority)
|
||||||
|
|
||||||
|
def trades
|
||||||
|
@trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a
|
||||||
|
end
|
||||||
|
|
||||||
|
def holdings
|
||||||
|
@holdings ||= account.holdings.chronological.to_a
|
||||||
|
end
|
||||||
|
|
||||||
|
def collect_unique_securities
|
||||||
|
unique_securities_from_trades = trades.map(&:entryable).map(&:security).uniq
|
||||||
|
|
||||||
|
return unique_securities_from_trades unless use_holdings
|
||||||
|
|
||||||
|
unique_securities_from_holdings = holdings.map(&:security).uniq
|
||||||
|
|
||||||
|
(unique_securities_from_trades + unique_securities_from_holdings).uniq
|
||||||
|
end
|
||||||
|
|
||||||
|
# Loads all known prices for all securities in the account with priority based on source:
|
||||||
|
# 1 - DB or provider prices
|
||||||
|
# 2 - Trade prices
|
||||||
|
# 3 - Holding prices
|
||||||
|
def load_prices
|
||||||
|
@security_cache = {}
|
||||||
|
securities = collect_unique_securities
|
||||||
|
|
||||||
|
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}"
|
||||||
|
|
||||||
|
# Highest priority prices
|
||||||
|
db_or_provider_prices = Security::Price.find_prices(
|
||||||
|
security: security,
|
||||||
|
start_date: account.start_date,
|
||||||
|
end_date: Date.current
|
||||||
|
).map do |price|
|
||||||
|
PriceWithPriority.new(
|
||||||
|
price: price,
|
||||||
|
priority: 1
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Medium priority prices from trades
|
||||||
|
trade_prices = trades
|
||||||
|
.select { |t| t.entryable.security_id == security.id }
|
||||||
|
.map do |trade|
|
||||||
|
PriceWithPriority.new(
|
||||||
|
price: Security::Price.new(
|
||||||
|
security: security,
|
||||||
|
price: trade.entryable.price,
|
||||||
|
currency: trade.entryable.currency,
|
||||||
|
date: trade.date
|
||||||
|
),
|
||||||
|
priority: 2
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Low priority prices from holdings (if applicable)
|
||||||
|
holding_prices = if use_holdings
|
||||||
|
holdings.select { |h| h.security_id == security.id }.map do |holding|
|
||||||
|
PriceWithPriority.new(
|
||||||
|
price: Security::Price.new(
|
||||||
|
security: security,
|
||||||
|
price: holding.price,
|
||||||
|
currency: holding.currency,
|
||||||
|
date: holding.date
|
||||||
|
),
|
||||||
|
priority: 3
|
||||||
|
)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
@security_cache[security.id] = {
|
||||||
|
security: security,
|
||||||
|
prices: db_or_provider_prices + trade_prices + holding_prices
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
38
app/models/account/holding/reverse_calculator.rb
Normal file
38
app/models/account/holding/reverse_calculator.rb
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
class Account::Holding::ReverseCalculator < Account::Holding::BaseCalculator
|
||||||
|
private
|
||||||
|
# Reverse calculators will use the existing holdings as a source of security ids and prices
|
||||||
|
# since it is common for a provider to supply "current day" holdings but not all the historical
|
||||||
|
# trades that make up those holdings.
|
||||||
|
def portfolio_cache
|
||||||
|
@portfolio_cache ||= Account::Holding::PortfolioCache.new(account, use_holdings: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculate_holdings
|
||||||
|
current_portfolio = generate_starting_portfolio
|
||||||
|
previous_portfolio = {}
|
||||||
|
|
||||||
|
holdings = []
|
||||||
|
|
||||||
|
Date.current.downto(account.start_date).each do |date|
|
||||||
|
today_trades = portfolio_cache.get_trades(date: date)
|
||||||
|
previous_portfolio = transform_portfolio(current_portfolio, today_trades, direction: :reverse)
|
||||||
|
holdings += build_holdings(current_portfolio, date)
|
||||||
|
current_portfolio = previous_portfolio
|
||||||
|
end
|
||||||
|
|
||||||
|
holdings
|
||||||
|
end
|
||||||
|
|
||||||
|
# Since this is a reverse sync, we start with today's holdings
|
||||||
|
def generate_starting_portfolio
|
||||||
|
holding_quantities = empty_portfolio
|
||||||
|
|
||||||
|
todays_holdings = account.holdings.where(date: Date.current)
|
||||||
|
|
||||||
|
todays_holdings.each do |holding|
|
||||||
|
holding_quantities[holding.security_id] = holding.qty
|
||||||
|
end
|
||||||
|
|
||||||
|
holding_quantities
|
||||||
|
end
|
||||||
|
end
|
58
app/models/account/holding/syncer.rb
Normal file
58
app/models/account/holding/syncer.rb
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
class Account::Holding::Syncer
|
||||||
|
def initialize(account, strategy:)
|
||||||
|
@account = account
|
||||||
|
@strategy = strategy
|
||||||
|
end
|
||||||
|
|
||||||
|
def sync_holdings
|
||||||
|
calculate_holdings
|
||||||
|
|
||||||
|
Rails.logger.info("Persisting #{@holdings.size} holdings")
|
||||||
|
persist_holdings
|
||||||
|
|
||||||
|
if strategy == :forward
|
||||||
|
purge_stale_holdings
|
||||||
|
end
|
||||||
|
|
||||||
|
@holdings
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
attr_reader :account, :strategy
|
||||||
|
|
||||||
|
def calculate_holdings
|
||||||
|
@holdings = calculator.calculate
|
||||||
|
end
|
||||||
|
|
||||||
|
def persist_holdings
|
||||||
|
current_time = Time.now
|
||||||
|
|
||||||
|
account.holdings.upsert_all(
|
||||||
|
@holdings.map { |h| h.attributes
|
||||||
|
.slice("date", "currency", "qty", "price", "amount", "security_id")
|
||||||
|
.merge("account_id" => account.id, "updated_at" => current_time) },
|
||||||
|
unique_by: %i[account_id security_id date currency]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def purge_stale_holdings
|
||||||
|
portfolio_security_ids = account.entries.account_trades.map { |entry| entry.entryable.security_id }.uniq
|
||||||
|
|
||||||
|
# If there are no securities in the portfolio, delete all holdings
|
||||||
|
if portfolio_security_ids.empty?
|
||||||
|
Rails.logger.info("Clearing all holdings (no securities)")
|
||||||
|
account.holdings.delete_all
|
||||||
|
else
|
||||||
|
deleted_count = account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account.start_date, portfolio_security_ids)
|
||||||
|
Rails.logger.info("Purged #{deleted_count} stale holdings") if deleted_count > 0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def calculator
|
||||||
|
if strategy == :reverse
|
||||||
|
Account::Holding::ReverseCalculator.new(account)
|
||||||
|
else
|
||||||
|
Account::Holding::ForwardCalculator.new(account)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,188 +0,0 @@
|
||||||
class Account::HoldingCalculator
|
|
||||||
def initialize(account)
|
|
||||||
@account = account
|
|
||||||
@securities_cache = {}
|
|
||||||
end
|
|
||||||
|
|
||||||
def calculate(reverse: false)
|
|
||||||
Rails.logger.tagged("Account::HoldingCalculator") do
|
|
||||||
preload_securities
|
|
||||||
|
|
||||||
Rails.logger.info("Calculating holdings with strategy: #{reverse ? "reverse sync" : "forward sync"}")
|
|
||||||
calculated_holdings = reverse ? reverse_holdings : forward_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
|
|
||||||
prior_holding_quantities = load_empty_holding_quantities
|
|
||||||
current_holding_quantities = {}
|
|
||||||
|
|
||||||
holdings = []
|
|
||||||
|
|
||||||
portfolio_start_date.upto(Date.current).map do |date|
|
|
||||||
today_trades = trades.select { |t| t.date == date }
|
|
||||||
current_holding_quantities = calculate_portfolio(prior_holding_quantities, today_trades, inverse: true)
|
|
||||||
holdings += generate_holding_records(current_holding_quantities, date)
|
|
||||||
prior_holding_quantities = current_holding_quantities
|
|
||||||
end
|
|
||||||
|
|
||||||
holdings
|
|
||||||
end
|
|
||||||
|
|
||||||
def generate_holding_records(portfolio, date)
|
|
||||||
Rails.logger.info "[HoldingCalculator] Generating holdings for #{portfolio.size} securities on #{date}"
|
|
||||||
|
|
||||||
portfolio.map do |security_id, qty|
|
|
||||||
security = securities_cache[security_id]
|
|
||||||
|
|
||||||
if security.blank?
|
|
||||||
Rails.logger.error "[HoldingCalculator] Security #{security_id} not found in cache for account #{account.id}"
|
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
price = security.dig(:prices)&.find { |p| p.date == date }
|
|
||||||
|
|
||||||
if price.blank?
|
|
||||||
Rails.logger.info "[HoldingCalculator] No price found for security #{security_id} on #{date}"
|
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
converted_price = Money.new(price.price, price.currency).exchange_to(account.currency, fallback_rate: 1).amount
|
|
||||||
|
|
||||||
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 "[HoldingCalculator] Preloading #{securities.size} securities for account #{account.id}"
|
|
||||||
|
|
||||||
securities.each do |security|
|
|
||||||
begin
|
|
||||||
Rails.logger.info "[HoldingCalculator] 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 "[HoldingCalculator] Found #{prices.size} prices for security #{security.id}"
|
|
||||||
|
|
||||||
@securities_cache[security.id] = {
|
|
||||||
security: security,
|
|
||||||
prices: prices
|
|
||||||
}
|
|
||||||
rescue => e
|
|
||||||
Rails.logger.error "[HoldingCalculator] Error processing security #{security.id}: #{e.message}"
|
|
||||||
Rails.logger.error "[HoldingCalculator] Security details: #{security.attributes}"
|
|
||||||
Rails.logger.error e.backtrace.join("\n")
|
|
||||||
next # Skip this security and continue with others
|
|
||||||
end
|
|
||||||
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
|
|
||||||
end
|
|
18
app/models/account/linkable.rb
Normal file
18
app/models/account/linkable.rb
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
module Account::Linkable
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
included do
|
||||||
|
belongs_to :plaid_account, optional: true
|
||||||
|
end
|
||||||
|
|
||||||
|
# A "linked" account gets transaction and balance data from a third party like Plaid
|
||||||
|
def linked?
|
||||||
|
plaid_account_id.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
# An "offline" or "unlinked" account is one where the user tracks values and
|
||||||
|
# adds transactions manually, without the help of a data provider
|
||||||
|
def unlinked?
|
||||||
|
!linked?
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,162 +0,0 @@
|
||||||
class Account::Syncer
|
|
||||||
def initialize(account, start_date: nil)
|
|
||||||
@account = account
|
|
||||||
@start_date = start_date
|
|
||||||
end
|
|
||||||
|
|
||||||
def run
|
|
||||||
Rails.logger.tagged("Account::Syncer") do
|
|
||||||
Rails.logger.info("Finding potential transfers to auto-match")
|
|
||||||
account.family.auto_match_transfers!
|
|
||||||
|
|
||||||
holdings = sync_holdings
|
|
||||||
Rails.logger.info("Calculated #{holdings.size} holdings")
|
|
||||||
|
|
||||||
balances = sync_balances(holdings)
|
|
||||||
Rails.logger.info("Calculated #{balances.size} balances")
|
|
||||||
|
|
||||||
account.reload
|
|
||||||
|
|
||||||
unless plaid_sync?
|
|
||||||
update_account_info(balances, holdings)
|
|
||||||
end
|
|
||||||
|
|
||||||
unless account.currency == account.family.currency
|
|
||||||
Rails.logger.info("Converting #{balances.size} balances and #{holdings.size} holdings from #{account.currency} to #{account.family.currency}")
|
|
||||||
convert_records_to_family_currency(balances, holdings)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Enrich if user opted in or if we're syncing transactions from a Plaid account on the hosted app
|
|
||||||
if account.family.data_enrichment_enabled? || (plaid_sync? && Rails.application.config.app_mode.hosted?)
|
|
||||||
Rails.logger.info("Enriching transaction data for account #{account.name}")
|
|
||||||
account.enrich_data
|
|
||||||
else
|
|
||||||
Rails.logger.info("Data enrichment disabled for account #{account.name}")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
attr_reader :account, :start_date
|
|
||||||
|
|
||||||
def account_start_date
|
|
||||||
@account_start_date ||= (account.entries.chronological.first&.date || Date.current) - 1.day
|
|
||||||
end
|
|
||||||
|
|
||||||
def update_account_info(balances, holdings)
|
|
||||||
new_balance = balances.sort_by(&:date).last.balance
|
|
||||||
new_holdings_value = holdings.select { |h| h.date == Date.current }.sum(&:amount)
|
|
||||||
new_cash_balance = new_balance - new_holdings_value
|
|
||||||
|
|
||||||
account.update!(
|
|
||||||
balance: new_balance,
|
|
||||||
cash_balance: new_cash_balance
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def sync_holdings
|
|
||||||
calculator = Account::HoldingCalculator.new(account)
|
|
||||||
calculated_holdings = calculator.calculate(reverse: plaid_sync?)
|
|
||||||
|
|
||||||
Account.transaction do
|
|
||||||
load_holdings(calculated_holdings)
|
|
||||||
purge_outdated_holdings unless plaid_sync?
|
|
||||||
end
|
|
||||||
|
|
||||||
calculated_holdings
|
|
||||||
end
|
|
||||||
|
|
||||||
def sync_balances(holdings)
|
|
||||||
calculator = Account::BalanceCalculator.new(account, holdings: holdings)
|
|
||||||
calculated_balances = calculator.calculate(reverse: plaid_sync?, start_date: start_date)
|
|
||||||
|
|
||||||
Account.transaction do
|
|
||||||
load_balances(calculated_balances)
|
|
||||||
purge_outdated_balances
|
|
||||||
end
|
|
||||||
|
|
||||||
calculated_balances
|
|
||||||
end
|
|
||||||
|
|
||||||
def convert_records_to_family_currency(balances, holdings)
|
|
||||||
from_currency = account.currency
|
|
||||||
to_currency = account.family.currency
|
|
||||||
|
|
||||||
exchange_rates = ExchangeRate.find_rates(
|
|
||||||
from: from_currency,
|
|
||||||
to: to_currency,
|
|
||||||
start_date: balances.min_by(&:date).date
|
|
||||||
)
|
|
||||||
|
|
||||||
converted_balances = balances.map do |balance|
|
|
||||||
exchange_rate = exchange_rates.find { |er| er.date == balance.date }
|
|
||||||
|
|
||||||
next unless exchange_rate.present?
|
|
||||||
|
|
||||||
account.balances.build(
|
|
||||||
date: balance.date,
|
|
||||||
balance: exchange_rate.rate * balance.balance,
|
|
||||||
currency: to_currency
|
|
||||||
)
|
|
||||||
end.compact
|
|
||||||
|
|
||||||
converted_holdings = holdings.map do |holding|
|
|
||||||
exchange_rate = exchange_rates.find { |er| er.date == holding.date }
|
|
||||||
|
|
||||||
next unless exchange_rate.present?
|
|
||||||
|
|
||||||
account.holdings.build(
|
|
||||||
security: holding.security,
|
|
||||||
date: holding.date,
|
|
||||||
qty: holding.qty,
|
|
||||||
price: exchange_rate.rate * holding.price,
|
|
||||||
amount: exchange_rate.rate * holding.amount,
|
|
||||||
currency: to_currency
|
|
||||||
)
|
|
||||||
end.compact
|
|
||||||
|
|
||||||
Account.transaction do
|
|
||||||
load_balances(converted_balances)
|
|
||||||
load_holdings(converted_holdings)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_balances(balances = [])
|
|
||||||
current_time = Time.now
|
|
||||||
account.balances.upsert_all(
|
|
||||||
balances.map { |b| b.attributes
|
|
||||||
.slice("date", "balance", "cash_balance", "currency")
|
|
||||||
.merge("updated_at" => current_time) },
|
|
||||||
unique_by: %i[account_id date currency]
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_holdings(holdings = [])
|
|
||||||
current_time = Time.now
|
|
||||||
account.holdings.upsert_all(
|
|
||||||
holdings.map { |h| h.attributes
|
|
||||||
.slice("date", "currency", "qty", "price", "amount", "security_id")
|
|
||||||
.merge("updated_at" => current_time) },
|
|
||||||
unique_by: %i[account_id security_id date currency]
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
def purge_outdated_balances
|
|
||||||
account.balances.delete_by("date < ?", account_start_date)
|
|
||||||
end
|
|
||||||
|
|
||||||
def plaid_sync?
|
|
||||||
account.plaid_account_id.present?
|
|
||||||
end
|
|
||||||
|
|
||||||
def purge_outdated_holdings
|
|
||||||
portfolio_security_ids = account.entries.account_trades.map { |entry| entry.entryable.security_id }.uniq
|
|
||||||
|
|
||||||
# If there are no securities in the portfolio, delete all holdings
|
|
||||||
if portfolio_security_ids.empty?
|
|
||||||
account.holdings.delete_all
|
|
||||||
else
|
|
||||||
account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account_start_date, portfolio_security_ids)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -45,6 +45,11 @@ class PlaidItem < ApplicationRecord
|
||||||
plaid_data = fetch_and_load_plaid_data
|
plaid_data = fetch_and_load_plaid_data
|
||||||
update!(status: :good) if requires_update?
|
update!(status: :good) if requires_update?
|
||||||
|
|
||||||
|
# Schedule account syncs
|
||||||
|
accounts.each do |account|
|
||||||
|
account.sync_later(start_date: start_date)
|
||||||
|
end
|
||||||
|
|
||||||
Rails.logger.info("Plaid data fetched and loaded")
|
Rails.logger.info("Plaid data fetched and loaded")
|
||||||
plaid_data
|
plaid_data
|
||||||
rescue Plaid::ApiError => e
|
rescue Plaid::ApiError => e
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
<% series = @account.balance_series(period: @period) %>
|
<% series = @account.balance_series(period: @period, view: @chart_view) %>
|
||||||
<% trend = series.trend %>
|
<% trend = series.trend %>
|
||||||
|
|
||||||
<%= turbo_frame_tag dom_id(@account, :chart_details) do %>
|
<%= turbo_frame_tag dom_id(@account, :chart_details) do %>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<%# locals: (account:, title: nil, tooltip: nil, **args) %>
|
<%# locals: (account:, title: nil, tooltip: nil, chart_view: nil, **args) %>
|
||||||
|
|
||||||
<% period = @period || Period.last_30_days %>
|
<% period = @period || Period.last_30_days %>
|
||||||
<% default_value_title = account.asset? ? t(".balance") : t(".owed") %>
|
<% default_value_title = account.asset? ? t(".balance") : t(".owed") %>
|
||||||
|
|
||||||
<div id="<%= dom_id(account, :chart) %>" class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg space-y-2">
|
<div id="<%= dom_id(account, :chart) %>" class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg space-y-2">
|
||||||
|
@ -15,11 +15,22 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
|
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
|
||||||
<%= period_select form: form, selected: period %>
|
<div class="flex items-center gap-2">
|
||||||
|
<% if chart_view.present? %>
|
||||||
|
<%= form.select :chart_view,
|
||||||
|
[["Total value", "balance"], ["Holdings", "holdings_balance"], ["Cash", "cash_balance"]],
|
||||||
|
{ selected: chart_view },
|
||||||
|
class: "border border-secondary rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0",
|
||||||
|
data: { "auto-submit-form-target": "auto" }
|
||||||
|
%>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= period_select form: form, selected: period %>
|
||||||
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= turbo_frame_tag dom_id(account, :chart_details), src: chart_account_path(account, period: period.key) do %>
|
<%= turbo_frame_tag dom_id(account, :chart_details), src: chart_account_path(account, period: period.key, chart_view: chart_view) do %>
|
||||||
<%= render "accounts/chart_loader" %>
|
<%= render "accounts/chart_loader" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
<%= render "accounts/show/chart",
|
<%= render "accounts/show/chart",
|
||||||
account: @account,
|
account: @account,
|
||||||
title: t(".chart_title"),
|
title: t(".chart_title"),
|
||||||
|
chart_view: @chart_view,
|
||||||
tooltip: render(
|
tooltip: render(
|
||||||
"investments/value_tooltip",
|
"investments/value_tooltip",
|
||||||
balance: @account.balance_money,
|
balance: @account.balance_money,
|
||||||
|
|
74
test/models/account/balance/forward_calculator_test.rb
Normal file
74
test/models/account/balance/forward_calculator_test.rb
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Account::Balance::ForwardCalculatorTest < 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, 0 ]
|
||||||
|
calculated = Account::Balance::ForwardCalculator.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::Balance::ForwardCalculator.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::Balance::ForwardCalculator.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::Balance::ForwardCalculator.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::Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||||
|
|
||||||
|
assert_equal expected, calculated
|
||||||
|
end
|
||||||
|
end
|
59
test/models/account/balance/reverse_calculator_test.rb
Normal file
59
test/models/account/balance/reverse_calculator_test.rb
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Account::Balance::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
|
||||||
|
|
||||||
|
# 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, @account.balance ]
|
||||||
|
calculated = Account::Balance::ReverseCalculator.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::Balance::ReverseCalculator.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::Balance::ReverseCalculator.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::Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
|
||||||
|
|
||||||
|
assert_equal expected, calculated
|
||||||
|
end
|
||||||
|
end
|
51
test/models/account/balance/syncer_test.rb
Normal file
51
test/models/account/balance/syncer_test.rb
Normal file
|
@ -0,0 +1,51 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Account::Balance::SyncerTest < 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 "syncs balances" do
|
||||||
|
Account::Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once
|
||||||
|
|
||||||
|
@account.expects(:start_date).returns(2.days.ago.to_date)
|
||||||
|
|
||||||
|
Account::Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
|
||||||
|
[
|
||||||
|
Account::Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
|
||||||
|
Account::Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_difference "@account.balances.count", 2 do
|
||||||
|
Account::Balance::Syncer.new(@account, strategy: :forward).sync_balances
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "purges stale balances and holdings" do
|
||||||
|
# Balance before start date is stale
|
||||||
|
@account.expects(:start_date).returns(2.days.ago.to_date).twice
|
||||||
|
stale_balance = Account::Balance.new(date: 3.days.ago.to_date, balance: 10000, cash_balance: 10000, currency: "USD")
|
||||||
|
|
||||||
|
Account::Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
|
||||||
|
[
|
||||||
|
stale_balance,
|
||||||
|
Account::Balance.new(date: 2.days.ago.to_date, balance: 10000, cash_balance: 10000, currency: "USD"),
|
||||||
|
Account::Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
|
||||||
|
Account::Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert_difference "@account.balances.count", 3 do
|
||||||
|
Account::Balance::Syncer.new(@account, strategy: :forward).sync_balances
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -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
|
|
146
test/models/account/holding/forward_calculator_test.rb
Normal file
146
test/models/account/holding/forward_calculator_test.rb
Normal file
|
@ -0,0 +1,146 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Account::Holding::ForwardCalculatorTest < 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::ForwardCalculator.new(@account).calculate
|
||||||
|
assert_equal [], calculated
|
||||||
|
end
|
||||||
|
|
||||||
|
test "forward portfolio calculation" do
|
||||||
|
load_prices
|
||||||
|
|
||||||
|
# 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::ForwardCalculator.new(@account).calculate
|
||||||
|
|
||||||
|
assert_equal expected.length, calculated.length
|
||||||
|
assert_holdings(expected, calculated)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Carries the previous record forward if no holding exists for a date
|
||||||
|
# to ensure that net worth historical rollups have a value for every date
|
||||||
|
test "uses locf to fill missing holdings" do
|
||||||
|
load_prices
|
||||||
|
|
||||||
|
create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account)
|
||||||
|
|
||||||
|
expected = [
|
||||||
|
Account::Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0),
|
||||||
|
Account::Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000),
|
||||||
|
Account::Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000)
|
||||||
|
]
|
||||||
|
|
||||||
|
# Price missing today, so we should carry forward the holding from 1 day ago
|
||||||
|
Security.stubs(:find).returns(@wmt)
|
||||||
|
Security::Price.stubs(:find_price).with(security: @wmt, date: 2.days.ago.to_date).returns(Security::Price.new(price: 100))
|
||||||
|
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::Holding::ForwardCalculator.new(@account).calculate
|
||||||
|
|
||||||
|
assert_equal expected.length, calculated.length
|
||||||
|
assert_holdings(expected, calculated)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "offline tickers sync holdings based on most recent trade price" do
|
||||||
|
offline_security = Security.create!(ticker: "OFFLINE", name: "Offline Ticker")
|
||||||
|
|
||||||
|
create_trade(offline_security, qty: 1, date: 3.days.ago.to_date, price: 90, account: @account)
|
||||||
|
create_trade(offline_security, qty: 1, date: 1.day.ago.to_date, price: 100, account: @account)
|
||||||
|
|
||||||
|
expected = [
|
||||||
|
Account::Holding.new(security: offline_security, date: 3.days.ago.to_date, qty: 1, price: 90, amount: 90),
|
||||||
|
Account::Holding.new(security: offline_security, date: 2.days.ago.to_date, qty: 1, price: 90, amount: 90),
|
||||||
|
Account::Holding.new(security: offline_security, date: 1.day.ago.to_date, qty: 2, price: 100, amount: 200),
|
||||||
|
Account::Holding.new(security: offline_security, date: Date.current, qty: 2, price: 100, amount: 200)
|
||||||
|
]
|
||||||
|
|
||||||
|
calculated = Account::Holding::ForwardCalculator.new(@account).calculate
|
||||||
|
|
||||||
|
assert_equal expected.length, calculated.length
|
||||||
|
assert_holdings(expected, calculated)
|
||||||
|
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
|
||||||
|
end
|
63
test/models/account/holding/portfolio_cache_test.rb
Normal file
63
test/models/account/holding/portfolio_cache_test.rb
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Account::Holding::PortfolioCacheTest < ActiveSupport::TestCase
|
||||||
|
include Account::EntriesTestHelper
|
||||||
|
|
||||||
|
setup do
|
||||||
|
# Prices, highest to lowest priority
|
||||||
|
@db_price = 210
|
||||||
|
@provider_price = 220
|
||||||
|
@trade_price = 200
|
||||||
|
@holding_price = 250
|
||||||
|
|
||||||
|
@account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 10000, currency: "USD", accountable: Investment.new)
|
||||||
|
@test_security = Security.create!(name: "Test Security", ticker: "TEST")
|
||||||
|
|
||||||
|
@trade = create_trade(@test_security, account: @account, qty: 1, date: Date.current, price: @trade_price)
|
||||||
|
@holding = Account::Holding.create!(security: @test_security, account: @account, date: Date.current, qty: 1, price: @holding_price, amount: @holding_price, currency: "USD")
|
||||||
|
Security::Price.create!(security: @test_security, date: Date.current, price: @db_price)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "gets price from DB if available" do
|
||||||
|
cache = Account::Holding::PortfolioCache.new(@account)
|
||||||
|
|
||||||
|
assert_equal @db_price, cache.get_price(@test_security.id, Date.current).price
|
||||||
|
end
|
||||||
|
|
||||||
|
test "if no price in DB, try fetching from provider" do
|
||||||
|
Security::Price.destroy_all
|
||||||
|
Security::Price.expects(:find_prices)
|
||||||
|
.with(security: @test_security, start_date: @account.start_date, end_date: Date.current)
|
||||||
|
.returns([
|
||||||
|
Security::Price.new(security: @test_security, date: Date.current, price: @provider_price, currency: "USD")
|
||||||
|
])
|
||||||
|
|
||||||
|
cache = Account::Holding::PortfolioCache.new(@account)
|
||||||
|
|
||||||
|
assert_equal @provider_price, cache.get_price(@test_security.id, Date.current).price
|
||||||
|
end
|
||||||
|
|
||||||
|
test "if no price from db or provider, try getting the price from trades" do
|
||||||
|
Security::Price.destroy_all # No DB prices
|
||||||
|
Security::Price.expects(:find_prices)
|
||||||
|
.with(security: @test_security, start_date: @account.start_date, end_date: Date.current)
|
||||||
|
.returns([]) # No provider prices
|
||||||
|
|
||||||
|
cache = Account::Holding::PortfolioCache.new(@account)
|
||||||
|
|
||||||
|
assert_equal @trade_price, cache.get_price(@test_security.id, Date.current).price
|
||||||
|
end
|
||||||
|
|
||||||
|
test "if no price from db, provider, or trades, search holdings" do
|
||||||
|
Security::Price.destroy_all # No DB prices
|
||||||
|
Security::Price.expects(:find_prices)
|
||||||
|
.with(security: @test_security, start_date: @account.start_date, end_date: Date.current)
|
||||||
|
.returns([]) # No provider prices
|
||||||
|
|
||||||
|
@account.entries.destroy_all # No prices from trades
|
||||||
|
|
||||||
|
cache = Account::Holding::PortfolioCache.new(@account, use_holdings: true)
|
||||||
|
|
||||||
|
assert_equal @holding_price, cache.get_price(@test_security.id, Date.current).price
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,6 +1,6 @@
|
||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class Account::HoldingCalculatorTest < ActiveSupport::TestCase
|
class Account::Holding::ReverseCalculatorTest < ActiveSupport::TestCase
|
||||||
include Account::EntriesTestHelper
|
include Account::EntriesTestHelper
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
|
@ -14,10 +14,8 @@ class Account::HoldingCalculatorTest < ActiveSupport::TestCase
|
||||||
end
|
end
|
||||||
|
|
||||||
test "no holdings" do
|
test "no holdings" do
|
||||||
forward = Account::HoldingCalculator.new(@account).calculate
|
calculated = Account::Holding::ReverseCalculator.new(@account).calculate
|
||||||
reverse = Account::HoldingCalculator.new(@account).calculate(reverse: true)
|
assert_equal [], calculated
|
||||||
assert_equal forward, reverse
|
|
||||||
assert_equal [], forward
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Should be able to handle this case, although we should not be reverse-syncing an account without provided current day holdings
|
# Should be able to handle this case, although we should not be reverse-syncing an account without provided current day holdings
|
||||||
|
@ -28,7 +26,7 @@ class Account::HoldingCalculatorTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
create_trade(voo, qty: -10, date: Date.current, price: 470, account: @account)
|
create_trade(voo, qty: -10, date: Date.current, price: 470, account: @account)
|
||||||
|
|
||||||
calculated = Account::HoldingCalculator.new(@account).calculate(reverse: true)
|
calculated = Account::Holding::ReverseCalculator.new(@account).calculate
|
||||||
assert_equal 2, calculated.length
|
assert_equal 2, calculated.length
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -74,7 +72,7 @@ class Account::HoldingCalculatorTest < ActiveSupport::TestCase
|
||||||
Account::Holding.new(security: @amzn, date: Date.current, qty: 0, price: 200, amount: 0)
|
Account::Holding.new(security: @amzn, date: Date.current, qty: 0, price: 200, amount: 0)
|
||||||
]
|
]
|
||||||
|
|
||||||
calculated = Account::HoldingCalculator.new(@account).calculate(reverse: true)
|
calculated = Account::Holding::ReverseCalculator.new(@account).calculate
|
||||||
|
|
||||||
assert_equal expected.length, calculated.length
|
assert_equal expected.length, calculated.length
|
||||||
|
|
||||||
|
@ -87,80 +85,6 @@ class Account::HoldingCalculatorTest < ActiveSupport::TestCase
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
test "forward portfolio calculation" do
|
|
||||||
load_prices
|
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
assert_equal expected.length, calculated.length
|
|
||||||
assert_holdings(expected, calculated)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Carries the previous record forward if no holding exists for a date
|
|
||||||
# to ensure that net worth historical rollups have a value for every date
|
|
||||||
test "uses locf to fill missing holdings" do
|
|
||||||
load_prices
|
|
||||||
|
|
||||||
create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account)
|
|
||||||
|
|
||||||
expected = [
|
|
||||||
Account::Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0),
|
|
||||||
Account::Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000),
|
|
||||||
Account::Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000)
|
|
||||||
]
|
|
||||||
|
|
||||||
# Price missing today, so we should carry forward the holding from 1 day ago
|
|
||||||
Security.stubs(:find).returns(@wmt)
|
|
||||||
Security::Price.stubs(:find_price).with(security: @wmt, date: 2.days.ago.to_date).returns(Security::Price.new(price: 100))
|
|
||||||
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
|
|
||||||
|
|
||||||
assert_equal expected.length, calculated.length
|
|
||||||
|
|
||||||
assert_holdings(expected, calculated)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
def assert_holdings(expected, calculated)
|
def assert_holdings(expected, calculated)
|
||||||
expected.each do |expected_entry|
|
expected.each do |expected_entry|
|
29
test/models/account/holding/syncer_test.rb
Normal file
29
test/models/account/holding/syncer_test.rb
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Account::Holding::SyncerTest < ActiveSupport::TestCase
|
||||||
|
include Account::EntriesTestHelper
|
||||||
|
|
||||||
|
setup do
|
||||||
|
@family = families(:empty)
|
||||||
|
@account = @family.accounts.create!(name: "Test", balance: 20000, cash_balance: 20000, currency: "USD", accountable: Investment.new)
|
||||||
|
@aapl = securities(:aapl)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "syncs holdings" do
|
||||||
|
create_trade(@aapl, account: @account, qty: 1, price: 200, date: Date.current)
|
||||||
|
|
||||||
|
# Should have yesterday's and today's holdings
|
||||||
|
assert_difference "@account.holdings.count", 2 do
|
||||||
|
Account::Holding::Syncer.new(@account, strategy: :forward).sync_holdings
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "purges stale holdings for unlinked accounts" do
|
||||||
|
# Since the account has no entries, there should be no holdings
|
||||||
|
Account::Holding.create!(account: @account, security: @aapl, qty: 1, price: 100, amount: 100, currency: "USD", date: Date.current)
|
||||||
|
|
||||||
|
assert_difference "Account::Holding.count", -1 do
|
||||||
|
Account::Holding::Syncer.new(@account, strategy: :forward).sync_holdings
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
|
@ -1,65 +0,0 @@
|
||||||
require "test_helper"
|
|
||||||
|
|
||||||
class Account::SyncerTest < 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 "converts foreign account balances and holdings to family currency" do
|
|
||||||
@account.family.update! currency: "USD"
|
|
||||||
@account.update! currency: "EUR"
|
|
||||||
|
|
||||||
@account.entries.create!(date: 1.day.ago.to_date, currency: "EUR", amount: 500, name: "Buy AAPL", entryable: Account::Trade.new(security: securities(:aapl), qty: 10, price: 50, currency: "EUR"))
|
|
||||||
|
|
||||||
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::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.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")
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
Account::Syncer.new(@account).run
|
|
||||||
|
|
||||||
assert_equal [ 1000, 1000 ], @account.balances.where(currency: "EUR").chronological.map(&:balance)
|
|
||||||
assert_equal [ 1200, 2000 ], @account.balances.where(currency: "USD").chronological.map(&:balance)
|
|
||||||
assert_equal [ 500, 500 ], @account.holdings.where(currency: "EUR").chronological.map(&:amount)
|
|
||||||
assert_equal [ 600, 1000 ], @account.holdings.where(currency: "USD").chronological.map(&:amount)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "purges stale balances and holdings" do
|
|
||||||
# Old, out of range holdings and balances
|
|
||||||
@account.holdings.create!(security: securities(:aapl), date: 10.years.ago.to_date, currency: "USD", qty: 100, price: 100, amount: 10000)
|
|
||||||
@account.balances.create!(date: 10.years.ago.to_date, currency: "USD", balance: 10000, cash_balance: 10000)
|
|
||||||
|
|
||||||
assert_equal 1, @account.holdings.count
|
|
||||||
assert_equal 1, @account.balances.count
|
|
||||||
|
|
||||||
Account::Syncer.new(@account).run
|
|
||||||
|
|
||||||
@account.reload
|
|
||||||
|
|
||||||
assert_equal 0, @account.holdings.count
|
|
||||||
|
|
||||||
# Balance sync always creates 1 balance if no entries present.
|
|
||||||
assert_equal 1, @account.balances.count
|
|
||||||
assert_equal 0, @account.balances.first.balance
|
|
||||||
end
|
|
||||||
end
|
|
Loading…
Add table
Add a link
Reference in a new issue