Files
sure/app/models/holding/reverse_calculator.rb
soky srm 6ebe8da928 Add investment tracking to expenses (#381)
* Add investment tracking to expenses

Add new sections to dashboard and reporting around investments.

* Create investment-integration-assessment.md

* Delete .claude/settings.local.json

Signed-off-by: soky srm <sokysrm@gmail.com>

* Category trades

* Simplify

* Simplification and test fixes

* FIX merge

* Update views

* Update 20251125141213_add_category_to_trades.rb

* FIX tests

* FIX statements and account status

* cleanup

* Add default cat for csv imports

* Delete docs/roadmap/investment-integration-assessment.md

Signed-off-by: soky srm <sokysrm@gmail.com>

* Update trend calculation

Use already existing column cost basis for trend calculation
   - Current value: qty * price (already stored as amount)
  - Cost basis total: qty * cost_basis
  - Unrealized gain: current value - cost basis total
Fixes N+1 query also

---------

Signed-off-by: soky srm <sokysrm@gmail.com>
2026-01-09 13:03:40 +01:00

117 lines
4.0 KiB
Ruby

class Holding::ReverseCalculator
attr_reader :account, :portfolio_snapshot
def initialize(account, portfolio_snapshot:)
@account = account
@portfolio_snapshot = portfolio_snapshot
end
def calculate
Rails.logger.tagged("Holding::ReverseCalculator") do
precompute_cost_basis
holdings = calculate_holdings
Holding.gapfill(holdings)
end
end
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
# Start with the portfolio snapshot passed in from the materializer
current_portfolio = portfolio_snapshot.to_h
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)
# If current day, always use holding prices (since that's what Plaid gives us). For historical values, use market data (since Plaid doesn't supply historical prices)
holdings += build_holdings(current_portfolio, date, price_source: date == Date.current ? "holding" : nil)
current_portfolio = previous_portfolio
end
holdings
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, price_source: nil)
portfolio.map do |security_id, qty|
price = portfolio_cache.get_price(security_id, date, source: price_source)
if price.nil?
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,
cost_basis: cost_basis_for(security_id, date)
)
end.compact
end
# Pre-compute cost basis for all securities at all dates using forward pass through trades
# Stores: { security_id => { date => cost_basis } }
def precompute_cost_basis
@cost_basis_by_date = Hash.new { |h, k| h[k] = {} }
tracker = Hash.new { |h, k| h[k] = { total_cost: BigDecimal("0"), total_qty: BigDecimal("0") } }
trades = portfolio_cache.get_trades.sort_by(&:date)
trade_index = 0
account.start_date.upto(Date.current).each do |date|
# Process all trades up to and including this date
while trade_index < trades.size && trades[trade_index].date <= date
trade_entry = trades[trade_index]
trade = trade_entry.entryable
if trade.qty > 0 # Only track buys
security_id = trade.security_id
trade_price = Money.new(trade.price, trade.currency)
converted_price = trade_price.exchange_to(account.currency, fallback_rate: 1).amount
tracker[security_id][:total_cost] += converted_price * trade.qty
tracker[security_id][:total_qty] += trade.qty
end
trade_index += 1
end
# Store current cost basis snapshot for each security at this date
tracker.each do |security_id, data|
next if data[:total_qty].zero?
@cost_basis_by_date[security_id][date] = data[:total_cost] / data[:total_qty]
end
end
end
def cost_basis_for(security_id, date)
@cost_basis_by_date.dig(security_id, date)
end
end