mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-24 23:59:40 +02:00
Plaid portfolio sync algorithm and calculation improvements (#1526)
* Start tests rework * Cash balance on schema * Add reverse syncer * Reverse balance sync with holdings * Reverse holdings sync * Reverse holdings sync should work with only trade entries * Consolidate brokerage cash * Add forward sync option * Update new balance info after syncs * Intraday balance calculator and sync fixes * Show only balance for trade entries * Tests passing * Update Gemfile.lock * Cleanup, performance improvements * Remove account reloads for reliable sync outputs * Simplify valuation view logic * Special handling for Plaid cash holding
This commit is contained in:
parent
a59ca5b7c6
commit
49c353e10c
72 changed files with 1152 additions and 1046 deletions
|
@ -16,7 +16,7 @@ class Account < ApplicationRecord
|
|||
has_many :balances, dependent: :destroy
|
||||
has_many :issues, as: :issuable, dependent: :destroy
|
||||
|
||||
monetize :balance
|
||||
monetize :balance, :cash_balance
|
||||
|
||||
enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true }
|
||||
|
||||
|
@ -32,8 +32,6 @@ class Account < ApplicationRecord
|
|||
|
||||
accepts_nested_attributes_for :accountable, update_only: true
|
||||
|
||||
delegate :value, :series, to: :accountable
|
||||
|
||||
class << self
|
||||
def by_group(period: Period.all, currency: Money.default_currency.iso_code)
|
||||
grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
|
||||
|
@ -59,7 +57,7 @@ class Account < ApplicationRecord
|
|||
|
||||
def create_and_sync(attributes)
|
||||
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
|
||||
account = new(attributes)
|
||||
account = new(attributes.merge(cash_balance: attributes[:balance]))
|
||||
|
||||
transaction do
|
||||
# Create 2 valuations for new accounts to establish a value history for users to see
|
||||
|
@ -94,15 +92,27 @@ class Account < ApplicationRecord
|
|||
def sync_data(start_date: nil)
|
||||
update!(last_synced_at: Time.current)
|
||||
|
||||
resolve_stale_issues
|
||||
Balance::Syncer.new(self, start_date: start_date).run
|
||||
Holding::Syncer.new(self, start_date: start_date).run
|
||||
Syncer.new(self, start_date: start_date).run
|
||||
end
|
||||
|
||||
def post_sync
|
||||
broadcast_remove_to(family, target: "syncing-notice")
|
||||
resolve_stale_issues
|
||||
accountable.post_sync
|
||||
end
|
||||
|
||||
def series(period: Period.last_30_days, currency: nil)
|
||||
balance_series = balances.in_period(period).where(currency: currency || self.currency)
|
||||
|
||||
if balance_series.empty? && period.date_range.end == Date.current
|
||||
TimeSeries.new([ { date: Date.current, value: balance_money.exchange_to(currency || self.currency) } ])
|
||||
else
|
||||
TimeSeries.from_collection(balance_series, :balance_money, favorable_direction: asset? ? "up" : "down")
|
||||
end
|
||||
rescue Money::ConversionError
|
||||
TimeSeries.new([])
|
||||
end
|
||||
|
||||
def original_balance
|
||||
balance_amount = balances.chronological.first&.balance || balance
|
||||
Money.new(balance_amount, currency)
|
||||
|
|
|
@ -1,57 +0,0 @@
|
|||
class Account::Balance::Calculator
|
||||
def initialize(account, sync_start_date)
|
||||
@account = account
|
||||
@sync_start_date = sync_start_date
|
||||
end
|
||||
|
||||
def calculate(is_partial_sync: false)
|
||||
cached_entries = account.entries.where("date >= ?", sync_start_date).to_a
|
||||
sync_starting_balance = is_partial_sync ? find_start_balance_for_partial_sync : find_start_balance_for_full_sync(cached_entries)
|
||||
|
||||
prior_balance = sync_starting_balance
|
||||
|
||||
(sync_start_date..Date.current).map do |date|
|
||||
current_balance = calculate_balance_for_date(date, entries: cached_entries, prior_balance:)
|
||||
|
||||
prior_balance = current_balance
|
||||
|
||||
build_balance(date, current_balance)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account, :sync_start_date
|
||||
|
||||
def find_start_balance_for_partial_sync
|
||||
account.balances.find_by(currency: account.currency, date: sync_start_date - 1.day)&.balance
|
||||
end
|
||||
|
||||
def find_start_balance_for_full_sync(cached_entries)
|
||||
account.balance + net_entry_flows(cached_entries.select { |e| e.account_transaction? })
|
||||
end
|
||||
|
||||
def calculate_balance_for_date(date, entries:, prior_balance:)
|
||||
valuation = entries.find { |e| e.date == date && e.account_valuation? }
|
||||
|
||||
return valuation.amount if valuation
|
||||
|
||||
entries = entries.select { |e| e.date == date }
|
||||
|
||||
prior_balance - net_entry_flows(entries)
|
||||
end
|
||||
|
||||
def net_entry_flows(entries, target_currency = account.currency)
|
||||
converted_entry_amounts = entries.map { |t| t.amount_money.exchange_to(target_currency, date: t.date) }
|
||||
|
||||
flows = converted_entry_amounts.sum(&:amount)
|
||||
|
||||
account.liability? ? flows * -1 : flows
|
||||
end
|
||||
|
||||
def build_balance(date, balance, currency = nil)
|
||||
account.balances.build \
|
||||
date: date,
|
||||
balance: balance,
|
||||
currency: currency || account.currency
|
||||
end
|
||||
end
|
|
@ -1,46 +0,0 @@
|
|||
class Account::Balance::Converter
|
||||
def initialize(account, sync_start_date)
|
||||
@account = account
|
||||
@sync_start_date = sync_start_date
|
||||
end
|
||||
|
||||
def convert(balances)
|
||||
calculate_converted_balances(balances)
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account, :sync_start_date
|
||||
|
||||
def calculate_converted_balances(balances)
|
||||
from_currency = account.currency
|
||||
to_currency = account.family.currency
|
||||
|
||||
if ExchangeRate.exchange_rates_provider.nil?
|
||||
account.observe_missing_exchange_rate_provider
|
||||
return []
|
||||
end
|
||||
|
||||
exchange_rates = ExchangeRate.find_rates from: from_currency,
|
||||
to: to_currency,
|
||||
start_date: sync_start_date
|
||||
|
||||
missing_exchange_rates = balances.map(&:date) - exchange_rates.map(&:date)
|
||||
|
||||
if missing_exchange_rates.any?
|
||||
account.observe_missing_exchange_rates(from: from_currency, to: to_currency, dates: missing_exchange_rates)
|
||||
return []
|
||||
end
|
||||
|
||||
balances.map do |balance|
|
||||
exchange_rate = exchange_rates.find { |er| er.date == balance.date }
|
||||
build_balance(balance.date, exchange_rate.rate * balance.balance, to_currency)
|
||||
end
|
||||
end
|
||||
|
||||
def build_balance(date, balance, currency = nil)
|
||||
account.balances.build \
|
||||
date: date,
|
||||
balance: balance,
|
||||
currency: currency || account.currency
|
||||
end
|
||||
end
|
|
@ -1,42 +0,0 @@
|
|||
class Account::Balance::Loader
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def load(balances, start_date)
|
||||
Account::Balance.transaction do
|
||||
upsert_balances!(balances)
|
||||
purge_stale_balances!(start_date)
|
||||
|
||||
account.reload
|
||||
|
||||
update_account_balance!(balances)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :account
|
||||
|
||||
def update_account_balance!(balances)
|
||||
last_balance = balances.select { |db| db.currency == account.currency }.last&.balance
|
||||
|
||||
if account.plaid_account.present?
|
||||
account.update! balance: account.plaid_account.current_balance || last_balance
|
||||
else
|
||||
account.update! balance: last_balance if last_balance.present?
|
||||
end
|
||||
end
|
||||
|
||||
def upsert_balances!(balances)
|
||||
current_time = Time.now
|
||||
balances_to_upsert = balances.map do |balance|
|
||||
balance.attributes.slice("date", "balance", "currency").merge("updated_at" => current_time)
|
||||
end
|
||||
|
||||
account.balances.upsert_all(balances_to_upsert, unique_by: %i[account_id date currency])
|
||||
end
|
||||
|
||||
def purge_stale_balances!(start_date)
|
||||
account.balances.delete_by("date < ?", start_date)
|
||||
end
|
||||
end
|
|
@ -1,51 +0,0 @@
|
|||
class Account::Balance::Syncer
|
||||
def initialize(account, start_date: nil)
|
||||
@account = account
|
||||
@provided_start_date = start_date
|
||||
@sync_start_date = calculate_sync_start_date(start_date)
|
||||
@loader = Account::Balance::Loader.new(account)
|
||||
@converter = Account::Balance::Converter.new(account, sync_start_date)
|
||||
@calculator = Account::Balance::Calculator.new(account, sync_start_date)
|
||||
end
|
||||
|
||||
def run
|
||||
daily_balances = calculator.calculate(is_partial_sync: is_partial_sync?)
|
||||
daily_balances += converter.convert(daily_balances) if account.currency != account.family.currency
|
||||
|
||||
loader.load(daily_balances, account_start_date)
|
||||
rescue Money::ConversionError => e
|
||||
account.observe_missing_exchange_rates(from: e.from_currency, to: e.to_currency, dates: [ e.date ])
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :sync_start_date, :provided_start_date, :account, :loader, :converter, :calculator
|
||||
|
||||
def account_start_date
|
||||
@account_start_date ||= begin
|
||||
oldest_entry = account.entries.chronological.first
|
||||
|
||||
return Date.current unless oldest_entry.present?
|
||||
|
||||
if oldest_entry.account_valuation?
|
||||
oldest_entry.date
|
||||
else
|
||||
oldest_entry.date - 1.day
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_sync_start_date(provided_start_date)
|
||||
return provided_start_date if provided_start_date.present? && prior_balance_available?(provided_start_date)
|
||||
|
||||
account_start_date
|
||||
end
|
||||
|
||||
def prior_balance_available?(date)
|
||||
account.balances.find_by(currency: account.currency, date: date - 1.day).present?
|
||||
end
|
||||
|
||||
def is_partial_sync?
|
||||
sync_start_date == provided_start_date
|
||||
end
|
||||
end
|
121
app/models/account/balance_calculator.rb
Normal file
121
app/models/account/balance_calculator.rb
Normal file
|
@ -0,0 +1,121 @@
|
|||
class Account::BalanceCalculator
|
||||
def initialize(account, holdings: nil)
|
||||
@account = account
|
||||
@holdings = holdings || []
|
||||
end
|
||||
|
||||
def calculate(reverse: false, start_date: nil)
|
||||
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
|
||||
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
|
94
app/models/account/balance_trend_calculator.rb
Normal file
94
app/models/account/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 Account::BalanceTrendCalculator
|
||||
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.account_valuation?
|
||||
current_balance = e.amount
|
||||
current_cash_balance = e.amount
|
||||
else
|
||||
multiplier = e.account.liability? ? 1 : -1
|
||||
balance_change = e.account_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: TimeSeries::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
|
|
@ -14,9 +14,22 @@ class Account::Entry < ApplicationRecord
|
|||
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? }
|
||||
validates :date, comparison: { greater_than: -> { min_supported_date } }
|
||||
|
||||
scope :chronological, -> { order(:date, :created_at) }
|
||||
scope :not_account_valuations, -> { where.not(entryable_type: "Account::Valuation") }
|
||||
scope :reverse_chronological, -> { order(date: :desc, created_at: :desc) }
|
||||
scope :chronological, -> {
|
||||
order(
|
||||
date: :asc,
|
||||
Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc,
|
||||
created_at: :asc
|
||||
)
|
||||
}
|
||||
|
||||
scope :reverse_chronological, -> {
|
||||
order(
|
||||
date: :desc,
|
||||
Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc,
|
||||
created_at: :desc
|
||||
)
|
||||
}
|
||||
|
||||
scope :without_transfers, -> { where(marked_as_transfer: false) }
|
||||
scope :with_converted_amount, ->(currency) {
|
||||
# Join with exchange rates to convert the amount to the given currency
|
||||
|
@ -30,12 +43,7 @@ class Account::Entry < ApplicationRecord
|
|||
}
|
||||
|
||||
def sync_account_later
|
||||
sync_start_date = if destroyed?
|
||||
previous_entry&.date
|
||||
else
|
||||
[ date_previously_was, date ].compact.min
|
||||
end
|
||||
|
||||
sync_start_date = [ date_previously_was, date ].compact.min unless destroyed?
|
||||
account.sync_later(start_date: sync_start_date)
|
||||
end
|
||||
|
||||
|
@ -51,45 +59,8 @@ class Account::Entry < ApplicationRecord
|
|||
entryable_type.demodulize.underscore
|
||||
end
|
||||
|
||||
def prior_balance
|
||||
account.balances.find_by(date: date - 1)&.balance || 0
|
||||
end
|
||||
|
||||
def prior_entry_balance
|
||||
entries_on_entry_date
|
||||
.not_account_valuations
|
||||
.last
|
||||
&.balance_after_entry || 0
|
||||
end
|
||||
|
||||
def balance_after_entry
|
||||
if account_valuation?
|
||||
Money.new(amount, currency)
|
||||
else
|
||||
new_balance = prior_balance
|
||||
entries_on_entry_date.each do |e|
|
||||
next if e.account_valuation?
|
||||
|
||||
change = e.amount
|
||||
change = account.liability? ? change : -change
|
||||
new_balance += change
|
||||
break if e == self
|
||||
end
|
||||
|
||||
Money.new(new_balance, currency)
|
||||
end
|
||||
end
|
||||
|
||||
def trend
|
||||
TimeSeries::Trend.new(
|
||||
current: balance_after_entry,
|
||||
previous: Money.new(prior_entry_balance, currency),
|
||||
favorable_direction: account.favorable_direction
|
||||
)
|
||||
end
|
||||
|
||||
def entries_on_entry_date
|
||||
account.entries.where(date: date).order(created_at: :asc)
|
||||
def balance_trend(entries, balances)
|
||||
Account::BalanceTrendCalculator.new(self, entries, balances).trend
|
||||
end
|
||||
|
||||
class << self
|
||||
|
@ -233,15 +204,4 @@ class Account::Entry < ApplicationRecord
|
|||
entryable_ids
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def previous_entry
|
||||
@previous_entry ||= account
|
||||
.entries
|
||||
.where("date < ?", date)
|
||||
.where("entryable_type = ?", entryable_type)
|
||||
.order(date: :desc)
|
||||
.first
|
||||
end
|
||||
end
|
||||
|
|
|
@ -22,9 +22,9 @@ class Account::Holding < ApplicationRecord
|
|||
|
||||
def weight
|
||||
return nil unless amount
|
||||
return 0 if amount.zero?
|
||||
|
||||
portfolio_value = account.holdings.current.known_value.sum(&:amount)
|
||||
portfolio_value.zero? ? 1 : amount / portfolio_value * 100
|
||||
account.balance.zero? ? 1 : amount / account.balance * 100
|
||||
end
|
||||
|
||||
# Basic approximation of cost-basis
|
||||
|
|
|
@ -1,136 +0,0 @@
|
|||
class Account::Holding::Syncer
|
||||
def initialize(account, start_date: nil)
|
||||
@account = account
|
||||
end_date = account.plaid_account.present? ? 1.day.ago.to_date : Date.current
|
||||
@sync_date_range = calculate_sync_start_date(start_date)..end_date
|
||||
@portfolio = {}
|
||||
|
||||
load_prior_portfolio if start_date
|
||||
end
|
||||
|
||||
def run
|
||||
holdings = []
|
||||
|
||||
sync_date_range.each do |date|
|
||||
holdings += build_holdings_for_date(date)
|
||||
end
|
||||
|
||||
upsert_holdings holdings
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :account, :sync_date_range
|
||||
|
||||
def sync_entries
|
||||
@sync_entries ||= account.entries
|
||||
.account_trades
|
||||
.includes(entryable: :security)
|
||||
.where("date >= ?", sync_date_range.begin)
|
||||
.order(:date)
|
||||
end
|
||||
|
||||
def get_cached_price(ticker, date)
|
||||
return nil unless security_prices.key?(ticker)
|
||||
|
||||
price = security_prices[ticker].find { |p| p.date == date }
|
||||
price ? price[:price] : nil
|
||||
end
|
||||
|
||||
def security_prices
|
||||
@security_prices ||= begin
|
||||
prices = {}
|
||||
ticker_securities = {}
|
||||
|
||||
sync_entries.each do |entry|
|
||||
security = entry.account_trade.security
|
||||
unless ticker_securities[security.ticker]
|
||||
ticker_securities[security.ticker] = {
|
||||
security: security,
|
||||
start_date: entry.date
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
ticker_securities.each do |ticker, data|
|
||||
fetched_prices = Security::Price.find_prices(
|
||||
security: data[:security],
|
||||
start_date: data[:start_date],
|
||||
end_date: Date.current
|
||||
)
|
||||
gapfilled_prices = Gapfiller.new(fetched_prices, start_date: data[:start_date], end_date: Date.current, cache: false).run
|
||||
prices[ticker] = gapfilled_prices
|
||||
end
|
||||
|
||||
prices
|
||||
end
|
||||
end
|
||||
|
||||
def build_holdings_for_date(date)
|
||||
trades = sync_entries.select { |trade| trade.date == date }
|
||||
|
||||
@portfolio = generate_next_portfolio(@portfolio, trades)
|
||||
|
||||
@portfolio.map do |ticker, holding|
|
||||
trade = trades.find { |trade| trade.account_trade.security_id == holding[:security_id] }
|
||||
trade_price = trade&.account_trade&.price
|
||||
|
||||
price = get_cached_price(ticker, date) || trade_price
|
||||
|
||||
account.holdings.build \
|
||||
date: date,
|
||||
security_id: holding[:security_id],
|
||||
qty: holding[:qty],
|
||||
price: price,
|
||||
amount: price ? (price * holding[:qty]) : nil,
|
||||
currency: holding[:currency]
|
||||
end
|
||||
end
|
||||
|
||||
def generate_next_portfolio(prior_portfolio, trade_entries)
|
||||
trade_entries.each_with_object(prior_portfolio) do |entry, new_portfolio|
|
||||
trade = entry.account_trade
|
||||
|
||||
price = trade.price
|
||||
prior_qty = prior_portfolio.dig(trade.security.ticker, :qty) || 0
|
||||
new_qty = prior_qty + trade.qty
|
||||
|
||||
new_portfolio[trade.security.ticker] = {
|
||||
qty: new_qty,
|
||||
price: price,
|
||||
amount: new_qty * price,
|
||||
currency: entry.currency,
|
||||
security_id: trade.security_id
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def upsert_holdings(holdings)
|
||||
current_time = Time.now
|
||||
holdings_to_upsert = holdings.map do |holding|
|
||||
holding.attributes
|
||||
.slice("date", "currency", "qty", "price", "amount", "security_id")
|
||||
.merge("updated_at" => current_time)
|
||||
end
|
||||
|
||||
account.holdings.upsert_all(holdings_to_upsert, unique_by: %i[account_id security_id date currency])
|
||||
end
|
||||
|
||||
def load_prior_portfolio
|
||||
prior_day_holdings = account.holdings.where(date: sync_date_range.begin - 1.day)
|
||||
|
||||
prior_day_holdings.each do |holding|
|
||||
@portfolio[holding.security.ticker] = {
|
||||
qty: holding.qty,
|
||||
price: holding.price,
|
||||
amount: holding.amount,
|
||||
currency: holding.currency,
|
||||
security_id: holding.security_id
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_sync_start_date(start_date)
|
||||
start_date || account.entries.account_trades.order(:date).first.try(:date) || Date.current
|
||||
end
|
||||
end
|
154
app/models/account/holding_calculator.rb
Normal file
154
app/models/account/holding_calculator.rb
Normal file
|
@ -0,0 +1,154 @@
|
|||
class Account::HoldingCalculator
|
||||
def initialize(account)
|
||||
@account = account
|
||||
@securities_cache = {}
|
||||
end
|
||||
|
||||
def calculate(reverse: false)
|
||||
preload_securities
|
||||
calculated_holdings = reverse ? reverse_holdings : forward_holdings
|
||||
gapfill_holdings(calculated_holdings)
|
||||
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)
|
||||
portfolio.map do |security_id, qty|
|
||||
security = securities_cache[security_id]
|
||||
price = security.dig(:prices)&.find { |p| p.date == date }
|
||||
|
||||
next if price.blank?
|
||||
|
||||
account.holdings.build(
|
||||
security: security.dig(:security),
|
||||
date: date,
|
||||
qty: qty,
|
||||
price: price.price,
|
||||
currency: price.currency,
|
||||
amount: qty * price.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.to_a
|
||||
end
|
||||
|
||||
def portfolio_start_date
|
||||
trades.first ? trades.first.date - 1.day : Date.current
|
||||
end
|
||||
|
||||
def preload_securities
|
||||
securities = trades.map(&:entryable).map(&:security).uniq
|
||||
|
||||
securities.each do |security|
|
||||
prices = Security::Price.find_prices(
|
||||
security: security,
|
||||
start_date: portfolio_start_date,
|
||||
end_date: Date.current
|
||||
)
|
||||
|
||||
@securities_cache[security.id] = {
|
||||
security: security,
|
||||
prices: prices
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def calculate_portfolio(holding_quantities, today_trades, inverse: false)
|
||||
new_quantities = holding_quantities.dup
|
||||
|
||||
today_trades.each do |trade|
|
||||
security_id = trade.entryable.security_id
|
||||
qty_change = inverse ? trade.entryable.qty : -trade.entryable.qty
|
||||
new_quantities[security_id] = (new_quantities[security_id] || 0) + qty_change
|
||||
end
|
||||
|
||||
new_quantities
|
||||
end
|
||||
|
||||
def load_empty_holding_quantities
|
||||
holding_quantities = {}
|
||||
|
||||
trades.map { |t| t.entryable.security_id }.uniq.each do |security_id|
|
||||
holding_quantities[security_id] = 0
|
||||
end
|
||||
|
||||
holding_quantities
|
||||
end
|
||||
|
||||
def load_current_holding_quantities
|
||||
holding_quantities = load_empty_holding_quantities
|
||||
|
||||
account.holdings.where(date: Date.current).map do |holding|
|
||||
holding_quantities[holding.security_id] = holding.qty
|
||||
end
|
||||
|
||||
holding_quantities
|
||||
end
|
||||
end
|
104
app/models/account/syncer.rb
Normal file
104
app/models/account/syncer.rb
Normal file
|
@ -0,0 +1,104 @@
|
|||
class Account::Syncer
|
||||
def initialize(account, start_date: nil)
|
||||
@account = account
|
||||
@start_date = start_date
|
||||
end
|
||||
|
||||
def run
|
||||
holdings = sync_holdings
|
||||
balances = sync_balances(holdings)
|
||||
update_account_info(balances, holdings) unless account.plaid_account_id.present?
|
||||
convert_foreign_records(balances)
|
||||
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: account.plaid_account_id.present?)
|
||||
|
||||
current_time = Time.now
|
||||
|
||||
Account.transaction do
|
||||
account.holdings.upsert_all(
|
||||
calculated_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]
|
||||
) if calculated_holdings.any?
|
||||
|
||||
# Purge outdated holdings
|
||||
account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account_start_date, calculated_holdings.map(&:security_id))
|
||||
end
|
||||
|
||||
calculated_holdings
|
||||
end
|
||||
|
||||
def sync_balances(holdings)
|
||||
calculator = Account::BalanceCalculator.new(account, holdings: holdings)
|
||||
calculated_balances = calculator.calculate(reverse: account.plaid_account_id.present?, start_date: start_date)
|
||||
|
||||
Account.transaction do
|
||||
load_balances(calculated_balances)
|
||||
|
||||
# Purge outdated balances
|
||||
account.balances.delete_by("date < ?", account_start_date)
|
||||
end
|
||||
|
||||
calculated_balances
|
||||
end
|
||||
|
||||
def convert_foreign_records(balances)
|
||||
converted_balances = convert_balances(balances)
|
||||
load_balances(converted_balances)
|
||||
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]
|
||||
) if balances.any?
|
||||
end
|
||||
|
||||
def convert_balances(balances)
|
||||
return [] if account.currency == account.family.currency
|
||||
|
||||
from_currency = account.currency
|
||||
to_currency = account.family.currency
|
||||
|
||||
exchange_rates = ExchangeRate.find_rates(
|
||||
from: from_currency,
|
||||
to: to_currency,
|
||||
start_date: balances.first.date
|
||||
)
|
||||
|
||||
balances.map do |balance|
|
||||
exchange_rate = exchange_rates.find { |er| er.date == balance.date }
|
||||
|
||||
account.balances.build(
|
||||
date: balance.date,
|
||||
balance: exchange_rate.rate * balance.balance,
|
||||
currency: to_currency
|
||||
) if exchange_rate.present?
|
||||
end
|
||||
end
|
||||
end
|
|
@ -59,7 +59,7 @@ class Account::TradeBuilder
|
|||
)
|
||||
else
|
||||
account.entries.build(
|
||||
name: signed_amount < 0 ? "Deposit from #{account.name}" : "Withdrawal to #{account.name}",
|
||||
name: signed_amount < 0 ? "Deposit to #{account.name}" : "Withdrawal from #{account.name}",
|
||||
date: date,
|
||||
amount: signed_amount,
|
||||
currency: currency,
|
||||
|
|
|
@ -10,44 +10,4 @@ class Account::Valuation < ApplicationRecord
|
|||
false
|
||||
end
|
||||
end
|
||||
|
||||
def name
|
||||
entry.name || (oldest? ? "Initial balance" : "Balance update")
|
||||
end
|
||||
|
||||
def trend
|
||||
@trend ||= create_trend
|
||||
end
|
||||
|
||||
def icon
|
||||
oldest? ? "plus" : entry.trend.icon
|
||||
end
|
||||
|
||||
def color
|
||||
oldest? ? "#D444F1" : entry.trend.color
|
||||
end
|
||||
|
||||
private
|
||||
def oldest?
|
||||
@oldest ||= account.entries.where("date < ?", entry.date).empty?
|
||||
end
|
||||
|
||||
def account
|
||||
@account ||= entry.account
|
||||
end
|
||||
|
||||
def create_trend
|
||||
TimeSeries::Trend.new(
|
||||
current: entry.amount_money,
|
||||
previous: prior_balance&.balance_money,
|
||||
favorable_direction: account.favorable_direction
|
||||
)
|
||||
end
|
||||
|
||||
def prior_balance
|
||||
@prior_balance ||= account.balances
|
||||
.where("date < ?", entry.date)
|
||||
.order(date: :desc)
|
||||
.first
|
||||
end
|
||||
end
|
||||
|
|
|
@ -18,26 +18,7 @@ module Accountable
|
|||
has_one :account, as: :accountable, touch: true
|
||||
end
|
||||
|
||||
def value
|
||||
account.balance_money
|
||||
end
|
||||
|
||||
def series(period: Period.all, currency: account.currency)
|
||||
balance_series = account.balances.in_period(period).where(currency: currency)
|
||||
|
||||
if balance_series.empty? && period.date_range.end == Date.current
|
||||
TimeSeries.new([ { date: Date.current, value: account.balance_money.exchange_to(currency) } ])
|
||||
else
|
||||
TimeSeries.from_collection(balance_series, :balance_money, favorable_direction: account.asset? ? "up" : "down")
|
||||
end
|
||||
rescue Money::ConversionError
|
||||
TimeSeries.new([])
|
||||
end
|
||||
|
||||
def post_sync
|
||||
broadcast_remove_to(account.family, target: "syncing-notice")
|
||||
|
||||
# Broadcast a simple replace event that the controller can handle
|
||||
broadcast_replace_to(
|
||||
account,
|
||||
target: "chart_account_#{account.id}",
|
||||
|
|
|
@ -1,48 +0,0 @@
|
|||
class Gapfiller
|
||||
attr_reader :series
|
||||
|
||||
def initialize(series, start_date:, end_date:, cache:)
|
||||
@series = series
|
||||
@date_range = start_date..end_date
|
||||
@cache = cache
|
||||
end
|
||||
|
||||
def run
|
||||
gapfilled_records = []
|
||||
|
||||
date_range.each do |date|
|
||||
record = series.find { |r| r.date == date }
|
||||
|
||||
if should_gapfill?(date, record)
|
||||
prev_record = gapfilled_records.find { |r| r.date == date - 1.day }
|
||||
|
||||
if prev_record
|
||||
new_record = create_gapfilled_record(prev_record, date)
|
||||
gapfilled_records << new_record
|
||||
end
|
||||
else
|
||||
gapfilled_records << record if record
|
||||
end
|
||||
end
|
||||
|
||||
gapfilled_records
|
||||
end
|
||||
|
||||
private
|
||||
attr_reader :date_range, :cache
|
||||
|
||||
def should_gapfill?(date, record)
|
||||
(date.on_weekend? || holiday?(date)) && record.nil?
|
||||
end
|
||||
|
||||
def holiday?(date)
|
||||
Holidays.on(date, :federalreserve, :us, :observed, :informal).any?
|
||||
end
|
||||
|
||||
def create_gapfilled_record(prev_record, date)
|
||||
new_record = prev_record.class.new(prev_record.attributes.except("id", "created_at", "updated_at"))
|
||||
new_record.date = date
|
||||
new_record.save! if cache
|
||||
new_record
|
||||
end
|
||||
end
|
|
@ -16,37 +16,6 @@ class Investment < ApplicationRecord
|
|||
[ "Angel", "angel" ]
|
||||
].freeze
|
||||
|
||||
def value
|
||||
account.balance_money + holdings_value
|
||||
end
|
||||
|
||||
def holdings_value
|
||||
account.holdings.current.known_value.sum(&:amount) || Money.new(0, account.currency)
|
||||
end
|
||||
|
||||
def series(period: Period.all, currency: account.currency)
|
||||
balance_series = account.balances.in_period(period).where(currency: currency)
|
||||
holding_series = account.holdings.known_value.in_period(period).where(currency: currency)
|
||||
|
||||
holdings_by_date = holding_series.group_by(&:date).transform_values do |holdings|
|
||||
holdings.sum(&:amount)
|
||||
end
|
||||
|
||||
combined_series = balance_series.map do |balance|
|
||||
holding_amount = holdings_by_date[balance.date] || 0
|
||||
|
||||
{ date: balance.date, value: Money.new(balance.balance + holding_amount, currency) }
|
||||
end
|
||||
|
||||
if combined_series.empty? && period.date_range.end == Date.current
|
||||
TimeSeries.new([ { date: Date.current, value: self.value.exchange_to(currency) } ])
|
||||
else
|
||||
TimeSeries.new(combined_series)
|
||||
end
|
||||
rescue Money::ConversionError
|
||||
TimeSeries.new([])
|
||||
end
|
||||
|
||||
def color
|
||||
"#1570EF"
|
||||
end
|
||||
|
@ -56,8 +25,6 @@ class Investment < ApplicationRecord
|
|||
end
|
||||
|
||||
def post_sync
|
||||
broadcast_remove_to(account, target: "syncing-notice")
|
||||
|
||||
broadcast_replace_to(
|
||||
account,
|
||||
target: "chart_account_#{account.id}",
|
||||
|
|
|
@ -167,6 +167,7 @@ class PlaidAccount < ApplicationRecord
|
|||
end
|
||||
|
||||
return nil if security.nil? || security.ticker_symbol.blank?
|
||||
return nil if security.ticker_symbol == "CUR:USD" # Internally, we do not consider cash a "holding" and track it separately
|
||||
|
||||
Security.find_or_create_by!(
|
||||
ticker: security.ticker_symbol,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue