bybit-connector-ruby 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,294 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'openssl'
5
+
6
+ module Bybit
7
+ module WebSocket
8
+ # Bybit V5 WebSocket client — public streams (spot / linear / inverse /
9
+ # option) and the private user-data stream.
10
+ #
11
+ # Design goals mirror the REST session:
12
+ # - one class, `channel:` selects endpoint (avoids Public/Private class
13
+ # duplication for ping / subscribe / dispatch logic)
14
+ # - explicit callbacks (`on_message`, `on_open`, `on_close`, `on_error`)
15
+ # passed as procs; no global registry / singleton
16
+ # - HMAC-SHA256 auth for `:private` is identical to REST signing except
17
+ # the payload is `"GET/realtime" + expires` — the docs are explicit
18
+ # - ping every `ping_interval` seconds via a lightweight background
19
+ # thread; server terminates connections after 20s silence
20
+ # - automatic resubscribe on reconnect (topic list is retained across
21
+ # `#connect` calls) so callers don't lose subscriptions on transient
22
+ # network blips
23
+ #
24
+ # This class depends on `websocket-client-simple` (a runtime dep of the
25
+ # gem). If missing, requiring it raises a clear LoadError below.
26
+ class Client
27
+ begin
28
+ require 'websocket-client-simple'
29
+ rescue LoadError => e
30
+ raise LoadError,
31
+ "Bybit::WebSocket::Client requires the 'websocket-client-simple' " \
32
+ "gem. Add `gem 'websocket-client-simple'` to your Gemfile. " \
33
+ "(#{e.message})"
34
+ end
35
+
36
+ # Channel → WS path (public streams are category-scoped; private has one).
37
+ PUBLIC_CHANNELS = %i[spot linear inverse option].freeze
38
+ PRIVATE_CHANNEL = :private
39
+ TRADE_CHANNEL = :trade
40
+
41
+ PATHS = {
42
+ spot: '/v5/public/spot',
43
+ linear: '/v5/public/linear',
44
+ inverse: '/v5/public/inverse',
45
+ option: '/v5/public/option',
46
+ private: '/v5/private',
47
+ trade: '/v5/trade'
48
+ }.freeze
49
+
50
+ HOST_MAINNET = 'stream.bybit.com'
51
+ HOST_TESTNET = 'stream-testnet.bybit.com'
52
+
53
+ DEFAULT_PING_INTERVAL = 20
54
+ # Bybit rejects auth expiry timestamps in the past AND >30s in the future.
55
+ DEFAULT_AUTH_EXPIRY_MS = 10_000
56
+
57
+ attr_reader :channel, :testnet, :url, :subscriptions
58
+
59
+ # @param channel [Symbol] one of `:spot`, `:linear`, `:inverse`,
60
+ # `:option`, `:private`, `:trade`.
61
+ # @param testnet [Boolean] hit stream-testnet.bybit.com instead of mainnet
62
+ # @param api_key [String] required for `:private` / `:trade`
63
+ # @param api_secret [String] required for `:private` / `:trade`
64
+ # @param ping_interval [Integer] seconds between ping frames (default 20)
65
+ # @param url [String] override the entire WS URL (skips host/path build)
66
+ # @param on_open, on_message, on_close, on_error [Proc]
67
+ def initialize(channel:, testnet: false,
68
+ api_key: nil, api_secret: nil,
69
+ ping_interval: DEFAULT_PING_INTERVAL,
70
+ url: nil,
71
+ on_open: nil, on_message: nil, on_close: nil, on_error: nil)
72
+ validate_channel!(channel)
73
+ @channel = channel
74
+ @testnet = testnet
75
+ @api_key = api_key
76
+ @api_secret = api_secret
77
+ @ping_interval = ping_interval
78
+ @url = url || build_url
79
+ @on_open = on_open
80
+ @on_message = on_message
81
+ @on_close = on_close
82
+ @on_error = on_error
83
+ @subscriptions = []
84
+ @ws = nil
85
+ @ping_thread = nil
86
+ @connected = false
87
+ @auth_ok = false
88
+ @closing = false
89
+ end
90
+
91
+ # Open the WebSocket, authenticate (if private/trade), and start pinging.
92
+ # Calling `connect` a second time closes the prior socket first, then
93
+ # opens a fresh one and replays any topics buffered in `@subscriptions`
94
+ # (call `disconnect` explicitly if you want to drop them). No automatic
95
+ # reconnect on network drop — the caller decides when to re-open.
96
+ def connect
97
+ require_auth! if requires_auth?
98
+ # Idempotent: tear down any prior socket / ping thread so a second
99
+ # `connect` call doesn't leak the previous ws or leave two pingers.
100
+ disconnect if @ws
101
+ @closing = false
102
+ client = self
103
+ @ws = ::WebSocket::Client::Simple.connect(@url)
104
+
105
+ @ws.on(:open) { client.send(:handle_open) }
106
+ @ws.on(:message) { |msg| client.send(:handle_message, msg) }
107
+ @ws.on(:close) { |ev| client.send(:handle_close, ev) }
108
+ @ws.on(:error) { |err| client.send(:handle_error, err) }
109
+ self
110
+ end
111
+
112
+ # Subscribe to one or more topics. Bybit expects an array under `args`:
113
+ # {"op":"subscribe","args":["orderbook.1.BTCUSDT","tickers.BTCUSDT"]}
114
+ # Callers can call `subscribe` before `connect`; topics are buffered and
115
+ # sent once the socket opens (private streams also wait for auth OK).
116
+ def subscribe(*topics)
117
+ topics = topics.flatten.compact.uniq
118
+ return if topics.empty?
119
+
120
+ @subscriptions |= topics
121
+ flush_subscriptions(topics) if ready?
122
+ self
123
+ end
124
+
125
+ def unsubscribe(*topics)
126
+ topics = topics.flatten.compact.uniq
127
+ return if topics.empty?
128
+
129
+ @subscriptions -= topics
130
+ send_frame(op: 'unsubscribe', args: topics) if ready?
131
+ self
132
+ end
133
+
134
+ # Send a WS message directly (escape hatch for placing orders over the
135
+ # `/v5/trade` stream; the docs specify per-op payloads).
136
+ def send_raw(payload)
137
+ return unless @ws
138
+
139
+ @ws.send(payload.is_a?(String) ? payload : JSON.generate(payload))
140
+ end
141
+
142
+ def disconnect
143
+ @closing = true
144
+ stop_pinger
145
+ @connected = false
146
+ @auth_ok = false
147
+ begin
148
+ @ws&.close
149
+ rescue StandardError
150
+ # Socket may already be half-closed by the peer or in a bad state —
151
+ # we only care about detaching, not about clean protocol shutdown.
152
+ end
153
+ @ws = nil
154
+ end
155
+
156
+ def connected?
157
+ @connected
158
+ end
159
+
160
+ def ready?
161
+ return false unless @connected
162
+
163
+ requires_auth? ? @auth_ok : true
164
+ end
165
+
166
+ private
167
+
168
+ def validate_channel!(channel)
169
+ allowed = PUBLIC_CHANNELS + [PRIVATE_CHANNEL, TRADE_CHANNEL]
170
+ return if allowed.include?(channel)
171
+
172
+ raise Bybit::ConfigurationError,
173
+ "invalid channel: #{channel.inspect} (must be one of #{allowed.inspect})"
174
+ end
175
+
176
+ def requires_auth?
177
+ [PRIVATE_CHANNEL, TRADE_CHANNEL].include?(channel)
178
+ end
179
+
180
+ def require_auth!
181
+ return unless @api_key.nil? || @api_secret.nil?
182
+
183
+ raise Bybit::ConfigurationError,
184
+ ":#{channel} WebSocket requires api_key + api_secret"
185
+ end
186
+
187
+ def build_url
188
+ host = @testnet ? HOST_TESTNET : HOST_MAINNET
189
+ path = PATHS.fetch(channel)
190
+ "wss://#{host}#{path}"
191
+ end
192
+
193
+ def handle_open
194
+ @connected = true
195
+ if requires_auth?
196
+ send_auth_frame
197
+ else
198
+ @auth_ok = true
199
+ flush_subscriptions(@subscriptions)
200
+ end
201
+ start_pinger
202
+ @on_open&.call
203
+ end
204
+
205
+ def handle_message(raw)
206
+ data = raw.respond_to?(:data) ? raw.data : raw.to_s
207
+ parsed = safe_parse(data)
208
+ if parsed.is_a?(Hash) && parsed['op'] == 'auth'
209
+ # V5 WS auth reply: {"op":"auth","success":true|false,"ret_msg":...}.
210
+ # `success` is authoritative — do NOT treat missing retCode as OK,
211
+ # else a rejection with only ret_msg would silently pass.
212
+ @auth_ok = parsed['success'] == true
213
+ flush_subscriptions(@subscriptions) if @auth_ok
214
+ end
215
+ @on_message&.call(parsed.nil? ? data : parsed)
216
+ end
217
+
218
+ def handle_close(event)
219
+ stop_pinger
220
+ @connected = false
221
+ @auth_ok = false
222
+ @on_close&.call(event)
223
+ end
224
+
225
+ def handle_error(err)
226
+ # Suppress the noisy IOError the reader thread emits when we close
227
+ # the socket deliberately (see `disconnect`). Real transport errors
228
+ # still fire — @closing only gates the shutdown window.
229
+ return if @closing
230
+
231
+ @on_error&.call(err)
232
+ end
233
+
234
+ # Ping every `ping_interval` seconds; Bybit closes idle sockets around
235
+ # 20s of silence, so keep this ≤ 20s.
236
+ def start_pinger
237
+ stop_pinger
238
+ ws = @ws
239
+ interval = @ping_interval
240
+ @ping_thread = Thread.new do
241
+ Thread.current.name = "bybit-ws-ping-#{channel}" if Thread.current.respond_to?(:name=)
242
+ loop do
243
+ sleep interval
244
+ break if ws.nil? || ws != @ws
245
+
246
+ begin
247
+ ws.send(JSON.generate(op: 'ping'))
248
+ rescue StandardError
249
+ break
250
+ end
251
+ end
252
+ end
253
+ end
254
+
255
+ def stop_pinger
256
+ return unless @ping_thread
257
+
258
+ begin
259
+ @ping_thread.kill
260
+ rescue StandardError
261
+ nil
262
+ end
263
+ @ping_thread = nil
264
+ end
265
+
266
+ def flush_subscriptions(topics)
267
+ return if topics.nil? || topics.empty?
268
+
269
+ send_frame(op: 'subscribe', args: topics)
270
+ end
271
+
272
+ def send_frame(payload)
273
+ return unless @ws
274
+
275
+ @ws.send(JSON.generate(payload))
276
+ end
277
+
278
+ # HMAC-SHA256 over "GET/realtime" + expires — the private-stream auth
279
+ # scheme documented at
280
+ # https://bybit-exchange.github.io/docs/v5/ws/connect#authentication
281
+ def send_auth_frame
282
+ expires = (Time.now.to_f * 1000).to_i + DEFAULT_AUTH_EXPIRY_MS
283
+ signature = OpenSSL::HMAC.hexdigest('SHA256', @api_secret, "GET/realtime#{expires}")
284
+ send_frame(op: 'auth', args: [@api_key, expires, signature])
285
+ end
286
+
287
+ def safe_parse(str)
288
+ JSON.parse(str)
289
+ rescue JSON::ParserError
290
+ nil
291
+ end
292
+ end
293
+ end
294
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Optional entry point. `require 'bybit'` loads the REST core only; the
4
+ # WebSocket client lives behind a separate `require 'bybit/websocket'` so
5
+ # REST-only consumers don't take the `websocket-client-simple` dependency
6
+ # at load time.
7
+
8
+ require 'bybit'
9
+ require 'bybit/websocket/client'
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: bybit-connector-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Bybit
8
- autorequire:
8
+ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-20 00:00:00.000000000 Z
11
+ date: 2026-11-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: faraday
@@ -24,6 +24,34 @@ dependencies:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
26
  version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: websocket-client-simple
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.6'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.6'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rake
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '13.0'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '13.0'
27
55
  - !ruby/object:Gem::Dependency
28
56
  name: rspec
29
57
  requirement: !ruby/object:Gem::Requirement
@@ -66,6 +94,20 @@ dependencies:
66
94
  - - "~>"
67
95
  - !ruby/object:Gem::Version
68
96
  version: '0.22'
97
+ - !ruby/object:Gem::Dependency
98
+ name: vcr
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: '6.2'
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: '6.2'
69
111
  - !ruby/object:Gem::Dependency
70
112
  name: webmock
71
113
  requirement: !ruby/object:Gem::Requirement
@@ -96,7 +138,7 @@ dependencies:
96
138
  version: '0.9'
97
139
  description: Ruby connector for the Bybit V5 REST API — keyword-arg method signatures,
98
140
  HMAC-SHA256 signing, typed error hierarchy, Faraday-based transport.
99
- email:
141
+ email:
100
142
  executables: []
101
143
  extensions: []
102
144
  extra_rdoc_files: []
@@ -112,22 +154,35 @@ files:
112
154
  - lib/bybit/error.rb
113
155
  - lib/bybit/rest_api/account_service.rb
114
156
  - lib/bybit/rest_api/affiliate_service.rb
157
+ - lib/bybit/rest_api/alpha_service.rb
115
158
  - lib/bybit/rest_api/asset_service.rb
116
159
  - lib/bybit/rest_api/base_service.rb
117
160
  - lib/bybit/rest_api/bot_service.rb
118
161
  - lib/bybit/rest_api/broker_service.rb
119
162
  - lib/bybit/rest_api/crypto_loan_service.rb
120
163
  - lib/bybit/rest_api/earn_service.rb
164
+ - lib/bybit/rest_api/event_service.rb
165
+ - lib/bybit/rest_api/fiat_service.rb
166
+ - lib/bybit/rest_api/institutional_loan_service.rb
121
167
  - lib/bybit/rest_api/market_service.rb
168
+ - lib/bybit/rest_api/misc_service.rb
122
169
  - lib/bybit/rest_api/p2p_service.rb
123
170
  - lib/bybit/rest_api/position_service.rb
171
+ - lib/bybit/rest_api/pre_upgrade_service.rb
172
+ - lib/bybit/rest_api/referral_service.rb
124
173
  - lib/bybit/rest_api/rfq_service.rb
125
174
  - lib/bybit/rest_api/spot_margin_service.rb
175
+ - lib/bybit/rest_api/spot_x_service.rb
176
+ - lib/bybit/rest_api/spread_trade_service.rb
177
+ - lib/bybit/rest_api/strategy_service.rb
178
+ - lib/bybit/rest_api/tax_service.rb
126
179
  - lib/bybit/rest_api/trade_service.rb
