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>
This commit is contained in:
soky srm
2026-01-09 13:03:40 +01:00
committed by GitHub
parent d185c6161c
commit 6ebe8da928
29 changed files with 907 additions and 131 deletions

View File

@@ -5,16 +5,17 @@ class PagesController < ApplicationController
def dashboard
@balance_sheet = Current.family.balance_sheet
@investment_statement = Current.family.investment_statement
@accounts = Current.family.accounts.visible.with_attached_logo
family_currency = Current.family.currency
# Use the same period for all widgets (set by Periodable concern)
# Use IncomeStatement for all cashflow data (now includes categorized trades)
income_totals = Current.family.income_statement.income_totals(period: @period)
expense_totals = Current.family.income_statement.expense_totals(period: @period)
@cashflow_sankey_data = build_cashflow_sankey_data(income_totals, expense_totals, family_currency)
@outflows_data = build_outflows_donut_data(expense_totals, family_currency)
@outflows_data = build_outflows_donut_data(expense_totals)
@dashboard_sections = build_dashboard_sections
@@ -81,6 +82,14 @@ class PagesController < ApplicationController
visible: Current.family.accounts.any? && @outflows_data[:categories].present?,
collapsible: true
},
{
key: "investment_summary",
title: "pages.dashboard.investment_summary.title",
partial: "pages/dashboard/investment_summary",
locals: { investment_statement: @investment_statement, period: @period },
visible: Current.family.accounts.any? && @investment_statement.investment_accounts.any?,
collapsible: true
},
{
key: "net_worth_chart",
title: "pages.dashboard.net_worth_chart.title",
@@ -117,12 +126,11 @@ class PagesController < ApplicationController
Provider::Registry.get_provider(:github)
end
def build_cashflow_sankey_data(income_totals, expense_totals, currency_symbol)
def build_cashflow_sankey_data(income_totals, expense_totals, currency)
nodes = []
links = []
node_indices = {} # Memoize node indices by a unique key: "type_categoryid"
node_indices = {}
# Helper to add/find node and return its index
add_node = ->(unique_key, display_name, value, percentage, color) {
node_indices[unique_key] ||= begin
nodes << { name: display_name, value: value.to_f.round(2), percentage: percentage.to_f.round(1), color: color }
@@ -130,93 +138,55 @@ class PagesController < ApplicationController
end
}
total_income_val = income_totals.total.to_f.round(2)
total_expense_val = expense_totals.total.to_f.round(2)
total_income = income_totals.total.to_f.round(2)
total_expense = expense_totals.total.to_f.round(2)
# --- Create Central Cash Flow Node ---
cash_flow_idx = add_node.call("cash_flow_node", "Cash Flow", total_income_val, 0, "var(--color-success)")
# Central Cash Flow node
cash_flow_idx = add_node.call("cash_flow_node", "Cash Flow", total_income, 100.0, "var(--color-success)")
# --- Process Income Side (Top-level categories only) ---
# Income side (top-level categories only)
income_totals.category_totals.each do |ct|
# Skip subcategories only include root income categories
next if ct.category.parent_id.present?
val = ct.total.to_f.round(2)
next if val.zero?
percentage_of_total_income = total_income_val.zero? ? 0 : (val / total_income_val * 100).round(1)
percentage = total_income.zero? ? 0 : (val / total_income * 100).round(1)
color = ct.category.color.presence || Category::COLORS.sample
node_display_name = ct.category.name
node_color = ct.category.color.presence || Category::COLORS.sample
current_cat_idx = add_node.call(
"income_#{ct.category.id}",
node_display_name,
val,
percentage_of_total_income,
node_color
)
links << {
source: current_cat_idx,
target: cash_flow_idx,
value: val,
color: node_color,
percentage: percentage_of_total_income
}
idx = add_node.call("income_#{ct.category.id}", ct.category.name, val, percentage, color)
links << { source: idx, target: cash_flow_idx, value: val, color: color, percentage: percentage }
end
# --- Process Expense Side (Top-level categories only) ---
# Expense side (top-level categories only)
expense_totals.category_totals.each do |ct|
# Skip subcategories only include root expense categories to keep Sankey shallow
next if ct.category.parent_id.present?
val = ct.total.to_f.round(2)
next if val.zero?
percentage_of_total_expense = total_expense_val.zero? ? 0 : (val / total_expense_val * 100).round(1)
percentage = total_expense.zero? ? 0 : (val / total_expense * 100).round(1)
color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR
node_display_name = ct.category.name
node_color = ct.category.color.presence || Category::UNCATEGORIZED_COLOR
current_cat_idx = add_node.call(
"expense_#{ct.category.id}",
node_display_name,
val,
percentage_of_total_expense,
node_color
)
links << {
source: cash_flow_idx,
target: current_cat_idx,
value: val,
color: node_color,
percentage: percentage_of_total_expense
}
idx = add_node.call("expense_#{ct.category.id}", ct.category.name, val, percentage, color)
links << { source: cash_flow_idx, target: idx, value: val, color: color, percentage: percentage }
end
# --- Process Surplus ---
leftover = (total_income_val - total_expense_val).round(2)
if leftover.positive?
percentage_of_total_income_for_surplus = total_income_val.zero? ? 0 : (leftover / total_income_val * 100).round(1)
surplus_idx = add_node.call("surplus_node", "Surplus", leftover, percentage_of_total_income_for_surplus, "var(--color-success)")
links << { source: cash_flow_idx, target: surplus_idx, value: leftover, color: "var(--color-success)", percentage: percentage_of_total_income_for_surplus }
# Surplus/Deficit
net = (total_income - total_expense).round(2)
if net.positive?
percentage = total_income.zero? ? 0 : (net / total_income * 100).round(1)
idx = add_node.call("surplus_node", "Surplus", net, percentage, "var(--color-success)")
links << { source: cash_flow_idx, target: idx, value: net, color: "var(--color-success)", percentage: percentage }
end
# Update Cash Flow and Income node percentages (relative to total income)
if node_indices["cash_flow_node"]
nodes[node_indices["cash_flow_node"]][:percentage] = 100.0
end
# No primary income node anymore, percentages are on individual income cats relative to total_income_val
{ nodes: nodes, links: links, currency_symbol: Money::Currency.new(currency_symbol).symbol }
{ nodes: nodes, links: links, currency_symbol: Money::Currency.new(currency).symbol }
end
def build_outflows_donut_data(expense_totals, family_currency)
def build_outflows_donut_data(expense_totals)
currency_symbol = Money::Currency.new(expense_totals.currency).symbol
total = expense_totals.total
# Only include top-level categories with non-zero amounts
categories = expense_totals.category_totals
.reject { |ct| ct.category.parent_id.present? || ct.total.zero? }
.sort_by { |ct| -ct.total }
@@ -232,6 +202,6 @@ class PagesController < ApplicationController
}
end
{ categories: categories, total: total.to_f.round(2), currency: family_currency, currency_symbol: Money::Currency.new(family_currency).symbol }
{ categories: categories, total: total.to_f.round(2), currency: expense_totals.currency, currency_symbol: currency_symbol }
end
end

