Files
sure/app/models/trade.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

119 lines
3.4 KiB
Ruby

class Trade < ApplicationRecord
include Entryable, Monetizable
monetize :price
monetize :fee
belongs_to :security
belongs_to :category, optional: true
# Use the same activity labels as Transaction
ACTIVITY_LABELS = Transaction::ACTIVITY_LABELS.dup.freeze
validates :qty, presence: true
validates :price, :currency, presence: true
validates :investment_activity_label, inclusion: { in: ACTIVITY_LABELS }, allow_nil: true
def exchange_rate
extra&.dig("exchange_rate")
end
def exchange_rate=(value)
if value.blank?
self.extra = (extra || {}).merge("exchange_rate" => nil, "exchange_rate_invalid" => false)
else
begin
normalized_value = Float(value)
raise ArgumentError unless normalized_value.finite?
self.extra = (extra || {}).merge("exchange_rate" => normalized_value, "exchange_rate_invalid" => false)
rescue ArgumentError, TypeError
self.extra = (extra || {}).merge("exchange_rate" => value, "exchange_rate_invalid" => true)
end
end
end
validate :exchange_rate_must_be_valid
# Trade types for categorization
def buy?
qty.positive?
end
def sell?
qty.negative?
end
class << self
def build_name(type, qty, ticker)
prefix = type == "buy" ? "Buy" : "Sell"
"#{prefix} #{qty.to_d.abs} shares of #{ticker}"
end
end
def unrealized_gain_loss
return nil unless qty.positive?
current_price = security.current_price
return nil if current_price.nil?
current_value = current_price * qty.abs
cost_basis = price_money * qty.abs
Trend.new(current: current_value, previous: cost_basis)
end
# Calculates realized gain/loss for sell trades based on avg_cost at time of sale
# Returns nil for buy trades or when cost basis cannot be determined
def realized_gain_loss
return @realized_gain_loss if defined?(@realized_gain_loss)
@realized_gain_loss = calculate_realized_gain_loss
end
# Trades are always excluded from expense budgets
# They represent portfolio management, not living expenses
def excluded_from_budget?
true
end
private
def exchange_rate_must_be_valid
if extra&.dig("exchange_rate_invalid")
errors.add(:exchange_rate, "must be a number")
elsif exchange_rate.present?
numeric_rate = Float(exchange_rate) rescue nil
if numeric_rate.nil? || !numeric_rate.finite? || numeric_rate <= 0
errors.add(:exchange_rate, "must be greater than 0")
end
end
end
def calculate_realized_gain_loss
return nil unless sell?
# Use preloaded holdings if available (set by reports controller to avoid N+1)
# Treat defined-but-empty preload as authoritative to prevent DB fallback
holding = if defined?(@preloaded_holdings)
# Use select + max_by for deterministic selection regardless of array order
(@preloaded_holdings || [])
.select { |h| h.security_id == security_id && h.date <= entry.date }
.max_by(&:date)
else
# Fall back to database query only when not preloaded
entry.account.holdings
.where(security_id: security_id)
.where("date <= ?", entry.date)
.order(date: :desc)
.first
end
return nil unless holding&.avg_cost
cost_basis = holding.avg_cost * qty.abs
sale_proceeds = price_money * qty.abs
Trend.new(current: sale_proceeds, previous: cost_basis)
end
end