1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-06 14:05:20 +02:00

Forward calculator tests passing

This commit is contained in:
Zach Gollwitzer 2025-07-22 08:54:35 -04:00
parent 19e9ccb503
commit 0eb93c069e
5 changed files with 170 additions and 47 deletions

View file

@ -15,8 +15,8 @@ class Balance::BaseCalculator
end
def holdings_value_for_date(date)
holdings = sync_cache.get_holdings(date)
holdings.sum(&:amount)
@holdings_value_for_date ||= {}
@holdings_value_for_date[date] ||= sync_cache.get_holdings(date).sum(&:amount)
end
def derive_cash_balance_on_date_from_total(total_balance:, date:)
@ -29,6 +29,67 @@ class Balance::BaseCalculator
end
end
def cash_adjustments_for_date(start_cash, net_cash_flows, valuation)
return 0 unless valuation && account.balance_type != :non_cash
valuation.amount - start_cash - net_cash_flows
end
def non_cash_adjustments_for_date(start_non_cash, non_cash_flows, valuation)
return 0 unless valuation && account.balance_type == :non_cash
valuation.amount - start_non_cash - non_cash_flows
end
# If holdings value goes from $100 -> $200 (change_holdings_value is $100)
# And non-cash flows (i.e. "buys") for day are +$50 (net_buy_sell_value is $50)
# 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
def market_value_change_on_date(date, flows)
return 0 unless account.balance_type == :investment
start_of_day_holdings_value = holdings_value_for_date(date.prev_day)
end_of_day_holdings_value = holdings_value_for_date(date)
change_holdings_value = end_of_day_holdings_value - start_of_day_holdings_value
net_buy_sell_value = flows[:non_cash_inflows] - flows[:non_cash_outflows]
change_holdings_value - net_buy_sell_value
end
def flows_for_date(date)
entries = sync_cache.get_entries(date)
cash_inflows = 0
cash_outflows = 0
non_cash_inflows = 0
non_cash_outflows = 0
txn_inflow_sum = entries.select { |e| e.amount < 0 && e.transaction? }.sum(&:amount)
txn_outflow_sum = entries.select { |e| e.amount >= 0 && e.transaction? }.sum(&:amount)
trade_cash_inflow_sum = entries.select { |e| e.amount < 0 && e.trade? }.sum(&:amount)
trade_cash_outflow_sum = entries.select { |e| e.amount >= 0 && e.trade? }.sum(&:amount)
if account.balance_type == :non_cash && account.accountable_type == "Loan"
non_cash_inflows = txn_inflow_sum.abs
non_cash_outflows = txn_outflow_sum
elsif account.balance_type != :non_cash
cash_inflows = txn_inflow_sum.abs + trade_cash_inflow_sum.abs
cash_outflows = txn_outflow_sum + trade_cash_outflow_sum
# Trades are inverse (a "buy" is outflow of cash, but "inflow" of non-cash, aka "holdings")
non_cash_outflows = trade_cash_inflow_sum.abs
non_cash_inflows = trade_cash_outflow_sum
end
{
cash_inflows: cash_inflows,
cash_outflows: cash_outflows,
non_cash_inflows: non_cash_inflows,
non_cash_outflows: non_cash_outflows
}
end
def derive_cash_balance(cash_balance, date)
entries = sync_cache.get_entries(date)
@ -57,15 +118,22 @@ class Balance::BaseCalculator
raise NotImplementedError, "Directional calculators must implement this method"
end
def build_balance(date:, cash_balance:, non_cash_balance:, start_cash_balance: nil, start_non_cash_balance: nil)
def build_balance(date:, **args)
Balance.new(
account_id: account.id,
currency: account.currency,
date: date,
balance: non_cash_balance + cash_balance,
cash_balance: cash_balance,
start_cash_balance: start_cash_balance || 0,
start_non_cash_balance: start_non_cash_balance || 0,
currency: account.currency
balance: args[:balance],
cash_balance: args[:cash_balance],
start_cash_balance: args[:start_cash_balance] || 0,
start_non_cash_balance: args[:start_non_cash_balance] || 0,
cash_inflows: args[:cash_inflows] || 0,
cash_outflows: args[:cash_outflows] || 0,
non_cash_inflows: args[:non_cash_inflows] || 0,
non_cash_outflows: args[:non_cash_outflows] || 0,
cash_adjustments: args[:cash_adjustments] || 0,
non_cash_adjustments: args[:non_cash_adjustments] || 0,
net_market_flows: args[:net_market_flows] || 0
)
end
end

View file

@ -2,13 +2,13 @@ class Balance::ForwardCalculator < Balance::BaseCalculator
def calculate
Rails.logger.tagged("Balance::ForwardCalculator") do
start_cash_balance = derive_cash_balance_on_date_from_total(
total_balance: account.opening_anchor_balance,
total_balance: 0,
date: account.opening_anchor_date
)
start_non_cash_balance = account.opening_anchor_balance - start_cash_balance
start_non_cash_balance = 0
calc_start_date.upto(calc_end_date).map do |date|
valuation = sync_cache.get_reconciliation_valuation(date)
valuation = sync_cache.get_valuation(date)
if valuation
end_cash_balance = derive_cash_balance_on_date_from_total(
@ -21,12 +21,22 @@ class Balance::ForwardCalculator < Balance::BaseCalculator
end_non_cash_balance = derive_end_non_cash_balance(start_non_cash_balance: start_non_cash_balance, date: date)
end
flows = flows_for_date(date)
market_value_change = market_value_change_on_date(date, flows)
output_balance = build_balance(
date: date,
balance: end_cash_balance + end_non_cash_balance,
cash_balance: end_cash_balance,
non_cash_balance: end_non_cash_balance,
start_cash_balance: start_cash_balance,
start_non_cash_balance: start_non_cash_balance
start_non_cash_balance: start_non_cash_balance,
cash_inflows: flows[:cash_inflows],
cash_outflows: flows[:cash_outflows],
non_cash_inflows: flows[:non_cash_inflows],
non_cash_outflows: flows[:non_cash_outflows],
cash_adjustments: cash_adjustments_for_date(start_cash_balance, flows[:cash_inflows] - flows[:cash_outflows], valuation),
non_cash_adjustments: non_cash_adjustments_for_date(start_non_cash_balance, flows[:non_cash_inflows] - flows[:non_cash_outflows], valuation),
net_market_flows: market_value_change
)
# Set values for the next iteration

View file

@ -3,8 +3,8 @@ class Balance::SyncCache
@account = account
end
def get_reconciliation_valuation(date)
converted_entries.find { |e| e.date == date && e.valuation? && e.valuation.reconciliation? }
def get_valuation(date)
converted_entries.find { |e| e.date == date && e.valuation? }
end
def get_holdings(date)

View file

@ -278,7 +278,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
date: 4.days.ago.to_date,
legacy_balances: { balance: 500, cash_balance: 500 },
balances: { start: 1000, start_cash: 1000, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 },
flows: { cash_inflows: 0, cash_outflows: 500 },
flows: { cash_inflows: 500, cash_outflows: 0 },
adjustments: 0
},
{
@ -525,8 +525,8 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase
},
{
date: Date.current,
legacy_balances: { balance: 5000, cash_balance: 4000 },
balances: { start: 5000, start_cash: 4000, start_non_cash: 1000, end_cash: 4000, end_non_cash: 1000, end: 5000 },
legacy_balances: { balance: 5100, cash_balance: 4000 },
balances: { start: 5000, start_cash: 4000, start_non_cash: 1000, end_cash: 4000, end_non_cash: 1100, end: 5100 },
flows: { net_market_flows: 100 }, # Holdings value increased by 100, despite no change in portfolio quantities
adjustments: 0
}

View file

