Files
sure/test/models/ibkr_account_processor_test.rb
CrossDrain 6262b0a493 fix(ibkr): correct historical cash/non-cash split for linked accounts (#1813)
* fix(ibkr): resolve weekend balance oscillations and improve data processing

Address issues where IBKR weekend/holiday data caused incorrect balance
calculations and improve the robustness of IBKR account processing.

- Fix historical balance oscillations by ignoring anomalous weekend rows
  and filling gaps by carrying forward the last known trading day value.
- Normalize report dates to the last trading day to ensure consistency.
- Improve `HoldingsProcessor` to skip individual bad lots instead of
  failing the entire group.
- Refactor `ActivitiesProcessor` to accumulate fee counts locally via
  return values instead of using instance variables.
- Add support for accounting parentheses notation in `DataHelpers`.
- Memoize the account object in `IbkrAccount::Processor` to reduce
  database queries.
- Update tests to reflect date normalization and improved precision
  assertions.

* fix(ibkr): derive historical cash from materializer balances, not equity summary

Real IBKR Flex exports do not include a reliable cash/stock breakdown in
EquitySummaryByReportDateInBase — only the total is consistently present.
The previous implementation parsed the missing cash field as zero and wrote
cash_balance=0 for every historical date, causing negative and wildly
incorrect cash values throughout the account history.

Instead, read the materializer's already-computed cash_balance for each date
(derived from holdings via the reverse calculator) and use only IBKR's total
as an authoritative balance anchor. This is consistent with how present-day
balances are handled and requires no weekend/holiday filtering since IBKR does
not emit weekend rows and holiday totals are legitimate data points.

Also accept equity summary rows without an explicit currency field (some Flex
configurations omit it) and explicitly reject BASE_SUMMARY aggregate rows.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* style: simplify boolean coercion in import_commission_transaction

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ibkr): cover trailing weekend gap and align qty with valid lots

HistoricalBalancesSync: extend fill_gaps to account.current_anchor_date
so days after the last equity summary row (e.g. Saturday/Sunday when a
sync runs over the weekend) are also overridden rather than left with the
materializer's stale total=cash value.

HoldingsProcessor: replace separate quantity sum + weighted_cost_basis_for
with a single valid_lots method that computes both from the same set of
parseable lots. Previously a lot with a valid position but unparseable
cost_basis_price was excluded from the cost basis calculation but still
counted in quantity, producing inconsistent qty/cost_basis values.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* review: address PR feedback on ibkr fix branch

- Remove all "Fix N:" review-artifact comment labels
- Add Sentry.capture_message for silenced anchor repair failure so it surfaces in production monitoring
- Add Rails.logger.warn for zero/nil total rows skipped in HistoricalBalancesSync
- Document normalize_to_last_trading_day holiday limitation and why gap-fill covers it
- Rewrite two non-obvious comments to stand alone without the label prefix

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* style: remove alignment padding in balance_rows hash

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ibkr): address two P1 review findings

- Allow zero and negative equity summary totals through HistoricalBalancesSync
  so fully-liquidated and margin accounts are not silently skipped (which would
  cause fill_gaps to propagate a stale non-zero total forward).
- Remove normalize_to_last_trading_day from HoldingsProcessor: shifting weekend
  report_dates to Friday caused Balance::SyncCache#get_holdings_value to find
  no holdings on Saturday/Sunday (exact-date lookup), collapsing non_cash to
  zero — reintroducing the very oscillation the fix was meant to prevent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(ibkr): add tests for historical balances sync and data helpers

- Add test case to verify non-cash balance calculation in historical balances sync
- Add test case to ensure rows with unparseable or nil totals are skipped
- Add new test file for IBKR data helpers

* fix(ibkr): prevent date range overflow during historical sync

Adjust the calculation of `last_date` in `HistoricalBalancesSync` to
ensure it does not exceed the current anchor date or today's date.
This prevents the sync process from attempting to fetch or process
future dates, which was causing oscillations in weekend data.

Also remove the conditional check for Sentry before capturing
error messages in the account processor.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 21:03:04 +02:00

282 lines
9.5 KiB
Ruby

require "test_helper"
class IbkrAccountProcessorTest < ActiveSupport::TestCase
fixtures :families, :ibkr_items, :ibkr_accounts, :accounts, :securities
setup do
@family = families(:dylan_family)
@ibkr_account = ibkr_accounts(:main_account)
@account = @family.accounts.create!(
name: "IBKR Investment",
balance: 0,
cash_balance: 0,
currency: "CHF",
accountable: Investment.new(subtype: "brokerage")
)
@ibkr_account.ensure_account_provider!(@account)
@ibkr_account.update!(
raw_holdings_payload: [
{
"asset_category" => "STK",
"conid" => "265598",
"security_id" => "US0378331005",
"security_id_type" => "ISIN",
"symbol" => securities(:aapl).ticker,
"position" => "10",
"mark_price" => "150.00",
"currency" => "USD",
"fx_rate_to_base" => "0.90",
"cost_basis_price" => "125.50",
"report_date" => Date.current.to_s,
"side" => "Long"
}
],
raw_activities_payload: {
trades: [
{
"asset_category" => "STK",
"trade_id" => "1001",
"transaction_id" => "1001a",
"conid" => "265598",
"symbol" => securities(:aapl).ticker,
"quantity" => "2",
"trade_price" => "140.00",
"currency" => "USD",
"fx_rate_to_base" => "0.90",
"buy_sell" => "BUY",
"trade_date" => Date.current.to_s,
"ib_commission" => "-1.25",
"ib_commission_currency" => "USD"
},
{
"asset_category" => "STK",
"trade_id" => "1002",
"transaction_id" => "1002a",
"conid" => "265598",
"symbol" => securities(:aapl).ticker,
"quantity" => "-1",
"trade_price" => "155.00",
"currency" => "USD",
"fx_rate_to_base" => "0.92",
"buy_sell" => "SELL",
"trade_date" => Date.current.to_s,
"ib_commission" => "-1.10",
"ib_commission_currency" => "USD"
}
],
cash_transactions: [
{
"transaction_id" => "4001",
"type" => "Deposits/Withdrawals",
"amount" => "500.00",
"currency" => "CHF",
"fx_rate_to_base" => "1",
"report_date" => Date.current.to_s
},
{
"transaction_id" => "4002",
"type" => "Dividends",
"amount" => "2.50",
"currency" => "USD",
"fx_rate_to_base" => "0.91",
"report_date" => Date.current.to_s,
"conid" => "265598"
}
]
},
report_date: Date.current,
current_balance: BigDecimal("3351.00"),
cash_balance: BigDecimal("1000.50"),
currency: "CHF"
)
end
test "processor imports holdings, trades, cash transactions, and commissions" do
IbkrAccount::Processor.new(@ibkr_account).process
@account.reload
assert_equal BigDecimal("3351.00"), @account.balance
assert_equal BigDecimal("1000.50"), @account.cash_balance
assert_equal "CHF", @account.currency
holding = @account.holdings.find_by(security: securities(:aapl), date: Date.current)
assert_not_nil holding
assert_equal BigDecimal("10"), holding.qty
assert_equal BigDecimal("150.00"), holding.price
assert_equal BigDecimal("125.50"), holding.cost_basis
assert_equal "USD", holding.currency
buy_trade = @account.entries.find_by(external_id: "ibkr_trade_1001")
sell_trade = @account.entries.find_by(external_id: "ibkr_trade_1002")
assert_not_nil buy_trade
assert_not_nil sell_trade
assert_equal "Buy", buy_trade.entryable.investment_activity_label
assert_equal "Sell", sell_trade.entryable.investment_activity_label
assert_equal BigDecimal("2"), buy_trade.entryable.qty
assert_equal BigDecimal("-1"), sell_trade.entryable.qty
assert_equal BigDecimal("280.0"), buy_trade.amount
assert_equal BigDecimal("-155.0"), sell_trade.amount
assert_equal "USD", buy_trade.currency
assert_equal "USD", sell_trade.currency
assert_equal 0.9, buy_trade.entryable.exchange_rate
assert_equal 0.92, sell_trade.entryable.exchange_rate
dividend = @account.entries.find_by(external_id: "ibkr_cash_4002")
assert_not_nil dividend
assert_equal "Dividend", dividend.entryable.investment_activity_label
assert_equal BigDecimal("-2.5"), dividend.amount
assert_equal securities(:aapl).id, dividend.entryable.extra["security_id"]
commission_one = @account.entries.find_by(external_id: "ibkr_trade_fee_1001")
commission_two = @account.entries.find_by(external_id: "ibkr_trade_fee_1002")
assert_not_nil commission_one
assert_not_nil commission_two
assert_equal BigDecimal("1.25"), commission_one.amount
assert_equal BigDecimal("1.1"), commission_two.amount
assert_equal "USD", commission_one.currency
assert_equal "USD", commission_two.currency
assert_equal securities(:aapl).id, commission_one.entryable.extra["security_id"]
assert_equal securities(:aapl).id, commission_two.entryable.extra["security_id"]
deposit = @account.entries.find_by(external_id: "ibkr_cash_4001")
assert_not_nil deposit
assert_equal "Contribution", deposit.entryable.investment_activity_label
assert_equal BigDecimal("-500"), deposit.amount
assert_equal "CHF", deposit.currency
assert_equal "USD", dividend.currency
end
test "processor computes weighted provider cost basis for grouped lots" do
@ibkr_account.update!(
raw_holdings_payload: [
{
"asset_category" => "STK",
"conid" => "265598",
"security_id" => "US0378331005",
"security_id_type" => "ISIN",
"symbol" => securities(:aapl).ticker,
"position" => "10",
"mark_price" => "150.00",
"currency" => "USD",
"fx_rate_to_base" => "0.90",
"cost_basis_price" => "125.50",
"report_date" => Date.current.to_s,
"side" => "Long"
},
{
"asset_category" => "STK",
"conid" => "265598",
"security_id" => "US0378331005",
"security_id_type" => "ISIN",
"symbol" => securities(:aapl).ticker,
"position" => "20",
"mark_price" => "150.00",
"currency" => "USD",
"fx_rate_to_base" => "0.90",
"cost_basis_price" => "122.00",
"report_date" => Date.current.to_s,
"side" => "Long"
}
]
)
IbkrAccount::Processor.new(@ibkr_account).process
holding = @account.holdings.find_by(security: securities(:aapl), date: Date.current)
assert_not_nil holding
assert_equal BigDecimal("30"), holding.qty
assert_in_delta BigDecimal("123.1667"), holding.cost_basis, BigDecimal("0.0001")
end
test "processor repairs default opening anchor after importing activity entries" do
result = Account::OpeningBalanceManager.new(@account).set_opening_balance(
balance: @ibkr_account.current_balance,
date: 2.years.ago.to_date
)
assert result.success?
opening_anchor = @account.valuations.opening_anchor.includes(:entry).first
assert_not_nil opening_anchor
assert_equal @ibkr_account.current_balance.to_d, opening_anchor.entry.amount.to_d
IbkrAccount::Processor.new(@ibkr_account).process
opening_anchor.reload
assert_equal BigDecimal("0"), opening_anchor.entry.amount.to_d
end
test "processor imports commission-free trades without creating fee entries" do
@ibkr_account.update!(
raw_activities_payload: {
trades: [
{
"asset_category" => "STK",
"trade_id" => "1003",
"transaction_id" => "1003a",
"conid" => "265598",
"symbol" => securities(:aapl).ticker,
"quantity" => "3",
"trade_price" => "145.00",
"currency" => "USD",
"fx_rate_to_base" => "0.91",
"buy_sell" => "BUY",
"trade_date" => Date.current.to_s
}
],
cash_transactions: []
}
)
IbkrAccount::Processor.new(@ibkr_account).process
trade = @account.entries.find_by(external_id: "ibkr_trade_1003")
fee = @account.entries.find_by(external_id: "ibkr_trade_fee_1003")
assert_not_nil trade
assert_equal BigDecimal("3"), trade.entryable.qty
assert_equal BigDecimal("435.0"), trade.amount
assert_equal "USD", trade.currency
assert_nil fee
end
test "processor logs and falls back to current date for invalid trade_date" do
@ibkr_account.update!(
raw_activities_payload: {
trades: [
{
"asset_category" => "STK",
"trade_id" => "1004",
"transaction_id" => "1004a",
"conid" => "265598",
"symbol" => securities(:aapl).ticker,
"quantity" => "1",
"trade_price" => "146.00",
"currency" => "USD",
"fx_rate_to_base" => "0.91",
"buy_sell" => "BUY",
"trade_date" => "not-a-date"
}
],
cash_transactions: []
}
)
Rails.logger.expects(:warn).with do |message|
message.include?("IbkrAccount::DataHelpers - Missing or invalid trade_date") &&
message.include?("1004")
end
IbkrAccount::Processor.new(@ibkr_account).process
trade = @account.entries.find_by(external_id: "ibkr_trade_1004")
assert_not_nil trade
assert_equal Date.current, trade.date
end
end