From 3c73548a2e5206d9ed3ded0efb339d51d76a8f76 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Sat, 19 Jul 2025 13:22:16 -0400 Subject: [PATCH 1/9] Initial schema iteration --- ...21103_add_start_end_columns_to_balances.rb | 158 ++++++++++++++++++ db/schema.rb | 15 +- 2 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20250719121103_add_start_end_columns_to_balances.rb diff --git a/db/migrate/20250719121103_add_start_end_columns_to_balances.rb b/db/migrate/20250719121103_add_start_end_columns_to_balances.rb new file mode 100644 index 00000000..2d1b6c32 --- /dev/null +++ b/db/migrate/20250719121103_add_start_end_columns_to_balances.rb @@ -0,0 +1,158 @@ +class AddStartEndColumnsToBalances < ActiveRecord::Migration[7.2] + def up + # Rename existing columns to deprecated versions + # rename_column :balances, :balance, :balance_deprecated + # rename_column :balances, :cash_balance, :cash_balance_deprecated + + # Add new columns for balance tracking + add_column :balances, :start_cash_balance, :decimal, precision: 19, scale: 4, null: false, default: 0.0 + add_column :balances, :start_non_cash_balance, :decimal, precision: 19, scale: 4, null: false, default: 0.0 + + # Flow tracking columns (absolute values) + add_column :balances, :cash_inflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0 + add_column :balances, :cash_outflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0 + add_column :balances, :non_cash_inflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0 + add_column :balances, :non_cash_outflows, :decimal, precision: 19, scale: 4, null: false, default: 0.0 + + # Market value changes + add_column :balances, :net_market_flows, :decimal, precision: 19, scale: 4, null: false, default: 0.0 + + # Manual adjustments from valuations + add_column :balances, :cash_adjustments, :decimal, precision: 19, scale: 4, null: false, default: 0.0 + add_column :balances, :non_cash_adjustments, :decimal, precision: 19, scale: 4, null: false, default: 0.0 + + # Add generated columns + change_table :balances do |t| + t.virtual :start_balance, type: :decimal, precision: 19, scale: 4, stored: true, + as: "start_cash_balance + start_non_cash_balance" + + t.virtual :end_cash_balance, type: :decimal, precision: 19, scale: 4, stored: true, + as: "start_cash_balance + cash_inflows - cash_outflows + cash_adjustments" + + t.virtual :end_non_cash_balance, type: :decimal, precision: 19, scale: 4, stored: true, + as: "start_non_cash_balance + non_cash_inflows - non_cash_outflows + net_market_flows + non_cash_adjustments" + + # Postgres doesn't support generated columns depending on other generated columns, + # but we want the integrity of the data to happen at the DB level, so this is the full formula. + # Formula: (cash components) + (non-cash components) + t.virtual :end_balance, type: :decimal, precision: 19, scale: 4, stored: true, + as: <<~SQL.squish + ( + start_cash_balance + + cash_inflows - + cash_outflows + + cash_adjustments + ) + ( + start_non_cash_balance + + non_cash_inflows - + non_cash_outflows + + net_market_flows + + non_cash_adjustments + ) + SQL + end + + # Migrate existing data + + # Step 1: Set start values using LOCF (Last Observation Carried Forward) + execute <<~SQL + UPDATE balances b1 + SET + start_cash_balance = COALESCE(prev.cash_balance, 0), + start_non_cash_balance = COALESCE(prev.balance - prev.cash_balance, 0) + FROM balances b1_inner + LEFT JOIN LATERAL ( + SELECT + b2.cash_balance, + b2.balance + FROM balances b2 + WHERE b2.account_id = b1_inner.account_id + AND b2.currency = b1_inner.currency + AND b2.date < b1_inner.date + ORDER BY b2.date DESC + LIMIT 1 + ) prev ON true + WHERE b1.id = b1_inner.id + SQL + + # Step 2: Calculate net flows as inflows (can be negative) + # We use net change as inflows, outflows stay 0 for historical data + execute <<~SQL + UPDATE balances SET + cash_inflows = cash_balance - start_cash_balance, + cash_outflows = 0, + non_cash_inflows = (balance - cash_balance) - start_non_cash_balance, + non_cash_outflows = 0, + net_market_flows = 0 + SQL + + # Step 3: Initialize adjustments to 0 + execute <<~SQL + UPDATE balances SET + cash_adjustments = 0, + non_cash_adjustments = 0 + SQL + + # Step 4: Calculate adjustments from valuation entries + execute <<~SQL + WITH valuation_data AS ( + SELECT#{' '} + e.account_id, + e.date, + e.amount as valuation_amount, + e.currency, + a.accountable_type + FROM entries e + JOIN accounts a ON a.id = e.account_id + WHERE e.entryable_type = 'Valuation' + ) + UPDATE balances b + SET + cash_adjustments = CASE + -- For investment accounts: valuation sets total, preserve holdings value + WHEN vd.accountable_type = 'Investment' THEN + vd.valuation_amount - (b.start_balance + b.cash_inflows + b.non_cash_inflows) + -- For loan accounts: adjustment goes to non-cash + WHEN vd.accountable_type = 'Loan' THEN + 0 + -- For all other accounts: adjustment goes to cash + ELSE + vd.valuation_amount - (b.start_balance + b.cash_inflows + b.non_cash_inflows) + END, + non_cash_adjustments = CASE + -- Only loan accounts get non-cash adjustments + WHEN vd.accountable_type = 'Loan' THEN + vd.valuation_amount - (b.start_balance + b.cash_inflows + b.non_cash_inflows) + ELSE + 0 + END + FROM valuation_data vd + WHERE b.account_id = vd.account_id + AND b.date = vd.date + AND b.currency = vd.currency + SQL + end + + def down + # Remove generated columns first (PostgreSQL requirement) + remove_column :balances, :start_balance + remove_column :balances, :end_cash_balance + remove_column :balances, :end_non_cash_balance + remove_column :balances, :end_balance + + # Remove new columns + remove_column :balances, :start_cash_balance + remove_column :balances, :start_non_cash_balance + remove_column :balances, :cash_inflows + remove_column :balances, :cash_outflows + remove_column :balances, :non_cash_inflows + remove_column :balances, :non_cash_outflows + remove_column :balances, :net_market_flows + remove_column :balances, :cash_adjustments + remove_column :balances, :non_cash_adjustments + + # Restore original column names + # rename_column :balances, :balance_deprecated, :balance + # rename_column :balances, :cash_balance_deprecated, :cash_balance + end +end diff --git a/db/schema.rb b/db/schema.rb index 3b839c95..d99ac8f7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2025_07_18_120146) do +ActiveRecord::Schema[7.2].define(version: 2025_07_19_121103) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -115,6 +115,19 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_18_120146) do t.datetime "created_at", null: false t.datetime "updated_at", null: false t.decimal "cash_balance", precision: 19, scale: 4, default: "0.0" + t.decimal "start_cash_balance", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "start_non_cash_balance", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "cash_inflows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "cash_outflows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "non_cash_inflows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "non_cash_outflows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "net_market_flows", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false + t.decimal "non_cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false + t.virtual "start_balance", type: :decimal, precision: 19, scale: 4, as: "(start_cash_balance + start_non_cash_balance)", stored: true + t.virtual "end_cash_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_cash_balance + cash_inflows) - cash_outflows) + cash_adjustments)", stored: true + t.virtual "end_non_cash_balance", type: :decimal, precision: 19, scale: 4, as: "((((start_non_cash_balance + non_cash_inflows) - non_cash_outflows) + net_market_flows) + non_cash_adjustments)", stored: true + t.virtual "end_balance", type: :decimal, precision: 19, scale: 4, as: "((((start_cash_balance + cash_inflows) - cash_outflows) + cash_adjustments) + ((((start_non_cash_balance + non_cash_inflows) - non_cash_outflows) + net_market_flows) + non_cash_adjustments))", stored: true t.index ["account_id", "date", "currency"], name: "index_account_balances_on_account_id_date_currency_unique", unique: true t.index ["account_id", "date"], name: "index_balances_on_account_id_and_date", order: { date: :desc } t.index ["account_id"], name: "index_balances_on_account_id" -- 2.53.0 From ecc669d4a85e724154ae59a64f94f096c6babc95 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Sun, 20 Jul 2025 06:32:04 -0400 Subject: [PATCH 2/9] Add new balance components --- ...21103_add_start_end_columns_to_balances.rb | 102 ++---------------- db/schema.rb | 7 +- 2 files changed, 12 insertions(+), 97 deletions(-) diff --git a/db/migrate/20250719121103_add_start_end_columns_to_balances.rb b/db/migrate/20250719121103_add_start_end_columns_to_balances.rb index 2d1b6c32..1c864439 100644 --- a/db/migrate/20250719121103_add_start_end_columns_to_balances.rb +++ b/db/migrate/20250719121103_add_start_end_columns_to_balances.rb @@ -1,9 +1,5 @@ class AddStartEndColumnsToBalances < ActiveRecord::Migration[7.2] def up - # Rename existing columns to deprecated versions - # rename_column :balances, :balance, :balance_deprecated - # rename_column :balances, :cash_balance, :cash_balance_deprecated - # Add new columns for balance tracking add_column :balances, :start_cash_balance, :decimal, precision: 19, scale: 4, null: false, default: 0.0 add_column :balances, :start_non_cash_balance, :decimal, precision: 19, scale: 4, null: false, default: 0.0 @@ -21,16 +17,20 @@ class AddStartEndColumnsToBalances < ActiveRecord::Migration[7.2] add_column :balances, :cash_adjustments, :decimal, precision: 19, scale: 4, null: false, default: 0.0 add_column :balances, :non_cash_adjustments, :decimal, precision: 19, scale: 4, null: false, default: 0.0 + # Flows factor determines *how* the flows affect the balance. + # Inflows increase asset accounts, while inflows decrease liability accounts (reducing debt via "payment") + add_column :balances, :flows_factor, :integer, null: false, default: 1 + # Add generated columns change_table :balances do |t| t.virtual :start_balance, type: :decimal, precision: 19, scale: 4, stored: true, as: "start_cash_balance + start_non_cash_balance" t.virtual :end_cash_balance, type: :decimal, precision: 19, scale: 4, stored: true, - as: "start_cash_balance + cash_inflows - cash_outflows + cash_adjustments" + as: "start_cash_balance + ((cash_inflows - cash_outflows) * flows_factor) + cash_adjustments" t.virtual :end_non_cash_balance, type: :decimal, precision: 19, scale: 4, stored: true, - as: "start_non_cash_balance + non_cash_inflows - non_cash_outflows + net_market_flows + non_cash_adjustments" + as: "start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * flows_factor) + net_market_flows + non_cash_adjustments" # Postgres doesn't support generated columns depending on other generated columns, # but we want the integrity of the data to happen at the DB level, so this is the full formula. @@ -39,98 +39,16 @@ class AddStartEndColumnsToBalances < ActiveRecord::Migration[7.2] as: <<~SQL.squish ( start_cash_balance + - cash_inflows - - cash_outflows + + ((cash_inflows - cash_outflows) * flows_factor) + cash_adjustments ) + ( start_non_cash_balance + - non_cash_inflows - - non_cash_outflows + + ((non_cash_inflows - non_cash_outflows) * flows_factor) + net_market_flows + non_cash_adjustments ) SQL end - - # Migrate existing data - - # Step 1: Set start values using LOCF (Last Observation Carried Forward) - execute <<~SQL - UPDATE balances b1 - SET - start_cash_balance = COALESCE(prev.cash_balance, 0), - start_non_cash_balance = COALESCE(prev.balance - prev.cash_balance, 0) - FROM balances b1_inner - LEFT JOIN LATERAL ( - SELECT - b2.cash_balance, - b2.balance - FROM balances b2 - WHERE b2.account_id = b1_inner.account_id - AND b2.currency = b1_inner.currency - AND b2.date < b1_inner.date - ORDER BY b2.date DESC - LIMIT 1 - ) prev ON true - WHERE b1.id = b1_inner.id - SQL - - # Step 2: Calculate net flows as inflows (can be negative) - # We use net change as inflows, outflows stay 0 for historical data - execute <<~SQL - UPDATE balances SET - cash_inflows = cash_balance - start_cash_balance, - cash_outflows = 0, - non_cash_inflows = (balance - cash_balance) - start_non_cash_balance, - non_cash_outflows = 0, - net_market_flows = 0 - SQL - - # Step 3: Initialize adjustments to 0 - execute <<~SQL - UPDATE balances SET - cash_adjustments = 0, - non_cash_adjustments = 0 - SQL - - # Step 4: Calculate adjustments from valuation entries - execute <<~SQL - WITH valuation_data AS ( - SELECT#{' '} - e.account_id, - e.date, - e.amount as valuation_amount, - e.currency, - a.accountable_type - FROM entries e - JOIN accounts a ON a.id = e.account_id - WHERE e.entryable_type = 'Valuation' - ) - UPDATE balances b - SET - cash_adjustments = CASE - -- For investment accounts: valuation sets total, preserve holdings value - WHEN vd.accountable_type = 'Investment' THEN - vd.valuation_amount - (b.start_balance + b.cash_inflows + b.non_cash_inflows) - -- For loan accounts: adjustment goes to non-cash - WHEN vd.accountable_type = 'Loan' THEN - 0 - -- For all other accounts: adjustment goes to cash - ELSE - vd.valuation_amount - (b.start_balance + b.cash_inflows + b.non_cash_inflows) - END, - non_cash_adjustments = CASE - -- Only loan accounts get non-cash adjustments - WHEN vd.accountable_type = 'Loan' THEN - vd.valuation_amount - (b.start_balance + b.cash_inflows + b.non_cash_inflows) - ELSE - 0 - END - FROM valuation_data vd - WHERE b.account_id = vd.account_id - AND b.date = vd.date - AND b.currency = vd.currency - SQL end def down @@ -150,9 +68,5 @@ class AddStartEndColumnsToBalances < ActiveRecord::Migration[7.2] remove_column :balances, :net_market_flows remove_column :balances, :cash_adjustments remove_column :balances, :non_cash_adjustments - - # Restore original column names - # rename_column :balances, :balance_deprecated, :balance - # rename_column :balances, :cash_balance_deprecated, :cash_balance end end diff --git a/db/schema.rb b/db/schema.rb index d99ac8f7..39f0eeb0 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -124,10 +124,11 @@ ActiveRecord::Schema[7.2].define(version: 2025_07_19_121103) do t.decimal "net_market_flows", precision: 19, scale: 4, default: "0.0", null: false t.decimal "cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false t.decimal "non_cash_adjustments", precision: 19, scale: 4, default: "0.0", null: false + t.integer "flows_factor", default: 1, null: false t.virtual "start_balance", type: :decimal, precision: 19, scale: 4, as: "(start_cash_balance + start_non_cash_balance)", stored: true - t.virtual "end_cash_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_cash_balance + cash_inflows) - cash_outflows) + cash_adjustments)", stored: true - t.virtual "end_non_cash_balance", type: :decimal, precision: 19, scale: 4, as: "((((start_non_cash_balance + non_cash_inflows) - non_cash_outflows) + net_market_flows) + non_cash_adjustments)", stored: true - t.virtual "end_balance", type: :decimal, precision: 19, scale: 4, as: "((((start_cash_balance + cash_inflows) - cash_outflows) + cash_adjustments) + ((((start_non_cash_balance + non_cash_inflows) - non_cash_outflows) + net_market_flows) + non_cash_adjustments))", stored: true + t.virtual "end_cash_balance", type: :decimal, precision: 19, scale: 4, as: "((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments)", stored: true + t.virtual "end_non_cash_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments)", stored: true + t.virtual "end_balance", type: :decimal, precision: 19, scale: 4, as: "(((start_cash_balance + ((cash_inflows - cash_outflows) * (flows_factor)::numeric)) + cash_adjustments) + (((start_non_cash_balance + ((non_cash_inflows - non_cash_outflows) * (flows_factor)::numeric)) + net_market_flows) + non_cash_adjustments))", stored: true t.index ["account_id", "date", "currency"], name: "index_account_balances_on_account_id_date_currency_unique", unique: true t.index ["account_id", "date"], name: "index_balances_on_account_id_and_date", order: { date: :desc } t.index ["account_id"], name: "index_balances_on_account_id" -- 2.53.0 From a2a0bd2e6c1bcec528079a5d15f0814747923e58 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Sun, 20 Jul 2025 06:32:35 -0400 Subject: [PATCH 3/9] Add existing data migrator to backfill components --- .../balance_component_migrator.rb | 59 +++++++ lib/tasks/data_migration.rake | 15 ++ .../balance_component_migrator_test.rb | 160 ++++++++++++++++++ 3 files changed, 234 insertions(+) create mode 100644 app/data_migrations/balance_component_migrator.rb create mode 100644 test/data_migrations/balance_component_migrator_test.rb diff --git a/app/data_migrations/balance_component_migrator.rb b/app/data_migrations/balance_component_migrator.rb new file mode 100644 index 00000000..806c2abc --- /dev/null +++ b/app/data_migrations/balance_component_migrator.rb @@ -0,0 +1,59 @@ +class BalanceComponentMigrator + def self.run + ActiveRecord::Base.transaction do + # Step 1: Update flows factor + ActiveRecord::Base.connection.execute <<~SQL + UPDATE balances SET + flows_factor = CASE WHEN a.classification = 'asset' THEN 1 ELSE -1 END + FROM accounts a + WHERE a.id = balances.account_id + SQL + + # Step 2: Set start values using LOCF (Last Observation Carried Forward) + ActiveRecord::Base.connection.execute <<~SQL + UPDATE balances b1 + SET + start_cash_balance = COALESCE(prev.cash_balance, 0), + start_non_cash_balance = COALESCE(prev.balance - prev.cash_balance, 0) + FROM balances b1_inner + LEFT JOIN LATERAL ( + SELECT + b2.cash_balance, + b2.balance + FROM balances b2 + WHERE b2.account_id = b1_inner.account_id + AND b2.currency = b1_inner.currency + AND b2.date < b1_inner.date + ORDER BY b2.date DESC + LIMIT 1 + ) prev ON true + WHERE b1.id = b1_inner.id + SQL + + # Step 3: Calculate net inflows + # A slight workaround to the fact that we can't easily derive inflows/outflows from our current data model, and + # the tradeoff not worth it since each new sync will fix it. So instead, we sum up *net* flows, and throw the signed + # amount in the "inflows" column, and zero-out the "outflows" column so our math works correctly with incomplete data. + ActiveRecord::Base.connection.execute <<~SQL + UPDATE balances SET + cash_inflows = (cash_balance - start_cash_balance) * flows_factor, + cash_outflows = 0, + non_cash_inflows = ((balance - cash_balance) - start_non_cash_balance) * flows_factor, + non_cash_outflows = 0, + net_market_flows = 0 + SQL + + # Verify data integrity + # All end_balance values should match the original balance + invalid_count = ActiveRecord::Base.connection.select_value(<<~SQL) + SELECT COUNT(*) + FROM balances b + WHERE ABS(b.balance - b.end_balance) > 0.0001 + SQL + + if invalid_count > 0 + raise "Data migration failed validation: #{invalid_count} balances have incorrect end_balance values" + end + end + end +end diff --git a/lib/tasks/data_migration.rake b/lib/tasks/data_migration.rake index 6d3a14fd..febdcb3b 100644 --- a/lib/tasks/data_migration.rake +++ b/lib/tasks/data_migration.rake @@ -154,4 +154,19 @@ namespace :data_migration do puts " Processed: #{accounts_processed} accounts" puts " Opening anchors set: #{opening_anchors_set}" end + + desc "Migrate balance components" + # 2025-07-20: Migrate balance components to support event-sourced ledger model. + # This task: + # 1. Sets the flows_factor for each account based on the account's classification + # 2. Sets the start_cash_balance, start_non_cash_balance, and start_balance for each balance + # 3. Sets the cash_inflows, cash_outflows, non_cash_inflows, non_cash_outflows, net_market_flows, cash_adjustments, and non_cash_adjustments for each balance + # 4. Sets the end_cash_balance, end_non_cash_balance, and end_balance for each balance + task migrate_balance_components: :environment do + puts "==> Migrating balance components..." + + BalanceComponentMigrator.run + + puts "✅ Balance component migration complete." + end end diff --git a/test/data_migrations/balance_component_migrator_test.rb b/test/data_migrations/balance_component_migrator_test.rb new file mode 100644 index 00000000..add8384c --- /dev/null +++ b/test/data_migrations/balance_component_migrator_test.rb @@ -0,0 +1,160 @@ +require "test_helper" + +class BalanceComponentMigratorTest < ActiveSupport::TestCase + include EntriesTestHelper + + setup do + @depository = accounts(:depository) + @investment = accounts(:investment) + @loan = accounts(:loan) + + # Start fresh + Balance.delete_all + end + + test "depository account with no gaps" do + create_balance_history(@depository, [ + { date: 5.days.ago, cash_balance: 1000, balance: 1000 }, + { date: 4.days.ago, cash_balance: 1100, balance: 1100 }, + { date: 3.days.ago, cash_balance: 1050, balance: 1050 }, + { date: 2.days.ago, cash_balance: 1200, balance: 1200 }, + { date: 1.day.ago, cash_balance: 1150, balance: 1150 } + ]) + + BalanceComponentMigrator.run + + assert_migrated_balances @depository, [ + { date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 1000, non_cash_inflows: 0, end_cash: 1000, end_non_cash: 0, end: 1000 }, + { date: 4.days.ago, start_cash: 1000, start_non_cash: 0, start: 1000, cash_inflows: 100, non_cash_inflows: 0, end_cash: 1100, end_non_cash: 0, end: 1100 }, + { date: 3.days.ago, start_cash: 1100, start_non_cash: 0, start: 1100, cash_inflows: -50, non_cash_inflows: 0, end_cash: 1050, end_non_cash: 0, end: 1050 }, + { date: 2.days.ago, start_cash: 1050, start_non_cash: 0, start: 1050, cash_inflows: 150, non_cash_inflows: 0, end_cash: 1200, end_non_cash: 0, end: 1200 }, + { date: 1.day.ago, start_cash: 1200, start_non_cash: 0, start: 1200, cash_inflows: -50, non_cash_inflows: 0, end_cash: 1150, end_non_cash: 0, end: 1150 } + ] + end + + test "depository account with gaps" do + create_balance_history(@depository, [ + { date: 5.days.ago, cash_balance: 1000, balance: 1000 }, + { date: 1.day.ago, cash_balance: 1150, balance: 1150 } + ]) + + BalanceComponentMigrator.run + + assert_migrated_balances @depository, [ + { date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 1000, non_cash_inflows: 0, end_cash: 1000, end_non_cash: 0, end: 1000 }, + { date: 1.day.ago, start_cash: 1000, start_non_cash: 0, start: 1000, cash_inflows: 150, non_cash_inflows: 0, end_cash: 1150, end_non_cash: 0, end: 1150 } + ] + end + + test "investment account with no gaps" do + create_balance_history(@investment, [ + { date: 3.days.ago, cash_balance: 100, balance: 200 }, + { date: 2.days.ago, cash_balance: 200, balance: 300 }, + { date: 1.day.ago, cash_balance: 0, balance: 300 } + ]) + + BalanceComponentMigrator.run + + assert_migrated_balances @investment, [ + { date: 3.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 100, non_cash_inflows: 100, end_cash: 100, end_non_cash: 100, end: 200 }, + { date: 2.days.ago, start_cash: 100, start_non_cash: 100, start: 200, cash_inflows: 100, non_cash_inflows: 0, end_cash: 200, end_non_cash: 100, end: 300 }, + { date: 1.day.ago, start_cash: 200, start_non_cash: 100, start: 300, cash_inflows: -200, non_cash_inflows: 200, end_cash: 0, end_non_cash: 300, end: 300 } + ] + end + + test "investment account with gaps" do + create_balance_history(@investment, [ + { date: 5.days.ago, cash_balance: 1000, balance: 1000 }, + { date: 1.day.ago, cash_balance: 1150, balance: 1150 } + ]) + + BalanceComponentMigrator.run + + assert_migrated_balances @investment, [ + { date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 1000, non_cash_inflows: 0, end_cash: 1000, end_non_cash: 0, end: 1000 }, + { date: 1.day.ago, start_cash: 1000, start_non_cash: 0, start: 1000, cash_inflows: 150, non_cash_inflows: 0, end_cash: 1150, end_non_cash: 0, end: 1150 } + ] + end + + # Negative flows factor test + test "loan account with no gaps" do + create_balance_history(@loan, [ + { date: 3.days.ago, cash_balance: 0, balance: 200 }, + { date: 2.days.ago, cash_balance: 0, balance: 300 }, + { date: 1.day.ago, cash_balance: 0, balance: 500 } + ]) + + BalanceComponentMigrator.run + + assert_migrated_balances @loan, [ + { date: 3.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 0, non_cash_inflows: -200, end_cash: 0, end_non_cash: 200, end: 200 }, + { date: 2.days.ago, start_cash: 0, start_non_cash: 200, start: 200, cash_inflows: 0, non_cash_inflows: -100, end_cash: 0, end_non_cash: 300, end: 300 }, + { date: 1.day.ago, start_cash: 0, start_non_cash: 300, start: 300, cash_inflows: 0, non_cash_inflows: -200, end_cash: 0, end_non_cash: 500, end: 500 } + ] + end + + test "loan account with gaps" do + create_balance_history(@loan, [ + { date: 5.days.ago, cash_balance: 0, balance: 1000 }, + { date: 1.day.ago, cash_balance: 0, balance: 2000 } + ]) + + BalanceComponentMigrator.run + + assert_migrated_balances @loan, [ + { date: 5.days.ago, start_cash: 0, start_non_cash: 0, start: 0, cash_inflows: 0, non_cash_inflows: -1000, end_cash: 0, end_non_cash: 1000, end: 1000 }, + { date: 1.day.ago, start_cash: 0, start_non_cash: 1000, start: 1000, cash_inflows: 0, non_cash_inflows: -1000, end_cash: 0, end_non_cash: 2000, end: 2000 } + ] + end + + private + def create_balance_history(account, balances) + balances.each do |balance| + account.balances.create!( + date: balance[:date].to_date, + balance: balance[:balance], + cash_balance: balance[:cash_balance], + currency: account.currency + ) + end + end + + def assert_migrated_balances(account, expected) + balances = account.balances.order(:date) + + expected.each_with_index do |expected_values, index| + balance = balances.find { |b| b.date == expected_values[:date].to_date } + assert balance, "Expected balance for #{expected_values[:date].to_date} but none found" + + # Assert expected values + assert_equal expected_values[:start_cash], balance.start_cash_balance, + "start_cash_balance mismatch for #{balance.date}" + assert_equal expected_values[:start_non_cash], balance.start_non_cash_balance, + "start_non_cash_balance mismatch for #{balance.date}" + assert_equal expected_values[:start], balance.start_balance, + "start_balance mismatch for #{balance.date}" + assert_equal expected_values[:cash_inflows], balance.cash_inflows, + "cash_inflows mismatch for #{balance.date}" + assert_equal expected_values[:non_cash_inflows], balance.non_cash_inflows, + "non_cash_inflows mismatch for #{balance.date}" + assert_equal expected_values[:end_cash], balance.end_cash_balance, + "end_cash_balance mismatch for #{balance.date}" + assert_equal expected_values[:end_non_cash], balance.end_non_cash_balance, + "end_non_cash_balance mismatch for #{balance.date}" + assert_equal expected_values[:end], balance.end_balance, + "end_balance mismatch for #{balance.date}" + + # Assert zeros for other fields + assert_equal 0, balance.cash_outflows, + "cash_outflows should be zero for #{balance.date}" + assert_equal 0, balance.non_cash_outflows, + "non_cash_outflows should be zero for #{balance.date}" + assert_equal 0, balance.cash_adjustments, + "cash_adjustments should be zero for #{balance.date}" + assert_equal 0, balance.non_cash_adjustments, + "non_cash_adjustments should be zero for #{balance.date}" + assert_equal 0, balance.net_market_flows, + "net_market_flows should be zero for #{balance.date}" + end + end +end -- 2.53.0 From 91d970c7fe1e126b07f13050c3af6930fdc11e77 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Sun, 20 Jul 2025 08:37:55 -0400 Subject: [PATCH 4/9] Update calculator test assertions for new balance components --- app/models/balance.rb | 10 +- app/models/balance/base_calculator.rb | 4 +- app/models/balance/forward_calculator.rb | 4 +- app/models/balance/reverse_calculator.rb | 8 +- .../models/balance/forward_calculator_test.rb | 272 ++++++++++++++---- .../models/balance/reverse_calculator_test.rb | 230 ++++++++++++--- test/support/ledger_testing_helper.rb | 90 +++++- 7 files changed, 503 insertions(+), 115 deletions(-) diff --git a/app/models/balance.rb b/app/models/balance.rb index ff28db90..dffc9f07 100644 --- a/app/models/balance.rb +++ b/app/models/balance.rb @@ -2,8 +2,16 @@ class Balance < ApplicationRecord include Monetizable belongs_to :account + validates :account, :date, :balance, presence: true - monetize :balance, :cash_balance + validates :flows_factor, inclusion: { in: [ -1, 1 ] } + + monetize :balance, :cash_balance, + :start_cash_balance, :start_non_cash_balance, :start_balance, + :cash_inflows, :cash_outflows, :non_cash_inflows, :non_cash_outflows, :net_market_flows, + :cash_adjustments, :non_cash_adjustments, + :end_cash_balance, :end_non_cash_balance, :end_balance + scope :in_period, ->(period) { period.nil? ? all : where(date: period.date_range) } scope :chronological, -> { order(:date) } end diff --git a/app/models/balance/base_calculator.rb b/app/models/balance/base_calculator.rb index 92ef5d3e..af95bef8 100644 --- a/app/models/balance/base_calculator.rb +++ b/app/models/balance/base_calculator.rb @@ -57,12 +57,14 @@ class Balance::BaseCalculator raise NotImplementedError, "Directional calculators must implement this method" end - def build_balance(date:, cash_balance:, non_cash_balance:) + def build_balance(date:, cash_balance:, non_cash_balance:, start_cash_balance: nil, start_non_cash_balance: nil) Balance.new( account_id: account.id, 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 ) end diff --git a/app/models/balance/forward_calculator.rb b/app/models/balance/forward_calculator.rb index bd9272b7..8cf96fa5 100644 --- a/app/models/balance/forward_calculator.rb +++ b/app/models/balance/forward_calculator.rb @@ -24,7 +24,9 @@ class Balance::ForwardCalculator < Balance::BaseCalculator output_balance = build_balance( date: date, cash_balance: end_cash_balance, - non_cash_balance: end_non_cash_balance + non_cash_balance: end_non_cash_balance, + start_cash_balance: start_cash_balance, + start_non_cash_balance: start_non_cash_balance ) # Set values for the next iteration diff --git a/app/models/balance/reverse_calculator.rb b/app/models/balance/reverse_calculator.rb index 1e75d5e4..36106d2f 100644 --- a/app/models/balance/reverse_calculator.rb +++ b/app/models/balance/reverse_calculator.rb @@ -24,7 +24,9 @@ class Balance::ReverseCalculator < Balance::BaseCalculator build_balance( date: date, cash_balance: end_cash_balance, - non_cash_balance: end_non_cash_balance + non_cash_balance: end_non_cash_balance, + start_cash_balance: start_cash_balance, + start_non_cash_balance: start_non_cash_balance ) else start_cash_balance = derive_start_cash_balance(end_cash_balance: end_cash_balance, date: date) @@ -35,7 +37,9 @@ class Balance::ReverseCalculator < Balance::BaseCalculator output_balance = build_balance( date: date, cash_balance: end_cash_balance, - non_cash_balance: end_non_cash_balance + non_cash_balance: end_non_cash_balance, + start_cash_balance: start_cash_balance, + start_non_cash_balance: start_non_cash_balance ) end_cash_balance = start_cash_balance diff --git a/test/models/balance/forward_calculator_test.rb b/test/models/balance/forward_calculator_test.rb index b6eb2d11..98ee9b54 100644 --- a/test/models/balance/forward_calculator_test.rb +++ b/test/models/balance/forward_calculator_test.rb @@ -21,8 +21,12 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ Date.current, { balance: 0, cash_balance: 0 } ] + expected_data: [ + { + date: Date.current, + legacy_balances: { balance: 0, cash_balance: 0 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 0, end: 0 } + } ] ) end @@ -41,9 +45,17 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase # Since we start at 0, this transaction (inflow) simply increases balance from 0 -> 1000 assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ 3.days.ago.to_date, { balance: 0, cash_balance: 0 } ], - [ 2.days.ago.to_date, { balance: 1000, cash_balance: 1000 } ] + expected_data: [ + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 0, cash_balance: 0 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 0, end: 0 } + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 1000, cash_balance: 1000 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 } + } ] ) end @@ -62,9 +74,17 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase # First valuation sets balance to 18000, then transaction increases balance to 19000 assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ 3.days.ago.to_date, { balance: 18000, cash_balance: 18000 } ], - [ 2.days.ago.to_date, { balance: 19000, cash_balance: 19000 } ] + expected_data: [ + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 18000, cash_balance: 18000 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 } + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 19000, cash_balance: 19000 }, + balances: { start: 18000, start_cash: 18000, start_non_cash: 0, end_cash: 19000, end_non_cash: 0, end: 19000 } + } ] ) end @@ -83,9 +103,17 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ 3.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ], - [ 2.days.ago.to_date, { balance: 18000, cash_balance: 18000 } ] + expected_data: [ + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 17000, cash_balance: 17000 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 } + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 18000, cash_balance: 18000 }, + balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 } + } ] ) end @@ -105,9 +133,17 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ 3.days.ago.to_date, { balance: 17000, cash_balance: 0.0 } ], - [ 2.days.ago.to_date, { balance: 18000, cash_balance: 0.0 } ] + expected_data: [ + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 17000, cash_balance: 0.0 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 17000, end: 17000 } + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 18000, cash_balance: 0.0 }, + balances: { start: 17000, start_cash: 0, start_non_cash: 17000, end_cash: 0, end_non_cash: 18000, end: 18000 } + } ] ) end @@ -127,9 +163,17 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ 3.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ], - [ 2.days.ago.to_date, { balance: 18000, cash_balance: 18000 } ] + expected_data: [ + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 17000, cash_balance: 17000 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 } + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 18000, cash_balance: 18000 }, + balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 } + } ] ) end @@ -152,11 +196,27 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ 5.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ], - [ 4.days.ago.to_date, { balance: 20500, cash_balance: 20500 } ], - [ 3.days.ago.to_date, { balance: 20500, cash_balance: 20500 } ], - [ 2.days.ago.to_date, { balance: 20400, cash_balance: 20400 } ] + expected_data: [ + { + date: 5.days.ago.to_date, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } + }, + { + date: 4.days.ago.to_date, + legacy_balances: { balance: 20500, cash_balance: 20500 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20500, end_non_cash: 0, end: 20500 } + }, + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 20500, cash_balance: 20500 }, + balances: { start: 20500, start_cash: 20500, start_non_cash: 0, end_cash: 20500, end_non_cash: 0, end: 20500 } + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 20400, cash_balance: 20400 }, + balances: { start: 20500, start_cash: 20500, start_non_cash: 0, end_cash: 20400, end_non_cash: 0, end: 20400 } + } ] ) end @@ -176,11 +236,27 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ 5.days.ago.to_date, { balance: 1000, cash_balance: 1000 } ], - [ 4.days.ago.to_date, { balance: 500, cash_balance: 500 } ], - [ 3.days.ago.to_date, { balance: 500, cash_balance: 500 } ], - [ 2.days.ago.to_date, { balance: 600, cash_balance: 600 } ] + expected_data: [ + { + date: 5.days.ago.to_date, + legacy_balances: { balance: 1000, cash_balance: 1000 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 } + }, + { + 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 } + }, + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 500, cash_balance: 500 }, + balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 } + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 600, cash_balance: 600 }, + balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 600, end_non_cash: 0, end: 600 } + } ] ) end @@ -203,17 +279,57 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ 10.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ], - [ 9.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ], - [ 8.days.ago.to_date, { balance: 25000, cash_balance: 25000 } ], - [ 7.days.ago.to_date, { balance: 25000, cash_balance: 25000 } ], - [ 6.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ], - [ 5.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ], - [ 4.days.ago.to_date, { balance: 17500, cash_balance: 17500 } ], - [ 3.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ], - [ 2.days.ago.to_date, { balance: 17000, cash_balance: 17000 } ], - [ 1.day.ago.to_date, { balance: 16900, cash_balance: 16900 } ] + expected_data: [ + { + date: 10.days.ago.to_date, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } + }, + { + date: 9.days.ago.to_date, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } + }, + { + date: 8.days.ago.to_date, + legacy_balances: { balance: 25000, cash_balance: 25000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 25000, end_non_cash: 0, end: 25000 } + }, + { + date: 7.days.ago.to_date, + legacy_balances: { balance: 25000, cash_balance: 25000 }, + balances: { start: 25000, start_cash: 25000, start_non_cash: 0, end_cash: 25000, end_non_cash: 0, end: 25000 } + }, + { + date: 6.days.ago.to_date, + legacy_balances: { balance: 17000, cash_balance: 17000 }, + balances: { start: 25000, start_cash: 25000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 } + }, + { + date: 5.days.ago.to_date, + legacy_balances: { balance: 17000, cash_balance: 17000 }, + balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 } + }, + { + date: 4.days.ago.to_date, + legacy_balances: { balance: 17500, cash_balance: 17500 }, + balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17500, end_non_cash: 0, end: 17500 } + }, + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 17000, cash_balance: 17000 }, + balances: { start: 17500, start_cash: 17500, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 } + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 17000, cash_balance: 17000 }, + balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 } + }, + { + date: 1.day.ago.to_date, + legacy_balances: { balance: 16900, cash_balance: 16900 }, + balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 16900, end_non_cash: 0, end: 16900 } + } ] ) end @@ -237,11 +353,27 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ 4.days.ago.to_date, { balance: 100, cash_balance: 100 } ], - [ 3.days.ago.to_date, { balance: 200, cash_balance: 200 } ], - [ 2.days.ago.to_date, { balance: 500, cash_balance: 500 } ], - [ 1.day.ago.to_date, { balance: 1100, cash_balance: 1100 } ] + expected_data: [ + { + date: 4.days.ago.to_date, + legacy_balances: { balance: 100, cash_balance: 100 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 100, end_non_cash: 0, end: 100 } + }, + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 200, cash_balance: 200 }, + balances: { start: 100, start_cash: 100, start_non_cash: 0, end_cash: 200, end_non_cash: 0, end: 200 } + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 500, cash_balance: 500 }, + balances: { start: 200, start_cash: 200, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 } + }, + { + date: 1.day.ago.to_date, + legacy_balances: { balance: 1100, cash_balance: 1100 }, + balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 1100, end_non_cash: 0, end: 1100 } + } ] ) end @@ -263,9 +395,17 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ 2.days.ago.to_date, { balance: 20000, cash_balance: 0 } ], - [ 1.day.ago.to_date, { balance: 18000, cash_balance: 0 } ] + expected_data: [ + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 20000, cash_balance: 0 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 20000, end: 20000 } + }, + { + date: 1.day.ago.to_date, + legacy_balances: { balance: 18000, cash_balance: 0 }, + balances: { start: 20000, start_cash: 0, start_non_cash: 20000, end_cash: 0, end_non_cash: 18000, end: 18000 } + } ] ) end @@ -286,9 +426,17 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ 3.days.ago.to_date, { balance: 500000, cash_balance: 0 } ], - [ 2.days.ago.to_date, { balance: 500000, cash_balance: 0 } ] + expected_data: [ + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 500000, cash_balance: 0 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 500000, end: 500000 } + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 500000, cash_balance: 0 }, + balances: { start: 500000, start_cash: 0, start_non_cash: 500000, end_cash: 0, end_non_cash: 500000, end: 500000 } + } ] ) end @@ -324,11 +472,27 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ 3.days.ago.to_date, { balance: 5000, cash_balance: 5000 } ], - [ 2.days.ago.to_date, { balance: 5000, cash_balance: 5000 } ], - [ 1.day.ago.to_date, { balance: 5000, cash_balance: 4000 } ], - [ Date.current, { balance: 5000, cash_balance: 4000 } ] + expected_data: [ + { + date: 3.days.ago.to_date, + legacy_balances: { balance: 5000, cash_balance: 5000 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 } + }, + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 5000, cash_balance: 5000 }, + balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 } + }, + { + date: 1.day.ago.to_date, + legacy_balances: { balance: 5000, cash_balance: 4000 }, + balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 4000, end_non_cash: 1000, end: 5000 } + }, + { + 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 } + } ] ) end diff --git a/test/models/balance/reverse_calculator_test.rb b/test/models/balance/reverse_calculator_test.rb index a9348220..58470395 100644 --- a/test/models/balance/reverse_calculator_test.rb +++ b/test/models/balance/reverse_calculator_test.rb @@ -16,8 +16,12 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ Date.current, { balance: 20000, cash_balance: 20000 } ] + expected_data: [ + { + date: Date.current, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } + } ] ) end @@ -47,12 +51,32 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase # a 100% full entries history. assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ Date.current, { balance: 20000, cash_balance: 20000 } ], # Current anchor - [ 1.day.ago, { balance: 20000, cash_balance: 20000 } ], - [ 2.days.ago, { balance: 20000, cash_balance: 20000 } ], - [ 3.days.ago, { balance: 20000, cash_balance: 20000 } ], - [ 4.days.ago, { balance: 15000, cash_balance: 15000 } ] # Opening anchor + expected_data: [ + { + date: Date.current, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } + }, # Current anchor + { + date: 1.day.ago, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } + }, + { + date: 2.days.ago, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } + }, + { + date: 3.days.ago, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 15000, start_cash: 15000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } + }, + { + date: 4.days.ago, + legacy_balances: { balance: 15000, cash_balance: 15000 }, + balances: { start: 15000, start_cash: 15000, start_non_cash: 0, end_cash: 15000, end_non_cash: 0, end: 15000 } + } # Opening anchor ] ) end @@ -75,9 +99,17 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ Date.current, { balance: 20000, cash_balance: 10000 } ], # Since $10,000 of holdings, cash has to be $10,000 to reach $20,000 total value - [ 1.day.ago, { balance: 15000, cash_balance: 5000 } ] # Since $10,000 of holdings, cash has to be $5,000 to reach $15,000 total value + expected_data: [ + { + date: Date.current, + legacy_balances: { balance: 20000, cash_balance: 10000 }, + balances: { start: 20000, start_cash: 10000, start_non_cash: 10000, end_cash: 10000, end_non_cash: 10000, end: 20000 } + }, # Since $10,000 of holdings, cash has to be $10,000 to reach $20,000 total value + { + date: 1.day.ago, + legacy_balances: { balance: 15000, cash_balance: 5000 }, + balances: { start: 15000, start_cash: 5000, start_non_cash: 10000, end_cash: 5000, end_non_cash: 10000, end: 15000 } + } # Since $10,000 of holdings, cash has to be $5,000 to reach $15,000 total value ] ) end @@ -96,13 +128,37 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ Date.current, { balance: 20000, cash_balance: 20000 } ], # Current balance - [ 1.day.ago, { balance: 20000, cash_balance: 20000 } ], # No change - [ 2.days.ago, { balance: 20000, cash_balance: 20000 } ], # After expense (+100) - [ 3.days.ago, { balance: 20100, cash_balance: 20100 } ], # Before expense - [ 4.days.ago, { balance: 20100, cash_balance: 20100 } ], # After income (-500) - [ 5.days.ago, { balance: 19600, cash_balance: 19600 } ] # After income (-500) + expected_data: [ + { + date: Date.current, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } + }, # Current balance + { + date: 1.day.ago, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } + }, # No change + { + date: 2.days.ago, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 20100, start_cash: 20100, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } + }, # After expense (+100) + { + date: 3.days.ago, + legacy_balances: { balance: 20100, cash_balance: 20100 }, + balances: { start: 20100, start_cash: 20100, start_non_cash: 0, end_cash: 20100, end_non_cash: 0, end: 20100 } + }, # Before expense + { + date: 4.days.ago, + legacy_balances: { balance: 20100, cash_balance: 20100 }, + balances: { start: 19600, start_cash: 19600, start_non_cash: 0, end_cash: 20100, end_non_cash: 0, end: 20100 } + }, # After income (-500) + { + date: 5.days.ago, + legacy_balances: { balance: 19600, cash_balance: 19600 }, + balances: { start: 19600, start_cash: 19600, start_non_cash: 0, end_cash: 19600, end_non_cash: 0, end: 19600 } + } # After income (-500) ] ) end @@ -122,13 +178,37 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase # Reversed order: showing how we work backwards assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ Date.current, { balance: 2000, cash_balance: 2000 } ], # Current balance - [ 1.day.ago, { balance: 2000, cash_balance: 2000 } ], # No change - [ 2.days.ago, { balance: 2000, cash_balance: 2000 } ], # After expense (+100) - [ 3.days.ago, { balance: 1900, cash_balance: 1900 } ], # Before expense - [ 4.days.ago, { balance: 1900, cash_balance: 1900 } ], # After CC payment (-500) - [ 5.days.ago, { balance: 2400, cash_balance: 2400 } ] + expected_data: [ + { + date: Date.current, + legacy_balances: { balance: 2000, cash_balance: 2000 }, + balances: { start: 2000, start_cash: 2000, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 } + }, # Current balance + { + date: 1.day.ago, + legacy_balances: { balance: 2000, cash_balance: 2000 }, + balances: { start: 2000, start_cash: 2000, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 } + }, # No change + { + date: 2.days.ago, + legacy_balances: { balance: 2000, cash_balance: 2000 }, + balances: { start: 1900, start_cash: 1900, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 } + }, # After expense (+100) + { + date: 3.days.ago, + legacy_balances: { balance: 1900, cash_balance: 1900 }, + balances: { start: 1900, start_cash: 1900, start_non_cash: 0, end_cash: 1900, end_non_cash: 0, end: 1900 } + }, # Before expense + { + date: 4.days.ago, + legacy_balances: { balance: 1900, cash_balance: 1900 }, + balances: { start: 2400, start_cash: 2400, start_non_cash: 0, end_cash: 1900, end_non_cash: 0, end: 1900 } + }, # After CC payment (-500) + { + date: 5.days.ago, + legacy_balances: { balance: 2400, cash_balance: 2400 }, + balances: { start: 2400, start_cash: 2400, start_non_cash: 0, end_cash: 2400, end_non_cash: 0, end: 2400 } + } ] ) end @@ -150,10 +230,22 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ Date.current, { balance: 198000, cash_balance: 0 } ], - [ 1.day.ago, { balance: 198000, cash_balance: 0 } ], - [ 2.days.ago, { balance: 200000, cash_balance: 0 } ] + expected_data: [ + { + date: Date.current, + legacy_balances: { balance: 198000, cash_balance: 0 }, + balances: { start: 198000, start_cash: 0, start_non_cash: 198000, end_cash: 0, end_non_cash: 198000, end: 198000 } + }, + { + date: 1.day.ago, + legacy_balances: { balance: 198000, cash_balance: 0 }, + balances: { start: 200000, start_cash: 0, start_non_cash: 200000, end_cash: 0, end_non_cash: 198000, end: 198000 } + }, + { + date: 2.days.ago, + legacy_balances: { balance: 200000, cash_balance: 0 }, + balances: { start: 200000, start_cash: 0, start_non_cash: 200000, end_cash: 0, end_non_cash: 200000, end: 200000 } + } ] ) end @@ -174,10 +266,22 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ Date.current, { balance: 1000, cash_balance: 0 } ], - [ 1.day.ago, { balance: 1000, cash_balance: 0 } ], - [ 2.days.ago, { balance: 1000, cash_balance: 0 } ] + expected_data: [ + { + date: Date.current, + legacy_balances: { balance: 1000, cash_balance: 0 }, + balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 } + }, + { + date: 1.day.ago, + legacy_balances: { balance: 1000, cash_balance: 0 }, + balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 } + }, + { + date: 2.days.ago, + legacy_balances: { balance: 1000, cash_balance: 0 }, + balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 } + } ] ) end @@ -206,10 +310,22 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase # (the single trade doesn't affect balance; it just alters cash vs. holdings composition) assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ Date.current, { balance: 20000, cash_balance: 19000 } ], # Current: $19k cash + $1k holdings (anchor) - [ 1.day.ago.to_date, { balance: 20000, cash_balance: 19000 } ], # After trade: $19k cash + $1k holdings - [ 2.days.ago.to_date, { balance: 20000, cash_balance: 20000 } ] # At first, account is 100% cash, no holdings (no trades) + expected_data: [ + { + date: Date.current, + legacy_balances: { balance: 20000, cash_balance: 19000 }, + balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 } + }, # Current: $19k cash + $1k holdings (anchor) + { + date: 1.day.ago.to_date, + legacy_balances: { balance: 20000, cash_balance: 19000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 19000, end_non_cash: 1000, end: 20000 } + }, # After trade: $19k cash + $1k holdings + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } + } # At first, account is 100% cash, no holdings (no trades) ] ) end @@ -240,10 +356,22 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ - [ Date.current, { balance: 20000, cash_balance: 19000 } ], # Current: $19k cash + $1k holdings ($500 MSFT, $500 AAPL) - [ 1.day.ago.to_date, { balance: 20000, cash_balance: 19000 } ], # After AAPL trade: $19k cash + $1k holdings - [ 2.days.ago.to_date, { balance: 20000, cash_balance: 19500 } ] # Before AAPL trade: $19.5k cash + $500 MSFT + expected_data: [ + { + date: Date.current, + legacy_balances: { balance: 20000, cash_balance: 19000 }, + balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 } + }, # Current: $19k cash + $1k holdings ($500 MSFT, $500 AAPL) + { + date: 1.day.ago.to_date, + legacy_balances: { balance: 20000, cash_balance: 19000 }, + balances: { start: 20000, start_cash: 19500, start_non_cash: 500, end_cash: 19000, end_non_cash: 1000, end: 20000 } + }, # After AAPL trade: $19k cash + $1k holdings + { + date: 2.days.ago.to_date, + legacy_balances: { balance: 20000, cash_balance: 19500 }, + balances: { start: 20000, start_cash: 19500, start_non_cash: 500, end_cash: 19500, end_non_cash: 500, end: 20000 } + } # Before AAPL trade: $19.5k cash + $500 MSFT ] ) end @@ -267,12 +395,24 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase assert_calculated_ledger_balances( calculated_data: calculated, - expected_balances: [ + expected_data: [ # No matter what, we force current day equal to the "anchor" balance (what provider gave us), and let "cash" float based on holdings value # This ensures the user sees the same top-line number reported by the provider (even if it creates a discrepancy in the cash balance) - [ Date.current, { balance: 20000, cash_balance: 18000 } ], - [ 1.day.ago, { balance: 20000, cash_balance: 18000 } ], - [ 2.days.ago, { balance: 15000, cash_balance: 15000 } ] # Opening anchor sets absolute balance + { + date: Date.current, + legacy_balances: { balance: 20000, cash_balance: 18000 }, + balances: { start: 20000, start_cash: 18000, start_non_cash: 2000, end_cash: 18000, end_non_cash: 2000, end: 20000 } + }, + { + date: 1.day.ago, + legacy_balances: { balance: 20000, cash_balance: 18000 }, + balances: { start: 20000, start_cash: 18000, start_non_cash: 2000, end_cash: 18000, end_non_cash: 2000, end: 20000 } + }, + { + date: 2.days.ago, + legacy_balances: { balance: 15000, cash_balance: 15000 }, + balances: { start: 15000, start_cash: 15000, start_non_cash: 0, end_cash: 15000, end_non_cash: 0, end: 15000 } + } # Opening anchor sets absolute balance ] ) end diff --git a/test/support/ledger_testing_helper.rb b/test/support/ledger_testing_helper.rb index 6ae71678..75c25f98 100644 --- a/test/support/ledger_testing_helper.rb +++ b/test/support/ledger_testing_helper.rb @@ -109,13 +109,20 @@ module LedgerTestingHelper created_account end - def assert_calculated_ledger_balances(calculated_data:, expected_balances:) - # Convert expected balances to a hash for easier lookup - expected_hash = expected_balances.to_h do |date, balance_data| - [ date.to_date, balance_data ] + def assert_calculated_ledger_balances(calculated_data:, expected_data:) + # Convert expected data to a hash for easier lookup + # Structure: [ { date:, legacy_balances: { balance:, cash_balance: }, balances: { start:, start_cash:, etc... }, flows: { ... }, adjustments: { ... } } ] + expected_hash = {} + expected_data.each do |data| + expected_hash[data[:date].to_date] = { + legacy_balances: data[:legacy_balances] || {}, + balances: data[:balances] || {}, + flows: data[:flows] || {}, + adjustments: data[:adjustments] || {} + } end - # Get all unique dates from both calculated and expected data + # Get all unique dates from all data sources all_dates = (calculated_data.map(&:date) + expected_hash.keys).uniq.sort # Check each date @@ -126,15 +133,76 @@ module LedgerTestingHelper if expected assert calculated_balance, "Expected balance for #{date} but none was calculated" - if expected[:balance] - assert_equal expected[:balance], calculated_balance.balance.to_d, - "Balance mismatch for #{date}" - end + legacy_balances = expected[:legacy_balances] + balances = expected[:balances] + flows = expected[:flows] + adjustments = expected[:adjustments] - if expected[:cash_balance] - assert_equal expected[:cash_balance], calculated_balance.cash_balance.to_d, + # Legacy balance assertions + if legacy_balances.any? + assert_equal legacy_balances[:balance], calculated_balance.balance.to_d, + "Balance mismatch for #{date}" + + assert_equal legacy_balances[:cash_balance], calculated_balance.cash_balance.to_d, "Cash balance mismatch for #{date}" end + + # Balance assertions + if balances.any? + assert_equal balances[:start_cash], calculated_balance.start_cash_balance.to_d, + "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, + "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) + + 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) + + # Generated column assertions + assert_equal balances[:start], calculated_balance.start_balance.to_d, + "Start balance mismatch for #{date}" if balances.key?(:start) + + assert_equal balances[:end], calculated_balance.end_balance.to_d, + "End balance mismatch for #{date}" if balances.key?(:end) + end + + # Flow assertions + if flows.any? + assert_equal flows[:cash_inflows], calculated_balance.cash_inflows.to_d, + "Cash inflows mismatch for #{date}" if flows.key?(:cash_inflows) + + assert_equal flows[:cash_outflows], calculated_balance.cash_outflows.to_d, + "Cash outflows mismatch for #{date}" if flows.key?(:cash_outflows) + + assert_equal flows[:non_cash_inflows], calculated_balance.non_cash_inflows.to_d, + "Non-cash inflows mismatch for #{date}" if flows.key?(:non_cash_inflows) + + assert_equal flows[:non_cash_outflows], calculated_balance.non_cash_outflows.to_d, + "Non-cash outflows mismatch for #{date}" if flows.key?(:non_cash_outflows) + + assert_equal flows[:net_market_flows], calculated_balance.net_market_flows.to_d, + "Net market flows mismatch for #{date}" if flows.key?(:net_market_flows) + end + + # Adjustment assertions + if adjustments.any? + assert_equal adjustments[:cash_adjustments], calculated_balance.cash_adjustments.to_d, + "Cash adjustments mismatch for #{date}" if adjustments.key?(:cash_adjustments) + + assert_equal adjustments[:non_cash_adjustments], calculated_balance.non_cash_adjustments.to_d, + "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, + "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, + "Temporary assertion failed: end_balance should equal balance for #{date}" else assert_nil calculated_balance, "Unexpected balance calculated for #{date}" end -- 2.53.0 From 8616b2c0de3fc86f713e0ca3b508b36b7485d79b Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Sun, 20 Jul 2025 09:17:59 -0400 Subject: [PATCH 5/9] Update flow assertions for forward calculator --- .../models/balance/forward_calculator_test.rb | 218 ++++++++++-------- test/support/ledger_testing_helper.rb | 63 ++++- 2 files changed, 178 insertions(+), 103 deletions(-) diff --git a/test/models/balance/forward_calculator_test.rb b/test/models/balance/forward_calculator_test.rb index 98ee9b54..ccd98c96 100644 --- a/test/models/balance/forward_calculator_test.rb +++ b/test/models/balance/forward_calculator_test.rb @@ -11,7 +11,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase # When syncing forwards, we don't care about the account balance. We generate everything based on entries, starting from 0. test "no entries sync" do account = create_account_with_ledger( - account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" }, + account: { type: Depository, currency: "USD" }, entries: [] ) @@ -25,7 +25,9 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: Date.current, legacy_balances: { balance: 0, cash_balance: 0 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 0, end: 0 } + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 0, end: 0 }, + flows: 0, + adjustments: 0 } ] ) @@ -34,7 +36,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase # Our system ensures all manual accounts have an opening anchor (for UX), but we should be able to handle a missing anchor by starting at 0 (i.e. "fresh account with no history") test "account without opening anchor starts at zero balance" do account = create_account_with_ledger( - account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" }, + account: { type: Depository, currency: "USD" }, entries: [ { type: "transaction", date: 2.days.ago.to_date, amount: -1000 } ] @@ -49,12 +51,16 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 3.days.ago.to_date, legacy_balances: { balance: 0, cash_balance: 0 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 0, end: 0 } + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 0, end: 0 }, + flows: 0, + adjustments: 0 }, { date: 2.days.ago.to_date, legacy_balances: { balance: 1000, cash_balance: 1000 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 } + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 }, + flows: { cash_inflows: 1000, cash_outflows: 0 }, + adjustments: 0 } ] ) @@ -62,7 +68,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase test "reconciliation valuation sets absolute balance before applying subsequent transactions" do account = create_account_with_ledger( - account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" }, + account: { type: Depository, currency: "USD" }, entries: [ { type: "reconciliation", date: 3.days.ago.to_date, balance: 18000 }, { type: "transaction", date: 2.days.ago.to_date, amount: -1000 } @@ -78,12 +84,16 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 3.days.ago.to_date, legacy_balances: { balance: 18000, cash_balance: 18000 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 } + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 }, + flows: 0, + adjustments: { cash_adjustments: 18000, non_cash_adjustments: 0 } }, { date: 2.days.ago.to_date, legacy_balances: { balance: 19000, cash_balance: 19000 }, - balances: { start: 18000, start_cash: 18000, start_non_cash: 0, end_cash: 19000, end_non_cash: 0, end: 19000 } + balances: { start: 18000, start_cash: 18000, start_non_cash: 0, end_cash: 19000, end_non_cash: 0, end: 19000 }, + flows: { cash_inflows: 1000, cash_outflows: 0 }, + adjustments: 0 } ] ) @@ -92,7 +102,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase test "cash-only accounts (depository, credit card) use valuations where cash balance equals total balance" do [ Depository, CreditCard ].each do |account_type| account = create_account_with_ledger( - account: { type: account_type, balance: 10000, cash_balance: 10000, currency: "USD" }, + account: { type: account_type, currency: "USD" }, entries: [ { type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 }, { type: "reconciliation", date: 2.days.ago.to_date, balance: 18000 } @@ -107,12 +117,16 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 3.days.ago.to_date, legacy_balances: { balance: 17000, cash_balance: 17000 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 } + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 }, + flows: 0, + adjustments: { cash_adjustments: 17000, non_cash_adjustments: 0 } }, { date: 2.days.ago.to_date, legacy_balances: { balance: 18000, cash_balance: 18000 }, - balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 } + balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 }, + flows: 0, + adjustments: { cash_adjustments: 1000, non_cash_adjustments: 0 } } ] ) @@ -122,7 +136,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase test "non-cash accounts (property, loan) use valuations where cash balance is always zero" do [ Property, Loan ].each do |account_type| account = create_account_with_ledger( - account: { type: account_type, balance: 10000, cash_balance: 10000, currency: "USD" }, + account: { type: account_type, currency: "USD" }, entries: [ { type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 }, { type: "reconciliation", date: 2.days.ago.to_date, balance: 18000 } @@ -137,12 +151,16 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 3.days.ago.to_date, legacy_balances: { balance: 17000, cash_balance: 0.0 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 17000, end: 17000 } + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 17000, end: 17000 }, + flows: 0, + adjustments: { cash_adjustments: 0, non_cash_adjustments: 17000 } }, { date: 2.days.ago.to_date, legacy_balances: { balance: 18000, cash_balance: 0.0 }, - balances: { start: 17000, start_cash: 0, start_non_cash: 17000, end_cash: 0, end_non_cash: 18000, end: 18000 } + balances: { start: 17000, start_cash: 0, start_non_cash: 17000, end_cash: 0, end_non_cash: 18000, end: 18000 }, + flows: 0, + adjustments: { cash_adjustments: 0, non_cash_adjustments: 1000 } } ] ) @@ -151,7 +169,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase test "mixed accounts (investment) use valuations where cash balance is total minus holdings" do account = create_account_with_ledger( - account: { type: Investment, balance: 10000, cash_balance: 10000, currency: "USD" }, + account: { type: Investment, currency: "USD" }, entries: [ { type: "opening_anchor", date: 3.days.ago.to_date, balance: 17000 }, { type: "reconciliation", date: 2.days.ago.to_date, balance: 18000 } @@ -167,12 +185,16 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 3.days.ago.to_date, legacy_balances: { balance: 17000, cash_balance: 17000 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 } + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 }, + flows: { market_flows: 0 }, + adjustments: { cash_adjustments: 17000, non_cash_adjustments: 0 } # Since no holdings present, adjustment is all cash }, { date: 2.days.ago.to_date, legacy_balances: { balance: 18000, cash_balance: 18000 }, - balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 } + balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 18000, end_non_cash: 0, end: 18000 }, + flows: { market_flows: 0 }, + adjustments: { cash_adjustments: 1000, non_cash_adjustments: 0 } # Since no holdings present, adjustment is all cash } ] ) @@ -184,7 +206,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase test "transactions on depository accounts affect cash balance" do account = create_account_with_ledger( - account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" }, + account: { type: Depository, currency: "USD" }, entries: [ { type: "opening_anchor", date: 5.days.ago.to_date, balance: 20000 }, { type: "transaction", date: 4.days.ago.to_date, amount: -500 }, # income @@ -200,22 +222,30 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 5.days.ago.to_date, legacy_balances: { balance: 20000, cash_balance: 20000 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + flows: 0, + adjustments: { cash_adjustments: 20000, non_cash_adjustments: 0 } }, { date: 4.days.ago.to_date, legacy_balances: { balance: 20500, cash_balance: 20500 }, - balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20500, end_non_cash: 0, end: 20500 } + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20500, end_non_cash: 0, end: 20500 }, + flows: { cash_inflows: 500, cash_outflows: 0 }, + adjustments: 0 }, { date: 3.days.ago.to_date, legacy_balances: { balance: 20500, cash_balance: 20500 }, - balances: { start: 20500, start_cash: 20500, start_non_cash: 0, end_cash: 20500, end_non_cash: 0, end: 20500 } + balances: { start: 20500, start_cash: 20500, start_non_cash: 0, end_cash: 20500, end_non_cash: 0, end: 20500 }, + flows: 0, + adjustments: 0 }, { date: 2.days.ago.to_date, legacy_balances: { balance: 20400, cash_balance: 20400 }, - balances: { start: 20500, start_cash: 20500, start_non_cash: 0, end_cash: 20400, end_non_cash: 0, end: 20400 } + balances: { start: 20500, start_cash: 20500, start_non_cash: 0, end_cash: 20400, end_non_cash: 0, end: 20400 }, + flows: { cash_inflows: 0, cash_outflows: 100 }, + adjustments: 0 } ] ) @@ -224,7 +254,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase test "transactions on credit card accounts affect cash balance inversely" do account = create_account_with_ledger( - account: { type: CreditCard, balance: 10000, cash_balance: 10000, currency: "USD" }, + account: { type: CreditCard, currency: "USD" }, entries: [ { type: "opening_anchor", date: 5.days.ago.to_date, balance: 1000 }, { type: "transaction", date: 4.days.ago.to_date, amount: -500 }, # CC payment @@ -240,22 +270,30 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 5.days.ago.to_date, legacy_balances: { balance: 1000, cash_balance: 1000 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 } + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 1000, end_non_cash: 0, end: 1000 }, + flows: 0, + adjustments: { cash_adjustments: 1000, non_cash_adjustments: 0 } }, { 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 } + 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 }, + adjustments: 0 }, { date: 3.days.ago.to_date, legacy_balances: { balance: 500, cash_balance: 500 }, - balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 } + balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 }, + flows: 0, + adjustments: 0 }, { date: 2.days.ago.to_date, legacy_balances: { balance: 600, cash_balance: 600 }, - balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 600, end_non_cash: 0, end: 600 } + balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 600, end_non_cash: 0, end: 600 }, + flows: { cash_inflows: 0, cash_outflows: 100 }, + adjustments: 0 } ] ) @@ -263,15 +301,12 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase test "depository account with transactions and balance reconciliations" do account = create_account_with_ledger( - account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" }, + account: { type: Depository, currency: "USD" }, entries: [ - { type: "opening_anchor", date: 10.days.ago.to_date, balance: 20000 }, - { type: "transaction", date: 8.days.ago.to_date, amount: -5000 }, - { type: "reconciliation", date: 6.days.ago.to_date, balance: 17000 }, - { type: "transaction", date: 6.days.ago.to_date, amount: -500 }, - { type: "transaction", date: 4.days.ago.to_date, amount: -500 }, - { type: "reconciliation", date: 3.days.ago.to_date, balance: 17000 }, - { type: "transaction", date: 1.day.ago.to_date, amount: 100 } + { type: "opening_anchor", date: 4.days.ago.to_date, balance: 20000 }, + { type: "transaction", date: 3.days.ago.to_date, amount: -5000 }, + { type: "reconciliation", date: 2.days.ago.to_date, balance: 17000 }, + { type: "transaction", date: 1.day.ago.to_date, amount: -500 } ] ) @@ -280,63 +315,41 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase assert_calculated_ledger_balances( calculated_data: calculated, expected_data: [ - { - date: 10.days.ago.to_date, - legacy_balances: { balance: 20000, cash_balance: 20000 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } - }, - { - date: 9.days.ago.to_date, - legacy_balances: { balance: 20000, cash_balance: 20000 }, - balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } - }, - { - date: 8.days.ago.to_date, - legacy_balances: { balance: 25000, cash_balance: 25000 }, - balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 25000, end_non_cash: 0, end: 25000 } - }, - { - date: 7.days.ago.to_date, - legacy_balances: { balance: 25000, cash_balance: 25000 }, - balances: { start: 25000, start_cash: 25000, start_non_cash: 0, end_cash: 25000, end_non_cash: 0, end: 25000 } - }, - { - date: 6.days.ago.to_date, - legacy_balances: { balance: 17000, cash_balance: 17000 }, - balances: { start: 25000, start_cash: 25000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 } - }, - { - date: 5.days.ago.to_date, - legacy_balances: { balance: 17000, cash_balance: 17000 }, - balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 } - }, { date: 4.days.ago.to_date, - legacy_balances: { balance: 17500, cash_balance: 17500 }, - balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17500, end_non_cash: 0, end: 17500 } + legacy_balances: { balance: 20000, cash_balance: 20000 }, + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + flows: 0, + adjustments: { cash_adjustments: 20000, non_cash_adjustments: 0 } }, { date: 3.days.ago.to_date, - legacy_balances: { balance: 17000, cash_balance: 17000 }, - balances: { start: 17500, start_cash: 17500, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 } + legacy_balances: { balance: 25000, cash_balance: 25000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 25000, end_non_cash: 0, end: 25000 }, + flows: { cash_inflows: 5000, cash_outflows: 0 }, + adjustments: 0 }, { date: 2.days.ago.to_date, legacy_balances: { balance: 17000, cash_balance: 17000 }, - balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 } + balances: { start: 25000, start_cash: 25000, start_non_cash: 0, end_cash: 17000, end_non_cash: 0, end: 17000 }, + flows: 0, + adjustments: { cash_adjustments: -8000, non_cash_adjustments: 0 } }, { date: 1.day.ago.to_date, - legacy_balances: { balance: 16900, cash_balance: 16900 }, - balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 16900, end_non_cash: 0, end: 16900 } + legacy_balances: { balance: 17500, cash_balance: 17500 }, + balances: { start: 17000, start_cash: 17000, start_non_cash: 0, end_cash: 17500, end_non_cash: 0, end: 17500 }, + flows: { cash_inflows: 500, cash_outflows: 0 }, + adjustments: 0 } ] ) end - test "accounts with transactions in multiple currencies convert to the account currency" do + test "accounts with transactions in multiple currencies convert to the account currency and flows are stored in account currency" do account = create_account_with_ledger( - account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" }, + account: { type: Depository, currency: "USD" }, entries: [ { type: "opening_anchor", date: 4.days.ago.to_date, balance: 100 }, { type: "transaction", date: 3.days.ago.to_date, amount: -100 }, @@ -357,22 +370,30 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 4.days.ago.to_date, legacy_balances: { balance: 100, cash_balance: 100 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 100, end_non_cash: 0, end: 100 } + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 100, end_non_cash: 0, end: 100 }, + flows: 0, + adjustments: { cash_adjustments: 100, non_cash_adjustments: 0 } }, { date: 3.days.ago.to_date, legacy_balances: { balance: 200, cash_balance: 200 }, - balances: { start: 100, start_cash: 100, start_non_cash: 0, end_cash: 200, end_non_cash: 0, end: 200 } + balances: { start: 100, start_cash: 100, start_non_cash: 0, end_cash: 200, end_non_cash: 0, end: 200 }, + flows: { cash_inflows: 100, cash_outflows: 0 }, + adjustments: 0 }, { date: 2.days.ago.to_date, legacy_balances: { balance: 500, cash_balance: 500 }, - balances: { start: 200, start_cash: 200, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 } + balances: { start: 200, start_cash: 200, start_non_cash: 0, end_cash: 500, end_non_cash: 0, end: 500 }, + flows: { cash_inflows: 300, cash_outflows: 0 }, + adjustments: 0 }, { date: 1.day.ago.to_date, legacy_balances: { balance: 1100, cash_balance: 1100 }, - balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 1100, end_non_cash: 0, end: 1100 } + balances: { start: 500, start_cash: 500, start_non_cash: 0, end_cash: 1100, end_non_cash: 0, end: 1100 }, + flows: { cash_inflows: 600, cash_outflows: 0 }, # Cash inflow is the USD equivalent of €500 (converted for balances table) + adjustments: 0 } ] ) @@ -381,7 +402,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase # A loan is a special case where despite being a "non-cash" account, it is typical to have "payment" transactions that reduce the loan principal (non cash balance) test "loan payment transactions affect non cash balance" do account = create_account_with_ledger( - account: { type: Loan, balance: 10000, cash_balance: 0, currency: "USD" }, + account: { type: Loan, currency: "USD" }, entries: [ { type: "opening_anchor", date: 2.days.ago.to_date, balance: 20000 }, # "Loan payment" of $2000, which reduces the principal @@ -399,12 +420,16 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 2.days.ago.to_date, legacy_balances: { balance: 20000, cash_balance: 0 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 20000, end: 20000 } + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 20000, end: 20000 }, + flows: 0, + adjustments: { cash_adjustments: 0, non_cash_adjustments: 20000 } # Valuations adjust non-cash balance for non-cash accounts like Loans }, { date: 1.day.ago.to_date, legacy_balances: { balance: 18000, cash_balance: 0 }, - balances: { start: 20000, start_cash: 0, start_non_cash: 20000, end_cash: 0, end_non_cash: 18000, end: 18000 } + balances: { start: 20000, start_cash: 0, start_non_cash: 20000, end_cash: 0, end_non_cash: 18000, end: 18000 }, + flows: { non_cash_inflows: 2000, non_cash_outflows: 0, cash_inflows: 0, cash_outflows: 0 }, # Loans are "special cases" where transactions do affect non-cash balance + adjustments: 0 } ] ) @@ -413,7 +438,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase test "non cash accounts can only use valuations and transactions will be recorded but ignored for balance calculation" do [ Property, Vehicle, OtherAsset, OtherLiability ].each do |account_type| account = create_account_with_ledger( - account: { type: account_type, balance: 10000, cash_balance: 10000, currency: "USD" }, + account: { type: account_type, currency: "USD" }, entries: [ { type: "opening_anchor", date: 3.days.ago.to_date, balance: 500000 }, @@ -430,12 +455,16 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 3.days.ago.to_date, legacy_balances: { balance: 500000, cash_balance: 0 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 500000, end: 500000 } + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 0, end_non_cash: 500000, end: 500000 }, + flows: 0, + adjustments: { cash_adjustments: 0, non_cash_adjustments: 500000 } }, { date: 2.days.ago.to_date, legacy_balances: { balance: 500000, cash_balance: 0 }, - balances: { start: 500000, start_cash: 0, start_non_cash: 500000, end_cash: 0, end_non_cash: 500000, end: 500000 } + balances: { start: 500000, start_cash: 0, start_non_cash: 500000, end_cash: 0, end_non_cash: 500000, end: 500000 }, + flows: 0, # Despite having a transaction, non-cash accounts ignore it for balance calculation + adjustments: 0 } ] ) @@ -452,7 +481,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase # Holdings are calculated separately and fed into the balance calculator; treated as "non-cash" test "investment account calculates balance from transactions and trades and treats holdings as non-cash, additive to balance" do account = create_account_with_ledger( - account: { type: Investment, balance: 10000, cash_balance: 10000, currency: "USD" }, + account: { type: Investment, currency: "USD" }, entries: [ # Account starts with brokerage cash of $5000 and no holdings { type: "opening_anchor", date: 3.days.ago.to_date, balance: 5000 }, @@ -462,7 +491,7 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase holdings: [ # Holdings calculator will calculate $1000 worth of holdings { date: 1.day.ago.to_date, ticker: "AAPL", qty: 10, price: 100, amount: 1000 }, - { date: Date.current, ticker: "AAPL", qty: 10, price: 100, amount: 1000 } + { date: Date.current, ticker: "AAPL", qty: 10, price: 110, amount: 1100 } # Price increased by 10%, so holdings value goes up by $100 without a trade ] ) @@ -476,29 +505,36 @@ class Balance::ForwardCalculatorTest < ActiveSupport::TestCase { date: 3.days.ago.to_date, legacy_balances: { balance: 5000, cash_balance: 5000 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 } + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 }, + flows: 0, + adjustments: { cash_adjustments: 5000, non_cash_adjustments: 0 } }, { date: 2.days.ago.to_date, legacy_balances: { balance: 5000, cash_balance: 5000 }, - balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 } + balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 5000, end_non_cash: 0, end: 5000 }, + flows: 0, + adjustments: 0 }, { date: 1.day.ago.to_date, legacy_balances: { balance: 5000, cash_balance: 4000 }, - balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 4000, end_non_cash: 1000, end: 5000 } + balances: { start: 5000, start_cash: 5000, start_non_cash: 0, end_cash: 4000, end_non_cash: 1000, end: 5000 }, + flows: { cash_inflows: 0, cash_outflows: 1000, non_cash_inflows: 1000, non_cash_outflows: 0, net_market_flows: 0 }, # Decrease cash by 1000, increase holdings by 1000 (i.e. "buy" of $1000 worth of AAPL) + adjustments: 0 }, { 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 } + balances: { start: 5000, start_cash: 4000, start_non_cash: 1000, end_cash: 4000, end_non_cash: 1000, end: 5000 }, + flows: { net_market_flows: 100 }, # Holdings value increased by 100, despite no change in portfolio quantities + adjustments: 0 } ] ) end private - def assert_balances(calculated_data:, expected_balances:) # Sort calculated data by date to ensure consistent ordering sorted_data = calculated_data.sort_by(&:date) diff --git a/test/support/ledger_testing_helper.rb b/test/support/ledger_testing_helper.rb index 75c25f98..8a10dd50 100644 --- a/test/support/ledger_testing_helper.rb +++ b/test/support/ledger_testing_helper.rb @@ -12,6 +12,8 @@ module LedgerTestingHelper created_account = families(:empty).accounts.create!( name: "Test Account", accountable: account_type.new, + balance: 0, # Doesn't matter, ledger derives this + cash_balance: 0, # Doesn't matter, ledger derives this **account_attrs ) @@ -170,25 +172,62 @@ module LedgerTestingHelper end # Flow assertions - if flows.any? - assert_equal flows[:cash_inflows], calculated_balance.cash_inflows.to_d, - "Cash inflows mismatch for #{date}" if flows.key?(:cash_inflows) + # 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, + "Cash inflows mismatch for #{date}" - assert_equal flows[:cash_outflows], calculated_balance.cash_outflows.to_d, - "Cash outflows mismatch for #{date}" if flows.key?(:cash_outflows) + assert_equal 0, calculated_balance.cash_outflows.to_d, + "Cash outflows mismatch for #{date}" - assert_equal flows[:non_cash_inflows], calculated_balance.non_cash_inflows.to_d, - "Non-cash inflows mismatch for #{date}" if flows.key?(:non_cash_inflows) + assert_equal 0, calculated_balance.non_cash_inflows.to_d, + "Non-cash inflows mismatch for #{date}" - assert_equal flows[:non_cash_outflows], calculated_balance.non_cash_outflows.to_d, - "Non-cash outflows mismatch for #{date}" if flows.key?(:non_cash_outflows) + assert_equal 0, calculated_balance.non_cash_outflows.to_d, + "Non-cash outflows mismatch for #{date}" - assert_equal flows[:net_market_flows], calculated_balance.net_market_flows.to_d, - "Net market flows mismatch for #{date}" if flows.key?(:net_market_flows) + assert_equal 0, calculated_balance.net_market_flows.to_d, + "Net market flows mismatch for #{date}" + elsif flows.is_a?(Hash) && flows.any? + # Cash flows - must be asserted together + if flows.key?(:cash_inflows) || flows.key?(:cash_outflows) + 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, + "Cash inflows mismatch for #{date}" + + assert_equal flows[:cash_outflows], calculated_balance.cash_outflows.to_d, + "Cash outflows mismatch for #{date}" + end + + # Non-cash flows - must be asserted together + if flows.key?(:non_cash_inflows) || flows.key?(:non_cash_outflows) + 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, + "Non-cash inflows mismatch for #{date}" + + assert_equal flows[:non_cash_outflows], calculated_balance.non_cash_outflows.to_d, + "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, + "Net market flows mismatch for #{date}" + end end # Adjustment assertions - if adjustments.any? + if adjustments.is_a?(Integer) && adjustments == 0 + assert_equal 0, calculated_balance.cash_adjustments.to_d, + "Cash adjustments mismatch for #{date}" + + assert_equal 0, calculated_balance.non_cash_adjustments.to_d, + "Non-cash adjustments mismatch for #{date}" + elsif adjustments.is_a?(Hash) && adjustments.any? assert_equal adjustments[:cash_adjustments], calculated_balance.cash_adjustments.to_d, "Cash adjustments mismatch for #{date}" if adjustments.key?(:cash_adjustments) -- 2.53.0 From 19e9ccb503ed52a6e949e8d91d063d11f8f9fcff Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Sun, 20 Jul 2025 09:33:00 -0400 Subject: [PATCH 6/9] Update reverse calculator flows assumptions --- .../models/balance/reverse_calculator_test.rb | 140 +++++++++++++----- 1 file changed, 105 insertions(+), 35 deletions(-) diff --git a/test/models/balance/reverse_calculator_test.rb b/test/models/balance/reverse_calculator_test.rb index 58470395..df792ed9 100644 --- a/test/models/balance/reverse_calculator_test.rb +++ b/test/models/balance/reverse_calculator_test.rb @@ -20,7 +20,9 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: Date.current, legacy_balances: { balance: 20000, cash_balance: 20000 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } + balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + flows: 0, + adjustments: { cash_adjustments: 20000, non_cash_adjustments: 0 } } ] ) @@ -55,27 +57,37 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: Date.current, legacy_balances: { balance: 20000, cash_balance: 20000 }, - balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + flows: 0, + adjustments: 0 }, # Current anchor { date: 1.day.ago, legacy_balances: { balance: 20000, cash_balance: 20000 }, - balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + flows: 0, + adjustments: 0 }, { date: 2.days.ago, legacy_balances: { balance: 20000, cash_balance: 20000 }, - balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + flows: 0, + adjustments: 0 }, { date: 3.days.ago, legacy_balances: { balance: 20000, cash_balance: 20000 }, - balances: { start: 15000, start_cash: 15000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } + balances: { start: 15000, start_cash: 15000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + flows: 0, + adjustments: { cash_adjustments: 5000, non_cash_adjustments: 0 } }, { date: 4.days.ago, legacy_balances: { balance: 15000, cash_balance: 15000 }, - balances: { start: 15000, start_cash: 15000, start_non_cash: 0, end_cash: 15000, end_non_cash: 0, end: 15000 } + balances: { start: 15000, start_cash: 15000, start_non_cash: 0, end_cash: 15000, end_non_cash: 0, end: 15000 }, + flows: 0, + adjustments: 0 } # Opening anchor ] ) @@ -103,12 +115,16 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: Date.current, legacy_balances: { balance: 20000, cash_balance: 10000 }, - balances: { start: 20000, start_cash: 10000, start_non_cash: 10000, end_cash: 10000, end_non_cash: 10000, end: 20000 } + balances: { start: 20000, start_cash: 10000, start_non_cash: 10000, end_cash: 10000, end_non_cash: 10000, end: 20000 }, + flows: { market_flows: 0 }, + adjustments: 0 }, # Since $10,000 of holdings, cash has to be $10,000 to reach $20,000 total value { date: 1.day.ago, legacy_balances: { balance: 15000, cash_balance: 5000 }, - balances: { start: 15000, start_cash: 5000, start_non_cash: 10000, end_cash: 5000, end_non_cash: 10000, end: 15000 } + balances: { start: 15000, start_cash: 5000, start_non_cash: 10000, end_cash: 5000, end_non_cash: 10000, end: 15000 }, + flows: { market_flows: 0 }, + adjustments: 0 } # Since $10,000 of holdings, cash has to be $5,000 to reach $15,000 total value ] ) @@ -132,32 +148,44 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: Date.current, legacy_balances: { balance: 20000, cash_balance: 20000 }, - balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + flows: 0, + adjustments: 0 }, # Current balance { date: 1.day.ago, legacy_balances: { balance: 20000, cash_balance: 20000 }, - balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + flows: 0, + adjustments: 0 }, # No change { date: 2.days.ago, legacy_balances: { balance: 20000, cash_balance: 20000 }, - balances: { start: 20100, start_cash: 20100, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } + balances: { start: 20100, start_cash: 20100, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + flows: { cash_inflows: 0, cash_outflows: 100 }, + adjustments: 0 }, # After expense (+100) { date: 3.days.ago, legacy_balances: { balance: 20100, cash_balance: 20100 }, - balances: { start: 20100, start_cash: 20100, start_non_cash: 0, end_cash: 20100, end_non_cash: 0, end: 20100 } + balances: { start: 20100, start_cash: 20100, start_non_cash: 0, end_cash: 20100, end_non_cash: 0, end: 20100 }, + flows: 0, + adjustments: 0 }, # Before expense { date: 4.days.ago, legacy_balances: { balance: 20100, cash_balance: 20100 }, - balances: { start: 19600, start_cash: 19600, start_non_cash: 0, end_cash: 20100, end_non_cash: 0, end: 20100 } + balances: { start: 19600, start_cash: 19600, start_non_cash: 0, end_cash: 20100, end_non_cash: 0, end: 20100 }, + flows: { cash_inflows: 500, cash_outflows: 0 }, + adjustments: 0 }, # After income (-500) { date: 5.days.ago, legacy_balances: { balance: 19600, cash_balance: 19600 }, - balances: { start: 19600, start_cash: 19600, start_non_cash: 0, end_cash: 19600, end_non_cash: 0, end: 19600 } + balances: { start: 19600, start_cash: 19600, start_non_cash: 0, end_cash: 19600, end_non_cash: 0, end: 19600 }, + flows: 0, + adjustments: { cash_adjustments: 19600, non_cash_adjustments: 0 } } # After income (-500) ] ) @@ -182,32 +210,44 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: Date.current, legacy_balances: { balance: 2000, cash_balance: 2000 }, - balances: { start: 2000, start_cash: 2000, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 } + balances: { start: 2000, start_cash: 2000, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 }, + flows: 0, + adjustments: 0 }, # Current balance { date: 1.day.ago, legacy_balances: { balance: 2000, cash_balance: 2000 }, - balances: { start: 2000, start_cash: 2000, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 } + balances: { start: 2000, start_cash: 2000, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 }, + flows: 0, + adjustments: 0 }, # No change { date: 2.days.ago, legacy_balances: { balance: 2000, cash_balance: 2000 }, - balances: { start: 1900, start_cash: 1900, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 } + balances: { start: 1900, start_cash: 1900, start_non_cash: 0, end_cash: 2000, end_non_cash: 0, end: 2000 }, + flows: { cash_inflows: 0, cash_outflows: 100 }, + adjustments: 0 }, # After expense (+100) { date: 3.days.ago, legacy_balances: { balance: 1900, cash_balance: 1900 }, - balances: { start: 1900, start_cash: 1900, start_non_cash: 0, end_cash: 1900, end_non_cash: 0, end: 1900 } + balances: { start: 1900, start_cash: 1900, start_non_cash: 0, end_cash: 1900, end_non_cash: 0, end: 1900 }, + flows: 0, + adjustments: 0 }, # Before expense { date: 4.days.ago, legacy_balances: { balance: 1900, cash_balance: 1900 }, - balances: { start: 2400, start_cash: 2400, start_non_cash: 0, end_cash: 1900, end_non_cash: 0, end: 1900 } + balances: { start: 2400, start_cash: 2400, start_non_cash: 0, end_cash: 1900, end_non_cash: 0, end: 1900 }, + flows: { cash_inflows: 0, cash_outflows: 500 }, + adjustments: 0 }, # After CC payment (-500) { date: 5.days.ago, legacy_balances: { balance: 2400, cash_balance: 2400 }, - balances: { start: 2400, start_cash: 2400, start_non_cash: 0, end_cash: 2400, end_non_cash: 0, end: 2400 } + balances: { start: 2400, start_cash: 2400, start_non_cash: 0, end_cash: 2400, end_non_cash: 0, end: 2400 }, + flows: 0, + adjustments: { cash_adjustments: 2400, non_cash_adjustments: 0 } } ] ) @@ -234,17 +274,23 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: Date.current, legacy_balances: { balance: 198000, cash_balance: 0 }, - balances: { start: 198000, start_cash: 0, start_non_cash: 198000, end_cash: 0, end_non_cash: 198000, end: 198000 } + balances: { start: 198000, start_cash: 0, start_non_cash: 198000, end_cash: 0, end_non_cash: 198000, end: 198000 }, + flows: 0, + adjustments: 0 }, { date: 1.day.ago, legacy_balances: { balance: 198000, cash_balance: 0 }, - balances: { start: 200000, start_cash: 0, start_non_cash: 200000, end_cash: 0, end_non_cash: 198000, end: 198000 } + balances: { start: 200000, start_cash: 0, start_non_cash: 200000, end_cash: 0, end_non_cash: 198000, end: 198000 }, + flows: { non_cash_inflows: 2000, non_cash_outflows: 0, cash_inflows: 0, cash_outflows: 0 }, + adjustments: 0 }, { date: 2.days.ago, legacy_balances: { balance: 200000, cash_balance: 0 }, - balances: { start: 200000, start_cash: 0, start_non_cash: 200000, end_cash: 0, end_non_cash: 200000, end: 200000 } + balances: { start: 200000, start_cash: 0, start_non_cash: 200000, end_cash: 0, end_non_cash: 200000, end: 200000 }, + flows: 0, + adjustments: { cash_adjustments: 0, non_cash_adjustments: 200000 } } ] ) @@ -270,17 +316,23 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: Date.current, legacy_balances: { balance: 1000, cash_balance: 0 }, - balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 } + balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 }, + flows: 0, + adjustments: 0 }, { date: 1.day.ago, legacy_balances: { balance: 1000, cash_balance: 0 }, - balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 } + balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 }, + flows: 0, + adjustments: 0 }, { date: 2.days.ago, legacy_balances: { balance: 1000, cash_balance: 0 }, - balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 } + balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 }, + flows: 0, + adjustments: { cash_adjustments: 0, non_cash_adjustments: 1000 } } ] ) @@ -314,17 +366,23 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: Date.current, legacy_balances: { balance: 20000, cash_balance: 19000 }, - balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 } + balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 }, + flows: { market_flows: 0 }, + adjustments: 0 }, # Current: $19k cash + $1k holdings (anchor) { date: 1.day.ago.to_date, legacy_balances: { balance: 20000, cash_balance: 19000 }, - balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 19000, end_non_cash: 1000, end: 20000 } + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 19000, end_non_cash: 1000, end: 20000 }, + flows: { cash_inflows: 0, cash_outflows: 1000, non_cash_inflows: 1000, non_cash_outflows: 0, net_market_flows: 0 }, + adjustments: 0 }, # After trade: $19k cash + $1k holdings { date: 2.days.ago.to_date, legacy_balances: { balance: 20000, cash_balance: 20000 }, - balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 } + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + flows: { market_flows: 0 }, + adjustments: { cash_adjustments: 20000, non_cash_adjustments: 0 } } # At first, account is 100% cash, no holdings (no trades) ] ) @@ -360,17 +418,23 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: Date.current, legacy_balances: { balance: 20000, cash_balance: 19000 }, - balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 } + balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 }, + flows: { market_flows: 0 }, + adjustments: 0 }, # Current: $19k cash + $1k holdings ($500 MSFT, $500 AAPL) { date: 1.day.ago.to_date, legacy_balances: { balance: 20000, cash_balance: 19000 }, - balances: { start: 20000, start_cash: 19500, start_non_cash: 500, end_cash: 19000, end_non_cash: 1000, end: 20000 } + balances: { start: 20000, start_cash: 19500, start_non_cash: 500, end_cash: 19000, end_non_cash: 1000, end: 20000 }, + flows: { cash_inflows: 0, cash_outflows: 500, non_cash_inflows: 500, non_cash_outflows: 0, net_market_flows: 0 }, + adjustments: 0 }, # After AAPL trade: $19k cash + $1k holdings { date: 2.days.ago.to_date, legacy_balances: { balance: 20000, cash_balance: 19500 }, - balances: { start: 20000, start_cash: 19500, start_non_cash: 500, end_cash: 19500, end_non_cash: 500, end: 20000 } + balances: { start: 20000, start_cash: 19500, start_non_cash: 500, end_cash: 19500, end_non_cash: 500, end: 20000 }, + flows: { market_flows: 0 }, + adjustments: { cash_adjustments: 19500, non_cash_adjustments: 500 } } # Before AAPL trade: $19.5k cash + $500 MSFT ] ) @@ -401,17 +465,23 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: Date.current, legacy_balances: { balance: 20000, cash_balance: 18000 }, - balances: { start: 20000, start_cash: 18000, start_non_cash: 2000, end_cash: 18000, end_non_cash: 2000, end: 20000 } + balances: { start: 20000, start_cash: 18000, start_non_cash: 2000, end_cash: 18000, end_non_cash: 2000, end: 20000 }, + flows: { market_flows: 0 }, + adjustments: 0 }, { date: 1.day.ago, legacy_balances: { balance: 20000, cash_balance: 18000 }, - balances: { start: 20000, start_cash: 18000, start_non_cash: 2000, end_cash: 18000, end_non_cash: 2000, end: 20000 } + balances: { start: 20000, start_cash: 18000, start_non_cash: 2000, end_cash: 18000, end_non_cash: 2000, end: 20000 }, + flows: { market_flows: 0 }, + adjustments: { cash_adjustments: 3000, non_cash_adjustments: 2000 } }, { date: 2.days.ago, legacy_balances: { balance: 15000, cash_balance: 15000 }, - balances: { start: 15000, start_cash: 15000, start_non_cash: 0, end_cash: 15000, end_non_cash: 0, end: 15000 } + balances: { start: 15000, start_cash: 15000, start_non_cash: 0, end_cash: 15000, end_non_cash: 0, end: 15000 }, + flows: { market_flows: 0 }, + adjustments: 0 } # Opening anchor sets absolute balance ] ) -- 2.53.0 From 0eb93c069ec628d6f0e163ef4f0168056b549b45 Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 22 Jul 2025 08:54:35 -0400 Subject: [PATCH 7/9] Forward calculator tests passing --- app/models/balance/base_calculator.rb | 84 ++++++++++++-- app/models/balance/forward_calculator.rb | 20 +++- app/models/balance/sync_cache.rb | 4 +- .../models/balance/forward_calculator_test.rb | 6 +- test/support/ledger_testing_helper.rb | 103 +++++++++++++----- 5 files changed, 170 insertions(+), 47 deletions(-) diff --git a/app/models/balance/base_calculator.rb b/app/models/balance/base_calculator.rb index af95bef8..c7c3bde0 100644 --- a/app/models/balance/base_calculator.rb +++ b/app/models/balance/base_calculator.rb @@ -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 diff --git a/app/models/balance/forward_calculator.rb b/app/models/balance/forward_calculator.rb index 8cf96fa5..42a420f7 100644 --- a/app/models/balance/forward_calculator.rb +++ b/app/models/balance/forward_calculator.rb @@ -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 diff --git a/app/models/balance/sync_cache.rb b/app/models/balance/sync_cache.rb index be2eaa19..aed2b64e 100644 --- a/app/models/balance/sync_cache.rb +++ b/app/models/balance/sync_cache.rb @@ -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) diff --git a/test/models/balance/forward_calculator_test.rb b/test/models/balance/forward_calculator_test.rb index ccd98c96..2a65ae19 100644 --- a/test/models/balance/forward_calculator_test.rb +++ b/test/models/balance/forward_calculator_test.rb @@ -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 } diff --git a/test/support/ledger_testing_helper.rb b/test/support/ledger_testing_helper.rb index 8a10dd50..dc18a039 100644 --- a/test/support/ledger_testing_helper.rb +++ b/test/support/ledger_testing_helper.rb @@ -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}" -- 2.53.0 From 79f723c14e23c596557ad341b5ebe15e4792896b Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Tue, 22 Jul 2025 20:09:47 -0400 Subject: [PATCH 8/9] Get all calculator tests passing --- app/models/balance/reverse_calculator.rb | 59 +++++++++---------- .../models/balance/reverse_calculator_test.rb | 51 ++++++++-------- test/support/ledger_testing_helper.rb | 4 +- 3 files changed, 57 insertions(+), 57 deletions(-) diff --git a/app/models/balance/reverse_calculator.rb b/app/models/balance/reverse_calculator.rb index 36106d2f..35a77445 100644 --- a/app/models/balance/reverse_calculator.rb +++ b/app/models/balance/reverse_calculator.rb @@ -11,6 +11,8 @@ class Balance::ReverseCalculator < Balance::BaseCalculator # Calculates in reverse-chronological order (End of day -> Start of day) account.current_anchor_date.downto(account.opening_anchor_date).map do |date| + flows = flows_for_date(date) + if use_opening_anchor_for_date?(date) end_cash_balance = derive_cash_balance_on_date_from_total( total_balance: account.opening_anchor_balance, @@ -20,33 +22,30 @@ class Balance::ReverseCalculator < Balance::BaseCalculator start_cash_balance = end_cash_balance start_non_cash_balance = end_non_cash_balance - - build_balance( - date: date, - 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 - ) + market_value_change = 0 else start_cash_balance = derive_start_cash_balance(end_cash_balance: end_cash_balance, date: date) start_non_cash_balance = derive_start_non_cash_balance(end_non_cash_balance: end_non_cash_balance, date: date) - - # Even though we've just calculated "start" balances, we set today equal to end of day, then use those - # in our next iteration (slightly confusing, but just the nature of a "reverse" sync) - output_balance = build_balance( - date: date, - 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 - ) - - end_cash_balance = start_cash_balance - end_non_cash_balance = start_non_cash_balance - - output_balance + market_value_change = market_value_change_on_date(date, flows) end + + output_balance = build_balance( + date: date, + balance: end_cash_balance + end_non_cash_balance, + cash_balance: end_cash_balance, + start_cash_balance: start_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], + net_market_flows: market_value_change + ) + + end_cash_balance = start_cash_balance + end_non_cash_balance = start_non_cash_balance + + output_balance end end end @@ -62,13 +61,6 @@ class Balance::ReverseCalculator < Balance::BaseCalculator account.asset? ? entry_flows : -entry_flows end - # Reverse syncs are a bit different than forward syncs because we do not allow "reconciliation" valuations - # to be used at all. This is primarily to keep the code and the UI easy to understand. For a more detailed - # explanation, see the test suite. - def use_opening_anchor_for_date?(date) - account.has_opening_anchor? && date == account.opening_anchor_date - end - # Alias method, for algorithmic clarity # Derives cash balance, starting from the end-of-day, applying entries in reverse to get the start-of-day balance def derive_start_cash_balance(end_cash_balance:, date:) @@ -80,4 +72,11 @@ class Balance::ReverseCalculator < Balance::BaseCalculator def derive_start_non_cash_balance(end_non_cash_balance:, date:) derive_non_cash_balance(end_non_cash_balance, date, direction: :reverse) end + + # Reverse syncs are a bit different than forward syncs because we do not allow "reconciliation" valuations + # to be used at all. This is primarily to keep the code and the UI easy to understand. For a more detailed + # explanation, see the test suite. + def use_opening_anchor_for_date?(date) + account.has_opening_anchor? && date == account.opening_anchor_date + end end diff --git a/test/models/balance/reverse_calculator_test.rb b/test/models/balance/reverse_calculator_test.rb index df792ed9..c3ba12ba 100644 --- a/test/models/balance/reverse_calculator_test.rb +++ b/test/models/balance/reverse_calculator_test.rb @@ -20,9 +20,9 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: Date.current, legacy_balances: { balance: 20000, cash_balance: 20000 }, - balances: { start: 0, start_cash: 0, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, flows: 0, - adjustments: { cash_adjustments: 20000, non_cash_adjustments: 0 } + adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 } } ] ) @@ -78,9 +78,9 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase { date: 3.days.ago, legacy_balances: { balance: 20000, cash_balance: 20000 }, - balances: { start: 15000, start_cash: 15000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, + balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, flows: 0, - adjustments: { cash_adjustments: 5000, non_cash_adjustments: 0 } + adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 } }, { date: 4.days.ago, @@ -135,8 +135,8 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase account: { type: Depository, balance: 20000, cash_balance: 20000, currency: "USD" }, entries: [ { type: "current_anchor", date: Date.current, balance: 20000 }, - { type: "transaction", date: 4.days.ago, amount: -500 }, # income - { type: "transaction", date: 2.days.ago, amount: 100 } # expense + { type: "transaction", date: 2.days.ago, amount: 100 }, # expense + { type: "transaction", date: 4.days.ago, amount: -500 } # income ] ) @@ -185,7 +185,7 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase legacy_balances: { balance: 19600, cash_balance: 19600 }, balances: { start: 19600, start_cash: 19600, start_non_cash: 0, end_cash: 19600, end_non_cash: 0, end: 19600 }, flows: 0, - adjustments: { cash_adjustments: 19600, non_cash_adjustments: 0 } + adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 } } # After income (-500) ] ) @@ -239,7 +239,7 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase date: 4.days.ago, legacy_balances: { balance: 1900, cash_balance: 1900 }, balances: { start: 2400, start_cash: 2400, start_non_cash: 0, end_cash: 1900, end_non_cash: 0, end: 1900 }, - flows: { cash_inflows: 0, cash_outflows: 500 }, + flows: { cash_inflows: 500, cash_outflows: 0 }, adjustments: 0 }, # After CC payment (-500) { @@ -247,7 +247,7 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase legacy_balances: { balance: 2400, cash_balance: 2400 }, balances: { start: 2400, start_cash: 2400, start_non_cash: 0, end_cash: 2400, end_non_cash: 0, end: 2400 }, flows: 0, - adjustments: { cash_adjustments: 2400, non_cash_adjustments: 0 } + adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 } } ] ) @@ -290,7 +290,7 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase legacy_balances: { balance: 200000, cash_balance: 0 }, balances: { start: 200000, start_cash: 0, start_non_cash: 200000, end_cash: 0, end_non_cash: 200000, end: 200000 }, flows: 0, - adjustments: { cash_adjustments: 0, non_cash_adjustments: 200000 } + adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 } } ] ) @@ -332,7 +332,7 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase legacy_balances: { balance: 1000, cash_balance: 0 }, balances: { start: 1000, start_cash: 0, start_non_cash: 1000, end_cash: 0, end_non_cash: 1000, end: 1000 }, flows: 0, - adjustments: { cash_adjustments: 0, non_cash_adjustments: 1000 } + adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 } } ] ) @@ -382,7 +382,7 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase legacy_balances: { balance: 20000, cash_balance: 20000 }, balances: { start: 20000, start_cash: 20000, start_non_cash: 0, end_cash: 20000, end_non_cash: 0, end: 20000 }, flows: { market_flows: 0 }, - adjustments: { cash_adjustments: 20000, non_cash_adjustments: 0 } + adjustments: { cash_adjustments: 0, non_cash_adjustments: 0 } } # At first, account is 100% cash, no holdings (no trades) ] ) @@ -426,15 +426,15 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase date: 1.day.ago.to_date, legacy_balances: { balance: 20000, cash_balance: 19000 }, balances: { start: 20000, start_cash: 19500, start_non_cash: 500, end_cash: 19000, end_non_cash: 1000, end: 20000 }, - flows: { cash_inflows: 0, cash_outflows: 500, non_cash_inflows: 500, non_cash_outflows: 0, net_market_flows: 0 }, + flows: { cash_inflows: 0, cash_outflows: 500, non_cash_inflows: 500, non_cash_outflows: 0, market_flows: 0 }, adjustments: 0 }, # After AAPL trade: $19k cash + $1k holdings { date: 2.days.ago.to_date, legacy_balances: { balance: 20000, cash_balance: 19500 }, - balances: { start: 20000, start_cash: 19500, start_non_cash: 500, end_cash: 19500, end_non_cash: 500, end: 20000 }, - flows: { market_flows: 0 }, - adjustments: { cash_adjustments: 19500, non_cash_adjustments: 500 } + balances: { start: 19500, start_cash: 19500, start_non_cash: 0, end_cash: 19500, end_non_cash: 500, end: 20000 }, + flows: { market_flows: -500 }, + adjustments: 0 } # Before AAPL trade: $19.5k cash + $500 MSFT ] ) @@ -450,8 +450,9 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase ], holdings: [ # Create holdings that differ in value from provider ($2,000 vs. the $1,000 reported by provider) - { date: Date.current, ticker: "AAPL", qty: 10, price: 100, amount: 2000 }, - { date: 1.day.ago, ticker: "AAPL", qty: 10, price: 100, amount: 2000 } + { date: Date.current, ticker: "AAPL", qty: 10, price: 100, amount: 1000 }, + { date: 1.day.ago, ticker: "AAPL", qty: 10, price: 100, amount: 1000 }, + { date: 2.days.ago, ticker: "AAPL", qty: 10, price: 100, amount: 1000 } ] ) @@ -464,22 +465,22 @@ class Balance::ReverseCalculatorTest < ActiveSupport::TestCase # This ensures the user sees the same top-line number reported by the provider (even if it creates a discrepancy in the cash balance) { date: Date.current, - legacy_balances: { balance: 20000, cash_balance: 18000 }, - balances: { start: 20000, start_cash: 18000, start_non_cash: 2000, end_cash: 18000, end_non_cash: 2000, end: 20000 }, + legacy_balances: { balance: 20000, cash_balance: 19000 }, + balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 }, flows: { market_flows: 0 }, adjustments: 0 }, { date: 1.day.ago, - legacy_balances: { balance: 20000, cash_balance: 18000 }, - balances: { start: 20000, start_cash: 18000, start_non_cash: 2000, end_cash: 18000, end_non_cash: 2000, end: 20000 }, + legacy_balances: { balance: 20000, cash_balance: 19000 }, + balances: { start: 20000, start_cash: 19000, start_non_cash: 1000, end_cash: 19000, end_non_cash: 1000, end: 20000 }, flows: { market_flows: 0 }, - adjustments: { cash_adjustments: 3000, non_cash_adjustments: 2000 } + adjustments: 0 }, { date: 2.days.ago, - legacy_balances: { balance: 15000, cash_balance: 15000 }, - balances: { start: 15000, start_cash: 15000, start_non_cash: 0, end_cash: 15000, end_non_cash: 0, end: 15000 }, + legacy_balances: { balance: 15000, cash_balance: 14000 }, + balances: { start: 15000, start_cash: 14000, start_non_cash: 1000, end_cash: 14000, end_non_cash: 1000, end: 15000 }, flows: { market_flows: 0 }, adjustments: 0 } # Opening anchor sets absolute balance diff --git a/test/support/ledger_testing_helper.rb b/test/support/ledger_testing_helper.rb index dc18a039..d1345c76 100644 --- a/test/support/ledger_testing_helper.rb +++ b/test/support/ledger_testing_helper.rb @@ -12,8 +12,8 @@ module LedgerTestingHelper created_account = families(:empty).accounts.create!( name: "Test Account", accountable: account_type.new, - balance: 0, # Doesn't matter, ledger derives this - cash_balance: 0, # Doesn't matter, ledger derives this + balance: account[:balance] || 0, # Doesn't matter, ledger derives this + cash_balance: account[:cash_balance] || 0, # Doesn't matter, ledger derives this **account_attrs ) -- 2.53.0 From 4170387aa18a6fceb2ea24754e559e8e80ca3c7a Mon Sep 17 00:00:00 2001 From: Zach Gollwitzer Date: Wed, 23 Jul 2025 09:43:43 -0400 Subject: [PATCH 9/9] Assert flows factor --- app/models/balance/base_calculator.rb | 3 ++- test/support/ledger_testing_helper.rb | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/models/balance/base_calculator.rb b/app/models/balance/base_calculator.rb index c7c3bde0..9d1d288f 100644 --- a/app/models/balance/base_calculator.rb +++ b/app/models/balance/base_calculator.rb @@ -133,7 +133,8 @@ class Balance::BaseCalculator 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 + net_market_flows: args[:net_market_flows] || 0, + flows_factor: account.classification == "asset" ? 1 : -1 ) end end diff --git a/test/support/ledger_testing_helper.rb b/test/support/ledger_testing_helper.rb index d1345c76..d5e08aec 100644 --- a/test/support/ledger_testing_helper.rb +++ b/test/support/ledger_testing_helper.rb @@ -135,6 +135,11 @@ module LedgerTestingHelper if expected assert calculated_balance, "Expected balance for #{date} but none was calculated" + # Always assert flows_factor is correct based on account classification + expected_flows_factor = calculated_balance.account.classification == "asset" ? 1 : -1 + assert_equal expected_flows_factor, calculated_balance.flows_factor, + "Flows factor mismatch for #{date}: expected #{expected_flows_factor} for #{calculated_balance.account.classification} account" + legacy_balances = expected[:legacy_balances] balances = expected[:balances] flows = expected[:flows] -- 2.53.0