Files
sure/app/models/holding/portfolio_cache.rb
Gian-Reto Tarnutzer ce5d7dd736 Add Interactive Brokers Provider (#1722)
* Display multi-currency holdings correctly

* Implement IBKR provider

* Fix: Use historical exchange rate for historical prices

* Add brokerage exchange rate for trades

* Sync historical balances from IBKR

* Add logos in activity history

* Fix privacy mode blur in account view

* Improve IBKR XML Flex report parser errors
2026-05-12 23:45:19 +02:00

169 lines
4.9 KiB
Ruby

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, security_ids: nil)
@account = account
@use_holdings = use_holdings
@security_ids = security_ids
load_prices
end
def get_trades(date: nil)
if date.blank?
trades
else
trades_by_date[date]&.dup || []
end
end
def get_price(security_id, date, source: nil)
security = @security_cache[security_id]
raise SecurityNotFound.new(security_id, account.id) unless security
price_with_priority = if source.present?
security[:prices_by_date_and_source][[ date, source ]]
else
security[:prices_by_date][date]
end
return nil unless price_with_priority
price = price_with_priority.price
return nil unless price
price_money = Money.new(price.price, price.currency)
begin
converted_amount = price_money.exchange_to(account.currency, date: date).amount
rescue Money::ConversionError
converted_amount = price.price
end
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, :source)
def trades
@trades ||= account.entries.includes(entryable: :security).trades.chronological.to_a
end
def trades_by_date
@trades_by_date ||= trades.group_by(&:date)
end
def trades_by_security_id
@trades_by_security_id ||= trades.group_by { |t| t.entryable.security_id }
end
def holdings
@holdings ||= account.holdings.chronological.to_a
end
def holdings_by_security_id
@holdings_by_security_id ||= holdings.group_by(&:security_id)
end
def collect_unique_securities
ids = trades_by_security_id.keys
ids |= holdings_by_security_id.keys if use_holdings
ids &= @security_ids if @security_ids
Security.where(id: ids).to_a
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}"
security_ids = securities.map(&:id)
# Bulk-load all DB prices for all securities in one query, grouped by security_id
db_prices_by_security_id = Security::Price
.where(security_id: security_ids, date: account.start_date..Date.current)
.group_by(&:security_id)
securities.each do |security|
Rails.logger.info "Loading security: ID=#{security.id} Ticker=#{security.ticker}"
# High priority prices from DB (synced from provider)
db_prices = (db_prices_by_security_id[security.id] || []).map do |price|
PriceWithPriority.new(
price: price,
priority: 1,
source: "db"
)
end
# Medium priority prices from trades
trade_prices = (trades_by_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,
source: "trade"
)
end
# Low priority prices from holdings (if applicable)
holding_prices = if use_holdings
(holdings_by_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,
source: "holding"
)
end
else
[]
end
all_prices = db_prices + trade_prices + holding_prices
# Index by date for O(1) lookup in get_price instead of O(N) linear scan
prices_by_date = all_prices.group_by { |p| p.price.date }
.transform_values { |ps| ps.min_by(&:priority) }
prices_by_date_and_source = all_prices.group_by { |p| [ p.price.date, p.source ] }
.transform_values { |ps| ps.min_by(&:priority) }
@security_cache[security.id] = {
security: security,
prices_by_date: prices_by_date,
prices_by_date_and_source: prices_by_date_and_source
}
end
end
end