1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-10 07:55:21 +02:00

Move price caching logic to dedicated model

This commit is contained in:
Zach Gollwitzer 2025-03-07 08:25:03 -05:00
parent b9125b05ff
commit b147ce0036
7 changed files with 159 additions and 210 deletions

View file

@ -73,11 +73,8 @@ class Account < ApplicationRecord
def sync_data(start_date: nil)
update!(last_synced_at: Time.current)
family.auto_match_transfers!
BalanceSyncer.new(self, strategy: linked? ? :reverse : :forward).sync_balances
sync_balances
enrich_data if enrichable?
end
@ -126,4 +123,10 @@ class Account < ApplicationRecord
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

View file

@ -1,5 +1,5 @@
class Account::Holding < ApplicationRecord
include Monetizable
include Monetizable, Gapfillable
monetize :amount

View file

@ -1,31 +1,32 @@
class Account::Holding::ForwardCalculator
attr_reader :account, :securities_cache
attr_reader :account
def initialize(account)
@account = account
@securities_cache = {}
end
def calculate
Rails.logger.tagged("Account::HoldingCalculator") do
preload_securities
Rails.logger.info("Calculating holdings with strategy: forward sync")
calculated_holdings = calculate_holdings
gapfill_holdings(calculated_holdings)
Account::Holding.gapfill(calculated_holdings)
end
end
private
def portfolio_cache
@portfolio_cache ||= Account::Holding::PortfolioCache.new(account)
end
def calculate_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 }
account.start_date.upto(Date.current).map do |date|
today_trades = portfolio_cache.get_trades(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
@ -38,97 +39,19 @@ class Account::Holding::ForwardCalculator
Rails.logger.info "Generating holdings for #{portfolio.size} securities on #{date}"
portfolio.map do |security_id, qty|
security = securities_cache[security_id]
price = security.dig(:prices)&.find { |p| p.date == date }
# We prefer to use prices from our data provider. But if the provider doesn't have an EOD price
# for this security, we search through the account's trades and use the "spot" price at the time of
# the most recent trade for that day's holding. This is not as accurate, but it allows users to define
# what we call "offline" securities (which is essential given we cannot get prices for all securities globally)
if price.blank?
converted_price = most_recent_trade_price(security_id, date)
else
converted_price = Money.new(price.price, price.currency).exchange_to(account.currency, fallback_rate: 1).amount
end
price = portfolio_cache.get_price(security_id, date)
account.holdings.build(
security: security.dig(:security),
security_id: security_id,
date: date,
qty: qty,
price: converted_price,
price: price,
currency: account.currency,
amount: qty * converted_price
amount: qty * price
)
end.compact
end
def gapfill_holdings(holdings)
filled_holdings = []
holdings.group_by { |h| h.security_id }.each do |security_id, security_holdings|
next if security_holdings.empty?
sorted = security_holdings.sort_by(&:date)
previous_holding = sorted.first
sorted.first.date.upto(Date.current) do |date|
holding = security_holdings.find { |h| h.date == date }
if holding
filled_holdings << holding
previous_holding = holding
else
# Create a new holding based on the previous day's data
filled_holdings << account.holdings.build(
security: previous_holding.security,
date: date,
qty: previous_holding.qty,
price: previous_holding.price,
currency: previous_holding.currency,
amount: previous_holding.amount
)
end
end
end
filled_holdings
end
def trades
@trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a
end
def portfolio_start_date
trades.first ? trades.first.date - 1.day : Date.current
end
def preload_securities
# Get securities from trades and current holdings
securities = trades.map(&:entryable).map(&:security).uniq
securities += account.holdings.where(date: Date.current).map(&:security)
securities.uniq!
Rails.logger.info "Preloading #{securities.size} securities for account #{account.id}"
securities.each do |security|
Rails.logger.info "Loading security: ID=#{security.id} Ticker=#{security.ticker}"
prices = Security::Price.find_prices(
security: security,
start_date: portfolio_start_date,
end_date: Date.current
)
Rails.logger.info "Found #{prices.size} prices for security #{security.id}"
@securities_cache[security.id] = {
security: security,
prices: prices
}
end
end
def calculate_portfolio(holding_quantities, today_trades, inverse: false)
new_quantities = holding_quantities.dup
@ -144,6 +67,8 @@ class Account::Holding::ForwardCalculator
def load_empty_holding_quantities
holding_quantities = {}
trades = portfolio_cache.get_trades
trades.map { |t| t.entryable.security_id }.uniq.each do |security_id|
holding_quantities[security_id] = 0
end
@ -160,15 +85,4 @@ class Account::Holding::ForwardCalculator
holding_quantities
end
def most_recent_trade_price(security_id, date)
first_trade = trades.select { |t| t.entryable.security_id == security_id }.min_by(&:date)
most_recent_trade = trades.select { |t| t.entryable.security_id == security_id && t.date <= date }.max_by(&:date)
if most_recent_trade
most_recent_trade.entryable.price
else
first_trade.entryable.price
end
end
end

View file

@ -0,0 +1,37 @@
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.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
end
end

View file

@ -0,0 +1,86 @@
class Account::Holding::PortfolioCache
attr_reader :account
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)
@account = account
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].find { |p| p.date == date }
# We prefer to use prices from our data provider. But if the provider doesn't have an EOD price
# for this security, we search through the account's trades and use the "spot" price at the time of
# the most recent trade for that day's holding. This is not as accurate, but it allows users to define
# what we call "offline" securities (which is essential given we cannot get prices for all securities globally)
if price.blank?
converted_price = most_recent_trade_price(security_id, date)
else
converted_price = Money.new(price.price, price.currency).exchange_to(account.currency, fallback_rate: 1).amount
end
converted_price
end
private
def trades
@trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a
end
def most_recent_trade_price(security_id, date)
first_trade = trades.select { |t| t.entryable.security_id == security_id }.min_by(&:date)
most_recent_trade = trades.select { |t| t.entryable.security_id == security_id && t.date <= date }.max_by(&:date)
if most_recent_trade
most_recent_trade.entryable.price
else
first_trade.entryable.price
end
end
def load_prices
@security_cache = {}
# Get securities from trades and current holdings
securities = trades.map(&:entryable).map(&:security).uniq
securities += account.holdings.where(date: Date.current).map(&:security)
securities.uniq!
Rails.logger.info "Preloading #{securities.size} securities for account #{account.id}"
securities.each do |security|
Rails.logger.info "Loading security: ID=#{security.id} Ticker=#{security.ticker}"
fetched_prices = Security::Price.find_prices(
security: security,
start_date: account.start_date,
end_date: Date.current
)
Rails.logger.info "Found #{fetched_prices.size} prices for security #{security.id}"
@security_cache[security.id] = {
security: security,
prices: fetched_prices
}
end
end
end

View file

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

View file

@ -1,31 +1,32 @@
class Account::Holding::ReverseCalculator
attr_reader :account, :securities_cache
attr_reader :account
def initialize(account)
@account = account
@securities_cache = {}
end
def calculate
Rails.logger.tagged("Account::HoldingCalculator") do
preload_securities
Rails.logger.info("Calculating holdings with strategy: reverse sync")
calculated_holdings = calculate_holdings
gapfill_holdings(calculated_holdings)
Account::Holding.gapfill(calculated_holdings)
end
end
private
def portfolio_cache
@portfolio_cache ||= Account::Holding::PortfolioCache.new(account)
end
def calculate_holdings
current_holding_quantities = load_current_holding_quantities
prior_holding_quantities = {}
holdings = []
Date.current.downto(portfolio_start_date).map do |date|
today_trades = trades.select { |t| t.date == date }
Date.current.downto(account.start_date).map do |date|
today_trades = portfolio_cache.get_trades(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
@ -38,97 +39,19 @@ class Account::Holding::ReverseCalculator
Rails.logger.info "Generating holdings for #{portfolio.size} securities on #{date}"
portfolio.map do |security_id, qty|
security = securities_cache[security_id]
price = security.dig(:prices)&.find { |p| p.date == date }
# We prefer to use prices from our data provider. But if the provider doesn't have an EOD price
# for this security, we search through the account's trades and use the "spot" price at the time of
# the most recent trade for that day's holding. This is not as accurate, but it allows users to define
# what we call "offline" securities (which is essential given we cannot get prices for all securities globally)
if price.blank?
converted_price = most_recent_trade_price(security_id, date)
else
converted_price = Money.new(price.price, price.currency).exchange_to(account.currency, fallback_rate: 1).amount
end
price = portfolio_cache.get_price(security_id, date)
account.holdings.build(
security: security.dig(:security),
security_id: security_id,
date: date,
qty: qty,
price: converted_price,
price: price,
currency: account.currency,
amount: qty * converted_price
amount: qty * price
)
end.compact
end
def gapfill_holdings(holdings)
filled_holdings = []
holdings.group_by { |h| h.security_id }.each do |security_id, security_holdings|
next if security_holdings.empty?
sorted = security_holdings.sort_by(&:date)
previous_holding = sorted.first
sorted.first.date.upto(Date.current) do |date|
holding = security_holdings.find { |h| h.date == date }
if holding
filled_holdings << holding
previous_holding = holding
else
# Create a new holding based on the previous day's data
filled_holdings << account.holdings.build(
security: previous_holding.security,
date: date,
qty: previous_holding.qty,
price: previous_holding.price,
currency: previous_holding.currency,
amount: previous_holding.amount
)
end
end
end
filled_holdings
end
def trades
@trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a
end
def portfolio_start_date
trades.first ? trades.first.date - 1.day : Date.current
end
def preload_securities
# Get securities from trades and current holdings
securities = trades.map(&:entryable).map(&:security).uniq
securities += account.holdings.where(date: Date.current).map(&:security)
securities.uniq!
Rails.logger.info "Preloading #{securities.size} securities for account #{account.id}"
securities.each do |security|
Rails.logger.info "Loading security: ID=#{security.id} Ticker=#{security.ticker}"
prices = Security::Price.find_prices(
security: security,
start_date: portfolio_start_date,
end_date: Date.current
)
Rails.logger.info "Found #{prices.size} prices for security #{security.id}"
@securities_cache[security.id] = {
security: security,
prices: prices
}
end
end
def calculate_portfolio(holding_quantities, today_trades, inverse: false)
new_quantities = holding_quantities.dup
@ -144,6 +67,8 @@ class Account::Holding::ReverseCalculator
def load_empty_holding_quantities
holding_quantities = {}
trades = portfolio_cache.get_trades
trades.map { |t| t.entryable.security_id }.uniq.each do |security_id|
holding_quantities[security_id] = 0
end
@ -160,15 +85,4 @@ class Account::Holding::ReverseCalculator
holding_quantities
end
def most_recent_trade_price(security_id, date)
first_trade = trades.select { |t| t.entryable.security_id == security_id }.min_by(&:date)
most_recent_trade = trades.select { |t| t.entryable.security_id == security_id && t.date <= date }.max_by(&:date)
if most_recent_trade
most_recent_trade.entryable.price
else
first_trade.entryable.price
end
end
end