View File

@@ -37,6 +37,9 @@ class ReportsController < ApplicationController
# Transactions breakdown
@transactions = build_transactions_breakdown
# Investment metrics (must be before build_reports_sections)
@investment_metrics = build_investment_metrics
# Build reports sections for collapsible/reorderable UI
@reports_sections = build_reports_sections
@@ -129,6 +132,14 @@ class ReportsController < ApplicationController
visible: Current.family.transactions.any?,
collapsible: true
},
{
key: "investment_performance",
title: "reports.investment_performance.title",
partial: "reports/investment_performance",
locals: { investment_metrics: @investment_metrics },
visible: @investment_metrics[:has_investments],
collapsible: true
},
{
key: "transactions_breakdown",
title: "reports.transactions_breakdown.title",
@@ -408,6 +419,25 @@ class ReportsController < ApplicationController
end
end
def build_investment_metrics
investment_statement = Current.family.investment_statement
investment_accounts = investment_statement.investment_accounts
return { has_investments: false } unless investment_accounts.any?
period_totals = investment_statement.totals(period: @period)
{
has_investments: true,
portfolio_value: investment_statement.portfolio_value_money,
unrealized_trend: investment_statement.unrealized_gains_trend,
period_contributions: period_totals.contributions,
period_withdrawals: period_totals.withdrawals,
top_holdings: investment_statement.top_holdings(limit: 5),
accounts: investment_accounts.to_a
}
end
def apply_transaction_filters(transactions)
# Filter by category (including subcategories)
if params[:filter_category_id].present?

View File

@@ -54,7 +54,7 @@ class TradesController < ApplicationController
def entry_params
params.require(:entry).permit(
:name, :date, :amount, :currency, :excluded, :notes, :nature,
entryable_attributes: [ :id, :qty, :price ]
entryable_attributes: [ :id, :qty, :price, :category_id ]
)
end

View File

@@ -1,5 +1,6 @@
class Category < ApplicationRecord
has_many :transactions, dependent: :nullify, class_name: "Transaction"
has_many :trades, dependent: :nullify
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
belongs_to :family
@@ -110,7 +111,8 @@ class Category < ApplicationRecord
[ "Loan Payments", "#e11d48", "credit-card", "expense" ],
[ "Services", "#7c3aed", "briefcase", "expense" ],
[ "Fees", "#6b7280", "receipt", "expense" ],
[ "Savings & Investments", "#059669", "piggy-bank", "expense" ]
[ "Savings & Investments", "#059669", "piggy-bank", "expense" ],
[ "Investment Contributions", "#0d9488", "trending-up", "expense" ]
]
end
end

View File

@@ -69,6 +69,10 @@ class Family < ApplicationRecord
@income_statement ||= IncomeStatement.new(self)
end
def investment_statement
@investment_statement ||= InvestmentStatement.new(self)
end
def eu?
country != "US" && country != "CA"
end

View File

@@ -28,32 +28,14 @@ class Holding < ApplicationRecord
end
# Basic approximation of cost-basis
# Uses pre-computed cost_basis if available (set during materialization),
# otherwise falls back to calculating from trades
def avg_cost
trades = account.trades
.with_entry
.joins(ActiveRecord::Base.sanitize_sql_array([
"LEFT JOIN exchange_rates ON (
exchange_rates.date = entries.date AND
exchange_rates.from_currency = trades.currency AND
exchange_rates.to_currency = ?
)", account.currency
]))
.where(security_id: security.id)
.where("trades.qty > 0 AND entries.date <= ?", date)
# Use stored cost_basis if available (eliminates N+1 queries)
return Money.new(cost_basis, currency) if cost_basis.present?
total_cost, total_qty = trades.pick(
Arel.sql("SUM(trades.price * trades.qty * COALESCE(exchange_rates.rate, 1))"),
Arel.sql("SUM(trades.qty)")
)
weighted_avg =
if total_qty && total_qty > 0
total_cost / total_qty
else
price
end
Money.new(weighted_avg || price, currency)
# Fallback to calculation for holdings without pre-computed cost_basis
calculate_avg_cost
end
def trend
@@ -100,4 +82,32 @@ class Holding < ApplicationRecord
current: amount_money,
previous: start_amount
end
def calculate_avg_cost
trades = account.trades
.with_entry
.joins(ActiveRecord::Base.sanitize_sql_array([
"LEFT JOIN exchange_rates ON (
exchange_rates.date = entries.date AND
exchange_rates.from_currency = trades.currency AND
exchange_rates.to_currency = ?
)", account.currency
]))
.where(security_id: security.id)
.where("trades.qty > 0 AND entries.date <= ?", date)
total_cost, total_qty = trades.pick(
Arel.sql("SUM(trades.price * trades.qty * COALESCE(exchange_rates.rate, 1))"),
Arel.sql("SUM(trades.qty)")
)
weighted_avg =
if total_qty && total_qty > 0
total_cost / total_qty
else
price
end
Money.new(weighted_avg || price, currency)
end
end

View File

@@ -3,6 +3,8 @@ class Holding::ForwardCalculator
def initialize(account)
@account = account
# Track cost basis per security: { security_id => { total_cost: BigDecimal, total_qty: BigDecimal } }
@cost_basis_tracker = Hash.new { |h, k| h[k] = { total_cost: BigDecimal("0"), total_qty: BigDecimal("0") } }
end
def calculate
@@ -13,6 +15,7 @@ class Holding::ForwardCalculator
account.start_date.upto(Date.current).each do |date|
trades = portfolio_cache.get_trades(date: date)
update_cost_basis_tracker(trades)
next_portfolio = transform_portfolio(current_portfolio, trades, direction: :forward)
holdings += build_holdings(next_portfolio, date)
current_portfolio = next_portfolio
@@ -65,8 +68,36 @@ class Holding::ForwardCalculator
qty: qty,
price: price.price,
currency: price.currency,
amount: qty * price.price
amount: qty * price.price,
cost_basis: cost_basis_for(security_id, price.currency)
)
end.compact
end
# Updates cost basis tracker with buy trades (qty > 0)
# Uses weighted average cost method
def update_cost_basis_tracker(trade_entries)
trade_entries.each do |trade_entry|
trade = trade_entry.entryable
next unless trade.qty > 0 # Only track buys
security_id = trade.security_id
tracker = @cost_basis_tracker[security_id]
# Convert trade price to account currency if needed
trade_price = Money.new(trade.price, trade.currency)
converted_price = trade_price.exchange_to(account.currency, fallback_rate: 1).amount
tracker[:total_cost] += converted_price * trade.qty
tracker[:total_qty] += trade.qty
end
end
# Returns the current cost basis for a security, or nil if no buys recorded
def cost_basis_for(security_id, currency)
tracker = @cost_basis_tracker[security_id]
return nil if tracker[:total_qty].zero?
tracker[:total_cost] / tracker[:total_qty]
end
end

View File

@@ -31,7 +31,7 @@ class Holding::Materializer
account.holdings.upsert_all(
@holdings.map { |h| h.attributes
.slice("date", "currency", "qty", "price", "amount", "security_id")
.slice("date", "currency", "qty", "price", "amount", "security_id", "cost_basis")
.merge("account_id" => account.id, "updated_at" => current_time) },
unique_by: %i[account_id security_id date currency]
)

View File

@@ -8,6 +8,7 @@ class Holding::ReverseCalculator
def calculate
Rails.logger.tagged("Holding::ReverseCalculator") do
precompute_cost_basis
holdings = calculate_holdings
Holding.gapfill(holdings)
end
@@ -69,8 +70,47 @@ class Holding::ReverseCalculator
qty: qty,
price: price.price,
currency: price.currency,
amount: qty * price.price
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

View File

@@ -11,10 +11,10 @@ class IncomeStatement
@family = family
end
def totals(transactions_scope: nil)
def totals(transactions_scope: nil, date_range:)
transactions_scope ||= family.transactions.visible
result = totals_query(transactions_scope: transactions_scope)
result = totals_query(transactions_scope: transactions_scope, date_range: date_range)
total_income = result.select { |t| t.classification == "income" }.sum(&:total)
total_expense = result.select { |t| t.classification == "expense" }.sum(&:total)
@@ -64,7 +64,7 @@ class IncomeStatement
end
def build_period_total(classification:, period:)
totals = totals_query(transactions_scope: family.transactions.visible.in_period(period)).select { |t| t.classification == classification }
totals = totals_query(transactions_scope: family.transactions.visible.in_period(period), date_range: period.date_range).select { |t| t.classification == classification }
classification_total = totals.sum(&:total)
uncategorized_category = family.categories.uncategorized
@@ -114,12 +114,12 @@ class IncomeStatement
]) { CategoryStats.new(family, interval:).call }
end
def totals_query(transactions_scope:)
def totals_query(transactions_scope:, date_range:)
sql_hash = Digest::MD5.hexdigest(transactions_scope.to_sql)
Rails.cache.fetch([
"income_statement", "totals_query", family.id, sql_hash, family.entries_cache_version
]) { Totals.new(family, transactions_scope: transactions_scope).call }
]) { Totals.new(family, transactions_scope: transactions_scope, date_range: date_range).call }
end
def monetizable_currency

View File

@@ -1,7 +1,11 @@
class IncomeStatement::Totals
def initialize(family, transactions_scope:)
def initialize(family, transactions_scope:, date_range:, include_trades: true)
@family = family
@transactions_scope = transactions_scope
@date_range = date_range
@include_trades = include_trades
validate_date_range!
end
def call
@@ -21,14 +25,31 @@ class IncomeStatement::Totals
def query_sql
ActiveRecord::Base.sanitize_sql_array([
optimized_query_sql,
@include_trades ? combined_query_sql : transactions_only_query_sql,
sql_params
])
end
# OPTIMIZED: Direct SUM aggregation without unnecessary time bucketing
# Eliminates CTE and intermediate date grouping for maximum performance
def optimized_query_sql
# Combined query that includes both transactions and trades
def combined_query_sql
<<~SQL
SELECT
category_id,
parent_category_id,
classification,
SUM(total) as total,
SUM(entry_count) as transactions_count
FROM (
#{transactions_subquery_sql}
UNION ALL
#{trades_subquery_sql}
) combined
GROUP BY category_id, parent_category_id, classification;
SQL
end
# Original transactions-only query (for backwards compatibility)
def transactions_only_query_sql
<<~SQL
SELECT
c.id as category_id,
@@ -38,6 +59,7 @@ class IncomeStatement::Totals
COUNT(ae.id) as transactions_count
FROM (#{@transactions_scope.to_sql}) at
JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'
JOIN accounts a ON a.id = ae.account_id
LEFT JOIN categories c ON c.id = at.category_id
LEFT JOIN exchange_rates er ON (
er.date = ae.date AND
@@ -46,13 +68,81 @@ class IncomeStatement::Totals
)
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
AND ae.excluded = false
AND a.family_id = :family_id
AND a.status IN ('draft', 'active')
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END;
SQL
end
def transactions_subquery_sql
<<~SQL
SELECT
c.id as category_id,
c.parent_id as parent_category_id,
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total,
COUNT(ae.id) as entry_count
FROM (#{@transactions_scope.to_sql}) at
JOIN entries ae ON ae.entryable_id = at.id AND ae.entryable_type = 'Transaction'
JOIN accounts a ON a.id = ae.account_id
LEFT JOIN categories c ON c.id = at.category_id
LEFT JOIN exchange_rates er ON (
er.date = ae.date AND
er.from_currency = ae.currency AND
er.to_currency = :target_currency
)
WHERE at.kind NOT IN ('funds_movement', 'one_time', 'cc_payment')
AND ae.excluded = false
AND a.family_id = :family_id
AND a.status IN ('draft', 'active')
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
SQL
end
def trades_subquery_sql
# Get trades for the same family and date range as transactions
# Only include trades that have a category assigned
<<~SQL
SELECT
c.id as category_id,
c.parent_id as parent_category_id,
CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END as classification,
ABS(SUM(ae.amount * COALESCE(er.rate, 1))) as total,
COUNT(ae.id) as entry_count
FROM trades t
JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Trade'
JOIN accounts a ON a.id = ae.account_id
LEFT JOIN categories c ON c.id = t.category_id
LEFT JOIN exchange_rates er ON (
er.date = ae.date AND
er.from_currency = ae.currency AND
er.to_currency = :target_currency
)
WHERE a.family_id = :family_id
AND a.status IN ('draft', 'active')
AND ae.excluded = false
AND ae.date BETWEEN :start_date AND :end_date
AND t.category_id IS NOT NULL
GROUP BY c.id, c.parent_id, CASE WHEN ae.amount < 0 THEN 'income' ELSE 'expense' END
SQL
end
def sql_params
{
target_currency: @family.currency
target_currency: @family.currency,
family_id: @family.id,
start_date: @date_range.begin,
end_date: @date_range.end
}
end
def validate_date_range!
unless @date_range.is_a?(Range)
raise ArgumentError, "date_range must be a Range, got #{@date_range.class}"
end
unless @date_range.begin.respond_to?(:to_date) && @date_range.end.respond_to?(:to_date)
raise ArgumentError, "date_range must contain date-like objects"
end
end
end

View File

@@ -0,0 +1,191 @@
require "digest/md5"
class InvestmentStatement
include Monetizable
monetize :total_contributions, :total_dividends, :total_interest, :unrealized_gains
attr_reader :family
def initialize(family)
@family = family
end
# Get totals for a specific period
def totals(period: Period.current_month)
trades_in_period = family.trades
.joins(:entry)
.where(entries: { date: period.date_range })
result = totals_query(trades_scope: trades_in_period)
PeriodTotals.new(
contributions: Money.new(result[:contributions], family.currency),
withdrawals: Money.new(result[:withdrawals], family.currency),
dividends: Money.new(result[:dividends], family.currency),
interest: Money.new(result[:interest], family.currency),
trades_count: result[:trades_count],
currency: family.currency
)
end
# Net contributions (contributions - withdrawals)
def net_contributions(period: Period.current_month)
t = totals(period: period)
t.contributions - t.withdrawals
end
# Total portfolio value across all investment accounts
def portfolio_value
investment_accounts.sum(&:balance)
end
def portfolio_value_money
Money.new(portfolio_value, family.currency)
end
# Total cash in investment accounts
def cash_balance
investment_accounts.sum(&:cash_balance)
end
def cash_balance_money
Money.new(cash_balance, family.currency)
end
# Total holdings value
def holdings_value
portfolio_value - cash_balance
end
def holdings_value_money
Money.new(holdings_value, family.currency)
end
# All current holdings across investment accounts
def current_holdings
return Holding.none unless investment_accounts.any?
account_ids = investment_accounts.pluck(:id)
# Get the latest holding for each security per account
Holding
.where(account_id: account_ids)
.where(currency: family.currency)
.where.not(qty: 0)
.where(
id: Holding
.where(account_id: account_ids)
.where(currency: family.currency)
.select("DISTINCT ON (holdings.account_id, holdings.security_id) holdings.id")
.order(Arel.sql("holdings.account_id, holdings.security_id, holdings.date DESC"))
)
.includes(:security, :account)
.order(amount: :desc)
end
# Top holdings by value
def top_holdings(limit: 5)
current_holdings.limit(limit)
end
# Portfolio allocation by security type/sector (simplified for now)
def allocation
holdings = current_holdings.to_a
total = holdings.sum(&:amount)
return [] if total.zero?
holdings.map do |holding|
HoldingAllocation.new(
security: holding.security,
amount: holding.amount_money,
weight: (holding.amount / total * 100).round(2),
trend: holding.trend
)
end
end
# Unrealized gains across all holdings
def unrealized_gains
current_holdings.sum do |holding|
trend = holding.trend
trend ? trend.value : 0
end
end
# Total contributions (all time) - returns numeric for monetize
def total_contributions
all_time_totals.contributions&.amount || 0
end
# Total dividends (all time) - returns numeric for monetize
def total_dividends
all_time_totals.dividends&.amount || 0
end
# Total interest (all time) - returns numeric for monetize
def total_interest
all_time_totals.interest&.amount || 0
end
def unrealized_gains_trend
holdings = current_holdings.to_a
return nil if holdings.empty?
current = holdings.sum(&:amount)
previous = holdings.sum { |h| h.qty * h.avg_cost.amount }
Trend.new(current: current, previous: previous)
end
# Day change across portfolio
def day_change
holdings = current_holdings.to_a
changes = holdings.map(&:day_change).compact
return nil if changes.empty?
current = changes.sum { |t| t.current.is_a?(Money) ? t.current.amount : t.current }
previous = changes.sum { |t| t.previous.is_a?(Money) ? t.previous.amount : t.previous }
Trend.new(
current: Money.new(current, family.currency),
previous: Money.new(previous, family.currency)
)
end
# Investment accounts
def investment_accounts
@investment_accounts ||= family.accounts.visible.where(accountable_type: %w[Investment Crypto])
end
private
def all_time_totals
@all_time_totals ||= totals(period: Period.all_time)
end
PeriodTotals = Data.define(:contributions, :withdrawals, :dividends, :interest, :trades_count, :currency) do
def net_flow
contributions - withdrawals
end
def total_income
dividends + interest
end
end
HoldingAllocation = Data.define(:security, :amount, :weight, :trend)
def totals_query(trades_scope:)
sql_hash = Digest::MD5.hexdigest(trades_scope.to_sql)
Rails.cache.fetch([
"investment_statement", "totals_query", family.id, sql_hash, family.entries_cache_version
]) { Totals.new(family, trades_scope: trades_scope).call }
end
def monetizable_currency
family.currency
end
end

View File

@@ -0,0 +1,56 @@
class InvestmentStatement::Totals
def initialize(family, trades_scope:)
@family = family
@trades_scope = trades_scope
end
def call
result = ActiveRecord::Base.connection.select_one(query_sql)
{
contributions: result["contributions"]&.to_d || 0,
withdrawals: result["withdrawals"]&.to_d || 0,
dividends: 0, # Dividends come through as transactions, not trades
interest: 0, # Interest comes through as transactions, not trades
trades_count: result["trades_count"]&.to_i || 0
}
end
private
def query_sql
ActiveRecord::Base.sanitize_sql_array([
aggregation_sql,
sql_params
])
end
# Aggregate trades by direction (buy vs sell)
# Buys (qty > 0) = contributions (cash going out to buy securities)
# Sells (qty < 0) = withdrawals (cash coming in from selling securities)
def aggregation_sql
<<~SQL
SELECT
COALESCE(SUM(CASE WHEN t.qty > 0 THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as contributions,
COALESCE(SUM(CASE WHEN t.qty < 0 THEN ABS(ae.amount * COALESCE(er.rate, 1)) ELSE 0 END), 0) as withdrawals,
COUNT(t.id) as trades_count
FROM (#{@trades_scope.to_sql}) t
JOIN entries ae ON ae.entryable_id = t.id AND ae.entryable_type = 'Trade'
JOIN accounts a ON a.id = ae.account_id
LEFT JOIN exchange_rates er ON (
er.date = ae.date AND
er.from_currency = ae.currency AND
er.to_currency = :target_currency
)
WHERE a.family_id = :family_id
AND a.status IN ('draft', 'active')
AND ae.excluded = false
SQL
end
def sql_params
{
family_id: @family.id,
target_currency: @family.currency
}
end
end

View File

@@ -4,10 +4,20 @@ class Trade < ApplicationRecord
monetize :price
belongs_to :security
belongs_to :category, optional: true
validates :qty, presence: true
validates :price, :currency, presence: true
# 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"

View File

@@ -41,7 +41,8 @@ class Trade::CreateForm
qty: signed_qty,
price: price,
currency: currency,
security: security
security: security,
category: investment_category_for(type)
)
)
@@ -53,6 +54,14 @@ class Trade::CreateForm
trade_entry
end
def investment_category_for(trade_type)
# Buy trades are categorized as "Savings & Investments" (expense)
# Sell trades are left uncategorized for now
return nil unless trade_type == "buy"
account.family.categories.find_by(name: "Savings & Investments")
end
def create_interest_income
signed_amount = amount.to_d * -1

View File

@@ -21,6 +21,7 @@ class TradeImport < Import
qty: row.qty,
currency: row.currency.presence || mapped_account.currency,
price: row.price,
category: investment_category_for(row.qty, mapped_account.family),
entry: Entry.new(
account: mapped_account,
date: row.date_iso,
@@ -76,6 +77,14 @@ class TradeImport < Import
end
private
def investment_category_for(qty, family)
# Buy trades (positive qty) are categorized as "Savings & Investments"
# Sell trades are left uncategorized - users will be prompted to categorize
return nil unless qty.to_d.positive?
family.categories.find_by(name: "Savings & Investments")
end
def find_or_create_security(ticker: nil, exchange_operating_mic: nil)
return nil unless ticker.present?

View File

@@ -46,6 +46,9 @@
data-action="
dragstart->dashboard-sortable#dragStart
dragend->dashboard-sortable#dragEnd
touchstart->dashboard-sortable#touchStart
touchmove->dashboard-sortable#touchMove
touchend->dashboard-sortable#touchEnd
keydown->dashboard-sortable#handleKeyDown">
<div class="px-4 py-2 flex items-center justify-between">
<div class="flex items-center gap-2">
@@ -64,12 +67,7 @@
</div>
<button
type="button"
class="cursor-grab active:cursor-grabbing text-secondary hover:text-primary transition-colors p-1.5 -m-1 touch-none"
data-dashboard-sortable-target="handle"
data-action="
touchstart->dashboard-sortable#touchStart
touchmove->dashboard-sortable#touchMove
touchend->dashboard-sortable#touchEnd"
class="cursor-grab active:cursor-grabbing text-secondary hover:text-primary transition-colors p-0.5 opacity-0 group-hover:opacity-100"
aria-label="<%= t("pages.dashboard.drag_to_reorder") %>">
<%= icon("grip-vertical", size: "sm") %>
</button>

View File

@@ -0,0 +1,100 @@
<%# locals: (investment_statement:, period:, **args) %>
<% if investment_statement.investment_accounts.any? %>
<div id="investment-summary" class="space-y-4">
<div class="flex justify-between gap-4 px-4">
<div class="space-y-2">
<div class="flex items-center gap-2">
<h2 class="text-lg font-medium"><%= t(".title") %></h2>
</div>
<p class="text-primary text-3xl font-medium">
<%= format_money(investment_statement.portfolio_value_money) %>
</p>
<% trend = investment_statement.unrealized_gains_trend %>
<% if trend %>
<div class="flex items-center gap-2 text-sm">
<span class="text-secondary"><%= t(".total_return") %>:</span>
<span class="font-medium" style="color: <%= trend.color %>">
<%= format_money(Money.new(trend.value, Current.family.currency)) %>
(<%= trend.percent_formatted %>)
</span>
</div>
<% end %>
</div>
</div>
<% holdings = investment_statement.top_holdings(limit: 5) %>
<% if holdings.any? %>
<div class="bg-container-inset rounded-xl p-1 mx-4">
<div class="px-4 py-2 flex items-center uppercase text-xs font-medium text-secondary">
<div class="flex-1"><%= t(".holding") %></div>
<div class="w-20 text-right"><%= t(".weight") %></div>
<div class="w-28 text-right"><%= t(".value") %></div>
<div class="w-24 text-right"><%= t(".return") %></div>
</div>
<div class="shadow-border-xs rounded-lg bg-container font-medium text-sm">
<% holdings.each_with_index do |holding, idx| %>
<div class="p-4 flex items-center <%= idx < holdings.size - 1 ? 'border-b border-primary' : '' %>">
<div class="flex-1 flex items-center gap-3">
<% if holding.security.logo_url.present? %>
<img src="<%= holding.security.logo_url %>" alt="<%= holding.ticker %>" class="w-8 h-8 rounded-full">
<% else %>
<div class="w-8 h-8 rounded-full bg-container-inset flex items-center justify-center text-xs font-medium text-secondary">
<%= holding.ticker[0..1] %>
</div>
<% end %>
<div>
<p class="font-medium"><%= holding.ticker %></p>
<p class="text-xs text-secondary"><%= truncate(holding.name, length: 20) %></p>
</div>
</div>
<div class="w-20 text-right text-secondary">
<%= number_to_percentage(holding.weight || 0, precision: 1) %>
</div>
<div class="w-28 text-right">
<%= format_money(holding.amount_money) %>
</div>
<div class="w-24 text-right">
<% if holding.trend %>
<span style="color: <%= holding.trend.color %>">
<%= holding.trend.percent_formatted %>
</span>
<% else %>
<span class="text-secondary">-</span>
<% end %>
</div>
</div>
<% end %>
</div>
</div>
<% end %>
<%# Investment Activity Summary %>
<% totals = investment_statement.totals(period: period) %>
<% if totals.trades_count > 0 %>
<div class="px-4 pt-2">
<p class="text-xs text-secondary uppercase font-medium mb-2"><%= t(".period_activity", period: period.label) %></p>
<div class="flex gap-4 text-sm">
<div>
<span class="text-secondary"><%= t(".contributions") %>:</span>
<span class="font-medium text-primary"><%= format_money(totals.contributions) %></span>
</div>
<div>
<span class="text-secondary"><%= t(".withdrawals") %>:</span>
<span class="font-medium text-primary"><%= format_money(totals.withdrawals) %></span>
</div>
<div>
<span class="text-secondary"><%= t(".trades") %>:</span>
<span class="font-medium text-primary"><%= totals.trades_count %></span>
</div>
</div>
</div>
<% end %>
</div>
<% end %>

View File

@@ -0,0 +1,137 @@
<%# locals: (investment_metrics:) %>
<% if investment_metrics[:has_investments] %>
<div class="space-y-6">
<h3 class="text-lg font-medium text-primary"><%= t("reports.investment_performance.title") %></h3>
<%# Investment Summary Cards %>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<%# Portfolio Value Card %>
<div class="bg-container-inset rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<%= icon("briefcase", class: "w-4 h-4 text-secondary") %>
<span class="text-sm text-secondary"><%= t("reports.investment_performance.portfolio_value") %></span>
</div>
<p class="text-xl font-semibold text-primary">
<%= format_money(investment_metrics[:portfolio_value]) %>
</p>
</div>
<%# Total Return Card %>
<div class="bg-container-inset rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<%= icon("trending-up", class: "w-4 h-4 text-secondary") %>
<span class="text-sm text-secondary"><%= t("reports.investment_performance.total_return") %></span>
</div>
<% if investment_metrics[:unrealized_trend] %>
<p class="text-xl font-semibold" style="color: <%= investment_metrics[:unrealized_trend].color %>">
<%= format_money(Money.new(investment_metrics[:unrealized_trend].value, Current.family.currency)) %>
(<%= investment_metrics[:unrealized_trend].percent_formatted %>)
</p>
<% else %>
<p class="text-xl font-semibold text-secondary">-</p>
<% end %>
</div>
<%# Period Contributions Card %>
<div class="bg-container-inset rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<%= icon("arrow-down-to-line", class: "w-4 h-4 text-secondary") %>
<span class="text-sm text-secondary"><%= t("reports.investment_performance.contributions") %></span>
</div>
<p class="text-xl font-semibold text-primary">
<%= format_money(investment_metrics[:period_contributions]) %>
</p>
</div>
<%# Period Withdrawals Card %>
<div class="bg-container-inset rounded-lg p-4">
<div class="flex items-center gap-2 mb-2">
<%= icon("arrow-up-from-line", class: "w-4 h-4 text-secondary") %>
<span class="text-sm text-secondary"><%= t("reports.investment_performance.withdrawals") %></span>
</div>
<p class="text-xl font-semibold text-primary">
<%= format_money(investment_metrics[:period_withdrawals]) %>
</p>
</div>
</div>
<%# Top Holdings Table %>
<% if investment_metrics[:top_holdings].any? %>
<div class="space-y-3">
<h4 class="text-sm font-medium text-secondary uppercase"><%= t("reports.investment_performance.top_holdings") %></h4>
<div class="bg-container-inset rounded-lg overflow-hidden">
<table class="w-full text-sm">
<thead class="bg-container">
<tr class="text-left text-secondary uppercase text-xs">
<th class="px-4 py-3 font-medium"><%= t("reports.investment_performance.holding") %></th>
<th class="px-4 py-3 font-medium text-right"><%= t("reports.investment_performance.weight") %></th>
<th class="px-4 py-3 font-medium text-right"><%= t("reports.investment_performance.value") %></th>
<th class="px-4 py-3 font-medium text-right"><%= t("reports.investment_performance.return") %></th>
</tr>
</thead>
<tbody class="divide-y divide-primary">
<% investment_metrics[:top_holdings].each do |holding| %>
<tr>
<td class="px-4 py-3">
<div class="flex items-center gap-3">
<% if holding.security.logo_url.present? %>
<img src="<%= holding.security.logo_url %>" alt="<%= holding.ticker %>" class="w-6 h-6 rounded-full">
<% else %>
<div class="w-6 h-6 rounded-full bg-container flex items-center justify-center text-xs font-medium text-secondary">
<%= holding.ticker[0..1] %>
</div>
<% end %>
<div>
<p class="font-medium text-primary"><%= holding.ticker %></p>
<p class="text-xs text-secondary"><%= truncate(holding.name, length: 25) %></p>
</div>
</div>
</td>
<td class="px-4 py-3 text-right text-secondary">
<%= number_to_percentage(holding.weight || 0, precision: 1) %>
</td>
<td class="px-4 py-3 text-right font-medium text-primary">
<%= format_money(holding.amount_money) %>
</td>
<td class="px-4 py-3 text-right">
<% if holding.trend %>
<span style="color: <%= holding.trend.color %>">
<%= holding.trend.percent_formatted %>
</span>
<% else %>
<span class="text-secondary">-</span>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>
<% end %>
<%# Investment Accounts Summary %>
<% if investment_metrics[:accounts].any? %>
<div class="space-y-3">
<h4 class="text-sm font-medium text-secondary uppercase"><%= t("reports.investment_performance.accounts") %></h4>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
<% investment_metrics[:accounts].each do |account| %>
<div class="bg-container-inset rounded-lg p-4 flex items-center justify-between">
<div class="flex items-center gap-3">
<%= render "accounts/logo", account: account, size: "sm" %>
<div>
<p class="font-medium text-primary text-sm"><%= account.name %></p>
<p class="text-xs text-secondary"><%= account.short_subtype_label %></p>
</div>
</div>
<p class="font-medium text-primary"><%= format_money(account.balance_money) %></p>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
<% end %>

View File

@@ -211,6 +211,9 @@
data-action="
dragstart->reports-sortable#dragStart
dragend->reports-sortable#dragEnd
touchstart->reports-sortable#touchStart
touchmove->reports-sortable#touchMove
touchend->reports-sortable#touchEnd
keydown->reports-sortable#handleKeyDown">
<div class="px-4 py-2 flex items-center justify-between">
<div class="flex items-center gap-2">
@@ -229,12 +232,7 @@
</div>
<button
type="button"
class="cursor-grab active:cursor-grabbing text-secondary hover:text-primary transition-colors p-1.5 -m-1 touch-none"
data-reports-sortable-target="handle"
data-action="
touchstart->reports-sortable#touchStart
touchmove->reports-sortable#touchMove
touchend->reports-sortable#touchEnd"
class="cursor-grab active:cursor-grabbing text-secondary hover:text-primary transition-colors p-0.5 opacity-0 group-hover:opacity-100"
aria-label="<%= t("reports.index.drag_to_reorder") %>">
<%= icon("grip-vertical", size: "sm") %>
</button>

View File

@@ -30,7 +30,7 @@
</div>
<div class="col-span-2 flex items-center">
<%= render "categories/badge", category: trade_category %>
<%= render "categories/badge", category: trade.category || trade_category %>
</div>
<div class="col-span-2 justify-self-end font-medium text-sm">

View File

@@ -43,6 +43,11 @@
step: "any",
precision: 10,
disabled: @entry.linked? %>
<%= ef.select :category_id,
Current.family.categories.expenses.alphabetically.map { |c| [c.name, c.id] },
{ include_blank: t(".no_category"), label: t(".category_label") },
{ data: { "auto-submit-form-target": "auto" } } %>
<% end %>
<% end %>
</div>

View File

@@ -35,3 +35,16 @@ en:
categories: "Categories"
value: "Value"
weight: "Weight"
investment_summary:
title: "Investments"
total_return: "Total Return"
holding: "Holding"
weight: "Weight"
value: "Value"
return: "Return"
period_activity: "%{period} Activity"
contributions: "Contributions"
withdrawals: "Withdrawals"
trades: "Trades"
no_investments: "No investment accounts"
add_investment: "Add an investment account to track your portfolio"

View File

@@ -108,6 +108,18 @@ en:
showing: Showing %{count} transactions
previous: Previous
next: Next
investment_performance:
title: Investment Performance
portfolio_value: Portfolio Value
total_return: Total Return
contributions: Period Contributions
withdrawals: Period Withdrawals
top_holdings: Top Holdings
holding: Holding
weight: Weight
value: Value
return: Return
accounts: Investment Accounts
google_sheets_instructions:
title_with_key: "✅ Copy URL for Google Sheets"
title_no_key: "⚠️ API Key Required"

View File

@@ -24,6 +24,7 @@ en:
title: New transaction
show:
additional: Additional
category_label: Category
cost_per_share_label: Cost per Share
date_label: Date
delete: Delete
@@ -32,6 +33,7 @@ en:
details: Details
exclude_subtitle: This trade will not be included in reports and calculations
exclude_title: Exclude from analytics
no_category: No category
note_label: Note
note_placeholder: Add any additional notes here...
quantity_label: Quantity

View File

@@ -0,0 +1,7 @@
class AddCategoryToTrades < ActiveRecord::Migration[7.2]
def change
unless column_exists?(:trades, :category_id)
add_reference :trades, :category, null: true, foreign_key: true, type: :uuid
end
end
end

View File

@@ -84,7 +84,8 @@ class CategoriesControllerTest < ActionDispatch::IntegrationTest
end
test "bootstrap" do
assert_difference "Category.count", 19 do
# 22 default categories minus 2 that already exist in fixtures (Income, Food & Drink)
assert_difference "Category.count", 20 do
post bootstrap_categories_url
end

View File

@@ -22,9 +22,10 @@ class IncomeStatementTest < ActiveSupport::TestCase
test "calculates totals for transactions" do
income_statement = IncomeStatement.new(@family)
assert_equal Money.new(1000, @family.currency), income_statement.totals.income_money
assert_equal Money.new(200 + 300 + 400, @family.currency), income_statement.totals.expense_money
assert_equal 4, income_statement.totals.transactions_count
totals = income_statement.totals(date_range: Period.last_30_days.date_range)
assert_equal Money.new(1000, @family.currency), totals.income_money
assert_equal Money.new(200 + 300 + 400, @family.currency), totals.expense_money
assert_equal 4, totals.transactions_count
end
test "calculates expenses for a period" do
@@ -157,7 +158,7 @@ class IncomeStatementTest < ActiveSupport::TestCase
inflow_transaction = create_transaction(account: @credit_card_account, amount: -500, kind: "funds_movement")
income_statement = IncomeStatement.new(@family)
totals = income_statement.totals
totals = income_statement.totals(date_range: Period.last_30_days.date_range)
# NOW WORKING: Excludes transfers correctly after refactoring
assert_equal 4, totals.transactions_count # Only original 4 transactions
@@ -170,7 +171,7 @@ class IncomeStatementTest < ActiveSupport::TestCase
loan_payment = create_transaction(account: @checking_account, amount: 1000, category: nil, kind: "loan_payment")
income_statement = IncomeStatement.new(@family)
totals = income_statement.totals
totals = income_statement.totals(date_range: Period.last_30_days.date_range)
# CONTINUES TO WORK: Includes loan payments as expenses (loan_payment not in exclusion list)
assert_equal 5, totals.transactions_count
@@ -183,7 +184,7 @@ class IncomeStatementTest < ActiveSupport::TestCase
one_time_transaction = create_transaction(account: @checking_account, amount: 250, category: @groceries_category, kind: "one_time")
income_statement = IncomeStatement.new(@family)
totals = income_statement.totals
totals = income_statement.totals(date_range: Period.last_30_days.date_range)
# NOW WORKING: Excludes one-time transactions correctly after refactoring
assert_equal 4, totals.transactions_count # Only original 4 transactions
@@ -196,7 +197,7 @@ class IncomeStatementTest < ActiveSupport::TestCase
payment_transaction = create_transaction(account: @checking_account, amount: 300, category: nil, kind: "cc_payment")
income_statement = IncomeStatement.new(@family)
totals = income_statement.totals
totals = income_statement.totals(date_range: Period.last_30_days.date_range)
# NOW WORKING: Excludes payment transactions correctly after refactoring
assert_equal 4, totals.transactions_count # Only original 4 transactions
@@ -210,7 +211,7 @@ class IncomeStatementTest < ActiveSupport::TestCase
excluded_transaction_entry.update!(excluded: true)
income_statement = IncomeStatement.new(@family)
totals = income_statement.totals
totals = income_statement.totals(date_range: Period.last_30_days.date_range)
# Should exclude excluded transactions
assert_equal 4, totals.transactions_count # Only original 4 transactions
@@ -278,7 +279,7 @@ class IncomeStatementTest < ActiveSupport::TestCase
create_transaction(account: @checking_account, amount: 150, category: nil)
income_statement = IncomeStatement.new(@family)
totals = income_statement.totals
totals = income_statement.totals(date_range: Period.last_30_days.date_range)
# Should still include uncategorized transaction in totals
assert_equal 5, totals.transactions_count

View File

@@ -62,4 +62,54 @@ class TradeImportTest < ActiveSupport::TestCase
assert_equal "complete", @import.status
end
test "auto-categorizes buy trades and leaves sell trades uncategorized" do
aapl = securities(:aapl)
aapl_resolver = mock
aapl_resolver.stubs(:resolve).returns(aapl)
Security::Resolver.stubs(:new).returns(aapl_resolver)
# Create the investment category if it doesn't exist
account = accounts(:depository)
family = account.family
savings_category = family.categories.find_or_create_by!(name: "Savings & Investments") do |c|
c.color = "#059669"
c.classification = "expense"
c.lucide_icon = "piggy-bank"
end
import = <<~CSV
date,ticker,qty,price,currency,name
01/01/2024,AAPL,10,150.00,USD,Apple Buy
01/02/2024,AAPL,-5,160.00,USD,Apple Sell
CSV
@import.update!(
account: account,
raw_file_str: import,
date_col_label: "date",
ticker_col_label: "ticker",
qty_col_label: "qty",
price_col_label: "price",
date_format: "%m/%d/%Y",
signage_convention: "inflows_positive"
)
@import.generate_rows_from_csv
@import.reload
assert_difference -> { Trade.count } => 2 do
@import.publish
end
# Find trades created by this import
imported_trades = Trade.joins(:entry).where(entries: { import_id: @import.id })
buy_trade = imported_trades.find { |t| t.qty.positive? }
sell_trade = imported_trades.find { |t| t.qty.negative? }
assert_not_nil buy_trade, "Buy trade should have been created"
assert_not_nil sell_trade, "Sell trade should have been created"
assert_equal savings_category, buy_trade.category, "Buy trade should be auto-categorized as Savings & Investments"
assert_nil sell_trade.category, "Sell trade should not be auto-categorized"
end
end