mirror of
https://github.com/we-promise/sure.git
synced 2026-04-14 17:44:07 +00:00
* Feat: Implement manual sync prices functionality and enhance holdings display * Feat: Enhance sync prices functionality with error handling and update UI components * Feat: Update sync prices error handling and enhance Spanish locale messages * Fix: Address CodeRabbit review feedback - Set fallback @provider_error when prices_updated == 0 so turbo stream never fails silently without a visible error message - Move attr_reader :provider_error to class header in Price::Importer for conventional placement alongside other attribute declarations - Precompute @last_price_updated in controller (show + sync_prices) instead of running a DB query directly inside ERB templates Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix: Replace bare rescue with explicit exception handling in turbo stream view Bare `rescue` silently swallows all exceptions, making debugging impossible. Match the pattern already used in show.html.erb: rescue ActiveRecord::RecordInvalid explicitly, then catch StandardError with logging (message + backtrace) before falling back to the unknown label. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix: Update test assertion to expect actual provider error message The stub returns "Yahoo Finance rate limit exceeded" as the provider error. After the @provider_error fallback fix, the controller now correctly surfaces the real provider error when present (using .presence || fallback), so the flash[:alert] is the actual error string, not the generic fallback. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix: Assert scoped security_ids in sync_prices materializer test Replace loose stub with constructor expectation to verify that Balance::Materializer is instantiated with the single-security scope. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix: Assert holding remap in remap_security test Add assertion that @holding.security_id is updated to the target security after remap, covering the core command outcome. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix: CI test failure - Update disconnect external assistant test to use env overrides --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
120 lines
4.2 KiB
Ruby
120 lines
4.2 KiB
Ruby
class Holding::ReverseCalculator
|
|
attr_reader :account, :portfolio_snapshot
|
|
|
|
def initialize(account, portfolio_snapshot:, security_ids: nil)
|
|
@account = account
|
|
@portfolio_snapshot = portfolio_snapshot
|
|
@security_ids = security_ids
|
|
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, security_ids: @security_ids)
|
|
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|
|
|
next if @security_ids && !@security_ids.include?(security_id)
|
|
|
|
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
|