1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 05:09:38 +02:00

Plaid portfolio sync algorithm and calculation improvements (#1526)

* Start tests rework

* Cash balance on schema

* Add reverse syncer

* Reverse balance sync with holdings

* Reverse holdings sync

* Reverse holdings sync should work with only trade entries

* Consolidate brokerage cash

* Add forward sync option

* Update new balance info after syncs

* Intraday balance calculator and sync fixes

* Show only balance for trade entries

* Tests passing

* Update Gemfile.lock

* Cleanup, performance improvements

* Remove account reloads for reliable sync outputs

* Simplify valuation view logic

* Special handling for Plaid cash holding
This commit is contained in:
Zach Gollwitzer 2024-12-10 17:41:20 -05:00 committed by GitHub
parent a59ca5b7c6
commit 49c353e10c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 1152 additions and 1046 deletions

View file

@ -50,7 +50,6 @@ gem "csv"
gem "redcarpet"
gem "stripe"
gem "intercom-rails"
gem "holidays"
gem "plaid"
group :development, :test do

View file

@ -185,7 +185,6 @@ GEM
thor (>= 1.0.0)
hashdiff (1.1.1)
highline (3.0.1)
holidays (8.8.0)
hotwire-livereload (1.4.1)
actioncable (>= 6.0.0)
listen (>= 3.0.0)
@ -493,7 +492,6 @@ DEPENDENCIES
faraday-multipart
faraday-retry
good_job
holidays
hotwire-livereload
hotwire_combobox
i18n-tasks

View file

@ -1,7 +0,0 @@
class Account::CashesController < ApplicationController
layout :with_sidebar
def index
@account = Current.family.accounts.find(params[:account_id])
end
end

View file

@ -5,8 +5,6 @@ class Account::HoldingsController < ApplicationController
def index
@account = Current.family.accounts.find(params[:account_id])
@holdings = Current.family.holdings.current
@holdings = @holdings.where(account: @account) if @account
end
def show

View file

@ -4,8 +4,8 @@ class AccountsController < ApplicationController
before_action :set_account, only: %i[sync]
def index
@manual_accounts = Current.family.accounts.manual.alphabetically
@plaid_items = Current.family.plaid_items.ordered
@manual_accounts = Current.family.accounts.where(scheduled_for_deletion: false).manual.alphabetically
@plaid_items = Current.family.plaid_items.where(scheduled_for_deletion: false).ordered
end
def summary
@ -14,7 +14,7 @@ class AccountsController < ApplicationController
@net_worth_series = snapshot[:net_worth_series]
@asset_series = snapshot[:asset_series]
@liability_series = snapshot[:liability_series]
@accounts = Current.family.accounts
@accounts = Current.family.accounts.active
@account_groups = @accounts.by_group(period: @period, currency: Current.family.currency)
end

View file

@ -3,6 +3,7 @@ module Localize
included do
around_action :switch_locale
around_action :switch_timezone
end
private
@ -10,4 +11,9 @@ module Localize
locale = Current.family.try(:locale) || I18n.default_locale
I18n.with_locale(locale, &action)
end
def switch_timezone(&action)
timezone = Current.family.try(:timezone) || Time.zone
Time.use_zone(timezone, &action)
end
end

View file

@ -41,7 +41,7 @@ class UsersController < ApplicationController
def user_params
params.require(:user).permit(
:first_name, :last_name, :profile_image, :redirect_to, :delete_profile_image, :onboarded_at,
family_attributes: [ :name, :currency, :country, :locale, :date_format, :id ]
family_attributes: [ :name, :currency, :country, :locale, :date_format, :timezone, :id ]
)
end

View file

@ -13,17 +13,12 @@ module Account::EntriesHelper
end
def entries_by_date(entries, selectable: true, totals: false)
entries.group_by(&:date).map do |date, grouped_entries|
# Valuations always go first, then sort by created_at desc
sorted_entries = grouped_entries.sort_by do |entry|
[ entry.account_valuation? ? 0 : 1, -entry.created_at.to_i ]
end
entries.reverse_chronological.group_by(&:date).map do |date, grouped_entries|
content = capture do
yield sorted_entries
yield grouped_entries
end
render partial: "account/entries/entry_group", locals: { date:, entries: sorted_entries, content:, selectable:, totals: }
render partial: "account/entries/entry_group", locals: { date:, entries: grouped_entries, content:, selectable:, totals: }
end.join.html_safe
end

View file

@ -1,13 +1,13 @@
module Account::CashesHelper
def brokerage_cash(account)
module Account::HoldingsHelper
def brokerage_cash_holding(account)
currency = Money::Currency.new(account.currency)
account.holdings.build \
date: Date.current,
qty: account.balance,
qty: account.cash_balance,
price: 1,
amount: account.balance,
currency: account.currency,
amount: account.cash_balance,
currency: currency.iso_code,
security: Security.new(ticker: currency.iso_code, name: currency.name)
end
end

View file

@ -363,4 +363,8 @@ module LanguagesHelper
end
.sort_by { |label, locale| label }
end
def timezone_options
ActiveSupport::TimeZone.all.map { |tz| [ tz.name + " (#{tz.tzinfo.identifier})", tz.tzinfo.identifier ] }
end
end

View file

@ -16,7 +16,7 @@ class Account < ApplicationRecord
has_many :balances, dependent: :destroy
has_many :issues, as: :issuable, dependent: :destroy
monetize :balance
monetize :balance, :cash_balance
enum :classification, { asset: "asset", liability: "liability" }, validate: { allow_nil: true }
@ -32,8 +32,6 @@ class Account < ApplicationRecord
accepts_nested_attributes_for :accountable, update_only: true
delegate :value, :series, to: :accountable
class << self
def by_group(period: Period.all, currency: Money.default_currency.iso_code)
grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
@ -59,7 +57,7 @@ class Account < ApplicationRecord
def create_and_sync(attributes)
attributes[:accountable_attributes] ||= {} # Ensure accountable is created, even if empty
account = new(attributes)
account = new(attributes.merge(cash_balance: attributes[:balance]))
transaction do
# Create 2 valuations for new accounts to establish a value history for users to see
@ -94,15 +92,27 @@ class Account < ApplicationRecord
def sync_data(start_date: nil)
update!(last_synced_at: Time.current)
resolve_stale_issues
Balance::Syncer.new(self, start_date: start_date).run
Holding::Syncer.new(self, start_date: start_date).run
Syncer.new(self, start_date: start_date).run
end
def post_sync
broadcast_remove_to(family, target: "syncing-notice")
resolve_stale_issues
accountable.post_sync
end
def series(period: Period.last_30_days, currency: nil)
balance_series = balances.in_period(period).where(currency: currency || self.currency)
if balance_series.empty? && period.date_range.end == Date.current
TimeSeries.new([ { date: Date.current, value: balance_money.exchange_to(currency || self.currency) } ])
else
TimeSeries.from_collection(balance_series, :balance_money, favorable_direction: asset? ? "up" : "down")
end
rescue Money::ConversionError
TimeSeries.new([])
end
def original_balance
balance_amount = balances.chronological.first&.balance || balance
Money.new(balance_amount, currency)

View file

@ -1,57 +0,0 @@
class Account::Balance::Calculator
def initialize(account, sync_start_date)
@account = account
@sync_start_date = sync_start_date
end
def calculate(is_partial_sync: false)
cached_entries = account.entries.where("date >= ?", sync_start_date).to_a
sync_starting_balance = is_partial_sync ? find_start_balance_for_partial_sync : find_start_balance_for_full_sync(cached_entries)
prior_balance = sync_starting_balance
(sync_start_date..Date.current).map do |date|
current_balance = calculate_balance_for_date(date, entries: cached_entries, prior_balance:)
prior_balance = current_balance
build_balance(date, current_balance)
end
end
private
attr_reader :account, :sync_start_date
def find_start_balance_for_partial_sync
account.balances.find_by(currency: account.currency, date: sync_start_date - 1.day)&.balance
end
def find_start_balance_for_full_sync(cached_entries)
account.balance + net_entry_flows(cached_entries.select { |e| e.account_transaction? })
end
def calculate_balance_for_date(date, entries:, prior_balance:)
valuation = entries.find { |e| e.date == date && e.account_valuation? }
return valuation.amount if valuation
entries = entries.select { |e| e.date == date }
prior_balance - net_entry_flows(entries)
end
def net_entry_flows(entries, target_currency = account.currency)
converted_entry_amounts = entries.map { |t| t.amount_money.exchange_to(target_currency, date: t.date) }
flows = converted_entry_amounts.sum(&:amount)
account.liability? ? flows * -1 : flows
end
def build_balance(date, balance, currency = nil)
account.balances.build \
date: date,
balance: balance,
currency: currency || account.currency
end
end

View file

@ -1,46 +0,0 @@
class Account::Balance::Converter
def initialize(account, sync_start_date)
@account = account
@sync_start_date = sync_start_date
end
def convert(balances)
calculate_converted_balances(balances)
end
private
attr_reader :account, :sync_start_date
def calculate_converted_balances(balances)
from_currency = account.currency
to_currency = account.family.currency
if ExchangeRate.exchange_rates_provider.nil?
account.observe_missing_exchange_rate_provider
return []
end
exchange_rates = ExchangeRate.find_rates from: from_currency,
to: to_currency,
start_date: sync_start_date
missing_exchange_rates = balances.map(&:date) - exchange_rates.map(&:date)
if missing_exchange_rates.any?
account.observe_missing_exchange_rates(from: from_currency, to: to_currency, dates: missing_exchange_rates)
return []
end
balances.map do |balance|
exchange_rate = exchange_rates.find { |er| er.date == balance.date }
build_balance(balance.date, exchange_rate.rate * balance.balance, to_currency)
end
end
def build_balance(date, balance, currency = nil)
account.balances.build \
date: date,
balance: balance,
currency: currency || account.currency
end
end

View file

@ -1,42 +0,0 @@
class Account::Balance::Loader
def initialize(account)
@account = account
end
def load(balances, start_date)
Account::Balance.transaction do
upsert_balances!(balances)
purge_stale_balances!(start_date)
account.reload
update_account_balance!(balances)
end
end
private
attr_reader :account
def update_account_balance!(balances)
last_balance = balances.select { |db| db.currency == account.currency }.last&.balance
if account.plaid_account.present?
account.update! balance: account.plaid_account.current_balance || last_balance
else
account.update! balance: last_balance if last_balance.present?
end
end
def upsert_balances!(balances)
current_time = Time.now
balances_to_upsert = balances.map do |balance|
balance.attributes.slice("date", "balance", "currency").merge("updated_at" => current_time)
end
account.balances.upsert_all(balances_to_upsert, unique_by: %i[account_id date currency])
end
def purge_stale_balances!(start_date)
account.balances.delete_by("date < ?", start_date)
end
end

View file

@ -1,51 +0,0 @@
class Account::Balance::Syncer
def initialize(account, start_date: nil)
@account = account
@provided_start_date = start_date
@sync_start_date = calculate_sync_start_date(start_date)
@loader = Account::Balance::Loader.new(account)
@converter = Account::Balance::Converter.new(account, sync_start_date)
@calculator = Account::Balance::Calculator.new(account, sync_start_date)
end
def run
daily_balances = calculator.calculate(is_partial_sync: is_partial_sync?)
daily_balances += converter.convert(daily_balances) if account.currency != account.family.currency
loader.load(daily_balances, account_start_date)
rescue Money::ConversionError => e
account.observe_missing_exchange_rates(from: e.from_currency, to: e.to_currency, dates: [ e.date ])
end
private
attr_reader :sync_start_date, :provided_start_date, :account, :loader, :converter, :calculator
def account_start_date
@account_start_date ||= begin
oldest_entry = account.entries.chronological.first
return Date.current unless oldest_entry.present?
if oldest_entry.account_valuation?
oldest_entry.date
else
oldest_entry.date - 1.day
end
end
end
def calculate_sync_start_date(provided_start_date)
return provided_start_date if provided_start_date.present? && prior_balance_available?(provided_start_date)
account_start_date
end
def prior_balance_available?(date)
account.balances.find_by(currency: account.currency, date: date - 1.day).present?
end
def is_partial_sync?
sync_start_date == provided_start_date
end
end

View file

@ -0,0 +1,121 @@
class Account::BalanceCalculator
def initialize(account, holdings: nil)
@account = account
@holdings = holdings || []
end
def calculate(reverse: false, start_date: nil)
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
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

View file

@ -0,0 +1,94 @@
# The current system calculates a single, end-of-day balance every day for each account for simplicity.
# In most cases, this is sufficient. However, for the "Activity View", we need to show intraday balances
# to show users how each entry affects their balances. This class calculates intraday balances by
# interpolating between end-of-day balances.
class Account::BalanceTrendCalculator
BalanceTrend = Struct.new(:trend, :cash, keyword_init: true)
class << self
def for(entries)
return nil if entries.blank?
account = entries.first.account
date_range = entries.minmax_by(&:date)
min_entry_date, max_entry_date = date_range.map(&:date)
# In case view is filtered and there are entry gaps, refetch all entries in range
all_entries = account.entries.where(date: min_entry_date..max_entry_date).chronological.to_a
balances = account.balances.where(date: (min_entry_date - 1.day)..max_entry_date).chronological.to_a
holdings = account.holdings.where(date: (min_entry_date - 1.day)..max_entry_date).to_a
new(all_entries, balances, holdings)
end
end
def initialize(entries, balances, holdings)
@entries = entries
@balances = balances
@holdings = holdings
end
def trend_for(entry)
intraday_balance = nil
intraday_cash_balance = nil
start_of_day_balance = balances.find { |b| b.date == entry.date - 1.day && b.currency == entry.currency }
end_of_day_balance = balances.find { |b| b.date == entry.date && b.currency == entry.currency }
return BalanceTrend.new(trend: nil) if start_of_day_balance.blank? || end_of_day_balance.blank?
todays_holdings_value = holdings.select { |h| h.date == entry.date }.sum(&:amount)
prior_balance = start_of_day_balance.balance
prior_cash_balance = start_of_day_balance.cash_balance
current_balance = nil
current_cash_balance = nil
todays_entries = entries.select { |e| e.date == entry.date }
todays_entries.each_with_index do |e, idx|
if e.account_valuation?
current_balance = e.amount
current_cash_balance = e.amount
else
multiplier = e.account.liability? ? 1 : -1
balance_change = e.account_trade? ? 0 : multiplier * e.amount
cash_change = multiplier * e.amount
current_balance = prior_balance + balance_change
current_cash_balance = prior_cash_balance + cash_change
end
if e.id == entry.id
# Final entry should always match the end-of-day balances
if idx == todays_entries.size - 1
intraday_balance = end_of_day_balance.balance
intraday_cash_balance = end_of_day_balance.cash_balance
else
intraday_balance = current_balance
intraday_cash_balance = current_cash_balance
end
break
else
prior_balance = current_balance
prior_cash_balance = current_cash_balance
end
end
return BalanceTrend.new(trend: nil) unless intraday_balance.present?
BalanceTrend.new(
trend: TimeSeries::Trend.new(
current: Money.new(intraday_balance, entry.currency),
previous: Money.new(prior_balance, entry.currency),
favorable_direction: entry.account.favorable_direction
),
cash: Money.new(intraday_cash_balance, entry.currency),
)
end
private
attr_reader :entries, :balances, :holdings
end

View file

@ -14,9 +14,22 @@ class Account::Entry < ApplicationRecord
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? }
validates :date, comparison: { greater_than: -> { min_supported_date } }
scope :chronological, -> { order(:date, :created_at) }
scope :not_account_valuations, -> { where.not(entryable_type: "Account::Valuation") }
scope :reverse_chronological, -> { order(date: :desc, created_at: :desc) }
scope :chronological, -> {
order(
date: :asc,
Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :asc,
created_at: :asc
)
}
scope :reverse_chronological, -> {
order(
date: :desc,
Arel.sql("CASE WHEN entryable_type = 'Account::Valuation' THEN 1 ELSE 0 END") => :desc,
created_at: :desc
)
}
scope :without_transfers, -> { where(marked_as_transfer: false) }
scope :with_converted_amount, ->(currency) {
# Join with exchange rates to convert the amount to the given currency
@ -30,12 +43,7 @@ class Account::Entry < ApplicationRecord
}
def sync_account_later
sync_start_date = if destroyed?
previous_entry&.date
else
[ date_previously_was, date ].compact.min
end
sync_start_date = [ date_previously_was, date ].compact.min unless destroyed?
account.sync_later(start_date: sync_start_date)
end
@ -51,45 +59,8 @@ class Account::Entry < ApplicationRecord
entryable_type.demodulize.underscore
end
def prior_balance
account.balances.find_by(date: date - 1)&.balance || 0
end
def prior_entry_balance
entries_on_entry_date
.not_account_valuations
.last
&.balance_after_entry || 0
end
def balance_after_entry
if account_valuation?
Money.new(amount, currency)
else
new_balance = prior_balance
entries_on_entry_date.each do |e|
next if e.account_valuation?
change = e.amount
change = account.liability? ? change : -change
new_balance += change
break if e == self
end
Money.new(new_balance, currency)
end
end
def trend
TimeSeries::Trend.new(
current: balance_after_entry,
previous: Money.new(prior_entry_balance, currency),
favorable_direction: account.favorable_direction
)
end
def entries_on_entry_date
account.entries.where(date: date).order(created_at: :asc)
def balance_trend(entries, balances)
Account::BalanceTrendCalculator.new(self, entries, balances).trend
end
class << self
@ -233,15 +204,4 @@ class Account::Entry < ApplicationRecord
entryable_ids
end
end
private
def previous_entry
@previous_entry ||= account
.entries
.where("date < ?", date)
.where("entryable_type = ?", entryable_type)
.order(date: :desc)
.first
end
end

View file

@ -22,9 +22,9 @@ class Account::Holding < ApplicationRecord
def weight
return nil unless amount
return 0 if amount.zero?
portfolio_value = account.holdings.current.known_value.sum(&:amount)
portfolio_value.zero? ? 1 : amount / portfolio_value * 100
account.balance.zero? ? 1 : amount / account.balance * 100
end
# Basic approximation of cost-basis

View file

@ -1,136 +0,0 @@
class Account::Holding::Syncer
def initialize(account, start_date: nil)
@account = account
end_date = account.plaid_account.present? ? 1.day.ago.to_date : Date.current
@sync_date_range = calculate_sync_start_date(start_date)..end_date
@portfolio = {}
load_prior_portfolio if start_date
end
def run
holdings = []
sync_date_range.each do |date|
holdings += build_holdings_for_date(date)
end
upsert_holdings holdings
end
private
attr_reader :account, :sync_date_range
def sync_entries
@sync_entries ||= account.entries
.account_trades
.includes(entryable: :security)
.where("date >= ?", sync_date_range.begin)
.order(:date)
end
def get_cached_price(ticker, date)
return nil unless security_prices.key?(ticker)
price = security_prices[ticker].find { |p| p.date == date }
price ? price[:price] : nil
end
def security_prices
@security_prices ||= begin
prices = {}
ticker_securities = {}
sync_entries.each do |entry|
security = entry.account_trade.security
unless ticker_securities[security.ticker]
ticker_securities[security.ticker] = {
security: security,
start_date: entry.date
}
end
end
ticker_securities.each do |ticker, data|
fetched_prices = Security::Price.find_prices(
security: data[:security],
start_date: data[:start_date],
end_date: Date.current
)
gapfilled_prices = Gapfiller.new(fetched_prices, start_date: data[:start_date], end_date: Date.current, cache: false).run
prices[ticker] = gapfilled_prices
end
prices
end
end
def build_holdings_for_date(date)
trades = sync_entries.select { |trade| trade.date == date }
@portfolio = generate_next_portfolio(@portfolio, trades)
@portfolio.map do |ticker, holding|
trade = trades.find { |trade| trade.account_trade.security_id == holding[:security_id] }
trade_price = trade&.account_trade&.price
price = get_cached_price(ticker, date) || trade_price
account.holdings.build \
date: date,
security_id: holding[:security_id],
qty: holding[:qty],
price: price,
amount: price ? (price * holding[:qty]) : nil,
currency: holding[:currency]
end
end
def generate_next_portfolio(prior_portfolio, trade_entries)
trade_entries.each_with_object(prior_portfolio) do |entry, new_portfolio|
trade = entry.account_trade
price = trade.price
prior_qty = prior_portfolio.dig(trade.security.ticker, :qty) || 0
new_qty = prior_qty + trade.qty
new_portfolio[trade.security.ticker] = {
qty: new_qty,
price: price,
amount: new_qty * price,
currency: entry.currency,
security_id: trade.security_id
}
end
end
def upsert_holdings(holdings)
current_time = Time.now
holdings_to_upsert = holdings.map do |holding|
holding.attributes
.slice("date", "currency", "qty", "price", "amount", "security_id")
.merge("updated_at" => current_time)
end
account.holdings.upsert_all(holdings_to_upsert, unique_by: %i[account_id security_id date currency])
end
def load_prior_portfolio
prior_day_holdings = account.holdings.where(date: sync_date_range.begin - 1.day)
prior_day_holdings.each do |holding|
@portfolio[holding.security.ticker] = {
qty: holding.qty,
price: holding.price,
amount: holding.amount,
currency: holding.currency,
security_id: holding.security_id
}
end
end
def calculate_sync_start_date(start_date)
start_date || account.entries.account_trades.order(:date).first.try(:date) || Date.current
end
end

View file

@ -0,0 +1,154 @@
class Account::HoldingCalculator
def initialize(account)
@account = account
@securities_cache = {}
end
def calculate(reverse: false)
preload_securities
calculated_holdings = reverse ? reverse_holdings : forward_holdings
gapfill_holdings(calculated_holdings)
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)
portfolio.map do |security_id, qty|
security = securities_cache[security_id]
price = security.dig(:prices)&.find { |p| p.date == date }
next if price.blank?
account.holdings.build(
security: security.dig(:security),
date: date,
qty: qty,
price: price.price,
currency: price.currency,
amount: qty * price.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.to_a
end
def portfolio_start_date
trades.first ? trades.first.date - 1.day : Date.current
end
def preload_securities
securities = trades.map(&:entryable).map(&:security).uniq
securities.each do |security|
prices = Security::Price.find_prices(
security: security,
start_date: portfolio_start_date,
end_date: Date.current
)
@securities_cache[security.id] = {
security: security,
prices: prices
}
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).map do |holding|
holding_quantities[holding.security_id] = holding.qty
end
holding_quantities
end
end

View file

@ -0,0 +1,104 @@
class Account::Syncer
def initialize(account, start_date: nil)
@account = account
@start_date = start_date
end
def run
holdings = sync_holdings
balances = sync_balances(holdings)
update_account_info(balances, holdings) unless account.plaid_account_id.present?
convert_foreign_records(balances)
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: account.plaid_account_id.present?)
current_time = Time.now
Account.transaction do
account.holdings.upsert_all(
calculated_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]
) if calculated_holdings.any?
# Purge outdated holdings
account.holdings.delete_by("date < ? OR security_id NOT IN (?)", account_start_date, calculated_holdings.map(&:security_id))
end
calculated_holdings
end
def sync_balances(holdings)
calculator = Account::BalanceCalculator.new(account, holdings: holdings)
calculated_balances = calculator.calculate(reverse: account.plaid_account_id.present?, start_date: start_date)
Account.transaction do
load_balances(calculated_balances)
# Purge outdated balances
account.balances.delete_by("date < ?", account_start_date)
end
calculated_balances
end
def convert_foreign_records(balances)
converted_balances = convert_balances(balances)
load_balances(converted_balances)
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]
) if balances.any?
end
def convert_balances(balances)
return [] if account.currency == account.family.currency
from_currency = account.currency
to_currency = account.family.currency
exchange_rates = ExchangeRate.find_rates(
from: from_currency,
to: to_currency,
start_date: balances.first.date
)
balances.map do |balance|
exchange_rate = exchange_rates.find { |er| er.date == balance.date }
account.balances.build(
date: balance.date,
balance: exchange_rate.rate * balance.balance,
currency: to_currency
) if exchange_rate.present?
end
end
end

View file

@ -59,7 +59,7 @@ class Account::TradeBuilder
)
else
account.entries.build(
name: signed_amount < 0 ? "Deposit from #{account.name}" : "Withdrawal to #{account.name}",
name: signed_amount < 0 ? "Deposit to #{account.name}" : "Withdrawal from #{account.name}",
date: date,
amount: signed_amount,
currency: currency,

View file

@ -10,44 +10,4 @@ class Account::Valuation < ApplicationRecord
false
end
end
def name
entry.name || (oldest? ? "Initial balance" : "Balance update")
end
def trend
@trend ||= create_trend
end
def icon
oldest? ? "plus" : entry.trend.icon
end
def color
oldest? ? "#D444F1" : entry.trend.color
end
private
def oldest?
@oldest ||= account.entries.where("date < ?", entry.date).empty?
end
def account
@account ||= entry.account
end
def create_trend
TimeSeries::Trend.new(
current: entry.amount_money,
previous: prior_balance&.balance_money,
favorable_direction: account.favorable_direction
)
end
def prior_balance
@prior_balance ||= account.balances
.where("date < ?", entry.date)
.order(date: :desc)
.first
end
end

View file

@ -18,26 +18,7 @@ module Accountable
has_one :account, as: :accountable, touch: true
end
def value
account.balance_money
end
def series(period: Period.all, currency: account.currency)
balance_series = account.balances.in_period(period).where(currency: currency)
if balance_series.empty? && period.date_range.end == Date.current
TimeSeries.new([ { date: Date.current, value: account.balance_money.exchange_to(currency) } ])
else
TimeSeries.from_collection(balance_series, :balance_money, favorable_direction: account.asset? ? "up" : "down")
end
rescue Money::ConversionError
TimeSeries.new([])
end
def post_sync
broadcast_remove_to(account.family, target: "syncing-notice")
# Broadcast a simple replace event that the controller can handle
broadcast_replace_to(
account,
target: "chart_account_#{account.id}",

View file

@ -1,48 +0,0 @@
class Gapfiller
attr_reader :series
def initialize(series, start_date:, end_date:, cache:)
@series = series
@date_range = start_date..end_date
@cache = cache
end
def run
gapfilled_records = []
date_range.each do |date|
record = series.find { |r| r.date == date }
if should_gapfill?(date, record)
prev_record = gapfilled_records.find { |r| r.date == date - 1.day }
if prev_record
new_record = create_gapfilled_record(prev_record, date)
gapfilled_records << new_record
end
else
gapfilled_records << record if record
end
end
gapfilled_records
end
private
attr_reader :date_range, :cache
def should_gapfill?(date, record)
(date.on_weekend? || holiday?(date)) && record.nil?
end
def holiday?(date)
Holidays.on(date, :federalreserve, :us, :observed, :informal).any?
end
def create_gapfilled_record(prev_record, date)
new_record = prev_record.class.new(prev_record.attributes.except("id", "created_at", "updated_at"))
new_record.date = date
new_record.save! if cache
new_record
end
end

View file

@ -16,37 +16,6 @@ class Investment < ApplicationRecord
[ "Angel", "angel" ]
].freeze
def value
account.balance_money + holdings_value
end
def holdings_value
account.holdings.current.known_value.sum(&:amount) || Money.new(0, account.currency)
end
def series(period: Period.all, currency: account.currency)
balance_series = account.balances.in_period(period).where(currency: currency)
holding_series = account.holdings.known_value.in_period(period).where(currency: currency)
holdings_by_date = holding_series.group_by(&:date).transform_values do |holdings|
holdings.sum(&:amount)
end
combined_series = balance_series.map do |balance|
holding_amount = holdings_by_date[balance.date] || 0
{ date: balance.date, value: Money.new(balance.balance + holding_amount, currency) }
end
if combined_series.empty? && period.date_range.end == Date.current
TimeSeries.new([ { date: Date.current, value: self.value.exchange_to(currency) } ])
else
TimeSeries.new(combined_series)
end
rescue Money::ConversionError
TimeSeries.new([])
end
def color
"#1570EF"
end
@ -56,8 +25,6 @@ class Investment < ApplicationRecord
end
def post_sync
broadcast_remove_to(account, target: "syncing-notice")
broadcast_replace_to(
account,
target: "chart_account_#{account.id}",

View file

@ -167,6 +167,7 @@ class PlaidAccount < ApplicationRecord
end
return nil if security.nil? || security.ticker_symbol.blank?
return nil if security.ticker_symbol == "CUR:USD" # Internally, we do not consider cash a "holding" and track it separately
Security.find_or_create_by!(
ticker: security.ticker_symbol,

View file

@ -1,21 +0,0 @@
<%# locals: (holding:) %>
<%= turbo_frame_tag dom_id(holding) do %>
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<div class="col-span-9 flex items-center gap-4">
<%= render "shared/circle_logo", name: holding.name %>
<div>
<%= tag.p holding.name %>
<%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>
</div>
</div>
<div class="col-span-3 text-right">
<% if holding.amount_money %>
<%= tag.p format_money holding.amount_money %>
<% else %>
<%= tag.p "?", class: "text-gray-500" %>
<% end %>
</div>
</div>
<% end %>

View file

@ -1,18 +0,0 @@
<%= turbo_frame_tag dom_id(@account, "cash") do %>
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
<div class="flex items-center justify-between">
<%= tag.h2 t(".cash"), class: "font-medium text-lg" %>
</div>
<div class="rounded-xl bg-gray-25 p-1">
<div class="grid grid-cols-12 items-center uppercase text-xs font-medium text-gray-500 px-4 py-2">
<%= tag.p t(".name"), class: "col-span-9" %>
<%= tag.p t(".value"), class: "col-span-3 justify-self-end" %>
</div>
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
<%= render partial: "account/cashes/cash", collection: [brokerage_cash(@account)], as: :holding %>
</div>
</div>
</div>
<% end %>

View file

@ -1,5 +1,5 @@
<%# locals: (entry:, selectable: true, show_balance: false) %>
<%# locals: (entry:, selectable: true, balance_trend: nil) %>
<%= turbo_frame_tag dom_id(entry) do %>
<%= render partial: entry.entryable.to_partial_path, locals: { entry:, selectable:, show_balance: } %>
<%= render partial: entry.entryable.to_partial_path, locals: { entry:, selectable:, balance_trend: } %>
<% end %>

View file

@ -73,13 +73,14 @@
<div>
<div class="rounded-tl-lg rounded-tr-lg bg-white border-alpha-black-25 shadow-xs">
<div class="space-y-4">
<% calculator = Account::BalanceTrendCalculator.for(@entries) %>
<%= entries_by_date(@entries) do |entries| %>
<%= render entries, show_balance: true %>
<% entries.each do |entry| %>
<%= render entry, balance_trend: calculator&.trend_for(entry) %>
<% end %>
<% end %>
</div>
</div>
<div class="p-4 bg-white rounded-bl-lg rounded-br-lg">

View file

@ -0,0 +1,32 @@
<%# locals: (account:) %>
<% currency = Money::Currency.new(account.currency) %>
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<div class="col-span-4 flex items-center gap-4">
<%= render "shared/circle_logo", name: currency.iso_code %>
<div class="space-y-0.5">
<%= tag.p t(".brokerage_cash"), class: "text-gray-900" %>
<%= tag.p account.currency, class: "text-gray-500 text-xs uppercase" %>
</div>
</div>
<div class="col-span-2 flex justify-end items-center gap-2">
<% cash_weight = account.balance.zero? ? 0 : account.cash_balance / account.balance * 100 %>
<%= render "shared/progress_circle", progress: cash_weight, text_class: "text-blue-500" %>
<%= tag.p number_to_percentage(cash_weight, precision: 1) %>
</div>
<div class="col-span-2 text-right">
<%= tag.p "--", class: "text-gray-500" %>
</div>
<div class="col-span-2 text-right">
<%= tag.p format_money account.cash_balance %>
</div>
<div class="col-span-2 text-right">
<%= tag.p "--", class: "text-gray-500" %>
</div>
</div>

View file

@ -2,7 +2,7 @@
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
<div class="flex items-center justify-between">
<%= tag.h2 t(".holdings"), class: "font-medium text-lg" %>
<%= link_to new_account_trade_path(@account),
<%= link_to new_account_trade_path(account_id: @account.id),
id: dom_id(@account, "new_trade"),
data: { turbo_frame: :modal },
class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %>
@ -21,8 +21,10 @@
</div>
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
<% if @holdings.any? %>
<%= render partial: "account/holdings/holding", collection: @holdings, spacer_template: "ruler" %>
<% if @account.holdings.current.any? %>
<%= render "account/holdings/cash", account: @account %>
<%= render "account/holdings/ruler" %>
<%= render partial: "account/holdings/holding", collection: @account.holdings.current, spacer_template: "ruler" %>
<% else %>
<p class="text-gray-500 text-sm p-4"><%= t(".no_holdings") %></p>
<% end %>

View file

@ -32,7 +32,7 @@
</div>
<% end %>
<%= form.date_field :date, label: true, value: Date.today, required: true %>
<%= form.date_field :date, label: true, value: Date.current, required: true %>
<% unless %w[buy sell].include?(type) %>
<%= form.money_field :amount, label: t(".amount"), required: true %>

View file

@ -1,4 +1,4 @@
<%# locals: (entry:, selectable: true, show_balance: false) %>
<%# locals: (entry:, selectable: true, balance_trend: nil) %>
<% trade, account = entry.account_trade, entry.account %>
@ -37,6 +37,12 @@
</div>
<div class="col-span-2 justify-self-end">
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
<% if balance_trend&.trend %>
<div class="flex items-center gap-2">
<%= tag.p format_money(balance_trend.trend.current), class: "font-medium text-sm text-gray-900" %>
</div>
<% else %>
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
<% end %>
</div>
</div>

View file

@ -13,7 +13,7 @@
data: { controller: "auto-submit-form" } do |f| %>
<%= f.date_field :date,
label: t(".date_label"),
max: Date.today,
max: Date.current,
"data-auto-submit-form-target": "auto" %>
<div class="flex items-center gap-2">

View file

@ -28,7 +28,7 @@
<%= f.fields_for :entryable do |ef| %>
<%= ef.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %>
<% end %>
<%= f.date_field :date, label: t(".date"), required: true, min: Account::Entry.min_supported_date, max: Date.today, value: Date.today %>
<%= f.date_field :date, label: t(".date"), required: true, min: Account::Entry.min_supported_date, max: Date.current, value: Date.current %>
</section>
<section>

View file

@ -1,4 +1,4 @@
<%# locals: (entry:, selectable: true, show_balance: false) %>
<%# locals: (entry:, selectable: true, balance_trend: nil) %>
<% transaction, account = entry.account_transaction, entry.account %>
<div class="grid grid-cols-12 items-center <%= entry.excluded ? "text-gray-400 bg-gray-25" : "text-gray-900" %> text-sm font-medium p-4">
@ -34,7 +34,7 @@
</div>
<% if entry.transfer.present? %>
<% unless show_balance %>
<% unless balance_trend %>
<div class="col-span-2"></div>
<% end %>
@ -46,7 +46,7 @@
<%= render "categories/menu", transaction: transaction %>
</div>
<% unless show_balance %>
<% unless balance_trend %>
<%= tag.div class: "col-span-2 overflow-hidden truncate" do %>
<% if entry.new_record? %>
<%= tag.p account.name %>
@ -66,12 +66,12 @@
class: ["text-green-600": entry.inflow?] %>
</div>
<% if show_balance %>
<% if balance_trend %>
<div class="col-span-2 justify-self-end">
<% if entry.account.investment? %>
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
<% if balance_trend.trend %>
<%= tag.p format_money(balance_trend.trend.current), class: "font-medium text-sm text-gray-900" %>
<% else %>
<%= tag.p format_money(entry.trend.current), class: "font-medium text-sm text-gray-900" %>
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
<% end %>
</div>
<% end %>

View file

@ -29,7 +29,7 @@
<%= f.collection_select :from_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from") }, required: true %>
<%= f.collection_select :to_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".to") }, required: true %>
<%= f.number_field :amount, label: t(".amount"), required: true, min: 0, placeholder: "100", step: 0.00000001 %>
<%= f.date_field :date, value: transfer.date || Date.today, label: t(".date"), required: true, max: Date.current %>
<%= f.date_field :date, value: transfer.date || Date.current, label: t(".date"), required: true, max: Date.current %>
</section>
<section>

View file

@ -8,7 +8,7 @@
<% end %>
<div class="space-y-3">
<%= form.date_field :date, label: true, required: true, value: Date.today, min: Account::Entry.min_supported_date, max: Date.today %>
<%= form.date_field :date, label: true, required: true, value: Date.current, min: Account::Entry.min_supported_date, max: Date.current %>
<%= form.money_field :amount, label: t(".amount"), required: true %>
</div>

View file

@ -1,7 +1,7 @@
<%# locals: (entry:, selectable: true, show_balance: false) %>
<%# locals: (entry:, selectable: true, balance_trend: nil) %>
<% account = entry.account %>
<% valuation = entry.account_valuation %>
<% color = balance_trend&.trend&.color || "#D444F1" %>
<% icon = balance_trend&.trend&.icon || "plus" %>
<div class="p-4 grid grid-cols-12 items-center text-gray-900 text-sm font-medium">
<div class="col-span-8 flex items-center gap-4">
@ -12,15 +12,15 @@
<% end %>
<div class="flex items-center gap-3">
<%= tag.div class: "w-8 h-8 rounded-full p-1.5 flex items-center justify-center", style: mixed_hex_styles(valuation.color) do %>
<%= lucide_icon valuation.icon, class: "w-4 h-4" %>
<%= tag.div class: "w-8 h-8 rounded-full p-1.5 flex items-center justify-center", style: mixed_hex_styles(color) do %>
<%= lucide_icon icon, class: "w-4 h-4" %>
<% end %>
<div class="truncate text-gray-900">
<% if entry.new_record? %>
<%= content_tag :p, entry.name %>
<% else %>
<%= link_to valuation.name,
<%= link_to entry.name || t(".balance_update"),
account_entry_path(entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>
@ -29,8 +29,12 @@
</div>
</div>
<div class="col-span-2 justify-self-end font-medium text-sm" style="color: <%= valuation.color %>">
<%= tag.span format_money(entry.trend.value) %>
<div class="col-span-2 justify-self-end font-medium text-sm">
<% if balance_trend&.trend %>
<%= tag.span format_money(balance_trend.trend.value), style: "color: #{balance_trend.trend.color}" %>
<% else %>
<%= tag.span "--", class: "text-gray-400" %>
<% end %>
</div>
<div class="col-span-2 justify-self-end">

View file

@ -11,7 +11,7 @@
<%= tooltip %>
</div>
<%= tag.p format_money(account.value), class: "text-gray-900 text-3xl font-medium" %>
<%= tag.p format_money(account.balance_money), class: "text-gray-900 text-3xl font-medium" %>
</div>
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %>

View file

@ -1,5 +0,0 @@
<%# locals: (account:) %>
<%= turbo_frame_tag dom_id(account, :cash), src: account_cashes_path(account_id: account.id) do %>
<%= render "account/entries/loading" %>
<% end %>

View file

@ -1,21 +0,0 @@
<%# locals: (account:, **args) %>
<div id="<%= dom_id(account, :chart) %>" class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg">
<div class="p-4 flex justify-between">
<div class="space-y-2">
<div class="flex items-center gap-1">
<%= tag.p t(".value"), class: "text-sm font-medium text-gray-500" %>
</div>
<%= tag.p format_money(account.value), class: "text-gray-900 text-3xl font-medium" %>
</div>
</div>
<div class="relative h-64 flex items-center justify-center">
<%= image_tag "placeholder-graph.svg", class: "w-full h-full object-cover rounded-bl-lg rounded-br-lg opacity-50" %>
<div class="absolute top-0 left-0 w-full h-full flex flex-col items-center justify-center space-y-1">
<p class="text-gray-900 text-sm">Historical investment data coming soon.</p>
<p class="text-gray-500 text-sm">We're working to bring you the full picture.</p>
</div>
</div>
</div>

View file

@ -1,4 +1,4 @@
<%# locals: (value:, cash:) %>
<%# locals: (balance:, holdings:, cash:) %>
<div data-controller="tooltip" data-tooltip-placement-value="right" data-tooltip-offset-value=10 data-tooltip-cross-axis-value=50>
<%= lucide_icon("info", class: "w-4 h-4 shrink-0 text-gray-500") %>
@ -7,14 +7,6 @@
<%= t(".total_value_tooltip") %>
</div>
<div class="flex pt-3">
<div class="text-gray-300">
<%= t(".holdings") %>
</div>
<div class="text-white ml-auto">
<%= tag.p format_money(value, precision: 0) %>
</div>
</div>
<div class="flex">
<div class="text-gray-300">
<%= t(".cash") %>
</div>
@ -22,5 +14,24 @@
<%= tag.p format_money(cash, precision: 0) %>
</div>
</div>
<div class="flex">
<div class="text-gray-300">
<%= t(".holdings") %>
</div>
<div class="text-white ml-auto">
<%= tag.p format_money(holdings, precision: 0) %>
</div>
</div>
<hr class="my-2 border-gray-500">
<div class="flex">
<div class="text-gray-300">
<%= t(".total") %>
</div>
<div class="text-white font-bold ml-auto">
<%= tag.p format_money(balance, precision: 0) %>
</div>
</div>
</div>
</div>

View file

@ -4,24 +4,20 @@
<%= tag.div class: "space-y-4" do %>
<%= render "accounts/show/header", account: @account %>
<% if @account.plaid_account_id.present? %>
<%= render "investments/chart", account: @account %>
<% else %>
<%= render "accounts/show/chart",
<%= render "accounts/show/chart",
account: @account,
title: t(".chart_title"),
tooltip: render(
"investments/value_tooltip",
value: @account.value,
cash: @account.balance_money
balance: @account.balance_money,
holdings: @account.balance - @account.cash_balance,
cash: @account.cash_balance
) %>
<% end %>
<div class="min-h-[800px]">
<%= render "accounts/show/tabs", account: @account, tabs: [
{ key: "activity", contents: render("accounts/show/activity", account: @account) },
{ key: "holdings", contents: render("investments/holdings_tab", account: @account) },
{ key: "cash", contents: render("investments/cash_tab", account: @account) }
{ key: "activity", contents: render("accounts/show/activity", account: @account) },
] %>
</div>
<% end %>

View file

@ -20,6 +20,11 @@
{ label: t(".language") },
{ data: { auto_submit_form_target: "auto" } } %>
<%= family_form.select :timezone,
timezone_options,
{ label: t(".timezone") },
{ data: { auto_submit_form_target: "auto" } } %>
<%= family_form.select :date_format,
date_format_options,
{ label: t(".date_format") },

View file

@ -1,10 +1,31 @@
<%# locals: (progress:, radius: 7, stroke: 2, text_class: "text-green-500") %>
<% circumference = Math::PI * 2 * radius %>
<% progress_percent = progress.clamp(0, 100) %>
<% stroke_dashoffset = ((100 - progress_percent) * circumference) / 100 %>
<%
circumference = Math::PI * 2 * radius
progress_percent = progress.clamp(0, 100)
stroke_dashoffset = ((100 - progress_percent) * circumference) / 100
center = radius + stroke / 2
%>
<svg width="<%= radius * 2 + stroke %>" height="<%= radius * 2 + stroke %>">
<!-- Background Circle -->
<circle class="fill-transparent stroke-current text-gray-300" r="<%= radius %>" cx="<%= radius + stroke / 2 %>" cy="<%= radius + stroke / 2 %>" stroke-width="<%= stroke %>" />
<circle
class="fill-transparent stroke-current text-gray-300"
r="<%= radius %>"
cx="<%= center %>"
cy="<%= center %>"
stroke-width="<%= stroke %>"
></circle>
<!-- Foreground Circle -->
<circle class="fill-transparent stroke-current <%= text_class %>" r="<%= radius %>" cx="<%= radius + stroke / 2 %>" cy="<%= radius + stroke / 2 %>" stroke-width="<%= stroke %>" stroke-dasharray="<%= circumference %>" stroke-dashoffset="<%= stroke_dashoffset %>" transform="rotate(-90, <%= radius + stroke / 2 %>, <%= radius + stroke / 2 %>)" />
</svg>
<circle
class="fill-transparent stroke-current <%= text_class %>"
r="<%= radius %>"
cx="<%= center %>"
cy="<%= center %>"
stroke-width="<%= stroke %>"
stroke-dasharray="<%= circumference %>"
stroke-dashoffset="<%= stroke_dashoffset %>"
transform="rotate(-90, <%= center %>, <%= center %>)"
></circle>
</svg>

View file

@ -13,7 +13,7 @@ et:
- E
- T
- K
- N
- "N"
- R
- L
abbr_month_names:

View file

@ -1,8 +0,0 @@
---
en:
account:
cashes:
index:
cash: Cash
name: Name
value: Total Balance

View file

@ -2,6 +2,8 @@
en:
account:
holdings:
cash:
brokerage_cash: Brokerage cash
destroy:
success: Holding deleted
holding:

View file

@ -2,6 +2,8 @@
en:
account:
valuations:
valuation:
balance_update: Balance update
form:
amount: Amount
submit: Add balance update

View file

@ -6,6 +6,8 @@ en:
troubleshoot: Troubleshoot
account_list:
new_account: New %{type}
chart:
no_change: no change
create:
success: "%{type} account created"
destroy:
@ -31,8 +33,6 @@ en:
manual_entry: Enter account balance
title: How would you like to add it?
title: What would you like to add?
chart:
no_change: no change
show:
chart:
balance: Balance

View file

@ -1,8 +1,6 @@
---
en:
investments:
chart:
value: Total value
edit:
edit: Edit %{account}
form:
@ -15,5 +13,6 @@ en:
value_tooltip:
cash: Cash
holdings: Holdings
total_value_tooltip: The total value is the sum of cash balance and your holdings
value, minus margin loans.
total: Portfolio balance
total_value_tooltip: The total portfolio balance is the sum of brokerage cash
(available for trading) and the current market value of your holdings.

View file

@ -39,6 +39,7 @@ en:
theme_subtitle: Choose a preferred theme for the app (coming soon...)
theme_system: System
theme_title: Theme
timezone: Timezone
profiles:
show:
confirm_delete:

View file

@ -1,8 +1,6 @@
---
en:
shared:
syncing_notice:
syncing: Syncing accounts data...
confirm_modal:
accept: Confirm
body_html: "<p>You will not be able to undo this decision</p>"
@ -15,6 +13,8 @@ en:
no_account_subtitle: Since no accounts have been added, there's no data to display.
Add your first accounts to start viewing dashboard data.
no_account_title: No accounts yet
syncing_notice:
syncing: Syncing accounts data...
upgrade_notification:
app_upgraded: The app has been upgraded to %{version}.
dismiss: Dismiss

View file

@ -75,7 +75,6 @@ Rails.application.routes.draw do
namespace :account do
resources :holdings, only: %i[index new show destroy]
resources :cashes, only: :index
resources :entries, only: :index

View file

@ -0,0 +1,6 @@
class AddBalanceComponents < ActiveRecord::Migration[7.2]
def change
add_column :accounts, :cash_balance, :decimal, precision: 19, scale: 4, default: 0
add_column :account_balances, :cash_balance, :decimal, precision: 19, scale: 4, default: 0
end
end

View file

@ -0,0 +1,5 @@
class AddFamilyTimezone < ActiveRecord::Migration[7.2]
def change
add_column :families, :timezone, :string
end
end

5
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2024_11_26_211249) do
ActiveRecord::Schema[7.2].define(version: 2024_12_07_002408) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@ -27,6 +27,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_26_211249) do
t.string "currency", default: "USD", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0"
t.index ["account_id", "date", "currency"], name: "index_account_balances_on_account_id_date_currency_unique", unique: true
t.index ["account_id"], name: "index_account_balances_on_account_id"
end
@ -112,6 +113,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_26_211249) do
t.uuid "plaid_account_id"
t.boolean "scheduled_for_deletion", default: false
t.datetime "last_synced_at"
t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0"
t.index ["accountable_id", "accountable_type"], name: "index_accounts_on_accountable_id_and_accountable_type"
t.index ["accountable_type"], name: "index_accounts_on_accountable_type"
t.index ["family_id", "accountable_type"], name: "index_accounts_on_family_id_and_accountable_type"
@ -218,6 +220,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_11_26_211249) do
t.string "date_format", default: "%m-%d-%Y"
t.string "country", default: "US"
t.datetime "last_synced_at"
t.string "timezone"
end
create_table "good_job_batches", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|

View file

@ -43,6 +43,7 @@ investment:
family: dylan_family
name: Robinhood Brokerage Account
balance: 10000
cash_balance: 5000
currency: USD
accountable_type: Investment
accountable: one

View file

@ -1 +1 @@
one: { }
one: {}

View file

@ -1,153 +0,0 @@
require "test_helper"
class Account::Balance::SyncerTest < ActiveSupport::TestCase
include Account::EntriesTestHelper
setup do
@account = families(:empty).accounts.create!(name: "Test", balance: 20000, currency: "USD", accountable: Depository.new)
@investment_account = families(:empty).accounts.create!(name: "Test Investment", balance: 50000, currency: "USD", accountable: Investment.new)
end
test "syncs account with no entries" do
assert_equal 0, @account.balances.count
run_sync_for @account
assert_equal [ @account.balance ], @account.balances.chronological.map(&:balance)
end
test "syncs account with valuations only" do
create_valuation(account: @account, date: 2.days.ago.to_date, amount: 22000)
run_sync_for @account
assert_equal 22000, @account.balance
assert_equal [ 22000, 22000, 22000 ], @account.balances.chronological.map(&:balance)
end
test "syncs account with transactions only" do
create_transaction(account: @account, date: 4.days.ago.to_date, amount: 100)
create_transaction(account: @account, date: 2.days.ago.to_date, amount: -500)
run_sync_for @account
assert_equal 20000, @account.balance
assert_equal [ 19600, 19500, 19500, 20000, 20000, 20000 ], @account.balances.chronological.map(&:balance)
end
test "syncs account with valuations and transactions when valuation starts" do
create_valuation(account: @account, date: 5.days.ago.to_date, amount: 20000)
create_transaction(account: @account, date: 3.days.ago.to_date, amount: -500)
create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100)
create_valuation(account: @account, date: 1.day.ago.to_date, amount: 25000)
run_sync_for(@account)
assert_equal 25000, @account.balance
assert_equal [ 20000, 20000, 20500, 20400, 25000, 25000 ], @account.balances.chronological.map(&:balance)
end
test "syncs account with valuations and transactions when transaction starts" do
new_account = families(:empty).accounts.create!(name: "Test Account", balance: 1000, currency: "USD", accountable: Depository.new)
create_transaction(account: new_account, date: 2.days.ago.to_date, amount: 250)
create_valuation(account: new_account, date: Date.current, amount: 1000)
run_sync_for(new_account)
assert_equal 1000, new_account.balance
assert_equal [ 1250, 1000, 1000, 1000 ], new_account.balances.chronological.map(&:balance)
end
test "syncs account with transactions in multiple currencies" 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")
create_transaction(account: @account, date: 1.day.ago.to_date, amount: 500, currency: "EUR") # €500 * 1.2 = $600
run_sync_for(@account)
assert_equal 20000, @account.balance
assert_equal [ 21000, 20900, 20600, 20000, 20000 ], @account.balances.chronological.map(&:balance)
end
test "converts foreign account balances to family currency" do
@account.update! currency: "EUR"
create_transaction(date: 1.day.ago.to_date, amount: 1000, account: @account, currency: "EUR")
create_exchange_rate(2.days.ago.to_date, from: "EUR", to: "USD", rate: 2)
create_exchange_rate(1.day.ago.to_date, from: "EUR", to: "USD", rate: 2)
create_exchange_rate(Date.current, from: "EUR", to: "USD", rate: 2)
with_env_overrides SYNTH_API_KEY: ENV["SYNTH_API_KEY"] || "fookey" do
run_sync_for(@account)
end
usd_balances = @account.balances.where(currency: "USD").chronological.map(&:balance)
eur_balances = @account.balances.where(currency: "EUR").chronological.map(&:balance)
assert_equal 20000, @account.balance
assert_equal [ 21000, 20000, 20000 ], eur_balances # native account balances
assert_equal [ 42000, 40000, 40000 ], usd_balances # converted balances at rate of 2:1
end
test "raises issue if missing exchange rates" do
create_transaction(date: Date.current, account: @account, currency: "EUR")
ExchangeRate.expects(:find_rate).with(from: "EUR", to: "USD", date: Date.current).returns(nil)
@account.expects(:observe_missing_exchange_rates).with(from: "EUR", to: "USD", dates: [ Date.current ])
syncer = Account::Balance::Syncer.new(@account)
syncer.run
end
# Account is able to calculate balances in its own currency (i.e. can still show a historical graph), but
# doesn't have exchange rates available to convert those calculated balances to the family currency
test "observes issue if exchange rate provider is not configured" do
@account.update! currency: "EUR"
syncer = Account::Balance::Syncer.new(@account)
@account.expects(:observe_missing_exchange_rate_provider)
with_env_overrides SYNTH_API_KEY: nil do
syncer.run
end
end
test "overwrites existing balances and purges stale balances" do
assert_equal 0, @account.balances.size
@account.balances.create! date: Date.current, currency: "USD", balance: 30000 # incorrect balance, will be updated
@account.balances.create! date: 10.years.ago.to_date, currency: "USD", balance: 35000 # Out of range balance, will be deleted
assert_equal 2, @account.balances.size
run_sync_for(@account)
assert_equal [ @account.balance ], @account.balances.chronological.map(&:balance)
end
test "partial sync does not affect balances prior to sync start date" do
existing_balance = @account.balances.create! date: 2.days.ago.to_date, currency: "USD", balance: 30000
transaction = create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100, currency: "USD")
run_sync_for(@account, start_date: 1.day.ago.to_date)
assert_equal [ existing_balance.balance, existing_balance.balance - transaction.amount, @account.balance ], @account.balances.chronological.map(&:balance)
end
private
def run_sync_for(account, start_date: nil)
syncer = Account::Balance::Syncer.new(account, start_date: start_date)
syncer.run
end
def create_exchange_rate(date, from:, to:, rate:)
ExchangeRate.create! date: date, from_currency: from, to_currency: to, rate: rate
end
end

View file

@ -0,0 +1,156 @@
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),
Account::Holding.new(date: 1.day.ago.to_date, security: securities(:msft), amount: 2000),
Account::Holding.new(date: 2.days.ago.to_date, security: securities(:msft), amount: 0)
]
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

View file

@ -43,13 +43,10 @@ class Account::EntryTest < ActiveSupport::TestCase
end
test "triggers sync with correct start date when transaction deleted" do
current_entry = create_transaction(date: 1.day.ago.to_date)
prior_entry = create_transaction(date: current_entry.date - 1.day)
@entry.destroy!
current_entry.destroy!
current_entry.account.expects(:sync_later).with(start_date: prior_entry.date)
current_entry.sync_account_later
@entry.account.expects(:sync_later).with(start_date: nil)
@entry.sync_account_later
end
test "can search entries" do
@ -99,26 +96,4 @@ class Account::EntryTest < ActiveSupport::TestCase
assert create_transaction(amount: -10).inflow?
assert create_transaction(amount: 10).outflow?
end
test "balance_after_entry skips account valuations" do
family = families(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
new_valuation = create_valuation(account: account, amount: 1)
transaction = create_transaction(date: new_valuation.date, account: account, amount: -100)
assert_equal Money.new(100), transaction.balance_after_entry
end
test "prior_entry_balance returns last transaction entry balance" do
family = families(:empty)
account = family.accounts.create! name: "Test", balance: 0, currency: "USD", accountable: Depository.new
new_valuation = create_valuation(account: account, amount: 1)
transaction = create_transaction(date: new_valuation.date, account: account, amount: -100)
assert_equal Money.new(100), transaction.prior_entry_balance
end
end

View file

@ -1,145 +0,0 @@
require "test_helper"
class Account::Holding::SyncerTest < ActiveSupport::TestCase
include Account::EntriesTestHelper, SecuritiesTestHelper
setup do
@account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 20000, currency: "USD", accountable: Investment.new)
end
test "account with no trades has no holdings" do
run_sync_for(@account)
assert_equal [], @account.holdings
end
test "can buy and sell securities" do
# First create securities with their prices
security1 = create_security("AMZN", prices: [
{ date: 2.days.ago.to_date, price: 214 },
{ date: 1.day.ago.to_date, price: 215 },
{ date: Date.current, price: 216 }
])
security2 = create_security("NVDA", prices: [
{ date: 1.day.ago.to_date, price: 122 },
{ date: Date.current, price: 124 }
])
# Then create trades after prices exist
create_trade(security1, account: @account, qty: 10, date: 2.days.ago.to_date) # buy 10 shares of AMZN
create_trade(security1, account: @account, qty: 2, date: 1.day.ago.to_date) # buy 2 shares of AMZN
create_trade(security2, account: @account, qty: 20, date: 1.day.ago.to_date) # buy 20 shares of NVDA
create_trade(security1, account: @account, qty: -10, date: Date.current) # sell 10 shares of AMZN
expected = [
{ security: security1, qty: 10, price: 214, amount: 10 * 214, date: 2.days.ago.to_date },
{ security: security1, qty: 12, price: 215, amount: 12 * 215, date: 1.day.ago.to_date },
{ security: security1, qty: 2, price: 216, amount: 2 * 216, date: Date.current },
{ security: security2, qty: 20, price: 122, amount: 20 * 122, date: 1.day.ago.to_date },
{ security: security2, qty: 20, price: 124, amount: 20 * 124, date: Date.current }
]
run_sync_for(@account)
assert_holdings(expected)
end
test "generates holdings with prices" do
provider = mock
Security::Price.stubs(:security_prices_provider).returns(provider)
provider.expects(:fetch_security_prices).never
amzn = create_security("AMZN", prices: [ { date: Date.current, price: 215 } ])
create_trade(amzn, account: @account, qty: 10, date: Date.current, price: 215)
expected = [
{ security: amzn, qty: 10, price: 215, amount: 10 * 215, date: Date.current }
]
run_sync_for(@account)
assert_holdings(expected)
end
test "generates all holdings even when missing security prices" do
amzn = create_security("AMZN", prices: [])
create_trade(amzn, account: @account, qty: 10, date: 2.days.ago.to_date, price: 210)
# 2 days ago — no daily price found, but since this is day of entry, we fall back to entry price
# 1 day ago — finds daily price, uses it
# Today — no daily price, no entry, so price and amount are `nil`
expected = [
{ security: amzn, qty: 10, price: 210, amount: 10 * 210, date: 2.days.ago.to_date },
{ security: amzn, qty: 10, price: 215, amount: 10 * 215, date: 1.day.ago.to_date },
{ security: amzn, qty: 10, price: nil, amount: nil, date: Date.current }
]
fetched_prices = [ Security::Price.new(ticker: "AMZN", date: 1.day.ago.to_date, price: 215) ]
Gapfiller.any_instance.expects(:run).returns(fetched_prices)
Security::Price.expects(:find_prices)
.with(security: amzn, start_date: 2.days.ago.to_date, end_date: Date.current)
.once
.returns(fetched_prices)
run_sync_for(@account)
assert_holdings(expected)
end
# It is common for data providers to not provide prices for weekends, so we need to carry the last observation forward
test "uses locf gapfilling when price is missing" do
friday = Date.new(2024, 9, 27) # A known Friday
saturday = friday + 1.day # weekend
sunday = saturday + 1.day # weekend
monday = sunday + 1.day # known Monday
# Prices should be gapfilled like this: 210, 210, 210, 220
tm = create_security("TM", prices: [
{ date: friday, price: 210 },
{ date: monday, price: 220 }
])
create_trade(tm, account: @account, qty: 10, date: friday, price: 210)
expected = [
{ security: tm, qty: 10, price: 210, amount: 10 * 210, date: friday },
{ security: tm, qty: 10, price: 210, amount: 10 * 210, date: saturday },
{ security: tm, qty: 10, price: 210, amount: 10 * 210, date: sunday },
{ security: tm, qty: 10, price: 220, amount: 10 * 220, date: monday }
]
run_sync_for(@account)
assert_holdings(expected)
end
private
def assert_holdings(expected_holdings)
holdings = @account.holdings.includes(:security).to_a
expected_holdings.each do |expected_holding|
actual_holding = holdings.find { |holding|
holding.security == expected_holding[:security] &&
holding.date == expected_holding[:date]
}
date = expected_holding[:date]
expected_price = expected_holding[:price]
expected_qty = expected_holding[:qty]
expected_amount = expected_holding[:amount]
ticker = expected_holding[:security].ticker
assert actual_holding, "expected #{ticker} holding on date: #{date}"
assert_equal expected_holding[:qty], actual_holding.qty, "expected #{expected_qty} qty for holding #{ticker} on date: #{date}"
assert_equal expected_holding[:amount].to_d, actual_holding.amount.to_d, "expected #{expected_amount} amount for holding #{ticker} on date: #{date}"
assert_equal expected_holding[:price].to_d, actual_holding.price.to_d, "expected #{expected_price} price for holding #{ticker} on date: #{date}"
end
end
def run_sync_for(account)
Account::Holding::Syncer.new(account).run
end
end

View file

@ -0,0 +1,231 @@
require "test_helper"
class Account::HoldingCalculatorTest < 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
forward = Account::HoldingCalculator.new(@account).calculate
reverse = Account::HoldingCalculator.new(@account).calculate(reverse: true)
assert_equal forward, reverse
assert_equal [], forward
end
# Should be able to handle this case, although we should not be reverse-syncing an account without provided current day holdings
test "reverse portfolio with trades but without current day holdings" do
voo = Security.create!(ticker: "VOO", name: "Vanguard S&P 500 ETF")
Security::Price.create!(security: voo, date: Date.current, price: 470)
Security::Price.create!(security: voo, date: 1.day.ago.to_date, price: 470)
create_trade(voo, qty: -10, date: Date.current, price: 470, account: @account)
calculated = Account::HoldingCalculator.new(@account).calculate(reverse: true)
assert_equal 2, calculated.length
end
test "reverse portfolio calculation" do
load_today_portfolio
# 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(reverse: true)
assert_equal expected.length, calculated.length
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
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|
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
# Portfolio holdings:
# +--------+-----+--------+---------+
# | Ticker | Qty | Price | Amount |
# +--------+-----+--------+---------+
# | VOO | 10 | $500 | $5,000 |
# | WMT | 100 | $100 | $10,000 |
# +--------+-----+--------+---------+
# Brokerage Cash: $5,000
# Holdings Value: $15,000
# Total Balance: $20,000
def load_today_portfolio
@account.update!(cash_balance: 5000)
load_prices
@account.holdings.create!(
date: Date.current,
price: 500,
qty: 10,
amount: 5000,
currency: "USD",
security: @voo
)
@account.holdings.create!(
date: Date.current,
price: 100,
qty: 100,
amount: 10000,
currency: "USD",
security: @wmt
)
end
end

View file

@ -5,16 +5,15 @@ class Account::HoldingTest < ActiveSupport::TestCase
include Account::EntriesTestHelper, SecuritiesTestHelper
setup do
@account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 20000, currency: "USD", accountable: Investment.new)
@account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 20000, cash_balance: 0, currency: "USD", accountable: Investment.new)
# Current day holding instances
@amzn, @nvda = load_holdings
end
test "calculates portfolio weight" do
expected_portfolio_value = 6960.0
expected_amzn_weight = 3240.0 / expected_portfolio_value * 100
expected_nvda_weight = 3720.0 / expected_portfolio_value * 100
expected_amzn_weight = 3240.0 / @account.balance * 100
expected_nvda_weight = 3720.0 / @account.balance * 100
assert_in_delta expected_amzn_weight, @amzn.weight, 0.001
assert_in_delta expected_nvda_weight, @nvda.weight, 0.001

View file

@ -0,0 +1,54 @@
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 to family currency" do
@account.family.update! currency: "USD"
@account.update! 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::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)
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

View file

@ -8,7 +8,7 @@ class TradesTest < ApplicationSystemTestCase
@account = accounts(:investment)
visit_account_trades
visit_account_portfolio
Security.stubs(:search).returns([
Security.new(
@ -66,10 +66,7 @@ class TradesTest < ApplicationSystemTestCase
private
def open_new_trade_modal
within "[data-testid='activity-menu']" do
click_on "New"
click_on "New transaction"
end
click_on "New transaction"
end
def within_trades(&block)
@ -77,6 +74,10 @@ class TradesTest < ApplicationSystemTestCase
end
def visit_account_trades
visit account_path(@account, tab: "activity")
end
def visit_account_portfolio
visit account_path(@account)
end

View file

@ -182,7 +182,7 @@ class TransactionsTest < ApplicationSystemTestCase
investment_account = accounts(:investment)
investment_account.entries.create!(name: "Investment account", date: Date.current, amount: 1000, currency: "USD", entryable: Account::Transaction.new)
transfer_date = Date.current
visit account_url(investment_account)
visit account_url(investment_account, tab: "activity")
within "[data-testid='activity-menu']" do
click_on "New"
click_on "New transaction"