diff --git a/app/models/ibkr_account/historical_balances_sync.rb b/app/models/ibkr_account/historical_balances_sync.rb index 4d302d7ba..45155a26e 100644 --- a/app/models/ibkr_account/historical_balances_sync.rb +++ b/app/models/ibkr_account/historical_balances_sync.rb @@ -11,8 +11,11 @@ class IbkrAccount::HistoricalBalancesSync return unless account.present? return if normalized_rows.empty? + rows = balance_rows + return if rows.empty? + account.balances.upsert_all( - balance_rows, + rows, unique_by: %i[account_id date currency] ) end @@ -109,12 +112,35 @@ class IbkrAccount::HistoricalBalancesSync def balance_rows current_time = Time.current + trade_flows_by_date # ensure @failed_fx_dates is populated before iterating - normalized_rows.each_with_index.map do |row, index| + normalized_rows.each_with_index.filter_map do |row, index| + next if @failed_fx_dates.include?(row[:date]) previous_row = index.zero? ? nil : normalized_rows[index - 1] start_cash_balance = previous_row ? previous_row[:cash] : row[:cash] start_non_cash_balance = previous_row ? previous_row[:non_cash] : row[:non_cash] + # Derive market return directly from IBKR's equity data so Period Return + # matches IBKR without requiring third-party security price providers. + # + # nmf = Δnon_cash - net_buy_sell + # Δnon_cash : change in holdings value per IBKR equity summary (exact) + # net_buy_sell: sum of trade entry amounts converted to base currency + # (positive = buy, negative = sell; IBKR fx_rate_to_base applied) + # + # non_cash_adjustments absorbs net_buy_sell so the virtual column + # end_non_cash_balance = start + nmf + adjustments stays equal to row[:non_cash]. + if previous_row + net_buy_sell = trade_flows_by_date[row[:date]] || 0 + nmf = row[:non_cash] - start_non_cash_balance - net_buy_sell + non_cash_adj = net_buy_sell + else + # First-day row has no prior period to diff against, so both values are + # intentionally zero — not a bug, just an unavoidable bootstrap constraint. + nmf = 0 + non_cash_adj = 0 + end + { account_id: account.id, date: row[:date], @@ -127,13 +153,44 @@ class IbkrAccount::HistoricalBalancesSync cash_outflows: 0, non_cash_inflows: 0, non_cash_outflows: 0, - net_market_flows: 0, + net_market_flows: nmf, cash_adjustments: row[:cash] - start_cash_balance, - non_cash_adjustments: row[:non_cash] - start_non_cash_balance, + non_cash_adjustments: non_cash_adj, flows_factor: 1, created_at: current_time, updated_at: current_time } end end + + # Net value of all trades on each date, in account base currency. + # Uses the IBKR-provided fx_rate_to_base stored on each Trade entry so the + # conversion is exact and consistent with IBKR's own calculations. + # Positive = net buy (cash out), negative = net sell (cash in). + def trade_flows_by_date + @trade_flows_by_date ||= begin + @failed_fx_dates = [] + if account + account.entries + .joins("INNER JOIN trades ON trades.id = entries.entryable_id AND entries.entryable_type = 'Trade'") + .where.not(trades: { qty: 0 }) + .includes(:entryable) + .each_with_object(Hash.new(0)) do |entry, flows| + custom_rate = entry.entryable.exchange_rate + base_amount = Money.new(entry.amount, entry.currency) + .exchange_to(account_currency, custom_rate: custom_rate, date: entry.date) + .amount + flows[entry.date] += base_amount + rescue Money::ConversionError + Rails.logger.warn( + "IbkrAccount::HistoricalBalancesSync - No FX rate for #{entry.currency}→#{account_currency} " \ + "on #{entry.date}; balance row for this date will not be persisted" + ) + @failed_fx_dates << entry.date + end + else + {} + end + end + end end diff --git a/test/models/ibkr_account/historical_balances_sync_test.rb b/test/models/ibkr_account/historical_balances_sync_test.rb index 66b5ee89b..825cc1aaf 100644 --- a/test/models/ibkr_account/historical_balances_sync_test.rb +++ b/test/models/ibkr_account/historical_balances_sync_test.rb @@ -213,6 +213,163 @@ class IbkrAccount::HistoricalBalancesSyncTest < ActiveSupport::TestCase assert_not_nil @account.balances.find_by(date: Date.new(2026, 5, 8), currency: "CHF") end + test "computes net_market_flows from equity delta minus trade flows" do + # Day 1: total=3000, cash=500, non_cash=2500 + # Day 2: total=3200, cash=500, non_cash=2700 (Δnon_cash=200) + # Buy trade on Day 2: CHF 150 (same currency as account, no FX) + # nmf = 200 - 150 = 50 + @ibkr_account.update!( + raw_equity_summary_payload: [ + { report_date: "2026-05-07", total: "3000.00" }, + { report_date: "2026-05-08", total: "3200.00" } + ] + ) + seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00) + seed_balance(date: Date.new(2026, 5, 8), balance: 3200.00, cash_balance: 500.00) + + security = Security.create!(ticker: "TEST", name: "Test Stock") + @account.entries.create!( + name: "Buy 100 TEST", + date: Date.new(2026, 5, 8), + amount: 150.00, + currency: "CHF", + entryable: Trade.new(security: security, qty: 100, price: 1.5, currency: "CHF") + ) + + IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync! + + day1 = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF") + day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF") + + assert_equal BigDecimal("0"), day1.net_market_flows + assert_equal BigDecimal("50"), day2.net_market_flows + + # Virtual column must still resolve to IBKR's equity total minus cash + assert_equal BigDecimal("2500.00"), day1.end_non_cash_balance + assert_equal BigDecimal("2700.00"), day2.end_non_cash_balance + end + + test "applies fx_rate_to_base when trade currency differs from account currency" do + # Trade in EUR with fx_rate_to_base=1.1 → CHF 165, not CHF 150 + # Day 1: non_cash=2500, Day 2: non_cash=2700 (Δ=200) + # nmf = 200 - 165 = 35 + @ibkr_account.update!( + raw_equity_summary_payload: [ + { report_date: "2026-05-07", total: "3000.00" }, + { report_date: "2026-05-08", total: "3200.00" } + ] + ) + seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00) + seed_balance(date: Date.new(2026, 5, 8), balance: 3200.00, cash_balance: 500.00) + + security = Security.create!(ticker: "TEST2", name: "Test Stock EUR") + trade = Trade.new(security: security, qty: 100, price: 1.5, currency: "EUR") + trade.exchange_rate = 1.1 + @account.entries.create!( + name: "Buy 100 TEST2", + date: Date.new(2026, 5, 8), + amount: 150.00, + currency: "EUR", + entryable: trade + ) + + IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync! + + day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF") + assert_in_delta 35.0, day2.net_market_flows.to_f, 0.01 + assert_equal BigDecimal("2700.00"), day2.end_non_cash_balance + end + + test "excludes balance row from upsert when Money::ConversionError prevents FX conversion" do + # EUR trade with no exchange_rate stored → custom_rate=nil → ConversionError raised. + # The affected date is excluded from the upsert entirely so net_market_flows is not + # silently wrong (the trade's value would otherwise flow into market appreciation). + # The seeded day2 balance is intentionally different from IBKR's total (3150 vs 3200) + # so we can assert the row was not overwritten by sync. + @ibkr_account.update!( + raw_equity_summary_payload: [ + { report_date: "2026-05-07", total: "3000.00" }, + { report_date: "2026-05-08", total: "3200.00" } + ] + ) + seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00) + seed_balance(date: Date.new(2026, 5, 8), balance: 3150.00, cash_balance: 500.00) + + security = Security.create!(ticker: "NORATE", name: "No Rate EUR Stock") + @account.entries.create!( + name: "Buy 100 NORATE", + date: Date.new(2026, 5, 8), + amount: 150.00, + currency: "EUR", + entryable: Trade.new(security: security, qty: 100, price: 1.5, currency: "EUR") + ) + + Money.any_instance.stubs(:exchange_to).raises( + Money::ConversionError.new(from_currency: "EUR", to_currency: "CHF", date: Date.new(2026, 5, 8)) + ) + + IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync! + + # Day 1 is unaffected — still synced normally + day1 = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF") + assert_equal BigDecimal("3000.00"), day1.balance + + # Day 2 was excluded from the upsert — seeded values are preserved, not overwritten + day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF") + assert_equal BigDecimal("3150.00"), day2.balance # seeded, not IBKR's 3200 + assert_equal BigDecimal("0"), day2.net_market_flows # seeded, not recomputed + end + + test "net_market_flows equals full non_cash delta when account has no trades" do + @ibkr_account.update!( + raw_equity_summary_payload: [ + { report_date: "2026-05-07", total: "3000.00" }, + { report_date: "2026-05-08", total: "3300.00" } + ] + ) + seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00) + seed_balance(date: Date.new(2026, 5, 8), balance: 3300.00, cash_balance: 500.00) + + IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync! + + day1 = @account.balances.find_by!(date: Date.new(2026, 5, 7), currency: "CHF") + day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF") + + assert_equal BigDecimal("0"), day1.net_market_flows + assert_equal BigDecimal("300"), day2.net_market_flows + assert_equal BigDecimal("2800.00"), day2.end_non_cash_balance + end + + test "sell trades reduce net_buy_sell so market loss is isolated in net_market_flows" do + # Day 1: total=3000, cash=500, non_cash=2500 + # Day 2: total=2700, cash=700, non_cash=2000 (Δnon_cash=-500) + # Sell 100 at CHF 1.50: entry.amount=-150 (negative = proceeds received) + # net_buy_sell=-150; nmf = -500 - (-150) = -350 (market caused -350 loss) + @ibkr_account.update!( + raw_equity_summary_payload: [ + { report_date: "2026-05-07", total: "3000.00" }, + { report_date: "2026-05-08", total: "2700.00" } + ] + ) + seed_balance(date: Date.new(2026, 5, 7), balance: 3000.00, cash_balance: 500.00) + seed_balance(date: Date.new(2026, 5, 8), balance: 2700.00, cash_balance: 700.00) + + security = Security.create!(ticker: "SELL_TEST", name: "Sell Test Stock") + @account.entries.create!( + name: "Sell 100 SELL_TEST", + date: Date.new(2026, 5, 8), + amount: -150.00, + currency: "CHF", + entryable: Trade.new(security: security, qty: -100, price: 1.5, currency: "CHF") + ) + + IbkrAccount::HistoricalBalancesSync.new(@ibkr_account).sync! + + day2 = @account.balances.find_by!(date: Date.new(2026, 5, 8), currency: "CHF") + assert_equal BigDecimal("-350"), day2.net_market_flows + assert_equal BigDecimal("2000.00"), day2.end_non_cash_balance + end + test "writes balance row with zero total for fully liquidated dates" do @ibkr_account.update!( raw_equity_summary_payload: [