diff --git a/CHANGELOG.md b/CHANGELOG.md index 563b372..0ca4500 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ ### 1.5.1 (Next) * Your contribution here. +* [#108](https://github.com/dblock/iex-ruby-client/pull/108): Added support for quote streaming endpoint - [@bguban](https://github.com/bguban). ### 1.5.0 (2021/08/15) * [#105](https://github.com/dblock/iex-ruby-client/pull/105): Added support for fetching latest foreign exchange rates - [@mathu97](https://github.com/mathu97). diff --git a/README.md b/README.md index cec7520..5ac5abb 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ A Ruby client for the [The IEX Cloud API](https://iexcloud.io/docs/api/). - [Configure](#configure) - [Get a Single Price](#get-a-single-price) - [Get a Quote](#get-a-quote) + - [Stream quotes](#stream-quotes) - [Get a OHLC (Open, High, Low, Close) price](#get-a-ohlc-open-high-low-close-price) - [Get a Market OHLC (Open, High, Low, Close) prices](#get-a-market-ohlc-open-high-low-close-prices) - [Get Historical Prices](#get-historical-prices) @@ -107,6 +108,21 @@ quote.change_percent_s # '+0.42%' See [#quote](https://iexcloud.io/docs/api/#quote) for detailed documentation or [quote.rb](lib/iex/resources/quote.rb) for returned fields. +### Stream quotes + +Streams quotes in real-time. + +`interval` option lets you limit amount of updates. Possible values: `1Second 5Second 1Minute` + +```ruby +client.stream_quote(['SPY', 'MSFT'], interval: '5Second') do |quote| + quote.latest_price # 90.165 + quote.symbol # 'MSFT' +end +``` + +See [#streaming-data](https://iexcloud.io/docs/api/#streaming-data) for detailed documentation or [quote.rb](lib/iex/resources/quote.rb) for returned fields. + ### Get a OHLC (Open, High, Low, Close) price Fetches a single stock OHLC price. Open and Close prices contain timestamp. diff --git a/iex-ruby-client.gemspec b/iex-ruby-client.gemspec index 9814760..1289cf6 100644 --- a/iex-ruby-client.gemspec +++ b/iex-ruby-client.gemspec @@ -19,7 +19,7 @@ Gem::Specification.new do |s| s.add_dependency 'faraday', '>= 0.17' s.add_dependency 'faraday_middleware' s.add_dependency 'hashie' - s.add_dependency 'money_helper' + s.add_dependency 'money_helper', '~>1' s.add_development_dependency 'rake', '~> 10' s.add_development_dependency 'rspec' s.add_development_dependency 'rubocop', '0.72.0' diff --git a/lib/iex/cloud/request.rb b/lib/iex/cloud/request.rb index 4136f00..a000b52 100644 --- a/lib/iex/cloud/request.rb +++ b/lib/iex/cloud/request.rb @@ -1,10 +1,29 @@ 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 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 + + options = { + endpoint: endpoint.gsub('://cloud.', '://cloud-sse.'), + request: { on_data: event_parser } + }.merge(options) + request(:get, path, options) + end + def post(path, options = {}) request(:post, path, options) end @@ -20,8 +39,10 @@ def delete(path, options = {}) private def request(method, path, options) - path = [endpoint, path].join('/') + options = options.dup + 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 +50,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..3ceea19 --- /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=test-iex-api-secret-token + 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\ndata: [{\"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\ndata: [{\"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,\"wee" + 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..7daaaef 100644 --- a/spec/iex/endpoints/quote_spec.rb +++ b/spec/iex/endpoints/quote_spec.rb @@ -2,39 +2,61 @@ 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 end - it 'fails with SymbolNotFoundError' do - expect { subject }.to raise_error IEX::Errors::SymbolNotFoundError, 'Symbol INVALID Not Found' + + let(:quote) { subject.last } + + it 'retrieves a quote', vcr: { cassette_name: 'stream_quote/spy' } do + expect(subject.size).to eq(2) + expect(quote.symbol).to eq('SPY') + expect(quote.close).to eq(433.63) end end end