Files
sure/test/models/ibkr_account_processor_test.rb
Gian-Reto Tarnutzer ce5d7dd736 Add Interactive Brokers Provider (#1722)
* Display multi-currency holdings correctly

* Implement IBKR provider

* Fix: Use historical exchange rate for historical prices

* Add brokerage exchange rate for trades

* Sync historical balances from IBKR

* Add logos in activity history

* Fix privacy mode blur in account view

* Improve IBKR XML Flex report parser errors
2026-05-12 23:45:19 +02:00

282 lines
9.4 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_equal BigDecimal("123.1667"), holding.cost_basis
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