-
+
<%= lucide_icon("x", class: "w-5 h-5 shrink-0") %>
diff --git a/app/views/shared/_drawer.html.erb b/app/views/shared/_drawer.html.erb
index 35e225c2..4da7ccb0 100644
--- a/app/views/shared/_drawer.html.erb
+++ b/app/views/shared/_drawer.html.erb
@@ -3,11 +3,11 @@
<%= turbo_frame_tag "drawer" do %>
diff --git a/app/views/accounts/show/_chart.html.erb b/app/views/accounts/show/_chart.html.erb
index d7427762..c78a3676 100644
--- a/app/views/accounts/show/_chart.html.erb
+++ b/app/views/accounts/show/_chart.html.erb
@@ -1,6 +1,6 @@
<%# locals: (account:, title: nil, tooltip: nil, **args) %>
-<% period = params[:period] ? Period.from_key(params[:period]) : Period.last_30_days %>
+<% period = @period || Period.last_30_days %>
<% default_value_title = account.asset? ? t(".balance") : t(".owed") %>
diff --git a/app/views/settings/preferences/show.html.erb b/app/views/settings/preferences/show.html.erb
index c51444dc..e053d969 100644
--- a/app/views/settings/preferences/show.html.erb
+++ b/app/views/settings/preferences/show.html.erb
@@ -25,6 +25,11 @@
{ label: t(".date_format") },
{ data: { auto_submit_form_target: "auto" } } %>
+ <%= form.select :default_period,
+ Period.all.map { |period| [ period.label, period.key ] },
+ { label: t(".default_period") },
+ { data: { auto_submit_form_target: "auto" } } %>
+
<%= family_form.select :country,
country_options,
{ label: t(".country") },
diff --git a/config/locales/views/settings/en.yml b/config/locales/views/settings/en.yml
index 8596904e..8bc0dbf0 100644
--- a/config/locales/views/settings/en.yml
+++ b/config/locales/views/settings/en.yml
@@ -20,6 +20,7 @@ en:
date_format: Date format
general_subtitle: Configure your preferences
general_title: General
+ default_period: Default Period
language: Language
page_title: Preferences
theme_dark: Dark
diff --git a/db/migrate/20250304140435_add_default_period_to_users.rb b/db/migrate/20250304140435_add_default_period_to_users.rb
new file mode 100644
index 00000000..ee2f5e2f
--- /dev/null
+++ b/db/migrate/20250304140435_add_default_period_to_users.rb
@@ -0,0 +1,5 @@
+class AddDefaultPeriodToUsers < ActiveRecord::Migration[7.2]
+ def change
+ add_column :users, :default_period, :string, default: "last_30_days", null: false
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index 492edb1e..59eabedf 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2025_03_03_141007) do
+ActiveRecord::Schema[7.2].define(version: 2025_03_04_140435) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -675,6 +675,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_03_03_141007) do
t.boolean "otp_required", default: false, null: false
t.string "otp_backup_codes", default: [], array: true
t.boolean "show_sidebar", default: true
+ t.string "default_period", default: "last_30_days", null: false
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["family_id"], name: "index_users_on_family_id"
t.index ["otp_secret"], name: "index_users_on_otp_secret", unique: true, where: "(otp_secret IS NOT NULL)"
From eac5d5e6630db73ef97e9a0bbaa994a1ea79bfca Mon Sep 17 00:00:00 2001
From: Zach Gollwitzer
Date: Fri, 7 Mar 2025 17:35:55 -0500
Subject: [PATCH 36/47] Populate holdings for "offline" securities properly
(#1958)
* Placeholder logic for missing prices
* Generate holdings properly for "offline" securities
* Separate forward and reverse calculators for holdings and balances
* Remove unnecessary currency conversion during sync
* Clearer sync process
* Move price caching logic to dedicated model
* Base holding calculator
* Base calculator for balances
* Finish balance calculators
* Better naming
* Logs cleanup
* Remove stale data type
* Remove stale test
* Fix price lookup logic for holdings sync
* Fix Plaid item sync regression
* Remove temp logging
* Calculate cash and holdings series
* Add holdings, cash, and balance series dropdown for investments
---
app/controllers/accounts_controller.rb | 1 +
.../concerns/accountable_resource.rb | 1 +
app/controllers/concerns/auto_sync.rb | 1 +
app/models/account.rb | 33 +--
app/models/account/balance/base_calculator.rb | 35 ++++
.../account/balance/forward_calculator.rb | 28 +++
.../account/balance/reverse_calculator.rb | 32 +++
app/models/account/balance/sync_cache.rb | 46 +++++
app/models/account/balance/syncer.rb | 69 +++++++
app/models/account/balance_calculator.rb | 124 ------------
app/models/account/chartable.rb | 59 ++++--
app/models/account/enrichable.rb | 12 ++
app/models/account/holding.rb | 2 +-
app/models/account/holding/base_calculator.rb | 63 ++++++
.../account/holding/forward_calculator.rb | 21 ++
app/models/account/holding/gapfillable.rb | 38 ++++
app/models/account/holding/portfolio_cache.rb | 132 ++++++++++++
.../account/holding/reverse_calculator.rb | 38 ++++
app/models/account/holding/syncer.rb | 58 ++++++
app/models/account/holding_calculator.rb | 188 ------------------
app/models/account/linkable.rb | 18 ++
app/models/account/syncer.rb | 162 ---------------
app/models/plaid_item.rb | 5 +
app/views/accounts/chart.html.erb | 2 +-
app/views/accounts/show/_chart.html.erb | 19 +-
app/views/investments/show.html.erb | 1 +
.../balance/forward_calculator_test.rb | 74 +++++++
.../balance/reverse_calculator_test.rb | 59 ++++++
test/models/account/balance/syncer_test.rb | 51 +++++
.../models/account/balance_calculator_test.rb | 156 ---------------
.../holding/forward_calculator_test.rb | 146 ++++++++++++++
.../account/holding/portfolio_cache_test.rb | 63 ++++++
.../reverse_calculator_test.rb} | 86 +-------
test/models/account/holding/syncer_test.rb | 29 +++
test/models/account/syncer_test.rb | 65 ------
35 files changed, 1109 insertions(+), 808 deletions(-)
create mode 100644 app/models/account/balance/base_calculator.rb
create mode 100644 app/models/account/balance/forward_calculator.rb
create mode 100644 app/models/account/balance/reverse_calculator.rb
create mode 100644 app/models/account/balance/sync_cache.rb
create mode 100644 app/models/account/balance/syncer.rb
delete mode 100644 app/models/account/balance_calculator.rb
create mode 100644 app/models/account/enrichable.rb
create mode 100644 app/models/account/holding/base_calculator.rb
create mode 100644 app/models/account/holding/forward_calculator.rb
create mode 100644 app/models/account/holding/gapfillable.rb
create mode 100644 app/models/account/holding/portfolio_cache.rb
create mode 100644 app/models/account/holding/reverse_calculator.rb
create mode 100644 app/models/account/holding/syncer.rb
delete mode 100644 app/models/account/holding_calculator.rb
create mode 100644 app/models/account/linkable.rb
delete mode 100644 app/models/account/syncer.rb
create mode 100644 test/models/account/balance/forward_calculator_test.rb
create mode 100644 test/models/account/balance/reverse_calculator_test.rb
create mode 100644 test/models/account/balance/syncer_test.rb
delete mode 100644 test/models/account/balance_calculator_test.rb
create mode 100644 test/models/account/holding/forward_calculator_test.rb
create mode 100644 test/models/account/holding/portfolio_cache_test.rb
rename test/models/account/{holding_calculator_test.rb => holding/reverse_calculator_test.rb} (61%)
create mode 100644 test/models/account/holding/syncer_test.rb
delete mode 100644 test/models/account/syncer_test.rb
diff --git a/app/controllers/accounts_controller.rb b/app/controllers/accounts_controller.rb
index 5be606d2..33b3980a 100644
--- a/app/controllers/accounts_controller.rb
+++ b/app/controllers/accounts_controller.rb
@@ -18,6 +18,7 @@ class AccountsController < ApplicationController
end
def chart
+ @chart_view = params[:chart_view] || "balance"
render layout: "application"
end
diff --git a/app/controllers/concerns/accountable_resource.rb b/app/controllers/concerns/accountable_resource.rb
index 524be0fc..d7d3b169 100644
--- a/app/controllers/concerns/accountable_resource.rb
+++ b/app/controllers/concerns/accountable_resource.rb
@@ -23,6 +23,7 @@ module AccountableResource
end
def show
+ @chart_view = params[:chart_view] || "balance"
@q = params.fetch(:q, {}).permit(:search)
entries = @account.entries.search(@q).reverse_chronological
diff --git a/app/controllers/concerns/auto_sync.rb b/app/controllers/concerns/auto_sync.rb
index d9e616e4..970eec0a 100644
--- a/app/controllers/concerns/auto_sync.rb
+++ b/app/controllers/concerns/auto_sync.rb
@@ -13,6 +13,7 @@ module AutoSync
def family_needs_auto_sync?
return false unless Current.family.present?
+ return false unless Current.family.accounts.active.any?
(Current.family.last_synced_at&.to_date || 1.day.ago) < Date.current
end
diff --git a/app/models/account.rb b/app/models/account.rb
index 75752077..0c037609 100644
--- a/app/models/account.rb
+++ b/app/models/account.rb
@@ -1,11 +1,10 @@
class Account < ApplicationRecord
- include Syncable, Monetizable, Issuable, Chartable
+ include Syncable, Monetizable, Issuable, Chartable, Enrichable, Linkable
validates :name, :balance, :currency, presence: true
belongs_to :family
belongs_to :import, optional: true
- belongs_to :plaid_account, optional: true
has_many :import_mappings, as: :mappable, dependent: :destroy, class_name: "Import::Mapping"
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
@@ -75,7 +74,16 @@ class Account < ApplicationRecord
def sync_data(start_date: nil)
update!(last_synced_at: Time.current)
- Syncer.new(self, start_date: start_date).run
+ Rails.logger.info("Auto-matching transfers")
+ family.auto_match_transfers!
+
+ Rails.logger.info("Processing balances (#{linked? ? 'reverse' : 'forward'})")
+ sync_balances
+
+ if enrichable?
+ Rails.logger.info("Enriching transaction data")
+ enrich_data
+ end
end
def post_sync
@@ -93,10 +101,6 @@ class Account < ApplicationRecord
holdings.where(currency: currency, date: holdings.maximum(:date)).order(amount: :desc)
end
- def enrich_data
- DataEnricher.new(self).run
- end
-
def update_with_sync!(attributes)
should_update_balance = attributes[:balance] && attributes[:balance].to_d != balance
@@ -123,11 +127,14 @@ class Account < ApplicationRecord
end
end
- def sparkline_series
- cache_key = family.build_cache_key("#{id}_sparkline")
-
- Rails.cache.fetch(cache_key) do
- balance_series
- end
+ def start_date
+ first_entry_date = entries.minimum(:date) || Date.current
+ first_entry_date - 1.day
end
+
+ private
+ def sync_balances
+ strategy = linked? ? :reverse : :forward
+ Balance::Syncer.new(self, strategy: strategy).sync_balances
+ end
end
diff --git a/app/models/account/balance/base_calculator.rb b/app/models/account/balance/base_calculator.rb
new file mode 100644
index 00000000..7acb51e8
--- /dev/null
+++ b/app/models/account/balance/base_calculator.rb
@@ -0,0 +1,35 @@
+class Account::Balance::BaseCalculator
+ attr_reader :account
+
+ def initialize(account)
+ @account = account
+ end
+
+ def calculate
+ Rails.logger.tagged(self.class.name) do
+ calculate_balances
+ end
+ end
+
+ private
+ def sync_cache
+ @sync_cache ||= Account::Balance::SyncCache.new(account)
+ end
+
+ def build_balance(date, cash_balance, holdings_value)
+ Account::Balance.new(
+ account_id: account.id,
+ date: date,
+ balance: holdings_value + cash_balance,
+ cash_balance: cash_balance,
+ currency: account.currency
+ )
+ end
+
+ def calculate_next_balance(prior_balance, transactions, direction: :forward)
+ flows = transactions.sum(&:amount)
+ negated = direction == :forward ? account.asset? : account.liability?
+ flows *= -1 if negated
+ prior_balance + flows
+ end
+end
diff --git a/app/models/account/balance/forward_calculator.rb b/app/models/account/balance/forward_calculator.rb
new file mode 100644
index 00000000..503e5b79
--- /dev/null
+++ b/app/models/account/balance/forward_calculator.rb
@@ -0,0 +1,28 @@
+class Account::Balance::ForwardCalculator < Account::Balance::BaseCalculator
+ private
+ def calculate_balances
+ current_cash_balance = 0
+ next_cash_balance = nil
+
+ @balances = []
+
+ account.start_date.upto(Date.current).each do |date|
+ entries = sync_cache.get_entries(date)
+ holdings = sync_cache.get_holdings(date)
+ holdings_value = holdings.sum(&:amount)
+ valuation = sync_cache.get_valuation(date)
+
+ next_cash_balance = if valuation
+ valuation.amount - holdings_value
+ else
+ calculate_next_balance(current_cash_balance, entries, direction: :forward)
+ end
+
+ @balances << build_balance(date, next_cash_balance, holdings_value)
+
+ current_cash_balance = next_cash_balance
+ end
+
+ @balances
+ end
+end
diff --git a/app/models/account/balance/reverse_calculator.rb b/app/models/account/balance/reverse_calculator.rb
new file mode 100644
index 00000000..151f4036
--- /dev/null
+++ b/app/models/account/balance/reverse_calculator.rb
@@ -0,0 +1,32 @@
+class Account::Balance::ReverseCalculator < Account::Balance::BaseCalculator
+ private
+ def calculate_balances
+ current_cash_balance = account.cash_balance
+ previous_cash_balance = nil
+
+ @balances = []
+
+ Date.current.downto(account.start_date).map do |date|
+ entries = sync_cache.get_entries(date)
+ holdings = sync_cache.get_holdings(date)
+ holdings_value = holdings.sum(&:amount)
+ valuation = sync_cache.get_valuation(date)
+
+ previous_cash_balance = if valuation
+ valuation.amount - holdings_value
+ else
+ calculate_next_balance(current_cash_balance, entries, direction: :reverse)
+ end
+
+ if valuation.present?
+ @balances << build_balance(date, previous_cash_balance, holdings_value)
+ else
+ @balances << build_balance(date, current_cash_balance, holdings_value)
+ end
+
+ current_cash_balance = previous_cash_balance
+ end
+
+ @balances
+ end
+end
diff --git a/app/models/account/balance/sync_cache.rb b/app/models/account/balance/sync_cache.rb
new file mode 100644
index 00000000..1fb7ea7f
--- /dev/null
+++ b/app/models/account/balance/sync_cache.rb
@@ -0,0 +1,46 @@
+class Account::Balance::SyncCache
+ def initialize(account)
+ @account = account
+ end
+
+ def get_valuation(date)
+ converted_entries.find { |e| e.date == date && e.account_valuation? }
+ end
+
+ def get_holdings(date)
+ converted_holdings.select { |h| h.date == date }
+ end
+
+ def get_entries(date)
+ converted_entries.select { |e| e.date == date && (e.account_transaction? || e.account_trade?) }
+ end
+
+ private
+ attr_reader :account
+
+ def converted_entries
+ @converted_entries ||= account.entries.order(:date).to_a.map do |e|
+ converted_entry = e.dup
+ converted_entry.amount = converted_entry.amount_money.exchange_to(
+ account.currency,
+ date: e.date,
+ fallback_rate: 1
+ ).amount
+ converted_entry.currency = account.currency
+ converted_entry
+ end
+ end
+
+ def converted_holdings
+ @converted_holdings ||= account.holdings.map do |h|
+ converted_holding = h.dup
+ converted_holding.amount = converted_holding.amount_money.exchange_to(
+ account.currency,
+ date: h.date,
+ fallback_rate: 1
+ ).amount
+ converted_holding.currency = account.currency
+ converted_holding
+ end
+ end
+end
diff --git a/app/models/account/balance/syncer.rb b/app/models/account/balance/syncer.rb
new file mode 100644
index 00000000..cc8ca68b
--- /dev/null
+++ b/app/models/account/balance/syncer.rb
@@ -0,0 +1,69 @@
+class Account::Balance::Syncer
+ attr_reader :account, :strategy
+
+ def initialize(account, strategy:)
+ @account = account
+ @strategy = strategy
+ end
+
+ def sync_balances
+ Account::Balance.transaction do
+ sync_holdings
+ calculate_balances
+
+ Rails.logger.info("Persisting #{@balances.size} balances")
+ persist_balances
+
+ purge_stale_balances
+
+ if strategy == :forward
+ update_account_info
+ end
+ end
+ end
+
+ private
+ def sync_holdings
+ @holdings = Account::Holding::Syncer.new(account, strategy: strategy).sync_holdings
+ end
+
+ def update_account_info
+ calculated_balance = @balances.sort_by(&:date).last&.balance || 0
+ calculated_holdings_value = @holdings.select { |h| h.date == Date.current }.sum(&:amount) || 0
+ calculated_cash_balance = calculated_balance - calculated_holdings_value
+
+ Rails.logger.info("Balance update: cash=#{calculated_cash_balance}, total=#{calculated_balance}")
+
+ account.update!(
+ balance: calculated_balance,
+ cash_balance: calculated_cash_balance
+ )
+ end
+
+ def calculate_balances
+ @balances = calculator.calculate
+ end
+
+ def persist_balances
+ current_time = Time.now
+ account.balances.upsert_all(
+ @balances.map { |b| b.attributes
+ .slice("date", "balance", "cash_balance", "currency")
+ .merge("updated_at" => current_time) },
+ unique_by: %i[account_id date currency]
+ )
+ end
+
+ def purge_stale_balances
+ deleted_count = account.balances.delete_by("date < ?", account.start_date)
+ Rails.logger.info("Purged #{deleted_count} stale balances") if deleted_count > 0
+ end
+
+ def calculator
+ if strategy == :reverse
+ Account::Balance::ReverseCalculator.new(account)
+ else
+ Account::Balance::ForwardCalculator.new(account)
+ end
+ end
+end
diff --git a/app/models/account/balance_calculator.rb b/app/models/account/balance_calculator.rb
deleted file mode 100644
index ca292dc5..00000000
--- a/app/models/account/balance_calculator.rb
+++ /dev/null
@@ -1,124 +0,0 @@
-class Account::BalanceCalculator
- def initialize(account, holdings: nil)
- @account = account
- @holdings = holdings || []
- end
-
- def calculate(reverse: false, start_date: nil)
- Rails.logger.tagged("Account::BalanceCalculator") do
- Rails.logger.info("Calculating cash balances with strategy: #{reverse ? "reverse sync" : "forward sync"}")
- cash_balances = reverse ? reverse_cash_balances : forward_cash_balances
-
- cash_balances.map do |balance|
- holdings_value = converted_holdings.select { |h| h.date == balance.date }.sum(&:amount)
- balance.balance = balance.balance + holdings_value
- balance
- end.compact
- end
- end
-
- private
- attr_reader :account, :holdings
-
- def oldest_date
- converted_entries.first ? converted_entries.first.date - 1.day : Date.current
- end
-
- def reverse_cash_balances
- prior_balance = account.cash_balance
-
- Date.current.downto(oldest_date).map do |date|
- entries_for_date = converted_entries.select { |e| e.date == date }
- holdings_for_date = converted_holdings.select { |h| h.date == date }
-
- valuation = entries_for_date.find { |e| e.account_valuation? }
-
- current_balance = if valuation
- # To get this to a cash valuation, we back out holdings value on day
- valuation.amount - holdings_for_date.sum(&:amount)
- else
- transactions = entries_for_date.select { |e| e.account_transaction? || e.account_trade? }
-
- calculate_balance(prior_balance, transactions)
- end
-
- balance_record = Account::Balance.new(
- account: account,
- date: date,
- balance: valuation ? current_balance : prior_balance,
- cash_balance: valuation ? current_balance : prior_balance,
- currency: account.currency
- )
-
- prior_balance = current_balance
-
- balance_record
- end
- end
-
- def forward_cash_balances
- prior_balance = 0
- current_balance = nil
-
- oldest_date.upto(Date.current).map do |date|
- entries_for_date = converted_entries.select { |e| e.date == date }
- holdings_for_date = converted_holdings.select { |h| h.date == date }
-
- valuation = entries_for_date.find { |e| e.account_valuation? }
-
- current_balance = if valuation
- # To get this to a cash valuation, we back out holdings value on day
- valuation.amount - holdings_for_date.sum(&:amount)
- else
- transactions = entries_for_date.select { |e| e.account_transaction? || e.account_trade? }
-
- calculate_balance(prior_balance, transactions, inverse: true)
- end
-
- balance_record = Account::Balance.new(
- account: account,
- date: date,
- balance: current_balance,
- cash_balance: current_balance,
- currency: account.currency
- )
-
- prior_balance = current_balance
-
- balance_record
- end
- end
-
- def converted_entries
- @converted_entries ||= @account.entries.order(:date).to_a.map do |e|
- converted_entry = e.dup
- converted_entry.amount = converted_entry.amount_money.exchange_to(
- account.currency,
- date: e.date,
- fallback_rate: 1
- ).amount
- converted_entry.currency = account.currency
- converted_entry
- end
- end
-
- def converted_holdings
- @converted_holdings ||= holdings.map do |h|
- converted_holding = h.dup
- converted_holding.amount = converted_holding.amount_money.exchange_to(
- account.currency,
- date: h.date,
- fallback_rate: 1
- ).amount
- converted_holding.currency = account.currency
- converted_holding
- end
- end
-
- def calculate_balance(prior_balance, transactions, inverse: false)
- flows = transactions.sum(&:amount)
- negated = inverse ? account.asset? : account.liability?
- flows *= -1 if negated
- prior_balance + flows
- end
-end
diff --git a/app/models/account/chartable.rb b/app/models/account/chartable.rb
index 2770da3b..f251e7f1 100644
--- a/app/models/account/chartable.rb
+++ b/app/models/account/chartable.rb
@@ -2,7 +2,9 @@ module Account::Chartable
extend ActiveSupport::Concern
class_methods do
- def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up")
+ def balance_series(currency:, period: Period.last_30_days, favorable_direction: "up", view: :balance)
+ raise ArgumentError, "Invalid view type" unless [ :balance, :cash_balance, :holdings_balance ].include?(view.to_sym)
+
balances = Account::Balance.find_by_sql([
balance_series_query,
{
@@ -21,8 +23,8 @@ module Account::Chartable
date: curr.date,
date_formatted: I18n.l(curr.date, format: :long),
trend: Trend.new(
- current: Money.new(curr.balance, currency),
- previous: prev.nil? ? nil : Money.new(prev.balance, currency),
+ current: Money.new(balance_value_for(curr, view), currency),
+ previous: prev.nil? ? nil : Money.new(balance_value_for(prev, view), currency),
favorable_direction: favorable_direction
)
)
@@ -33,8 +35,8 @@ module Account::Chartable
end_date: period.end_date,
interval: period.interval,
trend: Trend.new(
- current: Money.new(balances.last&.balance || 0, currency),
- previous: Money.new(balances.first&.balance || 0, currency),
+ current: Money.new(balance_value_for(balances.last, view) || 0, currency),
+ previous: Money.new(balance_value_for(balances.first, view) || 0, currency),
favorable_direction: favorable_direction
),
values: values
@@ -52,6 +54,8 @@ module Account::Chartable
SELECT
d.date,
SUM(CASE WHEN accounts.classification = 'asset' THEN ab.balance ELSE -ab.balance END * COALESCE(er.rate, 1)) as balance,
+ SUM(CASE WHEN accounts.classification = 'asset' THEN ab.cash_balance ELSE -ab.cash_balance END * COALESCE(er.rate, 1)) as cash_balance,
+ SUM(CASE WHEN accounts.classification = 'asset' THEN ab.balance - ab.cash_balance ELSE 0 END * COALESCE(er.rate, 1)) as holdings_balance,
COUNT(CASE WHEN accounts.currency <> :target_currency AND er.rate IS NULL THEN 1 END) as missing_rates
FROM dates d
LEFT JOIN accounts ON accounts.id IN (#{all.select(:id).to_sql})
@@ -70,26 +74,46 @@ module Account::Chartable
SQL
end
+ def balance_value_for(balance_record, view)
+ return 0 if balance_record.nil?
+
+ case view.to_sym
+ when :balance then balance_record.balance
+ when :cash_balance then balance_record.cash_balance
+ when :holdings_balance then balance_record.holdings_balance
+ else
+ raise ArgumentError, "Invalid view type: #{view}"
+ end
+ end
+
def invert_balances(balances)
balances.map do |balance|
balance.balance = -balance.balance
+ balance.cash_balance = -balance.cash_balance
+ balance.holdings_balance = -balance.holdings_balance
balance
end
end
def gapfill_balances(balances)
gapfilled = []
+ prev = nil
- prev_balance = nil
-
- [ nil, *balances ].each_cons(2).each_with_index do |(prev, curr), index|
- if index == 0 && curr.balance.nil?
- curr.balance = 0 # Ensure all series start with a non-nil balance
- elsif curr.balance.nil?
- curr.balance = prev.balance
+ balances.each do |curr|
+ if prev.nil?
+ # Initialize first record with zeros if nil
+ curr.balance ||= 0
+ curr.cash_balance ||= 0
+ curr.holdings_balance ||= 0
+ else
+ # Copy previous values for nil fields
+ curr.balance ||= prev.balance
+ curr.cash_balance ||= prev.cash_balance
+ curr.holdings_balance ||= prev.holdings_balance
end
gapfilled << curr
+ prev = curr
end
gapfilled
@@ -100,11 +124,20 @@ module Account::Chartable
classification == "asset" ? "up" : "down"
end
- def balance_series(period: Period.last_30_days)
+ def balance_series(period: Period.last_30_days, view: :balance)
self.class.where(id: self.id).balance_series(
currency: currency,
period: period,
+ view: view,
favorable_direction: favorable_direction
)
end
+
+ def sparkline_series
+ cache_key = family.build_cache_key("#{id}_sparkline")
+
+ Rails.cache.fetch(cache_key) do
+ balance_series
+ end
+ end
end
diff --git a/app/models/account/enrichable.rb b/app/models/account/enrichable.rb
new file mode 100644
index 00000000..236cce58
--- /dev/null
+++ b/app/models/account/enrichable.rb
@@ -0,0 +1,12 @@
+module Account::Enrichable
+ extend ActiveSupport::Concern
+
+ def enrich_data
+ DataEnricher.new(self).run
+ end
+
+ private
+ def enrichable?
+ family.data_enrichment_enabled? || (linked? && Rails.application.config.app_mode.hosted?)
+ end
+end
diff --git a/app/models/account/holding.rb b/app/models/account/holding.rb
index eb6e35ef..ba7a7e2d 100644
--- a/app/models/account/holding.rb
+++ b/app/models/account/holding.rb
@@ -1,5 +1,5 @@
class Account::Holding < ApplicationRecord
- include Monetizable
+ include Monetizable, Gapfillable
monetize :amount
diff --git a/app/models/account/holding/base_calculator.rb b/app/models/account/holding/base_calculator.rb
new file mode 100644
index 00000000..4359e9ab
--- /dev/null
+++ b/app/models/account/holding/base_calculator.rb
@@ -0,0 +1,63 @@
+class Account::Holding::BaseCalculator
+ attr_reader :account
+
+ def initialize(account)
+ @account = account
+ end
+
+ def calculate
+ Rails.logger.tagged(self.class.name) do
+ holdings = calculate_holdings
+ Account::Holding.gapfill(holdings)
+ end
+ end
+
+ private
+ def portfolio_cache
+ @portfolio_cache ||= Account::Holding::PortfolioCache.new(account)
+ end
+
+ def empty_portfolio
+ securities = portfolio_cache.get_securities
+ securities.each_with_object({}) { |security, hash| hash[security.id] = 0 }
+ end
+
+ def generate_starting_portfolio
+ empty_portfolio
+ 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)
+ portfolio.map do |security_id, qty|
+ price = portfolio_cache.get_price(security_id, date)
+
+ if price.nil?
+ Rails.logger.warn "No price found for security #{security_id} on #{date}"
+ next
+ end
+
+ Account::Holding.new(
+ account_id: account.id,
+ security_id: security_id,
+ date: date,
+ qty: qty,
+ price: price.price,
+ currency: price.currency,
+ amount: qty * price.price
+ )
+ end.compact
+ end
+end
diff --git a/app/models/account/holding/forward_calculator.rb b/app/models/account/holding/forward_calculator.rb
new file mode 100644
index 00000000..afb6b71f
--- /dev/null
+++ b/app/models/account/holding/forward_calculator.rb
@@ -0,0 +1,21 @@
+class Account::Holding::ForwardCalculator < Account::Holding::BaseCalculator
+ private
+ def portfolio_cache
+ @portfolio_cache ||= Account::Holding::PortfolioCache.new(account)
+ end
+
+ def calculate_holdings
+ current_portfolio = generate_starting_portfolio
+ next_portfolio = {}
+ holdings = []
+
+ account.start_date.upto(Date.current).each do |date|
+ trades = portfolio_cache.get_trades(date: date)
+ next_portfolio = transform_portfolio(current_portfolio, trades, direction: :forward)
+ holdings += build_holdings(next_portfolio, date)
+ current_portfolio = next_portfolio
+ end
+
+ holdings
+ end
+end
diff --git a/app/models/account/holding/gapfillable.rb b/app/models/account/holding/gapfillable.rb
new file mode 100644
index 00000000..e2462a6f
--- /dev/null
+++ b/app/models/account/holding/gapfillable.rb
@@ -0,0 +1,38 @@
+module Account::Holding::Gapfillable
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def gapfill(holdings)
+ filled_holdings = []
+
+ holdings.group_by { |h| h.security_id }.each do |security_id, security_holdings|
+ next if security_holdings.empty?
+
+ sorted = security_holdings.sort_by(&:date)
+ previous_holding = sorted.first
+
+ sorted.first.date.upto(Date.current) do |date|
+ holding = security_holdings.find { |h| h.date == date }
+
+ if holding
+ filled_holdings << holding
+ previous_holding = holding
+ else
+ # Create a new holding based on the previous day's data
+ filled_holdings << Account::Holding.new(
+ account: previous_holding.account,
+ security: previous_holding.security,
+ date: date,
+ qty: previous_holding.qty,
+ price: previous_holding.price,
+ currency: previous_holding.currency,
+ amount: previous_holding.amount
+ )
+ end
+ end
+ end
+
+ filled_holdings
+ end
+ end
+end
diff --git a/app/models/account/holding/portfolio_cache.rb b/app/models/account/holding/portfolio_cache.rb
new file mode 100644
index 00000000..6a839382
--- /dev/null
+++ b/app/models/account/holding/portfolio_cache.rb
@@ -0,0 +1,132 @@
+class Account::Holding::PortfolioCache
+ attr_reader :account, :use_holdings
+
+ class SecurityNotFound < StandardError
+ def initialize(security_id, account_id)
+ super("Security id=#{security_id} not found in portfolio cache for account #{account_id}. This should not happen unless securities were preloaded incorrectly.")
+ end
+ end
+
+ def initialize(account, use_holdings: false)
+ @account = account
+ @use_holdings = use_holdings
+ load_prices
+ end
+
+ def get_trades(date: nil)
+ if date.blank?
+ trades
+ else
+ trades.select { |t| t.date == date }
+ end
+ end
+
+ def get_price(security_id, date)
+ security = @security_cache[security_id]
+ raise SecurityNotFound.new(security_id, account.id) unless security
+
+ price = security[:prices].select { |p| p.price.date == date }.min_by(&:priority)&.price
+
+ return nil unless price
+
+ price_money = Money.new(price.price, price.currency)
+
+ converted_amount = price_money.exchange_to(account.currency, fallback_rate: 1).amount
+
+ Security::Price.new(
+ security_id: security_id,
+ date: price.date,
+ price: converted_amount,
+ currency: price.currency
+ )
+ end
+
+ def get_securities
+ @security_cache.map { |_, v| v[:security] }
+ end
+
+ private
+ PriceWithPriority = Data.define(:price, :priority)
+
+ def trades
+ @trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a
+ end
+
+ def holdings
+ @holdings ||= account.holdings.chronological.to_a
+ end
+
+ def collect_unique_securities
+ unique_securities_from_trades = trades.map(&:entryable).map(&:security).uniq
+
+ return unique_securities_from_trades unless use_holdings
+
+ unique_securities_from_holdings = holdings.map(&:security).uniq
+
+ (unique_securities_from_trades + unique_securities_from_holdings).uniq
+ end
+
+ # Loads all known prices for all securities in the account with priority based on source:
+ # 1 - DB or provider prices
+ # 2 - Trade prices
+ # 3 - Holding prices
+ def load_prices
+ @security_cache = {}
+ securities = collect_unique_securities
+
+ Rails.logger.info "Preloading #{securities.size} securities for account #{account.id}"
+
+ securities.each do |security|
+ Rails.logger.info "Loading security: ID=#{security.id} Ticker=#{security.ticker}"
+
+ # Highest priority prices
+ db_or_provider_prices = Security::Price.find_prices(
+ security: security,
+ start_date: account.start_date,
+ end_date: Date.current
+ ).map do |price|
+ PriceWithPriority.new(
+ price: price,
+ priority: 1
+ )
+ end
+
+ # Medium priority prices from trades
+ trade_prices = trades
+ .select { |t| t.entryable.security_id == security.id }
+ .map do |trade|
+ PriceWithPriority.new(
+ price: Security::Price.new(
+ security: security,
+ price: trade.entryable.price,
+ currency: trade.entryable.currency,
+ date: trade.date
+ ),
+ priority: 2
+ )
+ end
+
+ # Low priority prices from holdings (if applicable)
+ holding_prices = if use_holdings
+ holdings.select { |h| h.security_id == security.id }.map do |holding|
+ PriceWithPriority.new(
+ price: Security::Price.new(
+ security: security,
+ price: holding.price,
+ currency: holding.currency,
+ date: holding.date
+ ),
+ priority: 3
+ )
+ end
+ else
+ []
+ end
+
+ @security_cache[security.id] = {
+ security: security,
+ prices: db_or_provider_prices + trade_prices + holding_prices
+ }
+ end
+ end
+end
diff --git a/app/models/account/holding/reverse_calculator.rb b/app/models/account/holding/reverse_calculator.rb
new file mode 100644
index 00000000..d3677c88
--- /dev/null
+++ b/app/models/account/holding/reverse_calculator.rb
@@ -0,0 +1,38 @@
+class Account::Holding::ReverseCalculator < Account::Holding::BaseCalculator
+ 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 ||= Account::Holding::PortfolioCache.new(account, use_holdings: true)
+ end
+
+ def calculate_holdings
+ current_portfolio = generate_starting_portfolio
+ 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)
+ holdings += build_holdings(current_portfolio, date)
+ current_portfolio = previous_portfolio
+ end
+
+ holdings
+ end
+
+ # Since this is a reverse sync, we start with today's holdings
+ def generate_starting_portfolio
+ holding_quantities = empty_portfolio
+
+ todays_holdings = account.holdings.where(date: Date.current)
+
+ todays_holdings.each do |holding|
+ holding_quantities[holding.security_id] = holding.qty
+ end
+
+ holding_quantities
+ end
+end
diff --git a/app/models/account/holding/syncer.rb b/app/models/account/holding/syncer.rb
new file mode 100644
index 00000000..bfccd6f0
--- /dev/null
+++ b/app/models/account/holding/syncer.rb
@@ -0,0 +1,58 @@
+class Account::Holding::Syncer
+ def initialize(account, strategy:)
+ @account = account
+ @strategy = strategy
+ end
+
+ def sync_holdings
+ calculate_holdings
+
+ Rails.logger.info("Persisting #{@holdings.size} holdings")
+ persist_holdings
+
+ if strategy == :forward
+ purge_stale_holdings
+ end
+
+ @holdings
+ end
+
+ private
+ attr_reader :account, :strategy
+
+ def calculate_holdings
+ @holdings = calculator.calculate
+ end
+
+ def persist_holdings
+ current_time = Time.now
+
+ account.holdings.upsert_all(
+ @holdings.map { |h| h.attributes
+ .slice("date", "currency", "qty", "price", "amount", "security_id")
+ .merge("account_id" => account.id, "updated_at" => current_time) },
+ unique_by: %i[account_id security_id date currency]
+ )
+ end
+
+ def purge_stale_holdings
+ portfolio_security_ids = account.entries.account_trades.map { |entry| entry.entryable.security_id }.uniq
+
+ # If there are no securities in the portfolio, delete all holdings
+ if portfolio_security_ids.empty?
+ Rails.logger.info("Clearing all holdings (no securities)")
+ account.holdings.delete_all
+ else
+ deleted_count = account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account.start_date, portfolio_security_ids)
+ Rails.logger.info("Purged #{deleted_count} stale holdings") if deleted_count > 0
+ end
+ end
+
+ def calculator
+ if strategy == :reverse
+ Account::Holding::ReverseCalculator.new(account)
+ else
+ Account::Holding::ForwardCalculator.new(account)
+ end
+ end
+end
diff --git a/app/models/account/holding_calculator.rb b/app/models/account/holding_calculator.rb
deleted file mode 100644
index edb55acf..00000000
--- a/app/models/account/holding_calculator.rb
+++ /dev/null
@@ -1,188 +0,0 @@
-class Account::HoldingCalculator
- def initialize(account)
- @account = account
- @securities_cache = {}
- end
-
- def calculate(reverse: false)
- Rails.logger.tagged("Account::HoldingCalculator") do
- preload_securities
-
- Rails.logger.info("Calculating holdings with strategy: #{reverse ? "reverse sync" : "forward sync"}")
- calculated_holdings = reverse ? reverse_holdings : forward_holdings
-
- gapfill_holdings(calculated_holdings)
- end
- end
-
- private
- attr_reader :account, :securities_cache
-
- def reverse_holdings
- current_holding_quantities = load_current_holding_quantities
- prior_holding_quantities = {}
-
- holdings = []
-
- Date.current.downto(portfolio_start_date).map do |date|
- today_trades = trades.select { |t| t.date == date }
- prior_holding_quantities = calculate_portfolio(current_holding_quantities, today_trades)
- holdings += generate_holding_records(current_holding_quantities, date)
- current_holding_quantities = prior_holding_quantities
- end
-
- holdings
- end
-
- def forward_holdings
- prior_holding_quantities = load_empty_holding_quantities
- current_holding_quantities = {}
-
- holdings = []
-
- portfolio_start_date.upto(Date.current).map do |date|
- today_trades = trades.select { |t| t.date == date }
- current_holding_quantities = calculate_portfolio(prior_holding_quantities, today_trades, inverse: true)
- holdings += generate_holding_records(current_holding_quantities, date)
- prior_holding_quantities = current_holding_quantities
- end
-
- holdings
- end
-
- def generate_holding_records(portfolio, date)
- Rails.logger.info "[HoldingCalculator] Generating holdings for #{portfolio.size} securities on #{date}"
-
- portfolio.map do |security_id, qty|
- security = securities_cache[security_id]
-
- if security.blank?
- Rails.logger.error "[HoldingCalculator] Security #{security_id} not found in cache for account #{account.id}"
- next
- end
-
- price = security.dig(:prices)&.find { |p| p.date == date }
-
- if price.blank?
- Rails.logger.info "[HoldingCalculator] No price found for security #{security_id} on #{date}"
- next
- end
-
- converted_price = Money.new(price.price, price.currency).exchange_to(account.currency, fallback_rate: 1).amount
-
- account.holdings.build(
- security: security.dig(:security),
- date: date,
- qty: qty,
- price: converted_price,
- currency: account.currency,
- amount: qty * converted_price
- )
- end.compact
- end
-
- def gapfill_holdings(holdings)
- filled_holdings = []
-
- holdings.group_by { |h| h.security_id }.each do |security_id, security_holdings|
- next if security_holdings.empty?
-
- sorted = security_holdings.sort_by(&:date)
- previous_holding = sorted.first
-
- sorted.first.date.upto(Date.current) do |date|
- holding = security_holdings.find { |h| h.date == date }
-
- if holding
- filled_holdings << holding
- previous_holding = holding
- else
- # Create a new holding based on the previous day's data
- filled_holdings << account.holdings.build(
- security: previous_holding.security,
- date: date,
- qty: previous_holding.qty,
- price: previous_holding.price,
- currency: previous_holding.currency,
- amount: previous_holding.amount
- )
- end
- end
- end
-
- filled_holdings
- end
-
- def trades
- @trades ||= account.entries.includes(entryable: :security).account_trades.chronological.to_a
- end
-
- def portfolio_start_date
- trades.first ? trades.first.date - 1.day : Date.current
- end
-
- def preload_securities
- # Get securities from trades and current holdings
- securities = trades.map(&:entryable).map(&:security).uniq
- securities += account.holdings.where(date: Date.current).map(&:security)
- securities.uniq!
-
- Rails.logger.info "[HoldingCalculator] Preloading #{securities.size} securities for account #{account.id}"
-
- securities.each do |security|
- begin
- Rails.logger.info "[HoldingCalculator] Loading security: ID=#{security.id} Ticker=#{security.ticker}"
-
- prices = Security::Price.find_prices(
- security: security,
- start_date: portfolio_start_date,
- end_date: Date.current
- )
-
- Rails.logger.info "[HoldingCalculator] Found #{prices.size} prices for security #{security.id}"
-
- @securities_cache[security.id] = {
- security: security,
- prices: prices
- }
- rescue => e
- Rails.logger.error "[HoldingCalculator] Error processing security #{security.id}: #{e.message}"
- Rails.logger.error "[HoldingCalculator] Security details: #{security.attributes}"
- Rails.logger.error e.backtrace.join("\n")
- next # Skip this security and continue with others
- end
- end
- end
-
- def calculate_portfolio(holding_quantities, today_trades, inverse: false)
- new_quantities = holding_quantities.dup
-
- today_trades.each do |trade|
- security_id = trade.entryable.security_id
- qty_change = inverse ? trade.entryable.qty : -trade.entryable.qty
- new_quantities[security_id] = (new_quantities[security_id] || 0) + qty_change
- end
-
- new_quantities
- end
-
- def load_empty_holding_quantities
- holding_quantities = {}
-
- trades.map { |t| t.entryable.security_id }.uniq.each do |security_id|
- holding_quantities[security_id] = 0
- end
-
- holding_quantities
- end
-
- def load_current_holding_quantities
- holding_quantities = load_empty_holding_quantities
-
- account.holdings.where(date: Date.current, currency: account.currency).map do |holding|
- holding_quantities[holding.security_id] = holding.qty
- end
-
- holding_quantities
- end
-end
diff --git a/app/models/account/linkable.rb b/app/models/account/linkable.rb
new file mode 100644
index 00000000..ee0871bd
--- /dev/null
+++ b/app/models/account/linkable.rb
@@ -0,0 +1,18 @@
+module Account::Linkable
+ extend ActiveSupport::Concern
+
+ included do
+ belongs_to :plaid_account, optional: true
+ end
+
+ # A "linked" account gets transaction and balance data from a third party like Plaid
+ def linked?
+ plaid_account_id.present?
+ end
+
+ # An "offline" or "unlinked" account is one where the user tracks values and
+ # adds transactions manually, without the help of a data provider
+ def unlinked?
+ !linked?
+ end
+end
diff --git a/app/models/account/syncer.rb b/app/models/account/syncer.rb
deleted file mode 100644
index d664b8f1..00000000
--- a/app/models/account/syncer.rb
+++ /dev/null
@@ -1,162 +0,0 @@
-class Account::Syncer
- def initialize(account, start_date: nil)
- @account = account
- @start_date = start_date
- end
-
- def run
- Rails.logger.tagged("Account::Syncer") do
- Rails.logger.info("Finding potential transfers to auto-match")
- account.family.auto_match_transfers!
-
- holdings = sync_holdings
- Rails.logger.info("Calculated #{holdings.size} holdings")
-
- balances = sync_balances(holdings)
- Rails.logger.info("Calculated #{balances.size} balances")
-
- account.reload
-
- unless plaid_sync?
- update_account_info(balances, holdings)
- end
-
- unless account.currency == account.family.currency
- Rails.logger.info("Converting #{balances.size} balances and #{holdings.size} holdings from #{account.currency} to #{account.family.currency}")
- convert_records_to_family_currency(balances, holdings)
- end
-
- # Enrich if user opted in or if we're syncing transactions from a Plaid account on the hosted app
- if account.family.data_enrichment_enabled? || (plaid_sync? && Rails.application.config.app_mode.hosted?)
- Rails.logger.info("Enriching transaction data for account #{account.name}")
- account.enrich_data
- else
- Rails.logger.info("Data enrichment disabled for account #{account.name}")
- end
- end
- end
-
- private
- attr_reader :account, :start_date
-
- def account_start_date
- @account_start_date ||= (account.entries.chronological.first&.date || Date.current) - 1.day
- end
-
- def update_account_info(balances, holdings)
- new_balance = balances.sort_by(&:date).last.balance
- new_holdings_value = holdings.select { |h| h.date == Date.current }.sum(&:amount)
- new_cash_balance = new_balance - new_holdings_value
-
- account.update!(
- balance: new_balance,
- cash_balance: new_cash_balance
- )
- end
-
- def sync_holdings
- calculator = Account::HoldingCalculator.new(account)
- calculated_holdings = calculator.calculate(reverse: plaid_sync?)
-
- Account.transaction do
- load_holdings(calculated_holdings)
- purge_outdated_holdings unless plaid_sync?
- end
-
- calculated_holdings
- end
-
- def sync_balances(holdings)
- calculator = Account::BalanceCalculator.new(account, holdings: holdings)
- calculated_balances = calculator.calculate(reverse: plaid_sync?, start_date: start_date)
-
- Account.transaction do
- load_balances(calculated_balances)
- purge_outdated_balances
- end
-
- calculated_balances
- end
-
- def convert_records_to_family_currency(balances, holdings)
- from_currency = account.currency
- to_currency = account.family.currency
-
- exchange_rates = ExchangeRate.find_rates(
- from: from_currency,
- to: to_currency,
- start_date: balances.min_by(&:date).date
- )
-
- converted_balances = balances.map do |balance|
- exchange_rate = exchange_rates.find { |er| er.date == balance.date }
-
- next unless exchange_rate.present?
-
- account.balances.build(
- date: balance.date,
- balance: exchange_rate.rate * balance.balance,
- currency: to_currency
- )
- end.compact
-
- converted_holdings = holdings.map do |holding|
- exchange_rate = exchange_rates.find { |er| er.date == holding.date }
-
- next unless exchange_rate.present?
-
- account.holdings.build(
- security: holding.security,
- date: holding.date,
- qty: holding.qty,
- price: exchange_rate.rate * holding.price,
- amount: exchange_rate.rate * holding.amount,
- currency: to_currency
- )
- end.compact
-
- Account.transaction do
- load_balances(converted_balances)
- load_holdings(converted_holdings)
- end
- end
-
- def load_balances(balances = [])
- current_time = Time.now
- account.balances.upsert_all(
- balances.map { |b| b.attributes
- .slice("date", "balance", "cash_balance", "currency")
- .merge("updated_at" => current_time) },
- unique_by: %i[account_id date currency]
- )
- end
-
- def load_holdings(holdings = [])
- current_time = Time.now
- account.holdings.upsert_all(
- holdings.map { |h| h.attributes
- .slice("date", "currency", "qty", "price", "amount", "security_id")
- .merge("updated_at" => current_time) },
- unique_by: %i[account_id security_id date currency]
- )
- end
-
- def purge_outdated_balances
- account.balances.delete_by("date < ?", account_start_date)
- end
-
- def plaid_sync?
- account.plaid_account_id.present?
- end
-
- def purge_outdated_holdings
- portfolio_security_ids = account.entries.account_trades.map { |entry| entry.entryable.security_id }.uniq
-
- # If there are no securities in the portfolio, delete all holdings
- if portfolio_security_ids.empty?
- account.holdings.delete_all
- else
- account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account_start_date, portfolio_security_ids)
- end
- end
-end
diff --git a/app/models/plaid_item.rb b/app/models/plaid_item.rb
index c76af8aa..9ffdadf1 100644
--- a/app/models/plaid_item.rb
+++ b/app/models/plaid_item.rb
@@ -45,6 +45,11 @@ class PlaidItem < ApplicationRecord
plaid_data = fetch_and_load_plaid_data
update!(status: :good) if requires_update?
+ # Schedule account syncs
+ accounts.each do |account|
+ account.sync_later(start_date: start_date)
+ end
+
Rails.logger.info("Plaid data fetched and loaded")
plaid_data
rescue Plaid::ApiError => e
diff --git a/app/views/accounts/chart.html.erb b/app/views/accounts/chart.html.erb
index 22e2528e..6be29472 100644
--- a/app/views/accounts/chart.html.erb
+++ b/app/views/accounts/chart.html.erb
@@ -1,4 +1,4 @@
-<% series = @account.balance_series(period: @period) %>
+<% series = @account.balance_series(period: @period, view: @chart_view) %>
<% trend = series.trend %>
<%= turbo_frame_tag dom_id(@account, :chart_details) do %>
diff --git a/app/views/accounts/show/_chart.html.erb b/app/views/accounts/show/_chart.html.erb
index c78a3676..e6dadae8 100644
--- a/app/views/accounts/show/_chart.html.erb
+++ b/app/views/accounts/show/_chart.html.erb
@@ -1,6 +1,6 @@
-<%# locals: (account:, title: nil, tooltip: nil, **args) %>
+<%# locals: (account:, title: nil, tooltip: nil, chart_view: nil, **args) %>
-<% period = @period || Period.last_30_days %>
+<% period = @period || Period.last_30_days %>
<% default_value_title = account.asset? ? t(".balance") : t(".owed") %>
@@ -15,11 +15,22 @@
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %>
- <%= period_select form: form, selected: period %>
+
+ <% if chart_view.present? %>
+ <%= form.select :chart_view,
+ [["Total value", "balance"], ["Holdings", "holdings_balance"], ["Cash", "cash_balance"]],
+ { selected: chart_view },
+ class: "border border-secondary rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0",
+ data: { "auto-submit-form-target": "auto" }
+ %>
+ <% end %>
+
+ <%= period_select form: form, selected: period %>
+
<% end %>
- <%= turbo_frame_tag dom_id(account, :chart_details), src: chart_account_path(account, period: period.key) do %>
+ <%= turbo_frame_tag dom_id(account, :chart_details), src: chart_account_path(account, period: period.key, chart_view: chart_view) do %>
<%= render "accounts/chart_loader" %>
<% end %>
diff --git a/app/views/investments/show.html.erb b/app/views/investments/show.html.erb
index a1e34e49..7bd7da3b 100644
--- a/app/views/investments/show.html.erb
+++ b/app/views/investments/show.html.erb
@@ -7,6 +7,7 @@
<%= render "accounts/show/chart",
account: @account,
title: t(".chart_title"),
+ chart_view: @chart_view,
tooltip: render(
"investments/value_tooltip",
balance: @account.balance_money,
diff --git a/test/models/account/balance/forward_calculator_test.rb b/test/models/account/balance/forward_calculator_test.rb
new file mode 100644
index 00000000..cb96572f
--- /dev/null
+++ b/test/models/account/balance/forward_calculator_test.rb
@@ -0,0 +1,74 @@
+require "test_helper"
+
+class Account::Balance::ForwardCalculatorTest < ActiveSupport::TestCase
+ include Account::EntriesTestHelper
+
+ setup do
+ @account = families(:empty).accounts.create!(
+ name: "Test",
+ balance: 20000,
+ cash_balance: 20000,
+ currency: "USD",
+ accountable: Investment.new
+ )
+ end
+
+ # When syncing forwards, we don't care about the account balance. We generate everything based on entries, starting from 0.
+ test "no entries sync" do
+ assert_equal 0, @account.balances.count
+
+ expected = [ 0, 0 ]
+ calculated = Account::Balance::ForwardCalculator.new(@account).calculate
+
+ assert_equal expected, calculated.map(&:balance)
+ end
+
+ test "valuations sync" do
+ create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000)
+ create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000)
+
+ expected = [ 0, 17000, 17000, 19000, 19000, 19000 ]
+ calculated = Account::Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
+
+ assert_equal expected, calculated
+ end
+
+ test "transactions sync" do
+ create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income
+ create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense
+
+ expected = [ 0, 500, 500, 400, 400, 400 ]
+ calculated = Account::Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
+
+ assert_equal expected, calculated
+ end
+
+ test "multi-entry sync" do
+ create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000)
+ create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000)
+ create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500)
+ create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500)
+ create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000)
+ create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100)
+
+ expected = [ 0, 5000, 5000, 17000, 17000, 17500, 17000, 17000, 16900, 16900 ]
+ calculated = Account::Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
+
+ assert_equal expected, calculated
+ end
+
+ test "multi-currency sync" do
+ ExchangeRate.create! date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2
+
+ create_transaction(account: @account, date: 3.days.ago.to_date, amount: -100, currency: "USD")
+ create_transaction(account: @account, date: 2.days.ago.to_date, amount: -300, currency: "USD")
+
+ # Transaction in different currency than the account's main currency
+ create_transaction(account: @account, date: 1.day.ago.to_date, amount: -500, currency: "EUR") # €500 * 1.2 = $600
+
+ expected = [ 0, 100, 400, 1000, 1000 ]
+ calculated = Account::Balance::ForwardCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
+
+ assert_equal expected, calculated
+ end
+end
diff --git a/test/models/account/balance/reverse_calculator_test.rb b/test/models/account/balance/reverse_calculator_test.rb
new file mode 100644
index 00000000..e81c9eb5
--- /dev/null
+++ b/test/models/account/balance/reverse_calculator_test.rb
@@ -0,0 +1,59 @@
+require "test_helper"
+
+class Account::Balance::ReverseCalculatorTest < ActiveSupport::TestCase
+ include Account::EntriesTestHelper
+
+ setup do
+ @account = families(:empty).accounts.create!(
+ name: "Test",
+ balance: 20000,
+ cash_balance: 20000,
+ currency: "USD",
+ accountable: Investment.new
+ )
+ end
+
+ # When syncing backwards, we start with the account balance and generate everything from there.
+ test "no entries sync" do
+ assert_equal 0, @account.balances.count
+
+ expected = [ @account.balance, @account.balance ]
+ calculated = Account::Balance::ReverseCalculator.new(@account).calculate
+
+ assert_equal expected, calculated.map(&:balance)
+ end
+
+ test "valuations sync" do
+ create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000)
+ create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000)
+
+ expected = [ 17000, 17000, 19000, 19000, 20000, 20000 ]
+ calculated = Account::Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
+
+ assert_equal expected, calculated
+ end
+
+ test "transactions sync" do
+ create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income
+ create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense
+
+ expected = [ 19600, 20100, 20100, 20000, 20000, 20000 ]
+ calculated = Account::Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
+
+ assert_equal expected, calculated
+ end
+
+ test "multi-entry sync" do
+ create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000)
+ create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000)
+ create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500)
+ create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500)
+ create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000)
+ create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100)
+
+ expected = [ 12000, 17000, 17000, 17000, 16500, 17000, 17000, 20100, 20000, 20000 ]
+ calculated = Account::Balance::ReverseCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
+
+ assert_equal expected, calculated
+ end
+end
diff --git a/test/models/account/balance/syncer_test.rb b/test/models/account/balance/syncer_test.rb
new file mode 100644
index 00000000..72dfc568
--- /dev/null
+++ b/test/models/account/balance/syncer_test.rb
@@ -0,0 +1,51 @@
+require "test_helper"
+
+class Account::Balance::SyncerTest < ActiveSupport::TestCase
+ include Account::EntriesTestHelper
+
+ setup do
+ @account = families(:empty).accounts.create!(
+ name: "Test",
+ balance: 20000,
+ cash_balance: 20000,
+ currency: "USD",
+ accountable: Investment.new
+ )
+ end
+
+ test "syncs balances" do
+ Account::Holding::Syncer.any_instance.expects(:sync_holdings).returns([]).once
+
+ @account.expects(:start_date).returns(2.days.ago.to_date)
+
+ Account::Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
+ [
+ Account::Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
+ Account::Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
+ ]
+ )
+
+ assert_difference "@account.balances.count", 2 do
+ Account::Balance::Syncer.new(@account, strategy: :forward).sync_balances
+ end
+ end
+
+ test "purges stale balances and holdings" do
+ # Balance before start date is stale
+ @account.expects(:start_date).returns(2.days.ago.to_date).twice
+ stale_balance = Account::Balance.new(date: 3.days.ago.to_date, balance: 10000, cash_balance: 10000, currency: "USD")
+
+ Account::Balance::ForwardCalculator.any_instance.expects(:calculate).returns(
+ [
+ stale_balance,
+ Account::Balance.new(date: 2.days.ago.to_date, balance: 10000, cash_balance: 10000, currency: "USD"),
+ Account::Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "USD"),
+ Account::Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "USD")
+ ]
+ )
+
+ assert_difference "@account.balances.count", 3 do
+ Account::Balance::Syncer.new(@account, strategy: :forward).sync_balances
+ end
+ end
+end
diff --git a/test/models/account/balance_calculator_test.rb b/test/models/account/balance_calculator_test.rb
deleted file mode 100644
index 2f3879a8..00000000
--- a/test/models/account/balance_calculator_test.rb
+++ /dev/null
@@ -1,156 +0,0 @@
-require "test_helper"
-
-class Account::BalanceCalculatorTest < ActiveSupport::TestCase
- include Account::EntriesTestHelper
-
- setup do
- @account = families(:empty).accounts.create!(
- name: "Test",
- balance: 20000,
- cash_balance: 20000,
- currency: "USD",
- accountable: Investment.new
- )
- end
-
- # When syncing backwards, we start with the account balance and generate everything from there.
- test "reverse no entries sync" do
- assert_equal 0, @account.balances.count
-
- expected = [ @account.balance ]
- calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true)
-
- assert_equal expected, calculated.map(&:balance)
- end
-
- # When syncing forwards, we don't care about the account balance. We generate everything based on entries, starting from 0.
- test "forward no entries sync" do
- assert_equal 0, @account.balances.count
-
- expected = [ 0 ]
- calculated = Account::BalanceCalculator.new(@account).calculate
-
- assert_equal expected, calculated.map(&:balance)
- end
-
- test "forward valuations sync" do
- create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000)
- create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000)
-
- expected = [ 0, 17000, 17000, 19000, 19000, 19000 ]
- calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
-
- assert_equal expected, calculated
- end
-
- test "reverse valuations sync" do
- create_valuation(account: @account, date: 4.days.ago.to_date, amount: 17000)
- create_valuation(account: @account, date: 2.days.ago.to_date, amount: 19000)
-
- expected = [ 17000, 17000, 19000, 19000, 20000, 20000 ]
- calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true).sort_by(&:date).map(&:balance)
-
- assert_equal expected, calculated
- end
-
- test "forward transactions sync" do
- create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income
- create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense
-
- expected = [ 0, 500, 500, 400, 400, 400 ]
- calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
-
- assert_equal expected, calculated
- end
-
- test "reverse transactions sync" do
- create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500) # income
- create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) # expense
-
- expected = [ 19600, 20100, 20100, 20000, 20000, 20000 ]
- calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true).sort_by(&:date).map(&:balance)
-
- assert_equal expected, calculated
- end
-
- test "reverse multi-entry sync" do
- create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000)
- create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000)
- create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500)
- create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500)
- create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000)
- create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100)
-
- expected = [ 12000, 17000, 17000, 17000, 16500, 17000, 17000, 20100, 20000, 20000 ]
- calculated = Account::BalanceCalculator.new(@account).calculate(reverse: true) .sort_by(&:date).map(&:balance)
-
- assert_equal expected, calculated
- end
-
- test "forward multi-entry sync" do
- create_transaction(account: @account, date: 8.days.ago.to_date, amount: -5000)
- create_valuation(account: @account, date: 6.days.ago.to_date, amount: 17000)
- create_transaction(account: @account, date: 6.days.ago.to_date, amount: -500)
- create_transaction(account: @account, date: 4.days.ago.to_date, amount: -500)
- create_valuation(account: @account, date: 3.days.ago.to_date, amount: 17000)
- create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100)
-
- expected = [ 0, 5000, 5000, 17000, 17000, 17500, 17000, 17000, 16900, 16900 ]
- calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
-
- assert_equal expected, calculated
- end
-
- test "investment balance sync" do
- @account.update!(cash_balance: 18000)
-
- # Transactions represent deposits / withdrawals from the brokerage account
- # Ex: We deposit $20,000 into the brokerage account
- create_transaction(account: @account, date: 2.days.ago.to_date, amount: -20000)
-
- # Trades either consume cash (buy) or generate cash (sell). They do NOT change total balance, but do affect composition of cash/holdings.
- # Ex: We buy 20 shares of MSFT at $100 for a total of $2000
- create_trade(securities(:msft), account: @account, date: 1.day.ago.to_date, qty: 20, price: 100)
-
- holdings = [
- Account::Holding.new(date: Date.current, security: securities(:msft), amount: 2000, currency: "USD"),
- Account::Holding.new(date: 1.day.ago.to_date, security: securities(:msft), amount: 2000, currency: "USD"),
- Account::Holding.new(date: 2.days.ago.to_date, security: securities(:msft), amount: 0, currency: "USD")
- ]
-
- expected = [ 0, 20000, 20000, 20000 ]
- calculated_backwards = Account::BalanceCalculator.new(@account, holdings: holdings).calculate(reverse: true).sort_by(&:date).map(&:balance)
- calculated_forwards = Account::BalanceCalculator.new(@account, holdings: holdings).calculate.sort_by(&:date).map(&:balance)
-
- assert_equal calculated_forwards, calculated_backwards
- assert_equal expected, calculated_forwards
- end
-
- test "multi-currency sync" do
- ExchangeRate.create! date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2
-
- create_transaction(account: @account, date: 3.days.ago.to_date, amount: -100, currency: "USD")
- create_transaction(account: @account, date: 2.days.ago.to_date, amount: -300, currency: "USD")
-
- # Transaction in different currency than the account's main currency
- create_transaction(account: @account, date: 1.day.ago.to_date, amount: -500, currency: "EUR") # €500 * 1.2 = $600
-
- expected = [ 0, 100, 400, 1000, 1000 ]
- calculated = Account::BalanceCalculator.new(@account).calculate.sort_by(&:date).map(&:balance)
-
- assert_equal expected, calculated
- end
-
- private
- def create_holding(date:, security:, amount:)
- Account::Holding.create!(
- account: @account,
- security: security,
- date: date,
- qty: 0, # not used
- price: 0, # not used
- amount: amount,
- currency: @account.currency
- )
- end
-end
diff --git a/test/models/account/holding/forward_calculator_test.rb b/test/models/account/holding/forward_calculator_test.rb
new file mode 100644
index 00000000..70fd0e2a
--- /dev/null
+++ b/test/models/account/holding/forward_calculator_test.rb
@@ -0,0 +1,146 @@
+require "test_helper"
+
+class Account::Holding::ForwardCalculatorTest < ActiveSupport::TestCase
+ include Account::EntriesTestHelper
+
+ setup do
+ @account = families(:empty).accounts.create!(
+ name: "Test",
+ balance: 20000,
+ cash_balance: 20000,
+ currency: "USD",
+ accountable: Investment.new
+ )
+ end
+
+ test "no holdings" do
+ calculated = Account::Holding::ForwardCalculator.new(@account).calculate
+ assert_equal [], calculated
+ end
+
+ test "forward portfolio calculation" do
+ load_prices
+
+ # Build up to 10 shares of VOO (current value $5000)
+ create_trade(@voo, qty: 20, date: 3.days.ago.to_date, price: 470, account: @account)
+ create_trade(@voo, qty: -15, date: 2.days.ago.to_date, price: 480, account: @account)
+ create_trade(@voo, qty: 5, date: 1.day.ago.to_date, price: 490, account: @account)
+
+ # Amazon won't exist in current holdings because qty is zero, but should show up in historical portfolio
+ create_trade(@amzn, qty: 1, date: 2.days.ago.to_date, price: 200, account: @account)
+ create_trade(@amzn, qty: -1, date: 1.day.ago.to_date, price: 200, account: @account)
+
+ # Build up to 100 shares of WMT (current value $10000)
+ create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account)
+
+ expected = [
+ # 4 days ago
+ Account::Holding.new(security: @voo, date: 4.days.ago.to_date, qty: 0, price: 460, amount: 0),
+ Account::Holding.new(security: @wmt, date: 4.days.ago.to_date, qty: 0, price: 100, amount: 0),
+ Account::Holding.new(security: @amzn, date: 4.days.ago.to_date, qty: 0, price: 200, amount: 0),
+
+ # 3 days ago
+ Account::Holding.new(security: @voo, date: 3.days.ago.to_date, qty: 20, price: 470, amount: 9400),
+ Account::Holding.new(security: @wmt, date: 3.days.ago.to_date, qty: 0, price: 100, amount: 0),
+ Account::Holding.new(security: @amzn, date: 3.days.ago.to_date, qty: 0, price: 200, amount: 0),
+
+ # 2 days ago
+ Account::Holding.new(security: @voo, date: 2.days.ago.to_date, qty: 5, price: 480, amount: 2400),
+ Account::Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0),
+ Account::Holding.new(security: @amzn, date: 2.days.ago.to_date, qty: 1, price: 200, amount: 200),
+
+ # 1 day ago
+ Account::Holding.new(security: @voo, date: 1.day.ago.to_date, qty: 10, price: 490, amount: 4900),
+ Account::Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000),
+ Account::Holding.new(security: @amzn, date: 1.day.ago.to_date, qty: 0, price: 200, amount: 0),
+
+ # Today
+ Account::Holding.new(security: @voo, date: Date.current, qty: 10, price: 500, amount: 5000),
+ Account::Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000),
+ Account::Holding.new(security: @amzn, date: Date.current, qty: 0, price: 200, amount: 0)
+ ]
+
+ calculated = Account::Holding::ForwardCalculator.new(@account).calculate
+
+ assert_equal expected.length, calculated.length
+ assert_holdings(expected, calculated)
+ end
+
+ # Carries the previous record forward if no holding exists for a date
+ # to ensure that net worth historical rollups have a value for every date
+ test "uses locf to fill missing holdings" do
+ load_prices
+
+ create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account)
+
+ expected = [
+ Account::Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0),
+ Account::Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000),
+ Account::Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000)
+ ]
+
+ # Price missing today, so we should carry forward the holding from 1 day ago
+ Security.stubs(:find).returns(@wmt)
+ Security::Price.stubs(:find_price).with(security: @wmt, date: 2.days.ago.to_date).returns(Security::Price.new(price: 100))
+ Security::Price.stubs(:find_price).with(security: @wmt, date: 1.day.ago.to_date).returns(Security::Price.new(price: 100))
+ Security::Price.stubs(:find_price).with(security: @wmt, date: Date.current).returns(nil)
+
+ calculated = Account::Holding::ForwardCalculator.new(@account).calculate
+
+ assert_equal expected.length, calculated.length
+ assert_holdings(expected, calculated)
+ end
+
+ test "offline tickers sync holdings based on most recent trade price" do
+ offline_security = Security.create!(ticker: "OFFLINE", name: "Offline Ticker")
+
+ create_trade(offline_security, qty: 1, date: 3.days.ago.to_date, price: 90, account: @account)
+ create_trade(offline_security, qty: 1, date: 1.day.ago.to_date, price: 100, account: @account)
+
+ expected = [
+ Account::Holding.new(security: offline_security, date: 3.days.ago.to_date, qty: 1, price: 90, amount: 90),
+ Account::Holding.new(security: offline_security, date: 2.days.ago.to_date, qty: 1, price: 90, amount: 90),
+ Account::Holding.new(security: offline_security, date: 1.day.ago.to_date, qty: 2, price: 100, amount: 200),
+ Account::Holding.new(security: offline_security, date: Date.current, qty: 2, price: 100, amount: 200)
+ ]
+
+ calculated = Account::Holding::ForwardCalculator.new(@account).calculate
+
+ assert_equal expected.length, calculated.length
+ assert_holdings(expected, calculated)
+ end
+
+ private
+ def assert_holdings(expected, calculated)
+ expected.each do |expected_entry|
+ calculated_entry = calculated.find { |c| c.security == expected_entry.security && c.date == expected_entry.date }
+
+ assert_equal expected_entry.qty, calculated_entry.qty, "Qty mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}"
+ assert_equal expected_entry.price, calculated_entry.price, "Price mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}"
+ assert_equal expected_entry.amount, calculated_entry.amount, "Amount mismatch for #{expected_entry.security.ticker} on #{expected_entry.date}"
+ end
+ end
+
+ def load_prices
+ @voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF")
+ Security::Price.create!(security: @voo, date: 4.days.ago.to_date, price: 460)
+ Security::Price.create!(security: @voo, date: 3.days.ago.to_date, price: 470)
+ Security::Price.create!(security: @voo, date: 2.days.ago.to_date, price: 480)
+ Security::Price.create!(security: @voo, date: 1.day.ago.to_date, price: 490)
+ Security::Price.create!(security: @voo, date: Date.current, price: 500)
+
+ @wmt = Security.create!(ticker: "WMT", name: "Walmart Inc.")
+ Security::Price.create!(security: @wmt, date: 4.days.ago.to_date, price: 100)
+ Security::Price.create!(security: @wmt, date: 3.days.ago.to_date, price: 100)
+ Security::Price.create!(security: @wmt, date: 2.days.ago.to_date, price: 100)
+ Security::Price.create!(security: @wmt, date: 1.day.ago.to_date, price: 100)
+ Security::Price.create!(security: @wmt, date: Date.current, price: 100)
+
+ @amzn = Security.create!(ticker: "AMZN", name: "Amazon.com Inc.")
+ Security::Price.create!(security: @amzn, date: 4.days.ago.to_date, price: 200)
+ Security::Price.create!(security: @amzn, date: 3.days.ago.to_date, price: 200)
+ Security::Price.create!(security: @amzn, date: 2.days.ago.to_date, price: 200)
+ Security::Price.create!(security: @amzn, date: 1.day.ago.to_date, price: 200)
+ Security::Price.create!(security: @amzn, date: Date.current, price: 200)
+ end
+end
diff --git a/test/models/account/holding/portfolio_cache_test.rb b/test/models/account/holding/portfolio_cache_test.rb
new file mode 100644
index 00000000..b973fa00
--- /dev/null
+++ b/test/models/account/holding/portfolio_cache_test.rb
@@ -0,0 +1,63 @@
+require "test_helper"
+
+class Account::Holding::PortfolioCacheTest < ActiveSupport::TestCase
+ include Account::EntriesTestHelper
+
+ setup do
+ # Prices, highest to lowest priority
+ @db_price = 210
+ @provider_price = 220
+ @trade_price = 200
+ @holding_price = 250
+
+ @account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 10000, currency: "USD", accountable: Investment.new)
+ @test_security = Security.create!(name: "Test Security", ticker: "TEST")
+
+ @trade = create_trade(@test_security, account: @account, qty: 1, date: Date.current, price: @trade_price)
+ @holding = Account::Holding.create!(security: @test_security, account: @account, date: Date.current, qty: 1, price: @holding_price, amount: @holding_price, currency: "USD")
+ Security::Price.create!(security: @test_security, date: Date.current, price: @db_price)
+ end
+
+ test "gets price from DB if available" do
+ cache = Account::Holding::PortfolioCache.new(@account)
+
+ assert_equal @db_price, cache.get_price(@test_security.id, Date.current).price
+ end
+
+ test "if no price in DB, try fetching from provider" do
+ Security::Price.destroy_all
+ Security::Price.expects(:find_prices)
+ .with(security: @test_security, start_date: @account.start_date, end_date: Date.current)
+ .returns([
+ Security::Price.new(security: @test_security, date: Date.current, price: @provider_price, currency: "USD")
+ ])
+
+ cache = Account::Holding::PortfolioCache.new(@account)
+
+ assert_equal @provider_price, cache.get_price(@test_security.id, Date.current).price
+ end
+
+ test "if no price from db or provider, try getting the price from trades" do
+ Security::Price.destroy_all # No DB prices
+ Security::Price.expects(:find_prices)
+ .with(security: @test_security, start_date: @account.start_date, end_date: Date.current)
+ .returns([]) # No provider prices
+
+ cache = Account::Holding::PortfolioCache.new(@account)
+
+ assert_equal @trade_price, cache.get_price(@test_security.id, Date.current).price
+ end
+
+ test "if no price from db, provider, or trades, search holdings" do
+ Security::Price.destroy_all # No DB prices
+ Security::Price.expects(:find_prices)
+ .with(security: @test_security, start_date: @account.start_date, end_date: Date.current)
+ .returns([]) # No provider prices
+
+ @account.entries.destroy_all # No prices from trades
+
+ cache = Account::Holding::PortfolioCache.new(@account, use_holdings: true)
+
+ assert_equal @holding_price, cache.get_price(@test_security.id, Date.current).price
+ end
+end
diff --git a/test/models/account/holding_calculator_test.rb b/test/models/account/holding/reverse_calculator_test.rb
similarity index 61%
rename from test/models/account/holding_calculator_test.rb
rename to test/models/account/holding/reverse_calculator_test.rb
index 154c8afe..6e9535e5 100644
--- a/test/models/account/holding_calculator_test.rb
+++ b/test/models/account/holding/reverse_calculator_test.rb
@@ -1,6 +1,6 @@
require "test_helper"
-class Account::HoldingCalculatorTest < ActiveSupport::TestCase
+class Account::Holding::ReverseCalculatorTest < ActiveSupport::TestCase
include Account::EntriesTestHelper
setup do
@@ -14,10 +14,8 @@ class Account::HoldingCalculatorTest < ActiveSupport::TestCase
end
test "no holdings" do
- forward = Account::HoldingCalculator.new(@account).calculate
- reverse = Account::HoldingCalculator.new(@account).calculate(reverse: true)
- assert_equal forward, reverse
- assert_equal [], forward
+ calculated = Account::Holding::ReverseCalculator.new(@account).calculate
+ assert_equal [], calculated
end
# Should be able to handle this case, although we should not be reverse-syncing an account without provided current day holdings
@@ -28,7 +26,7 @@ class Account::HoldingCalculatorTest < ActiveSupport::TestCase
create_trade(voo, qty: -10, date: Date.current, price: 470, account: @account)
- calculated = Account::HoldingCalculator.new(@account).calculate(reverse: true)
+ calculated = Account::Holding::ReverseCalculator.new(@account).calculate
assert_equal 2, calculated.length
end
@@ -74,7 +72,7 @@ class Account::HoldingCalculatorTest < ActiveSupport::TestCase
Account::Holding.new(security: @amzn, date: Date.current, qty: 0, price: 200, amount: 0)
]
- calculated = Account::HoldingCalculator.new(@account).calculate(reverse: true)
+ calculated = Account::Holding::ReverseCalculator.new(@account).calculate
assert_equal expected.length, calculated.length
@@ -87,80 +85,6 @@ class Account::HoldingCalculatorTest < ActiveSupport::TestCase
end
end
- test "forward portfolio calculation" do
- load_prices
-
- # Build up to 10 shares of VOO (current value $5000)
- create_trade(@voo, qty: 20, date: 3.days.ago.to_date, price: 470, account: @account)
- create_trade(@voo, qty: -15, date: 2.days.ago.to_date, price: 480, account: @account)
- create_trade(@voo, qty: 5, date: 1.day.ago.to_date, price: 490, account: @account)
-
- # Amazon won't exist in current holdings because qty is zero, but should show up in historical portfolio
- create_trade(@amzn, qty: 1, date: 2.days.ago.to_date, price: 200, account: @account)
- create_trade(@amzn, qty: -1, date: 1.day.ago.to_date, price: 200, account: @account)
-
- # Build up to 100 shares of WMT (current value $10000)
- create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account)
-
- expected = [
- # 4 days ago
- Account::Holding.new(security: @voo, date: 4.days.ago.to_date, qty: 0, price: 460, amount: 0),
- Account::Holding.new(security: @wmt, date: 4.days.ago.to_date, qty: 0, price: 100, amount: 0),
- Account::Holding.new(security: @amzn, date: 4.days.ago.to_date, qty: 0, price: 200, amount: 0),
-
- # 3 days ago
- Account::Holding.new(security: @voo, date: 3.days.ago.to_date, qty: 20, price: 470, amount: 9400),
- Account::Holding.new(security: @wmt, date: 3.days.ago.to_date, qty: 0, price: 100, amount: 0),
- Account::Holding.new(security: @amzn, date: 3.days.ago.to_date, qty: 0, price: 200, amount: 0),
-
- # 2 days ago
- Account::Holding.new(security: @voo, date: 2.days.ago.to_date, qty: 5, price: 480, amount: 2400),
- Account::Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0),
- Account::Holding.new(security: @amzn, date: 2.days.ago.to_date, qty: 1, price: 200, amount: 200),
-
- # 1 day ago
- Account::Holding.new(security: @voo, date: 1.day.ago.to_date, qty: 10, price: 490, amount: 4900),
- Account::Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000),
- Account::Holding.new(security: @amzn, date: 1.day.ago.to_date, qty: 0, price: 200, amount: 0),
-
- # Today
- Account::Holding.new(security: @voo, date: Date.current, qty: 10, price: 500, amount: 5000),
- Account::Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000),
- Account::Holding.new(security: @amzn, date: Date.current, qty: 0, price: 200, amount: 0)
- ]
-
- calculated = Account::HoldingCalculator.new(@account).calculate
-
- assert_equal expected.length, calculated.length
- assert_holdings(expected, calculated)
- end
-
- # Carries the previous record forward if no holding exists for a date
- # to ensure that net worth historical rollups have a value for every date
- test "uses locf to fill missing holdings" do
- load_prices
-
- create_trade(@wmt, qty: 100, date: 1.day.ago.to_date, price: 100, account: @account)
-
- expected = [
- Account::Holding.new(security: @wmt, date: 2.days.ago.to_date, qty: 0, price: 100, amount: 0),
- Account::Holding.new(security: @wmt, date: 1.day.ago.to_date, qty: 100, price: 100, amount: 10000),
- Account::Holding.new(security: @wmt, date: Date.current, qty: 100, price: 100, amount: 10000)
- ]
-
- # Price missing today, so we should carry forward the holding from 1 day ago
- Security.stubs(:find).returns(@wmt)
- Security::Price.stubs(:find_price).with(security: @wmt, date: 2.days.ago.to_date).returns(Security::Price.new(price: 100))
- Security::Price.stubs(:find_price).with(security: @wmt, date: 1.day.ago.to_date).returns(Security::Price.new(price: 100))
- Security::Price.stubs(:find_price).with(security: @wmt, date: Date.current).returns(nil)
-
- calculated = Account::HoldingCalculator.new(@account).calculate
-
- assert_equal expected.length, calculated.length
-
- assert_holdings(expected, calculated)
- end
-
private
def assert_holdings(expected, calculated)
expected.each do |expected_entry|
diff --git a/test/models/account/holding/syncer_test.rb b/test/models/account/holding/syncer_test.rb
new file mode 100644
index 00000000..43ca5dcb
--- /dev/null
+++ b/test/models/account/holding/syncer_test.rb
@@ -0,0 +1,29 @@
+require "test_helper"
+
+class Account::Holding::SyncerTest < ActiveSupport::TestCase
+ include Account::EntriesTestHelper
+
+ setup do
+ @family = families(:empty)
+ @account = @family.accounts.create!(name: "Test", balance: 20000, cash_balance: 20000, currency: "USD", accountable: Investment.new)
+ @aapl = securities(:aapl)
+ end
+
+ test "syncs holdings" do
+ create_trade(@aapl, account: @account, qty: 1, price: 200, date: Date.current)
+
+ # Should have yesterday's and today's holdings
+ assert_difference "@account.holdings.count", 2 do
+ Account::Holding::Syncer.new(@account, strategy: :forward).sync_holdings
+ end
+ end
+
+ test "purges stale holdings for unlinked accounts" do
+ # Since the account has no entries, there should be no holdings
+ Account::Holding.create!(account: @account, security: @aapl, qty: 1, price: 100, amount: 100, currency: "USD", date: Date.current)
+
+ assert_difference "Account::Holding.count", -1 do
+ Account::Holding::Syncer.new(@account, strategy: :forward).sync_holdings
+ end
+ end
+end
diff --git a/test/models/account/syncer_test.rb b/test/models/account/syncer_test.rb
deleted file mode 100644
index 5cc85ee9..00000000
--- a/test/models/account/syncer_test.rb
+++ /dev/null
@@ -1,65 +0,0 @@
-require "test_helper"
-
-class Account::SyncerTest < ActiveSupport::TestCase
- include Account::EntriesTestHelper
-
- setup do
- @account = families(:empty).accounts.create!(
- name: "Test",
- balance: 20000,
- cash_balance: 20000,
- currency: "USD",
- accountable: Investment.new
- )
- end
-
- test "converts foreign account balances and holdings to family currency" do
- @account.family.update! currency: "USD"
- @account.update! currency: "EUR"
-
- @account.entries.create!(date: 1.day.ago.to_date, currency: "EUR", amount: 500, name: "Buy AAPL", entryable: Account::Trade.new(security: securities(:aapl), qty: 10, price: 50, currency: "EUR"))
-
- ExchangeRate.create!(date: 1.day.ago.to_date, from_currency: "EUR", to_currency: "USD", rate: 1.2)
- ExchangeRate.create!(date: Date.current, from_currency: "EUR", to_currency: "USD", rate: 2)
-
- Account::BalanceCalculator.any_instance.expects(:calculate).returns(
- [
- Account::Balance.new(date: 1.day.ago.to_date, balance: 1000, cash_balance: 1000, currency: "EUR"),
- Account::Balance.new(date: Date.current, balance: 1000, cash_balance: 1000, currency: "EUR")
- ]
- )
-
- Account::HoldingCalculator.any_instance.expects(:calculate).returns(
- [
- Account::Holding.new(security: securities(:aapl), date: 1.day.ago.to_date, qty: 10, price: 50, amount: 500, currency: "EUR"),
- Account::Holding.new(security: securities(:aapl), date: Date.current, qty: 10, price: 50, amount: 500, currency: "EUR")
- ]
- )
-
- Account::Syncer.new(@account).run
-
- assert_equal [ 1000, 1000 ], @account.balances.where(currency: "EUR").chronological.map(&:balance)
- assert_equal [ 1200, 2000 ], @account.balances.where(currency: "USD").chronological.map(&:balance)
- assert_equal [ 500, 500 ], @account.holdings.where(currency: "EUR").chronological.map(&:amount)
- assert_equal [ 600, 1000 ], @account.holdings.where(currency: "USD").chronological.map(&:amount)
- end
-
- test "purges stale balances and holdings" do
- # Old, out of range holdings and balances
- @account.holdings.create!(security: securities(:aapl), date: 10.years.ago.to_date, currency: "USD", qty: 100, price: 100, amount: 10000)
- @account.balances.create!(date: 10.years.ago.to_date, currency: "USD", balance: 10000, cash_balance: 10000)
-
- assert_equal 1, @account.holdings.count
- assert_equal 1, @account.balances.count
-
- Account::Syncer.new(@account).run
-
- @account.reload
-
- assert_equal 0, @account.holdings.count
-
- # Balance sync always creates 1 balance if no entries present.
- assert_equal 1, @account.balances.count
- assert_equal 0, @account.balances.first.balance
- end
-end
From 5f8a3c9f508cfbc24f265b9d8f6a6e79dae84ece Mon Sep 17 00:00:00 2001
From: Zach Gollwitzer
Date: Fri, 7 Mar 2025 17:48:26 -0500
Subject: [PATCH 37/47] Search securities with correct exchange mic
---
app/models/security/price/provided.rb | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/models/security/price/provided.rb b/app/models/security/price/provided.rb
index aed56702..c429e0a6 100644
--- a/app/models/security/price/provided.rb
+++ b/app/models/security/price/provided.rb
@@ -15,7 +15,7 @@ module Security::Price::Provided
response = provider.fetch_security_prices \
ticker: security.ticker,
- mic_code: security.exchange_mic,
+ mic_code: security.exchange_operating_mic,
start_date: date,
end_date: date
@@ -40,7 +40,7 @@ module Security::Price::Provided
response = provider.fetch_security_prices \
ticker: security.ticker,
- mic_code: security.exchange_mic,
+ mic_code: security.exchange_operating_mic,
start_date: start_date,
end_date: end_date
From 86bf47a32ead80b33161d5bc5afaf78ee20f432f Mon Sep 17 00:00:00 2001
From: Zach Gollwitzer
Date: Fri, 7 Mar 2025 18:02:08 -0500
Subject: [PATCH 38/47] Ensure holdings are normalized to account currency
---
app/models/account/holding/portfolio_cache.rb | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/models/account/holding/portfolio_cache.rb b/app/models/account/holding/portfolio_cache.rb
index 6a839382..bb6035cf 100644
--- a/app/models/account/holding/portfolio_cache.rb
+++ b/app/models/account/holding/portfolio_cache.rb
@@ -37,7 +37,7 @@ class Account::Holding::PortfolioCache
security_id: security_id,
date: price.date,
price: converted_amount,
- currency: price.currency
+ currency: account.currency
)
end
From a3cd5f4f1d589b933d8a400eaf63744b4427da96 Mon Sep 17 00:00:00 2001
From: Zach Gollwitzer
Date: Fri, 7 Mar 2025 19:09:54 -0500
Subject: [PATCH 39/47] Format money for trade history in holdings drawer
(#1961)
* Format money for trade history in holdings drawer
* Fix broken tests
* Lint fix
---
app/views/account/holdings/show.html.erb | 2 +-
app/views/accounts/show/_chart.html.erb | 3 +--
lib/money/formatting.rb | 10 +++++++++-
test/models/security/price_test.rb | 6 +++---
4 files changed, 14 insertions(+), 7 deletions(-)
diff --git a/app/views/account/holdings/show.html.erb b/app/views/account/holdings/show.html.erb
index b783ac5b..12199512 100644
--- a/app/views/account/holdings/show.html.erb
+++ b/app/views/account/holdings/show.html.erb
@@ -73,7 +73,7 @@
".trade_history_entry",
qty: trade_entry.account_trade.qty,
security: trade_entry.account_trade.security.ticker,
- price: format_money(trade_entry.account_trade.price)
+ price: trade_entry.account_trade.price_money.format
) %>
diff --git a/app/views/accounts/show/_chart.html.erb b/app/views/accounts/show/_chart.html.erb
index e6dadae8..47c91fb0 100644
--- a/app/views/accounts/show/_chart.html.erb
+++ b/app/views/accounts/show/_chart.html.erb
@@ -21,8 +21,7 @@
[["Total value", "balance"], ["Holdings", "holdings_balance"], ["Cash", "cash_balance"]],
{ selected: chart_view },
class: "border border-secondary rounded-lg text-sm pr-7 cursor-pointer text-primary focus:outline-hidden focus:ring-0",
- data: { "auto-submit-form-target": "auto" }
- %>
+ data: { "auto-submit-form-target": "auto" } %>
<% end %>
<%= period_select form: form, selected: period %>
diff --git a/lib/money/formatting.rb b/lib/money/formatting.rb
index 25185b1a..cd160bef 100644
--- a/lib/money/formatting.rb
+++ b/lib/money/formatting.rb
@@ -13,7 +13,7 @@ module Money::Formatting
local_option_overrides = locale_options(locale)
{
- unit: currency.symbol,
+ unit: get_symbol,
precision: currency.default_precision,
delimiter: currency.delimiter,
separator: currency.separator,
@@ -22,6 +22,14 @@ module Money::Formatting
end
private
+ def get_symbol
+ if currency.symbol == "$" && currency.iso_code != "USD"
+ [ currency.iso_code.first(2), currency.symbol ].join
+ else
+ currency.symbol
+ end
+ end
+
def locale_options(locale)
case [ currency.iso_code, locale.to_sym ]
when [ "EUR", :nl ], [ "EUR", :pt ]
diff --git a/test/models/security/price_test.rb b/test/models/security/price_test.rb
index 66b60469..32dd00f3 100644
--- a/test/models/security/price_test.rb
+++ b/test/models/security/price_test.rb
@@ -33,7 +33,7 @@ class Security::PriceTest < ActiveSupport::TestCase
tomorrow = Date.current + 1.day
@provider.expects(:fetch_security_prices)
- .with(ticker: security.ticker, mic_code: security.exchange_mic, start_date: tomorrow, end_date: tomorrow)
+ .with(ticker: security.ticker, mic_code: security.exchange_operating_mic, start_date: tomorrow, end_date: tomorrow)
.once
.returns(
OpenStruct.new(
@@ -54,7 +54,7 @@ class Security::PriceTest < ActiveSupport::TestCase
Security::Price.delete_all # Clear any existing prices
@provider.expects(:fetch_security_prices)
- .with(ticker: security.ticker, mic_code: security.exchange_mic, start_date: Date.current, end_date: Date.current)
+ .with(ticker: security.ticker, mic_code: security.exchange_operating_mic, start_date: Date.current, end_date: Date.current)
.once
.returns(OpenStruct.new(success?: false))
@@ -91,7 +91,7 @@ class Security::PriceTest < ActiveSupport::TestCase
@provider.expects(:fetch_security_prices)
.with(ticker: security.ticker,
- mic_code: security.exchange_mic,
+ mic_code: security.exchange_operating_mic,
start_date: 2.days.ago.to_date,
end_date: 2.days.ago.to_date)
.returns(OpenStruct.new(success?: true, prices: [ { date: 2.days.ago.to_date, price: missing_price, currency: "USD" } ]))
From 4b19ca50ebc901096937e74cbd20a42c2efbaf9c Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Mon, 10 Mar 2025 09:13:05 -0400
Subject: [PATCH 40/47] Bump i18n-tasks from 1.0.14 to 1.0.15 (#1974)
Bumps [i18n-tasks](https://github.com/glebm/i18n-tasks) from 1.0.14 to 1.0.15.
- [Release notes](https://github.com/glebm/i18n-tasks/releases)
- [Changelog](https://github.com/glebm/i18n-tasks/blob/main/CHANGES.md)
- [Commits](https://github.com/glebm/i18n-tasks/compare/v1.0.14...v1.0.15)
---
updated-dependencies:
- dependency-name: i18n-tasks
dependency-type: direct:development
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot]