diff --git a/README.md b/README.md index e850cda..4c4cb4f 100644 --- a/README.md +++ b/README.md @@ -117,14 +117,15 @@ EM.run do end ``` -**User Data:** +**User Data (Spot WebSocket API):** + +Spot listenKey REST endpoints (`POST/PUT/DELETE /api/v3/userDataStream`) were removed by Binance on 2026-02-20. Subscribe via the WebSocket API instead: ```ruby EM.run do - websocket = Binance::WebSocket.new + websocket = Binance::WebSocketApi.new - listen_key = Binance::Api::UserDataStream.start! - websocket.user_data_stream!(listen_key) do |listen_key, data| + websocket.user_data_stream! do |subscription_id, data| case data[:e].to_sym when :outboundAccountPosition when :balanceUpdate @@ -158,11 +159,10 @@ You can find more info on all `kline_candlestick` attributes & available interva - [`deposit_history`](https://binance-docs.github.io/apidocs/spot/en/#fiat-deposit-history-user_data): Get fiat deposit history. - [`withdraw_history`](https://binance-docs.github.io/apidocs/spot/en/#fiat-withdraw-history-user_data): Get fiat withdrawal history. -### Binance::Api::DataStream class methods +### Binance::Api::UserDataStream class methods + +Spot listenKey methods (`start!`, `keepalive!`, `stop!`) were removed by Binance and now raise. Use `Binance::WebSocketApi#user_data_stream!` for Spot user data. -- [`start!`](https://github.com/binance-exchange/binance-official-api-docs/blob/master/rest-api.md#start-user-data-stream-user_stream): Start a new user data stream. -- [`keepalive!`](https://github.com/binance-exchange/binance-official-api-docs/blob/master/rest-api.md#keepalive-user-data-stream-user_stream): Keepalive a user data stream. -- [`stop!`](https://github.com/binance-exchange/binance-official-api-docs/blob/master/rest-api.md#close-user-data-stream-user_stream): Close out a user data stream. - [`margin_start!`](https://binance-docs.github.io/apidocs/spot/en/#start-user-data-stream-user_stream): Start a new margin user data stream. - [`margin_keepalive!`](https://binance-docs.github.io/apidocs/spot/en/#keepalive-user-data-stream-user_stream): Keepalive a margin user data stream. @@ -182,10 +182,14 @@ You can find more info on all `kline_candlestick` attributes & available interva - [`candlesticks!`](https://github.com/binance/binance-spot-api-docs/blob/master/web-socket-streams.md#klinecandlestick-streams): Kline/candlestick bars for a symbol. - [`trades!`](https://github.com/binance/binance-spot-api-docs/blob/master/web-socket-streams.md#trade-streams): The Trade Streams push raw trade information. -- [`user_data_stream!`](https://github.com/binance/binance-spot-api-docs/blob/master/user-data-stream.md#web-socket-payloads): Account updates, balances changes, and order updates. - [`partial_book_depth!`](https://github.com/binance/binance-spot-api-docs/blob/master/web-socket-streams.md#partial-book-depth-streams): Top levels bids and asks, pushed every second. - [`book_depth!`](https://github.com/binance/binance-spot-api-docs/blob/master/web-socket-streams.md#partial-book-depth-streams): Order book price and quantity depth updates used to locally manage an order book. +### Binance::WebSocketApi instance methods + +- [`user_data_stream!`](https://developers.binance.com/docs/binance-spot-api-docs/websocket-api/user-data-stream-requests): Account updates, balance changes, and order updates via `userDataStream.subscribe.signature`. +- `user_data_unsubscribe!`: Stop a User Data Stream subscription. + See the [rubydoc](http://www.rubydoc.info/gems/binance-ruby/0.1.2/Binance) for information about parameters for each method listed above. For more information, please refer to the [official Rest API documentation](https://github.com/binance-exchange/binance-official-api-docs) written by the Binance team. diff --git a/lib/binance-ruby.rb b/lib/binance-ruby.rb index 7cd6a1a..392c007 100644 --- a/lib/binance-ruby.rb +++ b/lib/binance-ruby.rb @@ -18,3 +18,4 @@ require "binance/api/user_data_stream" require "binance/api/version" require "binance/websocket" +require "binance/websocket_api" diff --git a/lib/binance/api/configuration.rb b/lib/binance/api/configuration.rb index a83360d..ae155c6 100644 --- a/lib/binance/api/configuration.rb +++ b/lib/binance/api/configuration.rb @@ -30,6 +30,14 @@ def signed_request_signature(payload:, api_secret_key: nil) OpenSSL::HMAC.hexdigest(digest, api_secret_key || secret_key, payload) end + # WebSocket API SIGNED params: sort keys alphabetically, then HMAC-SHA256. + # See https://developers.binance.com/docs/binance-spot-api-docs/websocket-api/request-security + def signed_ws_api_params(params:, api_secret_key: nil) + unsigned = params.reject { |key, _| key.to_s == "signature" } + payload = unsigned.sort_by { |key, _| key.to_s }.map { |key, value| "#{key}=#{value}" }.join("&") + unsigned.merge(signature: signed_request_signature(payload: payload, api_secret_key: api_secret_key)) + end + def timestamp Time.now.utc.strftime("%s%3N") end diff --git a/lib/binance/api/user_data_stream.rb b/lib/binance/api/user_data_stream.rb index 9051bb5..d566ee8 100644 --- a/lib/binance/api/user_data_stream.rb +++ b/lib/binance/api/user_data_stream.rb @@ -1,18 +1,24 @@ module Binance module Api class UserDataStream + SPOT_LISTEN_KEY_REMOVED = <<~MSG.freeze + Spot listenKey User Data Stream endpoints were removed by Binance on 2026-02-20. + Use Binance::WebSocketApi#user_data_stream! instead + (userDataStream.subscribe.signature on the WebSocket API). + See https://developers.binance.com/docs/binance-spot-api-docs/websocket-api/user-data-stream-requests + MSG + class << self - # It's recommended to send a ping about every 30 minutes. def keepalive!(listen_key: nil, api_key: nil, api_secret_key: nil) - raise Error.new(message: "listen_key is required") if listen_key.nil? - Request.send!(api_key_type: :none, method: :put, path: "/api/v1/userDataStream", - params: { listenKey: listen_key }, security_type: :user_stream, - api_key: api_key, api_secret_key: api_secret_key) + raise Error.new(message: SPOT_LISTEN_KEY_REMOVED) end def start!(api_key: nil, api_secret_key: nil) - Request.send!(api_key_type: :none, method: :post, path: "/api/v1/userDataStream", - security_type: :user_stream, api_key: api_key, api_secret_key: api_secret_key)[:listenKey] + raise Error.new(message: SPOT_LISTEN_KEY_REMOVED) + end + + def stop!(listen_key: nil, api_key: nil, api_secret_key: nil) + raise Error.new(message: SPOT_LISTEN_KEY_REMOVED) end def margin_start!(api_key: nil, api_secret_key: nil) @@ -26,13 +32,6 @@ def margin_keepalive!(listen_key: nil, api_key: nil, api_secret_key: nil) params: { listenKey: listen_key }, security_type: :user_stream, api_key: api_key, api_secret_key: api_secret_key) end - - def stop!(listen_key: nil, api_key: nil, api_secret_key: nil) - raise Error.new(message: "listen_key is required") if listen_key.nil? - Request.send!(api_key_type: :none, method: :delete, path: "/api/v1/userDataStream", - params: { listenKey: listen_key }, security_type: :user_stream, - api_key: api_key, api_secret_key: api_secret_key) - end end end end diff --git a/lib/binance/api/version.rb b/lib/binance/api/version.rb index 1c95445..f92f3b9 100644 --- a/lib/binance/api/version.rb +++ b/lib/binance/api/version.rb @@ -1,5 +1,5 @@ module Binance module Api - VERSION = "1.5.1" + VERSION = "1.6.0" end end diff --git a/lib/binance/websocket.rb b/lib/binance/websocket.rb index 2a100ed..fadc3e1 100644 --- a/lib/binance/websocket.rb +++ b/lib/binance/websocket.rb @@ -8,7 +8,6 @@ def initialize(on_open: nil, on_close: nil) super wss_uri, nil, ping: 180 @request_id_inc = 0 - @user_stream_handlers = {} on :open do |event| on_open&.call(event) @@ -55,9 +54,14 @@ def candlesticks!(symbols, interval, &on_receive) subscribe(symbols_fmt.map { |s| "#{s.downcase}@kline_#{interval}" }) end - def user_data_stream!(listen_key, &on_receive) - @user_stream_handlers[listen_key] = on_receive - subscribe([listen_key]) + # Spot listenKey streams were removed by Binance on 2026-02-20. + # Use Binance::WebSocketApi#user_data_stream! instead. + def user_data_stream!(*) + raise Error.new( + "Spot listenKey user data streams are no longer available. " \ + "Use Binance::WebSocketApi#user_data_stream! " \ + "(userDataStream.subscribe.signature on the WebSocket API)." + ) end # stream name: @trade @@ -189,11 +193,6 @@ def process_data(data) @candlesticks_handler&.call(json[:stream], json[:data]) when :depthUpdate @book_depth_handler&.call(json[:stream], json[:data]) - when :outboundAccountPosition - when :balanceUpdate - when :executionReport # order update - listen_key = json[:stream] - @user_stream_handlers[listen_key]&.call(listen_key, json[:data]) when :trade @trades_handler&.call(json[:stream], json[:data]) end diff --git a/lib/binance/websocket_api.rb b/lib/binance/websocket_api.rb new file mode 100644 index 0000000..433a0f0 --- /dev/null +++ b/lib/binance/websocket_api.rb @@ -0,0 +1,134 @@ +require "securerandom" + +module Binance + # Spot WebSocket API client (wss://ws-api.binance.com). + # Use this for User Data Stream subscriptions after the listenKey REST endpoints were removed. + class WebSocketApi < Faye::WebSocket::Client + class Error < StandardError; end + + USER_DATA_EVENTS = %i[ + outboundAccountPosition + balanceUpdate + executionReport + listStatus + listenKeyExpired + eventStreamTerminated + externalLockUpdate + ].freeze + + def initialize(on_open: nil, on_close: nil, api_key: nil, api_secret_key: nil) + wss_uri = if ENV["BINANCE_TEST_NET_ENABLE"] + "wss://ws-api.testnet.binance.vision/ws-api/v3" + else + "wss://ws-api.binance.com:443/ws-api/v3" + end + + super wss_uri, nil, ping: 180 + + @api_key = api_key + @api_secret_key = api_secret_key + @user_data_handler = nil + @subscribe_on_open = false + @subscribe_options = {} + @subscription_id = nil + + on :open do |event| + send_user_data_subscribe! if @subscribe_on_open + on_open&.call(event) + end + + on :message do |event| + process_data(event.data) + end + + on :close do |event| + on_close&.call(event) + end + end + + # Subscribe to Spot user data events via userDataStream.subscribe.signature. + # Works with HMAC, RSA, and Ed25519 API keys (HMAC signing used by this gem). + # + # Yields |subscription_id, event_data| for each user data event. + def user_data_stream!(api_key: nil, api_secret_key: nil, recv_window: nil, &on_receive) + raise ArgumentError, "block required" unless on_receive + + @user_data_handler = on_receive + @subscribe_options = { + api_key: api_key, + api_secret_key: api_secret_key, + recv_window: recv_window, + } + + if ready_state == Faye::WebSocket::API::OPEN + send_user_data_subscribe! + else + @subscribe_on_open = true + end + end + + def user_data_unsubscribe!(subscription_id: nil) + params = {} + params[:subscriptionId] = subscription_id unless subscription_id.nil? + request("userDataStream.unsubscribe", params) + end + + private + + def send_user_data_subscribe! + @subscribe_on_open = false + options = @subscribe_options || {} + api_key = options[:api_key] || @api_key || Api::Configuration.api_key + api_secret_key = options[:api_secret_key] || @api_secret_key + + raise Error.new("API key is required for user data stream") if api_key.nil? || api_key.empty? + + params = { + apiKey: api_key, + timestamp: Api::Configuration.timestamp.to_i, + } + params[:recvWindow] = options[:recv_window] unless options[:recv_window].nil? + + signed = Api::Configuration.signed_ws_api_params(params: params, api_secret_key: api_secret_key) + request("userDataStream.subscribe.signature", signed) + end + + def request(method, params = {}) + payload = { + id: SecureRandom.uuid, + method: method, + } + payload[:params] = params unless params.nil? || params.empty? + send(payload.to_json) + end + + def process_data(data) + json = JSON.parse(data, symbolize_names: true) + + if json.key?(:event) + dispatch_user_event(json[:subscriptionId], json[:event]) + elsif json.key?(:status) + process_response(json) + end + end + + def process_response(json) + if json[:status] != 200 + error = json[:error] || {} + raise Error.new("(#{error[:code]}) #{error[:msg]}") + end + + subscription_id = json.dig(:result, :subscriptionId) + @subscription_id = subscription_id unless subscription_id.nil? + end + + def dispatch_user_event(subscription_id, event) + return unless event.is_a?(Hash) + + event_type = event[:e]&.to_sym + return unless event_type.nil? || USER_DATA_EVENTS.include?(event_type) + + @user_data_handler&.call(subscription_id, event) + end + end +end diff --git a/spec/binance/api/configuration_spec.rb b/spec/binance/api/configuration_spec.rb index 2223f7e..38ff9c6 100644 --- a/spec/binance/api/configuration_spec.rb +++ b/spec/binance/api/configuration_spec.rb @@ -39,4 +39,21 @@ subject { Binance::Api::Configuration.secret_key } it { is_expected.to eq('456') } end + + describe '.signed_ws_api_params' do + before { Binance::Api::Configuration.secret_key = 'NhqPtmdSJYdKjVHjA7PZj4Mge3R5YNiP1e3UZjInClVN65XAbvqqM6A7H5fATj0j' } + + it 'returns params with an HMAC signature over alphabetically sorted keys' do + params = { + timestamp: 1645423376532, + apiKey: 'vmPUZE6mv9SD5VNHk4HlWFsOr6aKE2zvsw0MuIgwCIPy6utIco14y7Ju91duEh8A', + } + + signed = Binance::Api::Configuration.signed_ws_api_params(params: params) + payload = "apiKey=#{params[:apiKey]}×tamp=#{params[:timestamp]}" + expect(signed[:signature]).to eq( + Binance::Api::Configuration.signed_request_signature(payload: payload) + ) + end + end end \ No newline at end of file diff --git a/spec/binance/api/data_stream_spec.rb b/spec/binance/api/data_stream_spec.rb index 369294d..432e5e7 100644 --- a/spec/binance/api/data_stream_spec.rb +++ b/spec/binance/api/data_stream_spec.rb @@ -1,95 +1,47 @@ require "spec_helper" RSpec.describe Binance::Api::UserDataStream do - let(:params) { {} } - let(:request_body) { params.map { |key, value| "#{key}=#{value}" }.join("&") } - describe "#keepalive!" do - let(:params) { { listenKey: listen_key } } - - subject { Binance::Api::UserDataStream.keepalive!(listen_key: listen_key) } - - context "when listen_key is nil" do - let(:listen_key) { nil } - - it { is_expected_block.to raise_error Binance::Api::Error } - end + subject { Binance::Api::UserDataStream.keepalive!(listen_key: "abc") } - context "when listen_key exists" do - let(:listen_key) { "pqia91ma19a5s61cv6a81va65sdf19v8a65a1a5s61cv6a81va65sdf19v8a65a1" } + it { is_expected_block.to raise_error(Binance::Api::Error, /listenKey User Data Stream endpoints were removed/) } + end - context "but api responds with error" do - let!(:request_stub) do - stub_request(:put, "https://api.binance.com/api/v1/userDataStream") - .with(query: request_body) - .to_return(status: 400, body: { msg: "error", code: "400" }.to_json) - end + describe "#start!" do + subject { Binance::Api::UserDataStream.start! } - it { is_expected_block.to raise_error Binance::Api::Error } + it { is_expected_block.to raise_error(Binance::Api::Error, /listenKey User Data Stream endpoints were removed/) } + end - it "should send api request" do - subject rescue Binance::Api::Error - expect(request_stub).to have_been_requested - end - end + describe "#stop!" do + subject { Binance::Api::UserDataStream.stop!(listen_key: "abc") } - context "and api succeeds" do - let!(:request_stub) do - stub_request(:put, "https://api.binance.com/api/v1/userDataStream") - .with(query: request_body) - .to_return(status: 200, body: "{}") - end - - it "should send api request" do - subject rescue Binance::Api::Error - expect(request_stub).to have_been_requested - end - end - end + it { is_expected_block.to raise_error(Binance::Api::Error, /listenKey User Data Stream endpoints were removed/) } end - describe "#start!" do + describe "#margin_start!" do let(:listen_key) { "pqia91ma19a5s61cv6a81va65sdf19v8a65a1a5s61cv6a81va65sdf19v8a65a1" } - subject { Binance::Api::UserDataStream.start! } + subject { Binance::Api::UserDataStream.margin_start! } - context "when listen_key exists" do - context "but api responds with error" do - let!(:request_stub) do - stub_request(:post, "https://api.binance.com/api/v1/userDataStream") - .to_return(status: 400, body: { msg: "error", code: "400" }.to_json) - end - - it { is_expected_block.to raise_error Binance::Api::Error } - - it "should send api request" do - subject rescue Binance::Api::Error - expect(request_stub).to have_been_requested - end + context "and api succeeds" do + let!(:request_stub) do + stub_request(:post, "https://api.binance.com/sapi/v1/userDataStream") + .to_return(status: 200, body: { listenKey: listen_key }.to_json) end - context "and api succeeds" do - let!(:request_stub) do - stub_request(:post, "https://api.binance.com/api/v1/userDataStream") - .to_return(status: 200, body: { listenKey: listen_key }.to_json) - end - - it "responds with listen_key" do - expect(subject).to eq(listen_key) - end - - it "should send api request" do - subject rescue Binance::Api::Error - expect(request_stub).to have_been_requested - end + it "responds with listen_key" do + expect(subject).to eq(listen_key) end end end - describe "#stop!" do + describe "#margin_keepalive!" do + let(:listen_key) { "pqia91ma19a5s61cv6a81va65sdf19v8a65a1a5s61cv6a81va65sdf19v8a65a1" } let(:params) { { listenKey: listen_key } } + let(:request_body) { params.map { |key, value| "#{key}=#{value}" }.join("&") } - subject { Binance::Api::UserDataStream.stop!(listen_key: listen_key) } + subject { Binance::Api::UserDataStream.margin_keepalive!(listen_key: listen_key) } context "when listen_key is nil" do let(:listen_key) { nil } @@ -98,34 +50,15 @@ end context "when listen_key exists" do - let(:listen_key) { "pqia91ma19a5s61cv6a81va65sdf19v8a65a1a5s61cv6a81va65sdf19v8a65a1" } - - context "but api responds with error" do - let!(:request_stub) do - stub_request(:delete, "https://api.binance.com/api/v1/userDataStream") - .with(query: request_body) - .to_return(status: 400, body: { msg: "error", code: "400" }.to_json) - end - - it { is_expected_block.to raise_error Binance::Api::Error } - - it "should send api request" do - subject rescue Binance::Api::Error - expect(request_stub).to have_been_requested - end + let!(:request_stub) do + stub_request(:put, "https://api.binance.com/sapi/v1/userDataStream") + .with(query: request_body) + .to_return(status: 200, body: "{}") end - context "and api succeeds" do - let!(:request_stub) do - stub_request(:delete, "https://api.binance.com/api/v1/userDataStream") - .with(query: request_body) - .to_return(status: 200, body: "{}") - end - - it "should send api request" do - subject rescue Binance::Api::Error - expect(request_stub).to have_been_requested - end + it "should send api request" do + subject + expect(request_stub).to have_been_requested end end end diff --git a/spec/binance/websocket_api_spec.rb b/spec/binance/websocket_api_spec.rb new file mode 100644 index 0000000..895e900 --- /dev/null +++ b/spec/binance/websocket_api_spec.rb @@ -0,0 +1,123 @@ +require "spec_helper" + +RSpec.describe Binance::WebSocketApi do + let(:api_key) { "vmPUZE6mv9SD5VNHk4HlWFsOr6aKE2zvsw0MuIgwCIPy6utIco14y7Ju91duEh8A" } + let(:secret_key) { "NhqPtmdSJYdKjVHjA7PZj4Mge3R5YNiP1e3UZjInClVN65XAbvqqM6A7H5fATj0j" } + let(:handlers) { {} } + let(:sent_payloads) { [] } + let(:websocket) { Binance::WebSocketApi.new } + + before do + stub_request(:any, "wss://ws-api.binance.com:443/ws-api/v3").to_return(status: 200, body: "") + Binance::Api::Configuration.api_key = api_key + Binance::Api::Configuration.secret_key = secret_key + + allow_any_instance_of(Binance::WebSocketApi).to receive(:on) do |_ws, kind, &block| + handlers[kind] = block + end + allow_any_instance_of(Binance::WebSocketApi).to receive(:ready_state).and_return(Faye::WebSocket::API::OPEN) + allow_any_instance_of(Binance::WebSocketApi).to receive(:send) do |_ws, payload| + sent_payloads << JSON.parse(payload, symbolize_names: true) + end + end + + after do + Binance::Api::Configuration.api_key = nil + Binance::Api::Configuration.secret_key = nil + end + + describe "#user_data_stream!" do + it "sends userDataStream.subscribe.signature with a valid HMAC signature" do + websocket.user_data_stream! { |_id, _data| } + + expect(sent_payloads.length).to eq(1) + request = sent_payloads.first + expect(request[:method]).to eq("userDataStream.subscribe.signature") + expect(request[:params][:apiKey]).to eq(api_key) + expect(request[:params][:timestamp]).to eq(Binance::Api::Configuration.timestamp.to_i) + expect(request[:params][:signature]).to be_a(String) + expect(request[:params][:signature].length).to eq(64) + end + + it "signs params in alphabetical order" do + websocket.user_data_stream! { |_id, _data| } + + params = sent_payloads.first[:params] + expected_payload = "apiKey=#{api_key}×tamp=#{params[:timestamp]}" + expected_signature = Binance::Api::Configuration.signed_request_signature(payload: expected_payload) + expect(params[:signature]).to eq(expected_signature) + end + + context "when the subscribe response is an error" do + it "raises WebSocketApi::Error" do + websocket.user_data_stream! { |_id, _data| } + + expect { + handlers[:message].call( + OpenStruct.new( + data: { + id: "abc", + status: 400, + error: { code: -2015, msg: "Invalid API-key" }, + }.to_json + ) + ) + }.to raise_error(Binance::WebSocketApi::Error, "(-2015) Invalid API-key") + end + end + + context "when a user data event arrives" do + let(:execution_report) do + JSON.parse(File.read("spec/fixtures/executionReport.json"), symbolize_names: true) + end + + it "invokes the handler with subscription id and event" do + received = nil + websocket.user_data_stream! { |subscription_id, data| received = [subscription_id, data] } + + handlers[:message].call( + OpenStruct.new( + data: { + subscriptionId: 0, + event: execution_report, + }.to_json + ) + ) + + expect(received).to eq([0, execution_report]) + end + end + + context "when an outboundAccountPosition event arrives" do + it "invokes the handler" do + received = nil + websocket.user_data_stream! { |_id, data| received = data } + + handlers[:message].call( + OpenStruct.new( + data: { + subscriptionId: 0, + event: { + e: "outboundAccountPosition", + E: 1, + u: 1, + B: [], + }, + }.to_json + ) + ) + + expect(received[:e]).to eq("outboundAccountPosition") + end + end + end + + describe "#user_data_unsubscribe!" do + it "sends userDataStream.unsubscribe" do + websocket.user_data_unsubscribe!(subscription_id: 0) + + expect(sent_payloads.first[:method]).to eq("userDataStream.unsubscribe") + expect(sent_payloads.first[:params][:subscriptionId]).to eq(0) + end + end +end diff --git a/spec/binance/websocket_spec.rb b/spec/binance/websocket_spec.rb index 541dda0..a782879 100644 --- a/spec/binance/websocket_spec.rb +++ b/spec/binance/websocket_spec.rb @@ -86,30 +86,9 @@ end describe '#user_data_stream!' do - let(:stream_name) { 'somerandom' } + subject { websocket.user_data_stream!('somerandom') } - context 'error' do - let(:json_string) { '{ "error": {"code": 0, "msg": "Unknown property","id": 123} }' } - - subject { websocket.user_data_stream!(stream_name) } - - it { is_expected_block.to raise_error Binance::WebSocket::Error } - end - - context 'executionReport' do - let(:json_string) do - { - stream: stream_name, - data: JSON.parse(File.read('spec/fixtures/executionReport.json'), symbolize_names: true) - }.to_json - end - - it 'calls on_receive' do - inc = 0 - websocket.user_data_stream!(stream_name) { inc = 1 } - expect(inc).to eq 1 - end - end + it { is_expected_block.to raise_error(Binance::WebSocket::Error, /WebSocketApi#user_data_stream!/) } end describe '#partial_book_depth!' do