mirror of
https://github.com/we-promise/sure.git
synced 2026-05-25 05:24:57 +00:00
* 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
119 lines
3.4 KiB
Ruby
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
|