diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb index 5be606d2..33b3980a 100644 --- a/app/controllers/accounts_controller.rb +++ b/app/controllers/accounts_controller.rb @@ -18,6 +18,7 @@ class AccountsController < ApplicationController end def chart + @chart_view = params[:chart_view] || "balance" render layout: "application" end diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb index 524be0fc..d7d3b169 100644 --- a/app/controllers/concerns/accountable_resource.rb +++ b/app/controllers/concerns/accountable_resource.rb @@ -23,6 +23,7 @@ module AccountableResource end def show + @chart_view = params[:chart_view] || "balance" @q = params.fetch(:q, {}).permit(:search) entries = @account.entries.search(@q).reverse_chronological diff --git a/app/controllers/concerns/auto_sync.rb b/app/controllers/concerns/auto_sync.rb index d9e616e4..970eec0a 100644 --- a/app/controllers/concerns/auto_sync.rb +++ b/app/controllers/concerns/auto_sync.rb @@ -13,6 +13,7 @@ module AutoSync def family_needs_auto_sync? 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 end diff --git a/app/models/account.rb b/app/models/account.rb index 75752077..0c037609 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -1,11 +1,10 @@ class Account < ApplicationRecord - include Syncable, Monetizable, Issuable, Chartable + include Syncable, Monetizable, Issuable, Chartable, Enrichable, Linkable validates :name, :balance, :currency, presence: true belongs_to :family 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 :entries, dependent: :destroy, class_name: "Account::Entry" @@ -75,7 +74,16 @@ class Account < ApplicationRecord def sync_data(start_date: nil) 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 def post_sync @@ -93,10 +101,6 @@ class Account < ApplicationRecord holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc) end - def enrich_data - DataEnricher.new(self).run - end - def update_with_sync!(attributes) should_update_balance = attributes[:balance] && attributes[:balance].to_d != balance @@ -123,11 +127,14 @@ class Account < ApplicationRecord end end - def sparkline_series - cache_key = family.build_cache_key("#{id}_sparkline") - - Rails.cache.fetch(cache_key) do - balance_series - end + def start_date + first_entry_date = entries.minimum(:date) || Date.current + first_entry_date - 1.day end + + private + def sync_balances + strategy = linked? ? :reverse : :forward + Balance::Syncer.new(self, strategy: strategy).sync_balances + end end diff --git a/app/models/account/balance/base_calculator.rb b/app/models/account/balance/base_calculator.rb new file mode 100644 index 00000000..7acb51e8 --- /dev/null +++ b/app/models/account/balance/base_calculator.rb @@ -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 diff --git a/app/models/account/balance/forward_calculator.rb b/app/models/account/balance/forward_calculator.rb new file mode 100644 index 00000000..503e5b79 --- /dev/null +++ b/app/models/account/balance/forward_calculator.rb @@ -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 diff --git a/app/models/account/balance/reverse_calculator.rb b/app/models/account/balance/reverse_calculator.rb new file mode 100644 index 00000000..151f4036 --- /dev/null +++ b/app/models/account/balance/reverse_calculator.rb @@ -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 diff --git a/app/models/account/balance/sync_cache.rb b/app/models/account/balance/sync_cache.rb new file mode 100644 index 00000000..1fb7ea7f --- /dev/null +++ b/app/models/account/balance/sync_cache.rb @@ -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 diff --git a/app/models/account/balance/syncer.rb b/app/models/account/balance/syncer.rb new file mode 100644 index 00000000..cc8ca68b --- /dev/null +++ b/app/models/account/balance/syncer.rb @@ -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 diff --git a/app/models/account/balance_calculator.rb b/app/models/account/balance_calculator.rb deleted file mode 100644 index ca292dc5..00000000 --- a/app/models/account/balance_calculator.rb +++ /dev/null @@ -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 diff --git a/app/models/account/chartable.rb b/app/models/account/chartable.rb index 2770da3b..f251e7f1 100644 --- a/app/models/account/chartable.rb +++ b/app/models/account/chartable.rb @@ -2,7 +2,9 @@ module Account::Chartable extend ActiveSupport::Concern 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([ balance_series_query, { @@ -21,8 +23,8 @@ module Account::Chartable date: curr.date, date_formatted: I18n.l(curr.date, format: :long), trend: Trend.new( - current: Money.new(curr.balance, currency), - previous: prev.nil? ? nil : Money.new(prev.balance, currency), + current: Money.new(balance_value_for(curr, view), currency), + previous: prev.nil? ? nil : Money.new(balance_value_for(prev, view), currency), favorable_direction: favorable_direction ) ) @@ -33,8 +35,8 @@ module Account::Chartable end_date: period.end_date, interval: period.interval, trend: Trend.new( - current: Money.new(balances.last&.balance || 0, currency), - previous: Money.new(balances.first&.balance || 0, currency), + current: Money.new(balance_value_for(balances.last, view) || 0, currency), + previous: Money.new(balance_value_for(balances.first, view) || 0, currency), favorable_direction: favorable_direction ), values: values @@ -52,6 +54,8 @@ module Account::Chartable SELECT 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.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 FROM dates d LEFT JOIN accounts ON accounts.id IN (#{all.select(:id).to_sql}) @@ -70,26 +74,46 @@ module Account::Chartable SQL 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) balances.map do |balance| balance.balance = -balance.balance + balance.cash_balance = -balance.cash_balance + balance.holdings_balance = -balance.holdings_balance balance end end def gapfill_balances(balances) gapfilled = [] + prev = nil - prev_balance = nil - - [ nil, *balances ].each_cons(2).each_with_index do |(prev, curr), index| - if index == 0 && curr.balance.nil? - curr.balance = 0 # Ensure all series start with a non-nil balance - elsif curr.balance.nil? - curr.balance = prev.balance + balances.each do |curr| + if prev.nil? + # Initialize first record with zeros if nil + curr.balance ||= 0 + curr.cash_balance ||= 0 + curr.holdings_balance ||= 0 + else + # Copy previous values for nil fields + curr.balance ||= prev.balance + curr.cash_balance ||= prev.cash_balance + curr.holdings_balance ||= prev.holdings_balance end gapfilled << curr + prev = curr end gapfilled @@ -100,11 +124,20 @@ module Account::Chartable classification == "asset" ? "up" : "down" 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( currency: currency, period: period, + view: view, favorable_direction: favorable_direction ) end + + def sparkline_series + cache_key = family.build_cache_key("#{id}_sparkline") + + Rails.cache.fetch(cache_key) do + balance_series + end + end end diff --git a/app/models/account/enrichable.rb b/app/models/account/enrichable.rb new file mode 100644 index 00000000..236cce58 --- /dev/null +++ b/app/models/account/enrichable.rb @@ -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 diff --git a/app/models/account/holding.rb b/app/models/account/holding.rb index eb6e35ef..ba7a7e2d 100644 --- a/app/models/account/holding.rb +++ b/app/models/account/holding.rb @@ -1,5 +1,5 @@ class Account::Holding < ApplicationRecord - include Monetizable + include Monetizable, Gapfillable monetize :amount diff --git a/app/models/account/holding/base_calculator.rb b/app/models/account/holding/base_calculator.rb new file mode 100644 index 00000000..4359e9ab --- /dev/null +++ b/app/models/account/holding/base_calculator.rb @@ -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 diff --git a/app/models/account/holding/forward_calculator.rb b/app/models/account/holding/forward_calculator.rb new file mode 100644 index 00000000..afb6b71f --- /dev/null +++ b/app/models/account/holding/forward_calculator.rb @@ -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 diff --git a/app/models/account/holding/gapfillable.rb b/app/models/account/holding/gapfillable.rb new file mode 100644 index 00000000..e2462a6f --- /dev/null +++ b/app/models/account/holding/gapfillable.rb @@ -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 diff --git a/app/models/account/holding/portfolio_cache.rb b/app/models/account/holding/portfolio_cache.rb new file mode 100644 index 00000000..6a839382 --- /dev/null +++ b/app/models/account/holding/portfolio_cache.rb @@ -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 diff --git a/app/models/account/holding/reverse_calculator.rb b/app/models/account/holding/reverse_calculator.rb new file mode 100644 index 00000000..d3677c88 --- /dev/null +++ b/app/models/account/holding/reverse_calculator.rb @@ -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 diff --git a/app/models/account/holding/syncer.rb b/app/models/account/holding/syncer.rb new file mode 100644 index 00000000..bfccd6f0 --- /dev/null +++ b/app/models/account/holding/syncer.rb @@ -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 diff --git a/app/models/account/holding_calculator.rb b/app/models/account/holding_calculator.rb deleted file mode 100644 index edb55acf..00000000 --- a/app/models/account/holding_calculator.rb +++ /dev/null @@ -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 diff --git a/app/models/account/linkable.rb b/app/models/account/linkable.rb new file mode 100644 index 00000000..ee0871bd --- /dev/null +++ b/app/models/account/linkable.rb @@ -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 diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb deleted file mode 100644 index d664b8f1..00000000 --- a/app/models/account/syncer.rb +++ /dev/null @@ -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 diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb index c76af8aa..9ffdadf1 100644 --- a/app/models/plaid_item.rb +++ b/app/models/plaid_item.rb @@ -45,6 +45,11 @@ class PlaidItem < ApplicationRecord plaid_data = fetch_and_load_plaid_data 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") plaid_data rescue Plaid::ApiError => e diff --git a/app/views/accounts/chart.html.erb b/app/views/accounts/chart.html.erb index 22e2528e..6be29472 100644 --- a/app/views/accounts/chart.html.erb +++ b/app/views/accounts/chart.html.erb @@ -1,4 +1,4 @@ -<% series = @account.balance_series(period: @period) %> +<% series = @account.balance_series(period: @period, view: @chart_view) %> <% trend = series.trend %> <%= turbo_frame_tag dom_id(@account, :chart_details) do %> diff --git a/app/views/accounts/show/_chart.html.erb b/app/views/accounts/show/_chart.html.erb index c78a3676..e6dadae8 100644 --- a/app/views/accounts/show/_chart.html.erb +++ b/app/views/accounts/show/_chart.html.erb @@ -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") %>
@@ -15,11 +15,22 @@
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %> - <%= period_select form: form, selected: period %> +
+ <% 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 %> +
<% end %> - <%= 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" %> <% end %> diff --git a/app/views/investments/show.html.erb b/app/views/investments/show.html.erb index a1e34e49..7bd7da3b 100644 --- a/app/views/investments/show.html.erb +++ b/app/views/investments/show.html.erb @@ -7,6 +7,7 @@ <%= render "accounts/show/chart", account: @account, title: t(".chart_title"), + chart_view: @chart_view, tooltip: render( "investments/value_tooltip", balance: @account.balance_money, diff --git a/test/models/account/balance/forward_calculator_test.rb b/test/models/account/balance/forward_calculator_test.rb new file mode 100644 index 00000000..cb96572f --- /dev/null +++ b/test/models/account/balance/forward_calculator_test.rb @@ -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 diff --git a/test/models/account/balance/reverse_calculator_test.rb b/test/models/account/balance/reverse_calculator_test.rb new file mode 100644 index 00000000..e81c9eb5 --- /dev/null +++ b/test/models/account/balance/reverse_calculator_test.rb @@ -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 diff --git a/test/models/account/balance/syncer_test.rb b/test/models/account/balance/syncer_test.rb new file mode 100644 index 00000000..72dfc568 --- /dev/null +++ b/test/models/account/balance/syncer_test.rb @@ -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 diff --git a/test/models/account/balance_calculator_test.rb b/test/models/account/balance_calculator_test.rb deleted file mode 100644 index 2f3879a8..00000000 --- a/test/models/account/balance_calculator_test.rb +++ /dev/null @@ -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 diff --git a/test/models/account/holding/forward_calculator_test.rb b/test/models/account/holding/forward_calculator_test.rb new file mode 100644 index 00000000..70fd0e2a --- /dev/null +++ b/test/models/account/holding/forward_calculator_test.rb @@ -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 diff --git a/test/models/account/holding/portfolio_cache_test.rb b/test/models/account/holding/portfolio_cache_test.rb new file mode 100644 index 00000000..b973fa00 --- /dev/null +++ b/test/models/account/holding/portfolio_cache_test.rb @@ -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 diff --git a/test/models/account/holding_calculator_test.rb b/test/models/account/holding/reverse_calculator_test.rb similarity index 61% rename from test/models/account/holding_calculator_test.rb rename to test/models/account/holding/reverse_calculator_test.rb index 154c8afe..6e9535e5 100644 --- a/test/models/account/holding_calculator_test.rb +++ b/test/models/account/holding/reverse_calculator_test.rb @@ -1,6 +1,6 @@ require "test_helper" -class Account::HoldingCalculatorTest < ActiveSupport::TestCase +class Account::Holding::ReverseCalculatorTest < ActiveSupport::TestCase include Account::EntriesTestHelper setup do @@ -14,10 +14,8 @@ class Account::HoldingCalculatorTest < ActiveSupport::TestCase end test "no holdings" do - forward = Account::HoldingCalculator.new(@account).calculate - reverse = Account::HoldingCalculator.new(@account).calculate(reverse: true) - assert_equal forward, reverse - assert_equal [], forward + calculated = Account::Holding::ReverseCalculator.new(@account).calculate + assert_equal [], calculated end # Should be able to handle this case, although we should not be reverse-syncing an account without provided current day holdings @@ -28,7 +26,7 @@ class Account::HoldingCalculatorTest < ActiveSupport::TestCase 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 end @@ -74,7 +72,7 @@ class Account::HoldingCalculatorTest < ActiveSupport::TestCase Account::Holding.new(security: @amzn, date: Date.current, qty: 0, price: 200, amount: 0) ] - calculated = Account::HoldingCalculator.new(@account).calculate(reverse: true) + calculated = Account::Holding::ReverseCalculator.new(@account).calculate assert_equal expected.length, calculated.length @@ -87,80 +85,6 @@ class Account::HoldingCalculatorTest < ActiveSupport::TestCase 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 def assert_holdings(expected, calculated) expected.each do |expected_entry| diff --git a/test/models/account/holding/syncer_test.rb b/test/models/account/holding/syncer_test.rb new file mode 100644 index 00000000..43ca5dcb --- /dev/null +++ b/test/models/account/holding/syncer_test.rb @@ -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 diff --git a/test/models/account/syncer_test.rb b/test/models/account/syncer_test.rb deleted file mode 100644 index 5cc85ee9..00000000 --- a/test/models/account/syncer_test.rb +++ /dev/null @@ -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