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
63
app/models/holding/base_calculator.rb
Normal file
63
app/models/holding/base_calculator.rb
Normal file
|
@ -0,0 +1,63 @@
|
|||
class Holding::BaseCalculator
|
||||
attr_reader :account
|
||||
|
||||
def initialize(account)
|
||||
@account = account
|
||||
end
|
||||
|
||||
def calculate
|
||||
Rails.logger.tagged(self.class.name) do
|
||||
holdings = calculate_holdings
|
||||
Holding.gapfill(holdings)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
def portfolio_cache
|
||||
@portfolio_cache ||= 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
|
||||
|
||||
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
|
21
app/models/holding/forward_calculator.rb
Normal file
21
app/models/holding/forward_calculator.rb
Normal file
|
@ -0,0 +1,21 @@
|
|||
class Holding::ForwardCalculator < Holding::BaseCalculator
|
||||
private
|
||||
def portfolio_cache
|
||||
@portfolio_cache ||= 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
|
38
app/models/holding/gapfillable.rb
Normal file
38
app/models/holding/gapfillable.rb
Normal file
|
@ -0,0 +1,38 @@
|
|||
module 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 << 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
|
131
app/models/holding/portfolio_cache.rb
Normal file
131
app/models/holding/portfolio_cache.rb
Normal file
|
@ -0,0 +1,131 @@
|
|||
class 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: account.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).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}"
|
||||
|
||||
# Load prices from provider to DB
|
||||
security.sync_provider_prices(start_date: account.start_date)
|
||||
|
||||
# High priority prices from DB (synced from provider)
|
||||
db_prices = security.prices.where(date: account.start_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_prices + trade_prices + holding_prices
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
38
app/models/holding/reverse_calculator.rb
Normal file
38
app/models/holding/reverse_calculator.rb
Normal file
|
@ -0,0 +1,38 @@
|
|||
class Holding::ReverseCalculator < 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 ||= 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
|
58
app/models/holding/syncer.rb
Normal file
58
app/models/holding/syncer.rb
Normal file
|
@ -0,0 +1,58 @@
|
|||
class 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.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
|
||||
Holding::ReverseCalculator.new(account)
|
||||
else
|
||||
Holding::ForwardCalculator.new(account)
|
||||
end
|
||||
end
|
||||
end
|
Loading…
Add table
Add a link
Reference in a new issue