Skip to content
This repository was archived by the owner on Jul 27, 2025. It is now read-only.

Commit da2045d

Browse files
authored
Additional cache columns on balances for activity view breakdowns (#2505)
* Initial schema iteration * Add new balance components * Add existing data migrator to backfill components * Update calculator test assertions for new balance components * Update flow assertions for forward calculator * Update reverse calculator flows assumptions * Forward calculator tests passing * Get all calculator tests passing * Assert flows factor
1 parent 347c0a7 commit da2045d

File tree

13 files changed

+1156
-174
lines changed

13 files changed

+1156
-174
lines changed
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
class BalanceComponentMigrator
2+
def self.run
3+
ActiveRecord::Base.transaction do
4+
# Step 1: Update flows factor
5+
ActiveRecord::Base.connection.execute <<~SQL
6+
UPDATE balances SET
7+
flows_factor = CASE WHEN a.classification = 'asset' THEN 1 ELSE -1 END
8+
FROM accounts a
9+
WHERE a.id = balances.account_id
10+
SQL
11+
12+
# Step 2: Set start values using LOCF (Last Observation Carried Forward)
13+
ActiveRecord::Base.connection.execute <<~SQL
14+
UPDATE balances b1
15+
SET
16+
start_cash_balance = COALESCE(prev.cash_balance, 0),
17+
start_non_cash_balance = COALESCE(prev.balance - prev.cash_balance, 0)
18+
FROM balances b1_inner
19+
LEFT JOIN LATERAL (
20+
SELECT
21+
b2.cash_balance,
22+
b2.balance
23+
FROM balances b2
24+
WHERE b2.account_id = b1_inner.account_id
25+
AND b2.currency = b1_inner.currency
26+
AND b2.date < b1_inner.date
27+
ORDER BY b2.date DESC
28+
LIMIT 1
29+
) prev ON true
30+
WHERE b1.id = b1_inner.id
31+
SQL
32+
33+
# Step 3: Calculate net inflows
34+
# A slight workaround to the fact that we can't easily derive inflows/outflows from our current data model, and
35+
# the tradeoff not worth it since each new sync will fix it. So instead, we sum up *net* flows, and throw the signed
36+
# amount in the "inflows" column, and zero-out the "outflows" column so our math works correctly with incomplete data.
37+
ActiveRecord::Base.connection.execute <<~SQL
38+
UPDATE balances SET
39+
cash_inflows = (cash_balance - start_cash_balance) * flows_factor,
40+
cash_outflows = 0,
41+
non_cash_inflows = ((balance - cash_balance) - start_non_cash_balance) * flows_factor,
42+
non_cash_outflows = 0,
43+
net_market_flows = 0
44+
SQL
45+
46+
# Verify data integrity
47+
# All end_balance values should match the original balance
48+
invalid_count = ActiveRecord::Base.connection.select_value(<<~SQL)
49+
SELECT COUNT(*)
50+
FROM balances b
51+
WHERE ABS(b.balance - b.end_balance) > 0.0001
52+
SQL
53+
54+
if invalid_count > 0
55+
raise "Data migration failed validation: #{invalid_count} balances have incorrect end_balance values"
56+
end
57+
end
58+
end
59+
end

app/models/balance.rb

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,16 @@ class Balance < ApplicationRecord
22
include Monetizable
33

44
belongs_to :account
5+
56
validates :account, :date, :balance, presence: true
6-
monetize :balance, :cash_balance
7+
validates :flows_factor, inclusion: { in: [ -1, 1 ] }
8+
9+
monetize :balance, :cash_balance,
10+
:start_cash_balance, :start_non_cash_balance, :start_balance,
11+
:cash_inflows, :cash_outflows, :non_cash_inflows, :non_cash_outflows, :net_market_flows,
12+
:cash_adjustments, :non_cash_adjustments,
13+
:end_cash_balance, :end_non_cash_balance, :end_balance
14+
715
scope :in_period, ->(period) { period.nil? ? all : where(date: period.date_range) }
816
scope :chronological, -> { order(:date) }
917
end

app/models/balance/base_calculator.rb

Lines changed: 77 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ def sync_cache
1515
end
1616

1717
def holdings_value_for_date(date)
18-
holdings = sync_cache.get_holdings(date)
19-
holdings.sum(&:amount)
18+
@holdings_value_for_date ||= {}
19+
@holdings_value_for_date[date] ||= sync_cache.get_holdings(date).sum(&:amount)
2020
end
2121

2222
def derive_cash_balance_on_date_from_total(total_balance:, date:)
@@ -29,6 +29,67 @@ def derive_cash_balance_on_date_from_total(total_balance:, date:)
2929
end
3030
end
3131

32+
def cash_adjustments_for_date(start_cash, net_cash_flows, valuation)
33+
return 0 unless valuation && account.balance_type != :non_cash
34+
35+
valuation.amount - start_cash - net_cash_flows
36+
end
37+
38+
def non_cash_adjustments_for_date(start_non_cash, non_cash_flows, valuation)
39+
return 0 unless valuation && account.balance_type == :non_cash
40+
41+
valuation.amount - start_non_cash - non_cash_flows
42+
end
43+
44+
# If holdings value goes from $100 -> $200 (change_holdings_value is $100)
45+
# And non-cash flows (i.e. "buys") for day are +$50 (net_buy_sell_value is $50)
46+
# That means value increased by $100, where $50 of that is due to the change in holdings value, and $50 is due to the buy/sell
47+
def market_value_change_on_date(date, flows)
48+
return 0 unless account.balance_type == :investment
49+
50+
start_of_day_holdings_value = holdings_value_for_date(date.prev_day)
51+
end_of_day_holdings_value = holdings_value_for_date(date)
52+
53+
change_holdings_value = end_of_day_holdings_value - start_of_day_holdings_value
54+
net_buy_sell_value = flows[:non_cash_inflows] - flows[:non_cash_outflows]
55+
56+
change_holdings_value - net_buy_sell_value
57+
end
58+
59+
def flows_for_date(date)
60+
entries = sync_cache.get_entries(date)
61+
62+
cash_inflows = 0
63+
cash_outflows = 0
64+
non_cash_inflows = 0
65+
non_cash_outflows = 0
66+
67+
txn_inflow_sum = entries.select { |e| e.amount < 0 && e.transaction? }.sum(&:amount)
68+
txn_outflow_sum = entries.select { |e| e.amount >= 0 && e.transaction? }.sum(&:amount)
69+
70+
trade_cash_inflow_sum = entries.select { |e| e.amount < 0 && e.trade? }.sum(&:amount)
71+
trade_cash_outflow_sum = entries.select { |e| e.amount >= 0 && e.trade? }.sum(&:amount)
72+
73+
if account.balance_type == :non_cash && account.accountable_type == "Loan"
74+
non_cash_inflows = txn_inflow_sum.abs
75+
non_cash_outflows = txn_outflow_sum
76+
elsif account.balance_type != :non_cash
77+
cash_inflows = txn_inflow_sum.abs + trade_cash_inflow_sum.abs
78+
cash_outflows = txn_outflow_sum + trade_cash_outflow_sum
79+
80+
# Trades are inverse (a "buy" is outflow of cash, but "inflow" of non-cash, aka "holdings")
81+
non_cash_outflows = trade_cash_inflow_sum.abs
82+
non_cash_inflows = trade_cash_outflow_sum
83+
end
84+
85+
{
86+
cash_inflows: cash_inflows,
87+
cash_outflows: cash_outflows,
88+
non_cash_inflows: non_cash_inflows,
89+
non_cash_outflows: non_cash_outflows
90+
}
91+
end
92+
3293
def derive_cash_balance(cash_balance, date)
3394
entries = sync_cache.get_entries(date)
3495

@@ -57,13 +118,23 @@ def signed_entry_flows(entries)
57118
raise NotImplementedError, "Directional calculators must implement this method"
58119
end
59120

60-
def build_balance(date:, cash_balance:, non_cash_balance:)
121+
def build_balance(date:, **args)
61122
Balance.new(
62123
account_id: account.id,
124+
currency: account.currency,
63125
date: date,
64-
balance: non_cash_balance + cash_balance,
65-
cash_balance: cash_balance,
66-
currency: account.currency
126+
balance: args[:balance],
127+
cash_balance: args[:cash_balance],
128+
start_cash_balance: args[:start_cash_balance] || 0,
129+
start_non_cash_balance: args[:start_non_cash_balance] || 0,
130+
cash_inflows: args[:cash_inflows] || 0,
131+
cash_outflows: args[:cash_outflows] || 0,
132+
non_cash_inflows: args[:non_cash_inflows] || 0,
133+
non_cash_outflows: args[:non_cash_outflows] || 0,
134+
cash_adjustments: args[:cash_adjustments] || 0,
135+
non_cash_adjustments: args[:non_cash_adjustments] || 0,
136+
net_market_flows: args[:net_market_flows] || 0,
137+
flows_factor: account.classification == "asset" ? 1 : -1
67138
)
68139
end
69140
end

app/models/balance/forward_calculator.rb

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ class Balance::ForwardCalculator < Balance::BaseCalculator
22
def calculate
33
Rails.logger.tagged("Balance::ForwardCalculator") do
44
start_cash_balance = derive_cash_balance_on_date_from_total(
5-
total_balance: account.opening_anchor_balance,
5+
total_balance: 0,
66
date: account.opening_anchor_date
77
)
8-
start_non_cash_balance = account.opening_anchor_balance - start_cash_balance
8+
start_non_cash_balance = 0
99

1010
calc_start_date.upto(calc_end_date).map do |date|
11-
valuation = sync_cache.get_reconciliation_valuation(date)
11+
valuation = sync_cache.get_valuation(date)
1212

1313
if valuation
1414
end_cash_balance = derive_cash_balance_on_date_from_total(
@@ -21,10 +21,22 @@ def calculate
2121
end_non_cash_balance = derive_end_non_cash_balance(start_non_cash_balance: start_non_cash_balance, date: date)
2222
end
2323

24+
flows = flows_for_date(date)
25+
market_value_change = market_value_change_on_date(date, flows)
26+
2427
output_balance = build_balance(
2528
date: date,
29+
balance: end_cash_balance + end_non_cash_balance,
2630
cash_balance: end_cash_balance,
27-
non_cash_balance: end_non_cash_balance
31+
start_cash_balance: start_cash_balance,
32+
start_non_cash_balance: start_non_cash_balance,
33+
cash_inflows: flows[:cash_inflows],
34+
cash_outflows: flows[:cash_outflows],
35+
non_cash_inflows: flows[:non_cash_inflows],
36+
non_cash_outflows: flows[:non_cash_outflows],
37+
cash_adjustments: cash_adjustments_for_date(start_cash_balance, flows[:cash_inflows] - flows[:cash_outflows], valuation),
38+
non_cash_adjustments: non_cash_adjustments_for_date(start_non_cash_balance, flows[:non_cash_inflows] - flows[:non_cash_outflows], valuation),
39+
net_market_flows: market_value_change
2840
)
2941

3042
# Set values for the next iteration

app/models/balance/reverse_calculator.rb

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ def calculate
1111

1212
# Calculates in reverse-chronological order (End of day -> Start of day)
1313
account.current_anchor_date.downto(account.opening_anchor_date).map do |date|
14+
flows = flows_for_date(date)
15+
1416
if use_opening_anchor_for_date?(date)
1517
end_cash_balance = derive_cash_balance_on_date_from_total(
1618
total_balance: account.opening_anchor_balance,
@@ -20,29 +22,30 @@ def calculate
2022

2123
start_cash_balance = end_cash_balance
2224
start_non_cash_balance = end_non_cash_balance
23-
24-
build_balance(
25-
date: date,
26-
cash_balance: end_cash_balance,
27-
non_cash_balance: end_non_cash_balance
28-
)
25+
market_value_change = 0
2926
else
3027
start_cash_balance = derive_start_cash_balance(end_cash_balance: end_cash_balance, date: date)
3128
start_non_cash_balance = derive_start_non_cash_balance(end_non_cash_balance: end_non_cash_balance, date: date)
29+
market_value_change = market_value_change_on_date(date, flows)
30+
end
3231

33-
# Even though we've just calculated "start" balances, we set today equal to end of day, then use those
34-
# in our next iteration (slightly confusing, but just the nature of a "reverse" sync)
35-
output_balance = build_balance(
36-
date: date,
37-
cash_balance: end_cash_balance,
38-
non_cash_balance: end_non_cash_balance
39-
)
32+
output_balance = build_balance(
33+
date: date,
34+
balance: end_cash_balance + end_non_cash_balance,
35+
cash_balance: end_cash_balance,
36+
start_cash_balance: start_cash_balance,
37+
start_non_cash_balance: start_non_cash_balance,
38+
cash_inflows: flows[:cash_inflows],
39+
cash_outflows: flows[:cash_outflows],
40+
non_cash_inflows: flows[:non_cash_inflows],
41+
non_cash_outflows: flows[:non_cash_outflows],
42+
net_market_flows: market_value_change
43+
)
4044

41-
end_cash_balance = start_cash_balance
42-
end_non_cash_balance = start_non_cash_balance
45+
end_cash_balance = start_cash_balance
46+
end_non_cash_balance = start_non_cash_balance
4347

44-
output_balance
45-
end
48+
output_balance
4649
end
4750
end
4851
end
@@ -58,13 +61,6 @@ def signed_entry_flows(entries)
5861
account.asset? ? entry_flows : -entry_flows
5962
end
6063

61-
# Reverse syncs are a bit different than forward syncs because we do not allow "reconciliation" valuations
62-
# to be used at all. This is primarily to keep the code and the UI easy to understand. For a more detailed
63-
# explanation, see the test suite.
64-
def use_opening_anchor_for_date?(date)
65-
account.has_opening_anchor? && date == account.opening_anchor_date
66-
end
67-
6864
# Alias method, for algorithmic clarity
6965
# Derives cash balance, starting from the end-of-day, applying entries in reverse to get the start-of-day balance
7066
def derive_start_cash_balance(end_cash_balance:, date:)
@@ -76,4 +72,11 @@ def derive_start_cash_balance(end_cash_balance:, date:)
7672
def derive_start_non_cash_balance(end_non_cash_balance:, date:)
7773
derive_non_cash_balance(end_non_cash_balance, date, direction: :reverse)
7874
end
75+
76+
# Reverse syncs are a bit different than forward syncs because we do not allow "reconciliation" valuations
77+
# to be used at all. This is primarily to keep the code and the UI easy to understand. For a more detailed
78+
# explanation, see the test suite.
79+
def use_opening_anchor_for_date?(date)
80+
account.has_opening_anchor? && date == account.opening_anchor_date
81+
end
7982
end

app/models/balance/sync_cache.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@ def initialize(account)
33
@account = account
44
end
55

6-
def get_reconciliation_valuation(date)
7-
converted_entries.find { |e| e.date == date && e.valuation? && e.valuation.reconciliation? }
6+
def get_valuation(date)
7+
converted_entries.find { |e| e.date == date && e.valuation? }
88
end
99

1010
def get_holdings(date)
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
class AddStartEndColumnsToBalances < ActiveRecord::Migration[7.2]
2+
def up
3+
# Add new columns for balance tracking
4+
add_column :balances, :start_cash_balance, :decimal, precision: 19, scale: 4, null: false, default: 0.0
5+
add_column :balances, :start_non_cash_balance, :decimal, precision: 19, scale: 4, null: false, default: 0.0
6+
7+
# Flow tracking columns (absolute values)
8+
add_column :balances, :cash_inflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0
9+
add_column :balances, :cash_outflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0
10+
add_column :balances, :non_cash_inflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0
11+
add_column :balances, :non_cash_outflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0
12+
13+
# Market value changes
14+
add_column :balances, :net_market_flows, :decimal, precision: 19, scale: 4, null: false, default: 0.0
15+
16+
# Manual adjustments from valuations
17+
add_column :balances, :cash_adjustments, :decimal, precision: 19, scale: 4, null: false, default: 0.0
18+
add_column :balances, :non_cash_adjustments, :decimal, precision: 19, scale: 4, null: false, default: 0.0
19+
20+
# Flows factor determines *how* the flows affect the balance.
21+
# Inflows increase asset accounts, while inflows decrease liability accounts (reducing debt via "payment")
22+
add_column :balances, :flows_factor, :integer, null: false, default: 1
23+
24+
# Add generated columns
25+
change_table :balances do |t|
26+
t.virtual :start_balance, type: :decimal, precision: 19, scale: 4, stored: true,
27+
as: "start_cash_balance + start_non_cash_balance"
28+
29+
t.virtual :end_cash_balance, type: :decimal, precision: 19, scale: 4, stored: true,
30+
as: "start_cash_balance + ((cash_inflows - cash_outflows) * flows_factor) + cash_adjustments"
31+
32+
t.virtual :end_non_cash_balance, type: :decimal, precision: 19, scale: 4, stored: true,
33+
as: "start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * flows_factor) + net_market_flows + non_cash_adjustments"
34+
35+
# Postgres doesn't support generated columns depending on other generated columns,
36+
# but we want the integrity of the data to happen at the DB level, so this is the full formula.
37+
# Formula: (cash components) + (non-cash components)
38+
t.virtual :end_balance, type: :decimal, precision: 19, scale: 4, stored: true,
39+
as: <<~SQL.squish
40+
(
41+
start_cash_balance +
42+
((cash_inflows - cash_outflows) * flows_factor) +
43+
cash_adjustments
44+
) + (
45+
start_non_cash_balance +
46+
((non_cash_inflows - non_cash_outflows) * flows_factor) +
47+
net_market_flows +
48+
non_cash_adjustments
49+
)
50+
SQL
51+
end
52+
end
53+
54+
def down
55+
# Remove generated columns first (PostgreSQL requirement)
56+
remove_column :balances, :start_balance
57+
remove_column :balances, :end_cash_balance
58+
remove_column :balances, :end_non_cash_balance
59+
remove_column :balances, :end_balance
60+
61+
# Remove new columns
62+
remove_column :balances, :start_cash_balance
63+
remove_column :balances, :start_non_cash_balance
64+
remove_column :balances, :cash_inflows
65+
remove_column :balances, :cash_outflows
66+
remove_column :balances, :non_cash_inflows
67+
remove_column :balances, :non_cash_outflows
68+
remove_column :balances, :net_market_flows
69+
remove_column :balances, :cash_adjustments
70+
remove_column :balances, :non_cash_adjustments
71+
end
72+
end

0 commit comments

Comments
 (0)