# frozen_string_literal: true require 'addressable' require 'base64' require 'json' require 'openssl' require 'rest-client' module Cryptum # This plugin is used to interact withbtje Coinbase REST API module API # Supported Method Parameters:: # Cryptum::API.generate_signature( # ) public_class_method def self.generate_signature(opts = {}) api_secret = opts[:api_secret] http_method = if opts[:http_method].nil? :GET else opts[:http_method].to_s.upcase.scrub.strip.chomp.to_sym end api_call = opts[:api_call].to_s.scrub.strip.chomp api_call = '/users/self/verify' if opts[:api_call].nil? if opts[:params].nil? path = api_call else uri = Addressable::URI.new uri.query_values = opts[:params] params = uri.query path = "#{api_call}?#{params}" end http_body = opts[:http_body].to_s.scrub.strip.chomp api_timestamp = Time.now.utc.to_i.to_s api_signature = Base64.strict_encode64( OpenSSL::HMAC.digest( 'sha256', Base64.strict_decode64(api_secret), "#{api_timestamp}#{http_method}#{path}#{http_body}" ) ) if http_body == '' api_signature = Base64.strict_encode64( OpenSSL::HMAC.digest( 'sha256', Base64.strict_decode64(api_secret), "#{api_timestamp}#{http_method}#{path}" ) ) end api_signature_response = {} api_signature_response[:api_timestamp] = api_timestamp api_signature_response[:api_signature] = api_signature api_signature_response rescue RestClient::ExceptionWithResponse => e File.open('/tmp/cryptum-errors.txt', 'a') do |f| f.puts Time.now.strftime('%Y-%m-%d %H:%M:%S.%N %z') f.puts "Module: #{self}" f.puts "URL: #{api_endpoint}#{api_call}" f.puts "PARAMS: #{params.inspect}" f.puts "HTTP POST BODY: #{http_body.inspect}" if http_body != '' f.puts "#{e}\n#{e.response}\n\n\n" end rescue StandardError => e raise e end private_class_method def self.rest_api_call(opts = {}) env = opts[:env] option_choice = opts[:option_choice] order_type = opts[:order_type] event_notes = opts[:event_notes] api_endpoint = opts[:api_endpoint] base_increment = opts[:base_increment].to_f api_key = env[:api_key] api_secret = env[:api_secret] api_passphrase = env[:api_passphrase] api_endpoint = 'https://api.exchange.coinbase.com' api_endpoint = 'https://api-public.sandbox.exchange.coinbase.com' if env[:env] == :sandbox api_endpoint = opts[:api_endpoint] if opts[:api_endpoint] http_method = if opts[:http_method].nil? :GET else opts[:http_method].to_s.upcase.scrub.strip.chomp.to_sym end api_call = opts[:api_call].to_s.scrub params = opts[:params] http_body = opts[:http_body].to_s.scrub.strip.chomp max_conn_attempts = 30 conn_attempt = 0 begin conn_attempt += 1 if option_choice.proxy rest_client = RestClient rest_client.proxy = option_choice.proxy rest_client_request = rest_client::Request else rest_client_request = RestClient::Request end api_signature_response = generate_signature( api_secret: api_secret, http_method: http_method, api_call: api_call, params: params, http_body: http_body ) api_signature = api_signature_response[:api_signature] api_timestamp = api_signature_response[:api_timestamp] case http_method when :GET headers = { content_type: 'application/json; charset=UTF-8', CB_ACCESS_TIMESTAMP: api_timestamp, CB_ACCESS_PASSPHRASE: api_passphrase, CB_ACCESS_KEY: api_key, CB_ACCESS_SIGN: api_signature } headers[:params] = params if params headers[:ORDER_TYPE] = order_type if order_type headers[:EVENT_NOTES] = event_notes if event_notes response = rest_client_request.execute( method: :GET, url: "#{api_endpoint}#{api_call}", headers: headers, verify_ssl: false ) when :DELETE headers = { content_type: 'application/json; charset=UTF-8', CB_ACCESS_TIMESTAMP: api_timestamp, CB_ACCESS_PASSPHRASE: api_passphrase, CB_ACCESS_KEY: api_key, CB_ACCESS_SIGN: api_signature } headers[:params] = params if params headers[:ORDER_TYPE] = order_type if order_type headers[:EVENT_NOTES] = event_notes if event_notes response = rest_client_request.execute( method: :DELETE, url: "#{api_endpoint}#{api_call}", headers: headers, verify_ssl: false ) when :POST headers = { content_type: 'application/json; charset=UTF-8', CB_ACCESS_TIMESTAMP: api_timestamp, CB_ACCESS_PASSPHRASE: api_passphrase, CB_ACCESS_KEY: api_key, CB_ACCESS_SIGN: api_signature } headers[:params] = params if params headers[:ORDER_TYPE] = order_type if order_type headers[:EVENT_NOTES] = event_notes if event_notes response = rest_client_request.execute( method: :POST, url: "#{api_endpoint}#{api_call}", headers: headers, payload: http_body, verify_ssl: false ) else raise @@logger.error("Unsupported HTTP Method #{http_method} for #{self} Plugin") end JSON.parse(response, symbolize_names: true) rescue RestClient::Unauthorized => e File.open('/tmp/cryptum-errors.txt', 'a') do |f| f.puts Time.now.strftime('%Y-%m-%d %H:%M:%S.%N %z') f.puts "#{self}\n#{e}\n\n\n" end raise e if conn_attempt > max_conn_attempts sleep 60 retry end rescue RestClient::ExceptionWithResponse => e File.open('/tmp/cryptum-errors.txt', 'a') do |f| f.puts Time.now.strftime('%Y-%m-%d %H:%M:%S.%N %z') f.puts "Module: #{self}" f.puts "URL: #{api_endpoint}#{api_call}" f.puts "PARAMS: #{params.inspect}" f.puts "HTTP POST BODY: #{http_body.inspect}" if http_body != '' f.puts "#{e}\n#{e.response}\n\n\n" end insufficient_funds = '{"message":"Insufficient funds"}' size -= base_increment if e.response == insufficient_funds sleep 0.3 retry rescue RestClient::TooManyRequests => e File.open('/tmp/cryptum-errors.txt', 'a') do |f| f.puts Time.now.strftime('%Y-%m-%d %H:%M:%S.%N %z') f.puts "Module: #{self}" f.puts "URL: #{api_endpoint}#{api_call}" f.puts "PARAMS: #{params.inspect}" f.puts "HTTP POST BODY: #{http_body.inspect}" if http_body != '' f.puts "#{e}\n#{e.response}\n\n\n" end sleep 1 retry end public_class_method def self.submit_limit_order(opts = {}) option_choice = opts[:option_choice] env = opts[:env] price = opts[:price] size = opts[:size] buy_or_sell = opts[:buy_or_sell] event_history = opts[:event_history] bot_conf = opts[:bot_conf] buy_order_id = opts[:buy_order_id] tpm = bot_conf[:target_profit_margin_percent].to_f tpm_cast_as_decimal = tpm / 100 product_id = option_choice.symbol.to_s.gsub('_', '-').upcase this_product = event_history.order_book[:this_product] base_increment = this_product[:base_increment] quote_increment = this_product[:quote_increment] # crypto_smallest_decimal = base_increment.to_s.split('.')[-1].length fiat_smallest_decimal = quote_increment.to_s.split('.')[-1].length order_hash = {} order_hash[:type] = 'limit' order_hash[:time_in_force] = 'GTC' if buy_or_sell == :buy order_hash[:time_in_force] = 'GTT' order_hash[:cancel_after] = 'min' end order_hash[:size] = size order_hash[:price] = price order_hash[:side] = buy_or_sell order_hash[:product_id] = product_id http_body = order_hash.to_json limit_order_resp = rest_api_call( option_choice: option_choice, env: env, http_method: :POST, api_call: '/orders', http_body: http_body, base_increment: base_increment ) # Populate Order ID on the Buy # to know what to do on the Sell case buy_or_sell when :buy this_order = event_history.order_book[:order_plan].shift this_order[:buy_order_id] = limit_order_resp[:id] this_order[:price] = price targ_price = price.to_f + (price.to_f * tpm_cast_as_decimal) this_order[:tpm] = format( '%0.2f', tpm ) this_order[:target_price] = format( "%0.#{fiat_smallest_decimal}f", targ_price ) this_order[:size] = size this_order[:color] = :cyan event_history.order_book[:order_history_meta].push(this_order) when :sell sell_order_id = limit_order_resp[:id] event_history.order_book[:order_history_meta].each do |meta| if meta[:buy_order_id] == buy_order_id meta[:sell_order_id] = sell_order_id meta[:color] = :yellow end end end event_history rescue StandardError => e raise e end public_class_method def self.gtfo(opts = {}) option_choice = opts[:option_choice] env = opts[:env] event_history = opts[:event_history] # Cancel all open orders cancel_all_open_orders( env: env, option_choice: option_choice ) product_id = option_choice.symbol.to_s.gsub('_', '-').upcase this_product = event_history.order_book[:this_product] base_increment = this_product[:base_increment] quote_increment = this_product[:quote_increment] crypto_smallest_decimal = base_increment.to_s.split('.')[-1].length fiat_smallest_decimal = quote_increment.to_s.split('.')[-1].length # TODO: Calculate / Price / Size last_three_prices_arr = [] last_ticker_price = event_history.order_book[:ticker_price].to_f second_to_last_ticker_price = event_history.order_book[:ticker_price_second_to_last].to_f third_to_last_ticker_price = event_history.order_book[:ticker_price_third_to_last].to_f last_three_prices_arr.push(last_ticker_price) last_three_prices_arr.push(second_to_last_ticker_price) last_three_prices_arr.push(third_to_last_ticker_price) limit_price = last_three_prices_arr.sort[1] price = format( "%0.#{fiat_smallest_decimal}f", limit_price ) crypto_currency = option_choice.symbol.to_s.upcase.split('_').first.to_sym portfolio = event_history.order_book[:portfolio] this_account = portfolio.select do |account| account[:currency] == crypto_currency.to_s end balance = format( "%0.#{crypto_smallest_decimal}f", this_account.first[:balance] ) current_crypto_fiat_value = format( '%0.2f', balance.to_f * price.to_f ) order_hash = {} order_hash[:type] = 'limit' order_hash[:time_in_force] = 'GTT' order_hash[:cancel_after] = 'min' order_hash[:size] = balance order_hash[:price] = price order_hash[:side] = :sell order_hash[:product_id] = product_id http_body = order_hash.to_json limit_order_resp = rest_api_call( option_choice: option_choice, env: env, http_method: :POST, api_call: '/orders', http_body: http_body, base_increment: base_increment ) # Populate Order ID on the Buy # to know what to do on the Sell this_order = {} this_order[:plan_no] = '0.0' this_order[:fiat_available] = '0.00' this_order[:risk_alloc] = current_crypto_fiat_value this_order[:allocation_decimal] = '1.0' this_order[:allocation_percent] = '100.0' this_order[:invest] = current_crypto_fiat_value this_order[:return] = current_crypto_fiat_value this_order[:profit] = '0.0' this_order[:buy_order_id] = 'N/A' this_order[:price] = price this_order[:tpm] = '0.00' this_order[:target_price] = current_crypto_fiat_value this_order[:size] = balance this_order[:color] = :magenta this_order[:sell_order_id] = limit_order_resp[:id] event_history.order_book[:order_history_meta].push(this_order) event_history rescue StandardError => e raise e end # public_class_method def self.cancel_open_order(opts = {}) # env = opts[:env] # option_choice = opts[:option_choice] # order_id = opts[:order_id] # order_type = opts[:order_type] # product_id = option_choice.symbol.to_s.gsub('_', '-').upcase # order_hash = {} # order_hash[:product_id] = product_id # params = order_hash # rest_api_call( # env: env, # http_method: :DELETE, # api_call: "/orders/#{order_id}", # option_choice: option_choice, # params: params, # order_type: order_type # ) # rescue StandardError => e # raise e # end public_class_method def self.cancel_all_open_orders(opts = {}) env = opts[:env] option_choice = opts[:option_choice] event_notes = opts[:event_notes] product_id = option_choice.symbol.to_s.gsub('_', '-').upcase order_hash = {} order_hash[:product_id] = product_id # http_body = order_hash.to_json params = order_hash canceled_order_id_arr = [] loop do canceled_order_id_arr = rest_api_call( env: env, http_method: :DELETE, api_call: '/orders', option_choice: option_choice, params: params, event_notes: event_notes ) break if canceled_order_id_arr.empty? end canceled_order_id_arr rescue StandardError => e raise e end public_class_method def self.get_products(opts = {}) option_choice = opts[:option_choice] env = opts[:env] products = rest_api_call( option_choice: option_choice, env: env, http_method: :GET, api_call: '/products' ) if products.length.positive? supported_products_filter = products.select do |product| product[:id].match?(/USD$/) && product[:status] == 'online' && product[:fx_stablecoin] == false end sorted_products = supported_products_filter.sort_by { |hash| hash[:id] } end sorted_products rescue StandardError => e raise e end private_class_method def self.get_exchange_rates(opts = {}) option_choice = opts[:option_choice] env = opts[:env] api_endpoint = 'https://api.coinbase.com/v2' exchange_rates_api_call = '/exchange-rates' # We don't always get fees back from Coinbase... # This is a hack to ensure we do. exchange = {} exchange = rest_api_call( option_choice: option_choice, env: env, http_method: :GET, api_endpoint: api_endpoint, api_call: exchange_rates_api_call ) exchange[:data][:rates] rescue StandardError => e raise e end public_class_method def self.get_portfolio(opts = {}) option_choice = opts[:option_choice] env = opts[:env] crypto = opts[:crypto] fiat = opts[:fiat] fiat_portfolio_file = opts[:fiat_portfolio_file] event_notes = opts[:event_notes] # Retrieve Exchange Rates exchange_rates = get_exchange_rates( option_choice: option_choice, env: env ) portfolio_complete_arr = [] portfolio_complete_arr = rest_api_call( option_choice: option_choice, env: env, http_method: :GET, api_call: '/accounts', event_notes: event_notes ) all_products = portfolio_complete_arr.select do |products| products if products[:balance].to_f.positive? end total_holdings = 0.00 all_products.each do |product| currency = product[:currency].to_sym this_exchange_rate = exchange_rates[currency].to_f total_holdings += product[:balance].to_f / this_exchange_rate end crypto_portfolio = portfolio_complete_arr.select do |product| product if product[:currency] == crypto end fiat_portfolio = portfolio_complete_arr.select do |product| product if product[:currency] == fiat end fiat_portfolio.last[:total_holdings] = format( '%0.8f', total_holdings ) File.write( fiat_portfolio_file, JSON.pretty_generate(fiat_portfolio) ) crypto_portfolio rescue StandardError => e raise e end public_class_method def self.get_fees(opts = {}) option_choice = opts[:option_choice] env = opts[:env] fees_api_call = '/fees' # We don't always get fees back from Coinbase... # This is a hack to ensure we do. fees = {} fees = rest_api_call( option_choice: option_choice, env: env, http_method: :GET, api_call: fees_api_call ) fees rescue StandardError => e raise e end # public_class_method def self.get_profiles(opts = {}) # option_choice = opts[:option_choice] # env = opts[:env] # profiles_api_call = '/profiles' # # We don't always get fees back from Coinbase... # # This is a hack to ensure we do. # profiles = {} # # loop do # profiles = rest_api_call( # option_choice: option_choice, # env: env, # http_method: :GET, # api_call: profiles_api_call # ) # # break unless fees.empty? # # sleep 0.3 # # end # profiles # rescue StandardError => e # raise e # end public_class_method def self.get_order_history(opts = {}) option_choice = opts[:option_choice] env = opts[:env] product_id = option_choice.symbol.to_s.gsub('_', '-').upcase orders_api_call = '/orders' params = {} params[:product_id] = product_id params[:status] = 'all' # We don't always get order_history back from Coinbase... # This is a hack to ensure we do. order_history = [] order_history = rest_api_call( option_choice: option_choice, env: env, http_method: :GET, api_call: orders_api_call, params: params ) # Cast UTC Timestamps as local times order_history.each do |order| order[:created_at] = Time.parse( order[:created_at] ).localtime.to_s next unless order[:done_at] order[:done_at] = Time.parse( order[:done_at] ).localtime.to_s end order_history rescue StandardError => e raise e end # Display Usage for this Module public_class_method def self.help puts "USAGE: signature = #{self}.generate_signature( api_secret: 'required - Coinbase Pro API Secret', http_method: 'optional - Defaults to :GET', api_call: 'optional - Defaults to /users/self/verify', params: 'optional - HTTP GET Parameters', http_body: 'optional HTTP POST Body' ) profiles = #{self}.get_profiles( env: 'required - Coinbase::Option.get_env Object' ) products = #{self}.get_products( env: 'required - Coinbase::Option.get_env Object' ) portfolio = #{self}.get_portfolio( env: 'required - Coinbase::Option.get_env Object' ) order_history = #{self}.get_order_history( env: 'required - Coinbase::Option.get_env Object' ) " end end end