From 8765a87520a672481bf11431c9baa98bfdd6dbc5 Mon Sep 17 00:00:00 2001 From: Bogdan Guban Date: Tue, 21 Sep 2021 23:45:38 +0300 Subject: [PATCH] Added base functionality --- lib/iex/api/config/client.rb | 2 + lib/iex/cloud/request.rb | 19 ++++++- lib/iex/endpoints/quote.rb | 13 +++++ spec/fixtures/iex/stream_quote/spy.yml | 61 +++++++++++++++++++++ spec/iex/endpoints/quote_spec.rb | 73 ++++++++++++++++---------- 5 files changed, 139 insertions(+), 29 deletions(-) create mode 100644 spec/fixtures/iex/stream_quote/spy.yml diff --git a/lib/iex/api/config/client.rb b/lib/iex/api/config/client.rb index 9c520a4..41bf614 100644 --- a/lib/iex/api/config/client.rb +++ b/lib/iex/api/config/client.rb @@ -6,6 +6,7 @@ module Client ca_file ca_path endpoint + sse_endpoint open_timeout proxy publishable_token @@ -24,6 +25,7 @@ def reset! self.ca_file = defined?(OpenSSL) ? OpenSSL::X509::DEFAULT_CERT_FILE : nil self.ca_path = defined?(OpenSSL) ? OpenSSL::X509::DEFAULT_CERT_DIR : nil self.endpoint = 'https://cloud.iexapis.com/v1' + self.sse_endpoint = 'https://cloud-sse.iexapis.com/v1' self.publishable_token = ENV['IEX_API_PUBLISHABLE_TOKEN'] self.secret_token = ENV['IEX_API_SECRET_TOKEN'] self.user_agent = "IEX Ruby Client/#{IEX::VERSION}" diff --git a/lib/iex/cloud/request.rb b/lib/iex/cloud/request.rb index 4136f00..6c2fe55 100644 --- a/lib/iex/cloud/request.rb +++ b/lib/iex/cloud/request.rb @@ -1,10 +1,25 @@ module IEX module Cloud module Request + STREAM_EVENT_DELIMITER = "\r\n\r\n".freeze + def get(path, options = {}) request(:get, path, options) end + def get_stream(path, options = {}) + buffer = "" + event_parser = Proc.new do |chunk| + events = (buffer + chunk).lines(STREAM_EVENT_DELIMITER) + buffer = events.last.end_with?(STREAM_EVENT_DELIMITER) ? '' : events.delete_at(-1) + events.each do |event| + yield JSON.parse(event.gsub(/\Adata: /, '')) + end + end + + request(:get, path, { endpoint: sse_endpoint, request: { on_data: event_parser } }.merge(options)) + end + def post(path, options = {}) request(:post, path, options) end @@ -20,8 +35,9 @@ def delete(path, options = {}) private def request(method, path, options) - path = [endpoint, path].join('/') + path = [options.delete(:endpoint) || endpoint, path].join('/') response = connection.send(method) do |request| + request.options.merge!(options.delete(:request)) if options.key?(:request) case method when :get, :delete request.url(path, options) @@ -29,7 +45,6 @@ def request(method, path, options) request.path = path request.body = options.to_json unless options.empty? end - request.options.merge!(options.delete(:request)) if options.key?(:request) end response.body end diff --git a/lib/iex/endpoints/quote.rb b/lib/iex/endpoints/quote.rb index 664f3b9..e711b98 100644 --- a/lib/iex/endpoints/quote.rb +++ b/lib/iex/endpoints/quote.rb @@ -6,6 +6,19 @@ def quote(symbol, options = {}) rescue Faraday::ResourceNotFound => e raise IEX::Errors::SymbolNotFoundError.new(symbol, e.response[:body]) end + + # @param symbols - a list of symbols + # @param options[:interval] sets intervals such as 1Second, 5Second, or 1Minute + def stream_quote(symbols, options = {}) + options[:symbols] = Array(symbols).join(',') + interval = options.delete(:interval) + + get_stream("stocksUS#{interval}", { token: secret_token }.merge(options)) do |payload| + payload.each do |quote| + yield IEX::Resources::Quote.new(quote) + end + end + end end end end diff --git a/spec/fixtures/iex/stream_quote/spy.yml b/spec/fixtures/iex/stream_quote/spy.yml new file mode 100644 index 0000000..c750224 --- /dev/null +++ b/spec/fixtures/iex/stream_quote/spy.yml @@ -0,0 +1,61 @@ +--- +http_interactions: +- request: + method: get + uri: https://cloud-sse.iexapis.com/v1/stocksUS5Second?symbols=SPY&token=sk_ae64ad9830e34024b9a653a133988b81 + body: + encoding: US-ASCII + string: '' + headers: + Accept: + - application/json; charset=utf-8 + Content-Type: + - application/json; charset=utf-8 + User-Agent: + - IEX Ruby Client/1.5.1 + Accept-Encoding: + - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 + response: + status: + code: 200 + message: OK + headers: + Server: + - nginx + Date: + - Tue, 21 Sep 2021 20:29:16 GMT + Content-Type: + - application/json; charset=utf-8 + Transfer-Encoding: + - chunked + Connection: + - keep-alive + Set-Cookie: + - ctoken=609af7a37a89493e802c2218673c7783; Max-Age=43200; Path=/; Expires=Wed, + 22 Sep 2021 08:29:16 GMT + Iexcloud-Messages-Used: + - '2' + Iexcloud-Credits-Used: + - '2' + Iexcloud-Premium-Messages-Used: + - '0' + Iexcloud-Premium-Credits-Used: + - '0' + X-Content-Type-Options: + - nosniff + Strict-Transport-Security: + - max-age=15768000 + Access-Control-Allow-Origin: + - "*" + Access-Control-Allow-Credentials: + - 'true' + Access-Control-Allow-Methods: + - GET, OPTIONS + Access-Control-Allow-Headers: + - Origin, X-Requested-With, Content-Type, Accept, Request-Source + body: + encoding: ASCII-8BIT + string: "data: [{\"avgTotalVolume\":68745683,\"calculationPrice\":\"close\",\"change\":-0.41,\"changePercent\":-0.00094,\"close\":433.63,\"closeSource\":\"official\",\"closeTime\":1632254400206,\"companyName\":\"SSgA Active Trust - S&P 500 ETF TRUST ETF\",\"currency\":\"USD\",\"delayedPrice\":433.72,\"delayedPriceTime\":1632254399904,\"extendedChange\":-0.9,\"extendedChangePercent\":-0.00208,\"extendedPrice\":432.73,\"extendedPriceTime\":1632256104619,\"high\":437.91,\"highSource\":\"15 minute delayed price\",\"highTime\":1632254404318,\"iexAskPrice\":432.81,\"iexAskSize\":500,\"iexBidPrice\":432.76,\"iexBidSize\":500,\"iexClose\":433.63,\"iexCloseTime\":1632254398127,\"iexLastUpdated\":1632256104619,\"iexMarketPercent\":0.013629911270082975,\"iexOpen\":432.73,\"iexOpenTime\":1632256104619,\"iexRealtimePrice\":432.73,\"iexRealtimeSize\":1,\"iexVolume\":1244956,\"lastTradeTime\":1632254418262,\"latestPrice\":433.63,\"latestSource\":\"Close\",\"latestTime\":\"September 21, 2021\",\"latestUpdate\":1632254400206,\"latestVolume\":91339993,\"low\":433.07,\"lowSource\":\"15 minute delayed price\",\"lowTime\":1632236056745,\"marketCap\":397369859400,\"oddLotDelayedPrice\":433.67,\"oddLotDelayedPriceTime\":1632254396989,\"open\":436.63,\"openTime\":1632231000366,\"openSource\":\"official\",\"peRatio\":null,\"previousClose\":434.04,\"previousVolume\":166445534,\"primaryExchange\":\"NYSE ARCA\",\"symbol\":\"SPY\",\"volume\":91339993,\"week52High\":452.6,\"week52Low\":315.36,\"ytdChange\":0.1713227149749243,\"isUSMarketOpen\":false}]\r\n\r\n" + http_version: + recorded_at: Tue, 21 Sep 2021 20:29:16 GMT +recorded_with: VCR 5.1.0 diff --git a/spec/iex/endpoints/quote_spec.rb b/spec/iex/endpoints/quote_spec.rb index 6b6ca4d..8490d64 100644 --- a/spec/iex/endpoints/quote_spec.rb +++ b/spec/iex/endpoints/quote_spec.rb @@ -2,39 +2,58 @@ describe IEX::Resources::Quote do include_context 'client' - context 'known symbol', vcr: { cassette_name: 'quote/msft' } do - subject do - client.quote('MSFT') - end - it 'retrieves a quote' do - expect(subject.symbol).to eq 'MSFT' - expect(subject.company_name).to eq 'Microsoft Corp.' - expect(subject.market_cap).to eq 915_754_985_600 - end - it 'coerces numbers' do - expect(subject.latest_price).to eq 119.36 - expect(subject.change).to eq(-0.61) - expect(subject.week_52_high).to eq 120.82 - expect(subject.week_52_low).to eq 87.73 - expect(subject.change_percent).to eq(-0.00508) - expect(subject.change_percent_s).to eq '-0.51%' - expect(subject.extended_change_percent).to eq(-0.00008) - expect(subject.extended_change_percent_s).to eq '-0.01%' + + describe '#quote' do + context 'known symbol', vcr: { cassette_name: 'quote/msft' } do + subject do + client.quote('MSFT') + end + it 'retrieves a quote' do + expect(subject.symbol).to eq 'MSFT' + expect(subject.company_name).to eq 'Microsoft Corp.' + expect(subject.market_cap).to eq 915_754_985_600 + end + it 'coerces numbers' do + expect(subject.latest_price).to eq 119.36 + expect(subject.change).to eq(-0.61) + expect(subject.week_52_high).to eq 120.82 + expect(subject.week_52_low).to eq 87.73 + expect(subject.change_percent).to eq(-0.00508) + expect(subject.change_percent_s).to eq '-0.51%' + expect(subject.extended_change_percent).to eq(-0.00008) + expect(subject.extended_change_percent_s).to eq '-0.01%' + end + it 'coerces times' do + expect(subject.latest_update).to eq 1_554_408_000_193 + expect(subject.latest_update_t).to eq Time.at(1_554_408_000) + expect(subject.iex_last_updated).to eq 1_554_407_999_529 + expect(subject.iex_last_updated_t).to eq Time.at(1_554_407_999) + end end - it 'coerces times' do - expect(subject.latest_update).to eq 1_554_408_000_193 - expect(subject.latest_update_t).to eq Time.at(1_554_408_000) - expect(subject.iex_last_updated).to eq 1_554_407_999_529 - expect(subject.iex_last_updated_t).to eq Time.at(1_554_407_999) + + context 'invalid symbol', vcr: { cassette_name: 'quote/invalid' } do + subject do + client.quote('INVALID') + end + it 'fails with SymbolNotFoundError' do + expect { subject }.to raise_error IEX::Errors::SymbolNotFoundError, 'Symbol INVALID Not Found' + end end end - context 'invalid symbol', vcr: { cassette_name: 'quote/invalid' } do + describe '#stream_quote' do subject do - client.quote('INVALID') + quotes = [] + client.stream_quote('SPY', interval: '5Second') do |quote| + quotes << quote + end + + quotes.first end - it 'fails with SymbolNotFoundError' do - expect { subject }.to raise_error IEX::Errors::SymbolNotFoundError, 'Symbol INVALID Not Found' + + it 'retrieves a quote', vcr: { cassette_name: 'stream_quote/spy' } do + expect(subject.symbol).to eq('SPY') + expect(subject.close).to eq(433.63) end end end