127
180
  - lib/bybit/rest_api/user_service.rb
128
181
  - lib/bybit/session.rb
129
182
  - lib/bybit/utils/wire_keys.rb
130
183
  - lib/bybit/version.rb
184
+ - lib/bybit/websocket.rb
185
+ - lib/bybit/websocket/client.rb
131
186
  homepage: https://github.com/bybit-exchange/bybit.ruby.api
132
187
  licenses:
133
188
  - MIT
@@ -136,7 +191,7 @@ metadata:
136
191
  documentation_uri: https://bybit-exchange.github.io/docs/v5/intro
137
192
  changelog_uri: https://github.com/bybit-exchange/bybit.ruby.api/blob/main/CHANGELOG.md
138
193
  rubygems_mfa_required: 'true'
139
- post_install_message:
194
+ post_install_message:
140
195
  rdoc_options: []
141
196
  require_paths:
142
197
  - lib
@@ -151,8 +206,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
151
206
  - !ruby/object:Gem::Version
152
207
  version: '0'
153
208
  requirements: []
154
- rubygems_version: 3.0.3.1
155
- signing_key:
209
+ rubygems_version: 3.5.22
210
+ signing_key:
156
211
  specification_version: 4
157
212
  summary: Official Bybit V5 REST API connector for Ruby
158
213
  test_files: []