Files
sure/app/models/ibkr_item/report_parser.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

144 lines
5.5 KiB
Ruby

class IbkrItem::ReportParser
include IbkrAccount::DataHelpers
class ParseError < StandardError; end
POSITION_VALUE_CONTAINER_NAMES = %w[ChangeInPositionValues].freeze
POSITION_VALUE_ROW_NAMES = %w[ChangeInPositionValue].freeze
CASH_REPORT_CONTAINER_NAMES = %w[CashReport CashReports].freeze
CASH_REPORT_ROW_NAMES = %w[CashReport CashReportCurrency CashReportRow].freeze
EQUITY_SUMMARY_CONTAINER_NAMES = %w[EquitySummaryInBase].freeze
EQUITY_SUMMARY_ROW_NAMES = %w[EquitySummaryByReportDateInBase].freeze
OPEN_POSITION_CONTAINER_NAMES = %w[OpenPositions].freeze
OPEN_POSITION_ROW_NAMES = %w[OpenPosition].freeze
TRADES_CONTAINER_NAMES = %w[Trades].freeze
TRADE_ROW_NAMES = %w[Trade].freeze
CASH_TRANSACTION_CONTAINER_NAMES = %w[CashTransactions].freeze
CASH_TRANSACTION_ROW_NAMES = %w[CashTransaction].freeze
def initialize(xml_body)
@document = Nokogiri::XML(xml_body.to_s) { |config| config.strict.noblanks }
rescue Nokogiri::XML::SyntaxError => e
raise ParseError, "Invalid IBKR Flex XML: #{e.message}"
end
def parse
validate_document!
{
metadata: root_metadata,
accounts: flex_statements.map { |statement| parse_statement(statement) }
}
end
private
def validate_document!
raise ParseError, "Invalid IBKR Flex XML: missing FlexQueryResponse root." unless @document.at_xpath("//FlexQueryResponse")
raise ParseError, "Invalid IBKR Flex XML: no FlexStatement nodes found." if flex_statements.empty?
end
def flex_statements
@document.xpath("//FlexStatement")
end
def root_metadata
node_attributes(@document.at_xpath("//FlexQueryResponse"))
end
def parse_statement(statement)
statement_data = node_attributes(statement)
account_information = node_attributes(statement.at_xpath("./AccountInformation"))
position_values = section_rows(statement, POSITION_VALUE_CONTAINER_NAMES, POSITION_VALUE_ROW_NAMES)
cash_report = section_rows(statement, CASH_REPORT_CONTAINER_NAMES, CASH_REPORT_ROW_NAMES)
equity_summary_in_base = section_rows(statement, EQUITY_SUMMARY_CONTAINER_NAMES, EQUITY_SUMMARY_ROW_NAMES)
open_positions = section_rows(statement, OPEN_POSITION_CONTAINER_NAMES, OPEN_POSITION_ROW_NAMES)
trades = section_rows(statement, TRADES_CONTAINER_NAMES, TRADE_ROW_NAMES)
cash_transactions = section_rows(statement, CASH_TRANSACTION_CONTAINER_NAMES, CASH_TRANSACTION_ROW_NAMES)
account_id = account_information["account_id"].presence || statement_data["account_id"]
raise ParseError, "Invalid IBKR Flex XML: missing account identifier in FlexStatement." if account_id.blank?
currency = account_information["currency"].presence&.upcase || "USD"
report_date = open_positions.filter_map { |row| parse_date(row["report_date"]) }.max ||
equity_summary_in_base.filter_map { |row| parse_date(row["report_date"]) }.max ||
parse_date(statement_data["to_date"]) ||
Date.current
{
ibkr_account_id: account_id,
name: account_id,
currency: currency,
cash_balance: extract_cash_balance(cash_report, currency),
current_balance: extract_total_balance(position_values, cash_report, currency),
report_date: report_date,
statement: statement_data,
cash_report: cash_report,
equity_summary_in_base: equity_summary_in_base,
open_positions: open_positions,
trades: trades,
cash_transactions: cash_transactions,
raw_payload: {
statement: statement_data,
cash_report: cash_report,
equity_summary_in_base: equity_summary_in_base,
open_positions: open_positions,
trades: trades,
cash_transactions: cash_transactions
}
}
end
def section_rows(statement, container_names, row_names)
rows = []
container_names.each do |container_name|
statement.xpath("./#{container_name}").each do |container|
children = container.element_children
if children.any?
rows.concat(children.select { |child| row_names.include?(child.name) })
elsif row_names.include?(container.name)
rows << container
end
end
end
if rows.empty?
row_names.each do |row_name|
rows.concat(statement.xpath("./#{row_name}"))
end
end
rows.map { |row| node_attributes(row) }.reject(&:blank?)
end
def node_attributes(node)
return {} unless node
node.attribute_nodes.each_with_object({}) do |attribute, result|
result[attribute.name.underscore] = attribute.value
end
end
def extract_cash_balance(cash_rows, account_currency)
base_summary = cash_rows.find { |row| row["currency"] == "BASE_SUMMARY" }
account_row = cash_rows.find { |row| row["currency"] == account_currency }
row = base_summary || account_row
parse_decimal(row&.fetch("ending_cash", nil)) || BigDecimal("0")
end
def extract_current_balance(position_values, account_currency)
base_summary = position_values.find { |row| row["currency"] == "BASE_SUMMARY" }
account_row = position_values.find { |row| row["currency"] == account_currency }
row = base_summary || account_row
parse_decimal(row&.fetch("end_of_period_value", nil)) || BigDecimal("0")
end
def extract_total_balance(position_values, cash_rows, account_currency)
extract_current_balance(position_values, account_currency) + extract_cash_balance(cash_rows, account_currency)
end
end