@ -142,51 +142,85 @@ module LedgerTestingHelper
# Legacy balance assertions
if legacy_balances.any?
assert_equal legacy_balances[:balance], calculated_balance.balance.to_d,
assert_equal legacy_balances[:balance], calculated_balance.balance,
"Balance mismatch for #{date}"
assert_equal legacy_balances[:cash_balance], calculated_balance.cash_balance.to_d,
assert_equal legacy_balances[:cash_balance], calculated_balance.cash_balance,
"Cash balance mismatch for #{date}"
end
# Balance assertions
if balances.any?
assert_equal balances[:start_cash], calculated_balance.start_cash_balance.to_d,
assert_equal balances[:start_cash], calculated_balance.start_cash_balance,
"Start cash balance mismatch for #{date}" if balances.key?(:start_cash)
assert_equal balances[:start_non_cash], calculated_balance.start_non_cash_balance.to_d,
assert_equal balances[:start_non_cash], calculated_balance.start_non_cash_balance,
"Start non-cash balance mismatch for #{date}" if balances.key?(:start_non_cash)
assert_equal balances[:end_cash], calculated_balance.end_cash_balance.to_d,
"End cash balance mismatch for #{date}" if balances.key?(:end_cash)
# Calculate end_cash_balance using the formula from the migration
if balances.key?(:end_cash)
# Determine flows_factor based on account classification
flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1
expected_end_cash = calculated_balance.start_cash_balance +
((calculated_balance.cash_inflows - calculated_balance.cash_outflows) * flows_factor) +
calculated_balance.cash_adjustments
assert_equal balances[:end_cash], expected_end_cash,
"End cash balance mismatch for #{date}"
end
assert_equal balances[:end_non_cash], calculated_balance.end_non_cash_balance.to_d,
"End non-cash balance mismatch for #{date}" if balances.key?(:end_non_cash)
# Calculate end_non_cash_balance using the formula from the migration
if balances.key?(:end_non_cash)
# Determine flows_factor based on account classification
flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1
expected_end_non_cash = calculated_balance.start_non_cash_balance +
((calculated_balance.non_cash_inflows - calculated_balance.non_cash_outflows) * flows_factor) +
calculated_balance.net_market_flows +
calculated_balance.non_cash_adjustments
assert_equal balances[:end_non_cash], expected_end_non_cash,
"End non-cash balance mismatch for #{date}"
end
# Generated column assertions
assert_equal balances[:start], calculated_balance.start_balance.to_d,
"Start balance mismatch for #{date}" if balances.key?(:start)
# Calculate start_balance using the formula from the migration
if balances.key?(:start)
expected_start = calculated_balance.start_cash_balance + calculated_balance.start_non_cash_balance
assert_equal balances[:start], expected_start,
"Start balance mismatch for #{date}"
end
assert_equal balances[:end], calculated_balance.end_balance.to_d,
"End balance mismatch for #{date}" if balances.key?(:end)
# Calculate end_balance using the formula from the migration since we're not persisting balances,
# and generated columns are not available until the record is persisted
if balances.key?(:end)
# Determine flows_factor based on account classification
flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1
expected_end_cash_component = calculated_balance.start_cash_balance +
((calculated_balance.cash_inflows - calculated_balance.cash_outflows) * flows_factor) +
calculated_balance.cash_adjustments
expected_end_non_cash_component = calculated_balance.start_non_cash_balance +
((calculated_balance.non_cash_inflows - calculated_balance.non_cash_outflows) * flows_factor) +
calculated_balance.net_market_flows +
calculated_balance.non_cash_adjustments
expected_end = expected_end_cash_component + expected_end_non_cash_component
assert_equal balances[:end], expected_end,
"End balance mismatch for #{date}"
end
end
# Flow assertions
# If flows passed is 0, we assert all columns are 0
if flows.is_a?(Integer) && flows == 0
assert_equal 0, calculated_balance.cash_inflows.to_d,
assert_equal 0, calculated_balance.cash_inflows,
"Cash inflows mismatch for #{date}"
assert_equal 0, calculated_balance.cash_outflows.to_d,
assert_equal 0, calculated_balance.cash_outflows,
"Cash outflows mismatch for #{date}"
assert_equal 0, calculated_balance.non_cash_inflows.to_d,
assert_equal 0, calculated_balance.non_cash_inflows,
"Non-cash inflows mismatch for #{date}"
assert_equal 0, calculated_balance.non_cash_outflows.to_d,
assert_equal 0, calculated_balance.non_cash_outflows,
"Non-cash outflows mismatch for #{date}"
assert_equal 0, calculated_balance.net_market_flows.to_d,
assert_equal 0, calculated_balance.net_market_flows,
"Net market flows mismatch for #{date}"
elsif flows.is_a?(Hash) && flows.any?
# Cash flows - must be asserted together
@ -194,10 +228,10 @@ module LedgerTestingHelper
assert flows.key?(:cash_inflows) && flows.key?(:cash_outflows),
"Cash inflows and outflows must be asserted together for #{date}"
assert_equal flows[:cash_inflows], calculated_balance.cash_inflows.to_d,
assert_equal flows[:cash_inflows], calculated_balance.cash_inflows,
"Cash inflows mismatch for #{date}"
assert_equal flows[:cash_outflows], calculated_balance.cash_outflows.to_d,
assert_equal flows[:cash_outflows], calculated_balance.cash_outflows,
"Cash outflows mismatch for #{date}"
end
@ -206,41 +240,52 @@ module LedgerTestingHelper
assert flows.key?(:non_cash_inflows) && flows.key?(:non_cash_outflows),
"Non-cash inflows and outflows must be asserted together for #{date}"
assert_equal flows[:non_cash_inflows], calculated_balance.non_cash_inflows.to_d,
assert_equal flows[:non_cash_inflows], calculated_balance.non_cash_inflows,
"Non-cash inflows mismatch for #{date}"
assert_equal flows[:non_cash_outflows], calculated_balance.non_cash_outflows.to_d,
assert_equal flows[:non_cash_outflows], calculated_balance.non_cash_outflows,
"Non-cash outflows mismatch for #{date}"
end
# Market flows - can be asserted independently
if flows.key?(:net_market_flows)
assert_equal flows[:net_market_flows], calculated_balance.net_market_flows.to_d,
assert_equal flows[:net_market_flows], calculated_balance.net_market_flows,
"Net market flows mismatch for #{date}"
end
end
# Adjustment assertions
if adjustments.is_a?(Integer) && adjustments == 0
assert_equal 0, calculated_balance.cash_adjustments.to_d,
assert_equal 0, calculated_balance.cash_adjustments,
"Cash adjustments mismatch for #{date}"
assert_equal 0, calculated_balance.non_cash_adjustments.to_d,
assert_equal 0, calculated_balance.non_cash_adjustments,
"Non-cash adjustments mismatch for #{date}"
elsif adjustments.is_a?(Hash) && adjustments.any?
assert_equal adjustments[:cash_adjustments], calculated_balance.cash_adjustments.to_d,
assert_equal adjustments[:cash_adjustments], calculated_balance.cash_adjustments,
"Cash adjustments mismatch for #{date}" if adjustments.key?(:cash_adjustments)
assert_equal adjustments[:non_cash_adjustments], calculated_balance.non_cash_adjustments.to_d,
assert_equal adjustments[:non_cash_adjustments], calculated_balance.non_cash_adjustments,
"Non-cash adjustments mismatch for #{date}" if adjustments.key?(:non_cash_adjustments)
end
# Temporary assertions during migration (remove after migration complete)
# TODO: Remove these assertions after migration is complete
assert_equal calculated_balance.cash_balance.to_d, calculated_balance.end_cash_balance.to_d,
# Since we're not persisting balances, we calculate the end values
flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1
expected_end_cash = calculated_balance.start_cash_balance +
((calculated_balance.cash_inflows - calculated_balance.cash_outflows) * flows_factor) +
calculated_balance.cash_adjustments
expected_end_balance = expected_end_cash +
calculated_balance.start_non_cash_balance +
((calculated_balance.non_cash_inflows - calculated_balance.non_cash_outflows) * flows_factor) +
calculated_balance.net_market_flows +
calculated_balance.non_cash_adjustments
assert_equal calculated_balance.cash_balance, expected_end_cash,
"Temporary assertion failed: end_cash_balance should equal cash_balance for #{date}"
assert_equal calculated_balance.balance.to_d, calculated_balance.end_balance.to_d,
assert_equal calculated_balance.balance, expected_end_balance,
"Temporary assertion failed: end_balance should equal balance for #{date}"
else
assert_nil calculated_balance, "Unexpected balance calculated for #{date}"