mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-05 05:25:24 +02:00
Account:: namespace simplifications and cleanup (#2110)
* Flatten Holding model * Flatten balance model * Entries domain renames * Fix valuations reference * Fix trades stream * Fix brakeman warnings * Fix tests * Replace existing entryable type references in DB
This commit is contained in:
parent
f181ba941f
commit
e657c40d19
172 changed files with 1297 additions and 1258 deletions
35
app/models/balance/base_calculator.rb
Normal file
35
app/models/balance/base_calculator.rb
Normal file
|
@ -0,0 +1,35 @@
|
|||
class 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 ||= Balance::SyncCache.new(account)
|
||||
end
|
||||
|
||||
def build_balance(date, cash_balance, holdings_value)
|
||||
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/balance/forward_calculator.rb
Normal file
28
app/models/balance/forward_calculator.rb
Normal file
|
@ -0,0 +1,28 @@
|
|||
class Balance::ForwardCalculator < 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/balance/reverse_calculator.rb
Normal file
32
app/models/balance/reverse_calculator.rb
Normal file
|
@ -0,0 +1,32 @@
|
|||
class Balance::ReverseCalculator < 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/balance/sync_cache.rb
Normal file
46
app/models/balance/sync_cache.rb
Normal file
|
@ -0,0 +1,46 @@
|
|||
class Balance::SyncCache
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def get_valuation(date)
|
||||
converted_entries.find { |e| e.date == date && e.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.transaction? || e.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
|
71
app/models/balance/syncer.rb
Normal file
71
app/models/balance/syncer.rb
Normal file
|
@ -0,0 +1,71 @@
|
|||
class Balance::Syncer
|
||||
attr_reader :account, :strategy
|
||||
|
||||
def initialize(account, strategy:)
|
||||
@account = account
|
||||
@strategy = strategy
|
||||
end
|
||||
|
||||
def sync_balances
|
||||
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
|
||||
|
||||
account.sync_required_exchange_rates
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def sync_holdings
|
||||
@holdings = 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
|
||||
Balance::ReverseCalculator.new(account)
|
||||
else
|
||||
Balance::ForwardCalculator.new(account)
|
||||
end
|
||||
end
|
||||
end
|
94
app/models/balance/trend_calculator.rb
Normal file
94
app/models/balance/trend_calculator.rb
Normal file
|
@ -0,0 +1,94 @@
|
|||
# The current system calculates a single, end-of-day balance every day for each account for simplicity.
|
||||
# In most cases, this is sufficient. However, for the "Activity View", we need to show intraday balances
|
||||
# to show users how each entry affects their balances. This class calculates intraday balances by
|
||||
# interpolating between end-of-day balances.
|
||||
class Balance::TrendCalculator
|
||||
BalanceTrend = Struct.new(:trend, :cash, keyword_init: true)
|
||||
|
||||
class << self
|
||||
def for(entries)
|
||||
return nil if entries.blank?
|
||||
|
||||
account = entries.first.account
|
||||
|
||||
date_range = entries.minmax_by(&:date)
|
||||
min_entry_date, max_entry_date = date_range.map(&:date)
|
||||
|
||||
# In case view is filtered and there are entry gaps, refetch all entries in range
|
||||
all_entries = account.entries.where(date: min_entry_date..max_entry_date).chronological.to_a
|
||||
balances = account.balances.where(date: (min_entry_date - 1.day)..max_entry_date).chronological.to_a
|
||||
holdings = account.holdings.where(date: (min_entry_date - 1.day)..max_entry_date).to_a
|
||||
|
||||
new(all_entries, balances, holdings)
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(entries, balances, holdings)
|
||||
@entries = entries
|
||||
@balances = balances
|
||||
@holdings = holdings
|
||||
end
|
||||
|
||||
def trend_for(entry)
|
||||
intraday_balance = nil
|
||||
intraday_cash_balance = nil
|
||||
|
||||
start_of_day_balance = balances.find { |b| b.date == entry.date - 1.day && b.currency == entry.currency }
|
||||
end_of_day_balance = balances.find { |b| b.date == entry.date && b.currency == entry.currency }
|
||||
|
||||
return BalanceTrend.new(trend: nil) if start_of_day_balance.blank? || end_of_day_balance.blank?
|
||||
|
||||
todays_holdings_value = holdings.select { |h| h.date == entry.date }.sum(&:amount)
|
||||
|
||||
prior_balance = start_of_day_balance.balance
|
||||
prior_cash_balance = start_of_day_balance.cash_balance
|
||||
current_balance = nil
|
||||
current_cash_balance = nil
|
||||
|
||||
todays_entries = entries.select { |e| e.date == entry.date }
|
||||
|
||||
todays_entries.each_with_index do |e, idx|
|
||||
if e.valuation?
|
||||
current_balance = e.amount
|
||||
current_cash_balance = e.amount
|
||||
else
|
||||
multiplier = e.account.liability? ? 1 : -1
|
||||
balance_change = e.trade? ? 0 : multiplier * e.amount
|
||||
cash_change = multiplier * e.amount
|
||||
|
||||
current_balance = prior_balance + balance_change
|
||||
current_cash_balance = prior_cash_balance + cash_change
|
||||
end
|
||||
|
||||
if e.id == entry.id
|
||||
# Final entry should always match the end-of-day balances
|
||||
if idx == todays_entries.size - 1
|
||||
intraday_balance = end_of_day_balance.balance
|
||||
intraday_cash_balance = end_of_day_balance.cash_balance
|
||||
else
|
||||
intraday_balance = current_balance
|
||||
intraday_cash_balance = current_cash_balance
|
||||
end
|
||||
|
||||
break
|
||||
else
|
||||
prior_balance = current_balance
|
||||
prior_cash_balance = current_cash_balance
|
||||
end
|
||||
end
|
||||
|
||||
return BalanceTrend.new(trend: nil) unless intraday_balance.present?
|
||||
|
||||
BalanceTrend.new(
|
||||
trend: Trend.new(
|
||||
current: Money.new(intraday_balance, entry.currency),
|
||||
previous: Money.new(prior_balance, entry.currency),
|
||||
favorable_direction: entry.account.favorable_direction
|
||||
),
|
||||
cash: Money.new(intraday_cash_balance, entry.currency),
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :entries, :balances, :holdings
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue