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:
parent
b9125b05ff
commit
b147ce0036
7 changed files with 159 additions and 210 deletions
|
@ -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
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
class Account::Holding < ApplicationRecord
|
||||
include Monetizable
|
||||
include Monetizable, Gapfillable
|
||||
|
||||
monetize :amount
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
37
app/models/account/holding/gapfillable.rb
Normal file
37
app/models/account/holding/gapfillable.rb
Normal 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
|
86
app/models/account/holding/portfolio_cache.rb
Normal file
86
app/models/account/holding/portfolio_cache.rb
Normal 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
|
|
@ -1,5 +0,0 @@
|
|||
class Account::Holding::PortfolioPriceCache
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
end
|
|
@ -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
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue