mirror of
https://github.com/we-promise/sure.git
synced 2026-05-25 13:34:58 +00:00
* 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
145 lines
4.2 KiB
Ruby
145 lines
4.2 KiB
Ruby
class Provider::IbkrFlex
|
|
include HTTParty
|
|
extend SslConfigurable
|
|
|
|
class Error < StandardError; end
|
|
class AuthenticationError < Error; end
|
|
class ConfigurationError < Error; end
|
|
class ApiError < Error
|
|
attr_reader :status_code, :response_body, :error_code
|
|
|
|
def initialize(message, status_code: nil, response_body: nil, error_code: nil)
|
|
super(message)
|
|
@status_code = status_code
|
|
@response_body = response_body
|
|
@error_code = error_code
|
|
end
|
|
end
|
|
|
|
base_uri "https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService"
|
|
headers "User-Agent" => "Sure Finance IBKR Flex Client"
|
|
default_options.merge!({ timeout: 120 }.merge(httparty_ssl_options))
|
|
|
|
MAX_RETRIES = 3
|
|
INITIAL_RETRY_DELAY = 2
|
|
MAX_RETRY_DELAY = 30
|
|
POLL_INTERVAL = 3
|
|
MAX_POLL_ATTEMPTS = 20
|
|
PENDING_ERROR_CODES = %w[1004 1019].freeze
|
|
|
|
RETRYABLE_ERRORS = [
|
|
SocketError,
|
|
Net::OpenTimeout,
|
|
Net::ReadTimeout,
|
|
Errno::ECONNRESET,
|
|
Errno::ECONNREFUSED,
|
|
Errno::ETIMEDOUT,
|
|
EOFError
|
|
].freeze
|
|
|
|
attr_reader :query_id, :token
|
|
|
|
def initialize(query_id:, token:)
|
|
raise ConfigurationError, "query_id is required" if query_id.blank?
|
|
raise ConfigurationError, "token is required" if token.blank?
|
|
|
|
@query_id = query_id.to_s.strip
|
|
@token = token.to_s.strip
|
|
end
|
|
|
|
def download_statement
|
|
reference_code = request_reference_code
|
|
poll_statement(reference_code)
|
|
end
|
|
|
|
private
|
|
|
|
def request_reference_code
|
|
response = with_retries("SendRequest") do
|
|
self.class.get("/SendRequest", query: { t: token, q: query_id, v: 3 })
|
|
end
|
|
|
|
xml = parse_xml(response.body)
|
|
error = response_error(xml, response)
|
|
raise error if error
|
|
|
|
reference_code = xml.at_xpath("//ReferenceCode")&.text.to_s.strip
|
|
raise ApiError.new("IBKR Flex did not return a reference code.", status_code: response.code, response_body: response.body) if reference_code.blank?
|
|
|
|
reference_code
|
|
end
|
|
|
|
def poll_statement(reference_code)
|
|
attempts = 0
|
|
|
|
loop do
|
|
attempts += 1
|
|
response = with_retries("GetStatement") do
|
|
self.class.get("/GetStatement", query: { t: token, q: reference_code, v: 3 })
|
|
end
|
|
|
|
xml = parse_xml(response.body)
|
|
return response.body if xml.at_xpath("//FlexQueryResponse")
|
|
|
|
error = response_error(xml, response)
|
|
if error.is_a?(ApiError) && PENDING_ERROR_CODES.include?(error.error_code.to_s)
|
|
raise ApiError.new("IBKR Flex statement is still being generated.", error_code: error.error_code) if attempts >= MAX_POLL_ATTEMPTS
|
|
|
|
sleep(POLL_INTERVAL)
|
|
next
|
|
end
|
|
|
|
raise(error || ApiError.new("IBKR Flex returned an unexpected response.", status_code: response.code, response_body: response.body))
|
|
end
|
|
end
|
|
|
|
def response_error(xml, response)
|
|
error_code = xml.at_xpath("//ErrorCode")&.text.to_s.strip.presence
|
|
error_message = xml.at_xpath("//ErrorMessage")&.text.to_s.strip.presence
|
|
|
|
return nil if error_code.blank? && response.success?
|
|
|
|
message = error_message.presence || "IBKR Flex request failed"
|
|
|
|
case error_code
|
|
when "1012", "1015"
|
|
AuthenticationError.new(message)
|
|
when "1014"
|
|
ConfigurationError.new(message)
|
|
else
|
|
ApiError.new(message, status_code: response.code, response_body: response.body, error_code: error_code)
|
|
end
|
|
end
|
|
|
|
def parse_xml(body)
|
|
Nokogiri::XML(body.to_s)
|
|
end
|
|
|
|
def with_retries(operation_name, max_retries: MAX_RETRIES)
|
|
retries = 0
|
|
|
|
begin
|
|
yield
|
|
rescue *RETRYABLE_ERRORS => e
|
|
retries += 1
|
|
|
|
if retries <= max_retries
|
|
delay = calculate_retry_delay(retries)
|
|
Rails.logger.warn(
|
|
"IBKR Flex: #{operation_name} failed (attempt #{retries}/#{max_retries}): #{e.class}: #{e.message}. Retrying in #{delay}s..."
|
|
)
|
|
sleep(delay)
|
|
retry
|
|
end
|
|
|
|
raise ApiError.new("Network error after #{max_retries} retries: #{e.message}")
|
|
end
|
|
end
|
|
|
|
def calculate_retry_delay(retry_count)
|
|
base_delay = INITIAL_RETRY_DELAY * (2**(retry_count - 1))
|
|
jitter = base_delay * rand * 0.25
|
|
[ base_delay + jitter, MAX_RETRY_DELAY ].min
|
|
end
|
|
end
|