bybit-connector-ruby 0.1.0 → 0.1.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2e337e2d3927cdb53b707d0bfd441323cb293a43e2c3ded843d273c47f439af5
4
- data.tar.gz: c4dfa85d62da8da24fc1cccdc5101631404182a3910de24f65f899ed972771dd
3
+ metadata.gz: 4ad3a1a82830f6e29a98bfb09ec65e58c83e8300a9a1c2066ba7547757d1e5a3
4
+ data.tar.gz: 1a10e04e6f81895ec0870384674e9d9722cf1096a80272787b021822febe90a0
5
5
  SHA512:
6
- metadata.gz: 9bad03bebb0cc8d633b4f014d42da79b592ade8d8f90b77a4a9ed239a0d46ae094aa8ba236a01616abb5b0b7596a85627cccad53ebce950334c544eef3ff0570
7
- data.tar.gz: 310c666e1c0b387b84d91ab4fff0f78f401224eb09f74724bd64e021c4bf5fc1ff4f3458e383ed7a040bba69528602bedbb48f2da8b3d54cf1af30a142fa3f42
6
+ metadata.gz: 16be1085483d3e5aa52b98749a525c8d384eef26edac8cbdccfd1ef4e2053f2a744bc31ba8e8364be77d9812828de2776962ecba0c76db0b579c21249f5f2eb2
7
+ data.tar.gz: 07042d066d149a8f4e6b0760c2feac68d20b13aa7af59222ed1eed228b1bd35d9b4d84aa89933ecd03637369ddb7149600e3ceffb257b75d85cff19d448b8274
data/CHANGELOG.md CHANGED
@@ -5,7 +5,8 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## 0.1.0 — 2026-07-20
8
+
9
+ ## [0.1.1] - 2026-07-22
9
10
 
10
11
  ### Added
11
12
  - `AccountService#batch_set_collateral(...)`
@@ -34,9 +35,48 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
34
35
  - `AccountService#upgrade_to_uta_pro(...)`
35
36
  - `AffiliateService#get_sub_list(...)`
36
37
  - `AffiliateService#get_user_list(...)`
38
+ - `AssetService#account_coin_balance_query(...)`
39
+ - `AssetService#asset_info_query(...)`
40
+ - `AssetService#cancel_withdraw(...)`
41
+ - `AssetService#coin_convert_limit_query(...)`
42
+ - `AssetService#coin_list_query(...)`
43
+ - `AssetService#convert_execute(...)`
44
+ - `AssetService#convert_history_query(...)`
45
+ - `AssetService#get_asset_overview(...)`
37
46
  - `AssetService#get_coin_balance(...)`
38
47
  - `AssetService#get_coin_greeks(...)`
48
+ - `AssetService#get_coin_info(...)`
49
+ - `AssetService#get_convert_result(...)`
50
+ - `AssetService#get_delivery_record(...)`
51
+ - `AssetService#get_deposit_address(...)`
52
+ - `AssetService#get_portfolio_margin(...)`
53
+ - `AssetService#get_settlement_record(...)`
54
+ - `AssetService#get_sub_member_deposit_address(...)`
55
+ - `AssetService#get_total_members_assets(...)`
56
+ - `AssetService#get_vasp_list(...)`
57
+ - `AssetService#get_withdrawable_amount_by_coin(...)`
58
+ - `AssetService#inter_transfer(...)`
59
+ - `AssetService#inter_transfer_list_query(...)`
60
+ - `AssetService#list_convert_orders(...)`
61
+ - `AssetService#list_convert_orders_by_page(...)`
62
+ - `AssetService#list_deposit_records(...)`
39
63
  - `AssetService#list_funding_history(...)`
64
+ - `AssetService#list_internal_deposit_records(...)`
65
+ - `AssetService#list_small_balance_coins(...)`
66
+ - `AssetService#list_small_balance_history(...)`
67
+ - `AssetService#list_sub_member_deposit_records(...)`
68
+ - `AssetService#list_withdraw_addresses(...)`
69
+ - `AssetService#list_withdraw_records(...)`
70
+ - `AssetService#quote_apply(...)`
71
+ - `AssetService#send_withdraw(...)`
72
+ - `AssetService#set_default_deposit_to_account(...)`
73
+ - `AssetService#small_asset_convert(...)`
74
+ - `AssetService#small_asset_quote(...)`
75
+ - `AssetService#sub_member_list_query(...)`
76
+ - `AssetService#transfer_coin_list_query(...)`
77
+ - `AssetService#transfer_sub_member_save(...)`
78
+ - `AssetService#universal_transfer(...)`
79
+ - `AssetService#universal_transfer_list_query(...)`
40
80
  - `BrokerService#distribute_award(...)`
41
81
  - `BrokerService#get_award_info(...)`
42
82
  - `BrokerService#get_distribution_record(...)`
@@ -257,3 +297,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
257
297
  - `UserService#sign_agreement(...)`
258
298
  - `UserService#update_api_key(...)`
259
299
  - `UserService#update_sub_api_key(...)`
300
+ - `InstitutionalLoanService#get_product_info(...)`
301
+ - `InstitutionalLoanService#get_margin_coin_info(...)`
302
+ - `InstitutionalLoanService#get_loan_orders(...)`
303
+ - `InstitutionalLoanService#get_repayment_orders(...)`
304
+ - `InstitutionalLoanService#get_ltv(...)`
305
+ - `InstitutionalLoanService#bind_or_unbind_uid(...)`
306
+ - `InstitutionalLoanService#repay_loan(...)`
307
+ - `MiscService#get_announcements(...)`
308
+ - `MiscService#get_system_status(...)`
309
+ - `PreUpgradeService#get_order_history(...)`
310
+ - `PreUpgradeService#get_trade_history(...)`
311
+ - `PreUpgradeService#get_closed_pnl(...)`
312
+ - `PreUpgradeService#get_transaction_log(...)`
313
+ - `PreUpgradeService#get_option_delivery_record(...)`
314
+ - `PreUpgradeService#get_usdc_session_settlement(...)`
315
+ - `Bybit::WebSocket::Client` — public (spot/linear/inverse/option), private,
316
+ and trade streams; HMAC-SHA256 auth, ping/pong, deferred subscribe (topics
317
+ buffered until socket opens; private streams wait for auth OK), idempotent
318
+ `#connect` (second call replays buffered subscriptions), testnet host
319
+ support. Load via `require 'bybit/websocket'` (opt-in dep on
320
+ `websocket-client-simple`).
321
+ - VCR-driven end-to-end specs asserting the exact wire bytes and X-BAPI-SIGN
322
+ header for signed GET (query) and signed POST (body) routing.
323
+ - SimpleCov floor coverage gate for `lib/bybit/rest_api/*_service.rb` — new
324
+ services without matching specs now drop CI.
325
+ - README: WebSocket usage section.
326
+
327
+ ### Fixed
328
+ - `Session#parse_response` now maps HTTP 401/403 → `Bybit::AuthError` and 429
329
+ → `Bybit::RateLimitError` even when the response body is non-JSON
330
+ (CDN/WAF path), matching the contract in README.md#Error-Handling.
331
+ - `BrokerService#list_broker_earnings` — caller-facing kwargs renamed from
332
+ `:begin_` / `:end_` to `:start_date` / `:end_date`; internal rewrite now
333
+ goes through `WireKeys::RESERVED_ALIASES` instead of hand-rolled shim.
data/README.md CHANGED
@@ -2,7 +2,23 @@
2
2
 
3
3
  Official lightweight Ruby connector for the [Bybit V5 REST API](https://bybit-exchange.github.io/docs/v5/intro).
4
4
 
5
- `bybit-connector-ruby` wraps the Bybit V5 HTTP endpoints as a set of typed Ruby methods with keyword-arg signatures. Its goal is the same as [`pybit`](https://github.com/bybit-exchange/pybit) on the Python side: an easy-to-use, high-performance connector with a small dependency footprint.
5
+ `bybit-connector-ruby` wraps the Bybit V5 HTTP endpoints as a set of typed Ruby methods with keyword-arg signatures.
6
+ ## Prerequisites
7
+
8
+ Before you write any code you need a Bybit API key. Two accounts to know
9
+ about:
10
+
11
+ - **Testnet** — [testnet.bybit.com](https://testnet.bybit.com) → sign up →
12
+ API Management → *Create New Key*. This is where you should point every
13
+ new integration until you're confident about behavior. Testnet balances
14
+ are virtual; nothing you do here touches real funds.
15
+ - **Mainnet** — [bybit.com](https://www.bybit.com) → API Management. Real
16
+ money. Enable only the permissions you actually need (spot / derivatives /
17
+ withdrawals) and prefer IP-restricted keys.
18
+
19
+ Testnet and mainnet keys are **separate** — an API key issued on one won't
20
+ work against the other. The SDK selects the environment via
21
+ `testnet: true|false` (or an explicit `base_url:` override).
6
22
 
7
23
  ## Installation
8
24
 
@@ -23,30 +39,43 @@ gem 'bybit-connector-ruby'
23
39
  ```ruby
24
40
  require 'bybit'
25
41
 
42
+ # All calls below run against testnet — swap testnet: false for mainnet
43
+ # once you're happy with the behavior.
26
44
  Bybit.configure do |c|
27
- c.api_key = ENV['BYBIT_KEY']
28
- c.api_secret = ENV['BYBIT_SECRET']
29
- c.testnet = true # omit / false for mainnet
45
+ c.api_key = ENV['BYBIT_TESTNET_KEY']
46
+ c.api_secret = ENV['BYBIT_TESTNET_SECRET']
47
+ c.testnet = true
30
48
  end
31
49
 
32
50
  client = Bybit::Client.new
33
51
 
34
- # Public endpoint — no auth needed
52
+ # 1. Public endpoint — no auth needed.
35
53
  puts client.market.get_server_time
36
54
 
37
- # Signed endpoint — apiKey + apiSecret required
55
+ # 2. Signed endpoint — api_key + api_secret required.
38
56
  wallet = client.account.get_wallet_balance(account_type: 'UNIFIED')
39
57
  puts wallet['result']['list']
40
58
 
41
- # Place an order
59
+ # 3. Place a LIMIT order well below market so it sits on the book and does
60
+ # NOT fill (safe to run repeatedly). Adjust `price:` if BTC ever trades
61
+ # at $10k again — otherwise this stays a resting order you can cancel.
42
62
  order = client.trade.create_order(
43
63
  category: 'linear', symbol: 'BTCUSDT',
44
64
  side: 'Buy', order_type: 'Limit', qty: '0.01',
45
- price: '30000', time_in_force: 'GTC',
65
+ price: '10000', time_in_force: 'GTC'
46
66
  )
47
- puts "orderId: #{order['result']['orderId']}"
67
+ order_id = order['result']['orderId']
68
+ puts "orderId: #{order_id}"
69
+
70
+ # 4. Cancel it before moving on.
71
+ client.trade.cancel_order(category: 'linear', symbol: 'BTCUSDT', order_id: order_id)
48
72
  ```
49
73
 
74
+ > ⚠️ **Before switching `testnet: false`**: verify the price / qty in
75
+ > `create_order` won't cross the top of the book — a Limit Buy at $10k on
76
+ > mainnet becomes a market fill instantly (if BTC ever drops that low),
77
+ > and a Limit Sell at $1M does the reverse.
78
+
50
79
  See `examples/quickstart.rb` for a runnable script.
51
80
 
52
81
  ## Configuration
@@ -58,12 +87,17 @@ Bybit.configure do |c|
58
87
  c.api_key = ENV['BYBIT_KEY']
59
88
  c.api_secret = ENV['BYBIT_SECRET']
60
89
  c.testnet = false # default false
61
- c.recv_window = '5000' # ms, X-BAPI-RECV-WINDOW header
90
+ c.recv_window = '5000' # milliseconds Bybit rejects requests
91
+ # whose signed timestamp is older than
92
+ # this window. Bump to 10000+ if your
93
+ # clock drifts or the network is noisy.
62
94
  c.timeout = 10 # Faraday timeout, seconds
63
95
  end
64
96
  ```
65
97
 
66
- Or pass overrides to `Client.new`:
98
+ Or pass overrides to `Client.new` — **per-client overrides win over
99
+ `Bybit.configure`**, so you can share defaults globally and still spin up a
100
+ dedicated client with a different key or environment:
67
101
 
68
102
  ```ruby
69
103
  client = Bybit::Client.new(api_key: '...', api_secret: '...', testnet: true)
@@ -97,11 +131,14 @@ Each API group is a property on `Bybit::Client`:
97
131
  - `client.affiliate` — sub-affiliate lists
98
132
  - `client.broker` — broker earnings, distributions
99
133
  - `client.crypto_loan` — flexible / fixed crypto loans
134
+ - `client.institutional_loan` — /v5/ins-loan/* (OTC institutional loans)
100
135
  - `client.rfq` — request-for-quote (block trades)
101
136
  - `client.spot_margin` — UTA spot margin
102
137
  - `client.earn` — earn, liquidity mining, RWA, PWM, hold-to-earn
103
138
  - `client.p2p` — P2P advertise / order / chat
104
139
  - `client.bot` — DCA / grid / futures-combo / futures-grid / martingale
140
+ - `client.pre_upgrade` — /v5/pre-upgrade/* historical queries for Classic → UTA upgrades
141
+ - `client.misc` — /v5/announcements/index, /v5/system/status
105
142
 
106
143
  ## Error Handling
107
144
 
@@ -139,6 +176,12 @@ Full hierarchy:
139
176
 
140
177
  Every `ApiError` exposes `#ret_code`, `#ret_msg`, `#result`, `#time`, `#http_status`. See the [Bybit V5 error-code list](https://bybit-exchange.github.io/docs/v5/error) for meanings.
141
178
 
179
+ > The `sleep 1 && retry` shown above is fine for exploration, **not** for
180
+ > production — Bybit will escalate throttling on tight retry loops. For
181
+ > real workloads, wire a `Faraday::Retry` middleware with exponential
182
+ > backoff via the "bring your own Faraday connection" hook in
183
+ > [Configuration](#configuration) above.
184
+
142
185
  ## Return Value
143
186
 
144
187
  Every service method returns the raw parsed JSON as a `Hash`:
@@ -151,14 +194,106 @@ response['result'] # => { 'category' => 'spot', 'symbol' => 'BTCUSDT', 'list
151
194
  response['time'] # => 1234567890000
152
195
  ```
153
196
 
154
- ## Testnet
197
+ > Keys are **strings, not symbols** — `response[:result]` is `nil`. This is
198
+ > a common first-hour gotcha; pass through `JSON.parse` semantics rather
199
+ > than converting.
200
+
201
+ ## Method Reference
202
+
203
+ Method names follow the Bybit V5 endpoint slug in snake_case, grouped by
204
+ domain. A few illustrative mappings:
205
+
206
+ | Bybit V5 path | SDK method |
207
+ | ------------------------------------- | ----------------------------------------- |
208
+ | `GET /v5/market/kline` | `client.market.get_kline` |
209
+ | `GET /v5/market/tickers` | `client.market.get_tickers` |
210
+ | `POST /v5/order/create` | `client.trade.create_order` |
211
+ | `POST /v5/order/cancel` | `client.trade.cancel_order` |
212
+ | `GET /v5/position/list` | `client.position.get_positions` |
213
+ | `GET /v5/account/wallet-balance` | `client.account.get_wallet_balance` |
155
214
 
156
- Toggle `testnet: true` for [https://testnet.bybit.com](https://testnet.bybit.com):
215
+ For the full list, generate YARD docs locally:
216
+
217
+ ```
218
+ bundle exec yard doc
219
+ open doc/index.html
220
+ ```
221
+
222
+ or browse the published copy on
223
+ [rubydoc.info/gems/bybit-connector-ruby](https://rubydoc.info/gems/bybit-connector-ruby).
224
+
225
+ ## Pagination
226
+
227
+ Bybit V5 uses **opaque cursor pagination** — the response's
228
+ `result['nextPageCursor']` (empty string when the page is the last one)
229
+ feeds back in as the `cursor:` argument on the next call:
157
230
 
158
231
  ```ruby
159
- client = Bybit::Client.new(api_key: ENV['BYBIT_TESTNET_KEY'], api_secret: ENV['BYBIT_TESTNET_SECRET'], testnet: true)
232
+ cursor = nil
233
+ loop do
234
+ resp = client.trade.get_order_history(category: 'linear', limit: 50, cursor: cursor)
235
+ resp['result']['list'].each { |order| process(order) }
236
+ cursor = resp['result']['nextPageCursor']
237
+ break if cursor.nil? || cursor.empty?
238
+ end
160
239
  ```
161
240
 
241
+ The same pattern works for `get_positions`, `get_execution_history`,
242
+ `get_transaction_log`, and every other paginated endpoint.
243
+
244
+ ## WebSockets
245
+
246
+ Hosts (mirror the REST split):
247
+
248
+ - Mainnet — `wss://stream.bybit.com/v5/{public/<category>|private|trade}`
249
+ - Testnet — `wss://stream-testnet.bybit.com/v5/{public/<category>|private|trade}`
250
+
251
+ WebSocket support ships behind an opt-in require so REST-only consumers don't
252
+ load the underlying `websocket-client-simple` dependency at boot. Four
253
+ callbacks are available; all are optional but you'll almost always want at
254
+ least `on_message`.
255
+
256
+ ```ruby
257
+ require 'bybit/websocket'
258
+
259
+ # Public stream — no auth needed.
260
+ public_ws = Bybit::WebSocket::Client.new(
261
+ channel: :linear, # :spot / :linear / :inverse / :option
262
+ testnet: true,
263
+ on_open: -> { puts 'connected' },
264
+ on_message: ->(msg) { puts msg.inspect }, # your handler
265
+ on_close: ->(_ev) { puts 'closed' },
266
+ on_error: ->(err) { warn "ws error: #{err}" }
267
+ ).connect
268
+
269
+ public_ws.subscribe('tickers.BTCUSDT', 'orderbook.1.BTCUSDT')
270
+
271
+ # Private stream — HMAC-SHA256 auth over "GET/realtime" + expiry.
272
+ private_ws = Bybit::WebSocket::Client.new(
273
+ channel: :private,
274
+ testnet: true,
275
+ api_key: ENV['BYBIT_TESTNET_KEY'],
276
+ api_secret: ENV['BYBIT_TESTNET_SECRET'],
277
+ on_message: ->(msg) { puts msg.inspect } # your handler
278
+ ).connect
279
+
280
+ # Subscribe returns immediately; the actual `op:subscribe` frame is deferred
281
+ # until the private-stream auth reply lands, so you can call subscribe
282
+ # anytime — before or after `#connect`.
283
+ private_ws.subscribe('position', 'order', 'wallet')
284
+
285
+ # Later:
286
+ private_ws.unsubscribe('wallet')
287
+ private_ws.disconnect
288
+ ```
289
+
290
+ Supported channels: `:spot`, `:linear`, `:inverse`, `:option`, `:private`,
291
+ `:trade`. `testnet: true` swaps in the `stream-testnet.bybit.com` host.
292
+ Pings ship every 20 seconds — Bybit closes idle sockets after ~20s of
293
+ silence. There is no automatic reconnect on network drop: callers listening
294
+ on `on_close` can invoke `#connect` again to reopen the socket and replay
295
+ the buffered `#subscriptions`.
296
+
162
297
  ## Development
163
298
 
164
299
  ```
@@ -1,29 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # End-to-end quickstart — public + signed calls + rescue matrix.
4
- # Run with: BYBIT_KEY=... BYBIT_SECRET=... ruby examples/quickstart.rb
3
+ # End-to-end quickstart — mirrors README's Quick Start:
4
+ # 1. public unsigned call
5
+ # 2. signed GET (wallet balance)
6
+ # 3. safe LIMIT order well below market (does not fill)
7
+ # 4. cancel that order
8
+ # Run with:
9
+ # BYBIT_TESTNET_KEY=... BYBIT_TESTNET_SECRET=... ruby examples/quickstart.rb
5
10
 
6
11
  require 'bybit'
7
12
 
8
13
  Bybit.configure do |c|
9
- c.api_key = ENV['BYBIT_KEY']
10
- c.api_secret = ENV['BYBIT_SECRET']
11
- c.testnet = true # flip to false for mainnet
14
+ c.api_key = ENV.fetch('BYBIT_TESTNET_KEY')
15
+ c.api_secret = ENV.fetch('BYBIT_TESTNET_SECRET')
16
+ c.testnet = true # flip to false for mainnet — see README warning
12
17
  end
13
18
 
14
19
  client = Bybit::Client.new
15
20
 
16
- # 1. Public endpoint — server time (no auth needed)
21
+ # 1. Public endpoint — server time (no auth needed).
17
22
  puts '--- server time ---'
18
23
  puts client.market.get_server_time.inspect
19
24
 
20
- # 2. Signed endpoint — wallet balance
25
+ # 2. Signed endpoint — wallet balance.
21
26
  puts '--- wallet balance ---'
22
27
  begin
23
28
  wallet = client.account.get_wallet_balance(account_type: 'UNIFIED')
24
29
  puts wallet['result']['list'].inspect
25
30
  rescue Bybit::AuthError => e
26
31
  warn "auth failed: [#{e.ret_code}] #{e.ret_msg}"
32
+ exit 1
27
33
  rescue Bybit::RateLimitError => e
28
34
  warn "rate-limited: #{e.ret_msg}"
29
35
  rescue Bybit::TimeoutError => e
@@ -33,3 +39,26 @@ rescue Bybit::NetworkError => e
33
39
  rescue Bybit::ApiError => e
34
40
  warn "api error [#{e.ret_code}]: #{e.ret_msg}"
35
41
  end
42
+
43
+ # 3. Place a LIMIT order well below market so it sits on the book and does
44
+ # NOT fill (safe to run repeatedly). Adjust price if BTC ever trades that
45
+ # low; otherwise this stays a resting order you can cancel below.
46
+ puts '--- place limit order ---'
47
+ begin
48
+ order = client.trade.create_order(
49
+ category: 'linear', symbol: 'BTCUSDT',
50
+ side: 'Buy', order_type: 'Limit', qty: '0.01',
51
+ price: '10000', time_in_force: 'GTC'
52
+ )
53
+ order_id = order['result']['orderId']
54
+ puts "orderId: #{order_id}"
55
+
56
+ # 4. Cancel it before exiting so we don't leave test orders on the book.
57
+ puts '--- cancel order ---'
58
+ cancel = client.trade.cancel_order(
59
+ category: 'linear', symbol: 'BTCUSDT', order_id: order_id
60
+ )
61
+ puts cancel['result'].inspect
62
+ rescue Bybit::ApiError => e
63
+ warn "api error [#{e.ret_code}]: #{e.ret_msg}"
64
+ end
data/lib/bybit/client.rb CHANGED
@@ -8,9 +8,12 @@ require 'bybit/rest_api/bot_service'
8
8
  require 'bybit/rest_api/broker_service'
9
9
  require 'bybit/rest_api/crypto_loan_service'
10
10
  require 'bybit/rest_api/earn_service'
11
+ require 'bybit/rest_api/institutional_loan_service'
11
12
  require 'bybit/rest_api/market_service'
13
+ require 'bybit/rest_api/misc_service'
12
14
  require 'bybit/rest_api/p2p_service'
13
15
  require 'bybit/rest_api/position_service'
16
+ require 'bybit/rest_api/pre_upgrade_service'
14
17
  require 'bybit/rest_api/rfq_service'
15
18
  require 'bybit/rest_api/spot_margin_service'
16
19
  require 'bybit/rest_api/trade_service'
@@ -34,7 +37,8 @@ module Bybit
34
37
  class Client
35
38
  # gen-sdk-ruby:client-readers:start
36
39
  attr_reader :account, :affiliate, :asset, :bot, :broker, :crypto_loan, :earn,
37
- :market, :p2p, :position, :rfq, :spot_margin, :trade, :user
40
+ :institutional_loan, :market, :misc, :p2p, :position, :pre_upgrade,
41
+ :rfq, :spot_margin, :trade, :user
38
42
 
39
43
  # gen-sdk-ruby:client-readers:end
40
44
 
@@ -52,9 +56,12 @@ module Bybit
52
56
  @broker = RestApi::BrokerService.new(session)
53
57
  @crypto_loan = RestApi::CryptoLoanService.new(session)
54
58
  @earn = RestApi::EarnService.new(session)
59
+ @institutional_loan = RestApi::InstitutionalLoanService.new(session)
55
60
  @market = RestApi::MarketService.new(session)
61
+ @misc = RestApi::MiscService.new(session)
56
62
  @p2p = RestApi::P2pService.new(session)
57
63
  @position = RestApi::PositionService.new(session)
64
+ @pre_upgrade = RestApi::PreUpgradeService.new(session)
58
65
  @rfq = RestApi::RfqService.new(session)
59
66
  @spot_margin = RestApi::SpotMarginService.new(session)
60
67
  @trade = RestApi::TradeService.new(session)
data/lib/bybit/error.rb CHANGED
@@ -22,16 +22,24 @@ module Bybit
22
22
 
23
23
  # Raised when the server returns HTTP 200 + retCode != 0 (the V5 norm),
24
24
  # or when a transport-level error is enriched with a Bybit body payload.
25
+ # Accepts either a V5 response Hash (extracts retCode/retMsg/result/time)
26
+ # or a plain String message (used for HTTP-status mapping when the CDN/WAF
27
+ # returns a non-JSON body — e.g. 401/403/429 → AuthError/RateLimitError
28
+ # without a decodable retCode).
25
29
  class ApiError < Error
26
30
  attr_reader :ret_code, :ret_msg, :result, :time, :http_status
27
31
 
28
32
  def initialize(response, http_status: nil)
29
- @ret_code = response['retCode']
30
- @ret_msg = response['retMsg']
31
- @result = response['result']
32
- @time = response['time']
33
33
  @http_status = http_status
34
- super("[#{@ret_code}] #{@ret_msg}")
34
+ if response.is_a?(Hash)
35
+ @ret_code = response['retCode']
36
+ @ret_msg = response['retMsg']
37
+ @result = response['result']
38
+ @time = response['time']
39
+ super("[#{@ret_code}] #{@ret_msg}")
40
+ else
41
+ super(response.to_s)
42
+ end
35
43
  end
36
44
  end
37
45
 
@@ -78,17 +78,22 @@ module Bybit
78
78
  #
79
79
  # GET /v5/broker/earnings-info
80
80
  #
81
+ # Bybit's spec names the date-range params `begin` / `end`, which are
82
+ # Ruby keywords — callers use `:start_date` / `:end_date` and we rename
83
+ # them here before camelize. `:begin_` / `:end_` are also accepted (via
84
+ # WireKeys::RESERVED_ALIASES) for callers who prefer the underscore form.
85
+ #
81
86
  # @option kwargs [String] :biz_type Business type
82
- # @option kwargs [String] :begin_ Begin date
83
- # @option kwargs [String] :end_ End date
87
+ # @option kwargs [String] :start_date Begin date (wire: `begin`)
88
+ # @option kwargs [String] :end_date End date (wire: `end`)
84
89
  # @option kwargs [String] :uid Sub UID
85
90
  # @option kwargs [Integer] :limit Result limit
86
91
  # @option kwargs [String] :cursor Pagination cursor
87
92
  # @return [Hash] Bybit V5 ApiResponse envelope (retCode / retMsg / result / retExtInfo / time).
88
93
  def list_broker_earnings(**kwargs)
89
94
  params = kwargs.dup
90
- params[:begin] = params.delete(:begin_) if params.key?(:begin_)
91
- params[:end] = params.delete(:end_) if params.key?(:end_)
95
+ params[:begin_] = params.delete(:start_date) if params.key?(:start_date)
96
+ params[:end_] = params.delete(:end_date) if params.key?(:end_date)
92
97
  params = Bybit::Utils::WireKeys.camelize(params)
93
98
  @session.sign_request(method: :get, path: '/v5/broker/earnings-info', params: params)
94
99
  end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bybit
4
+ module RestApi
5
+ # /v5/ins-loan/* — institutional (OTC) loan namespace. Distinct from
6
+ # `crypto_loan` (retail flexible / fixed loans): these endpoints require
7
+ # a Bybit-provisioned institutional risk-unit UID and are read-only for
8
+ # non-institutional keys.
9
+ class InstitutionalLoanService < BaseService
10
+ # Get Product Info
11
+ #
12
+ # GET /v5/ins-loan/product-infos
13
+ #
14
+ # @option kwargs [String] :product_id Product ID; omit to return all products.
15
+ # @return [Hash] Bybit V5 ApiResponse envelope (retCode / retMsg / result / retExtInfo / time).
16
+ # @see https://bybit-exchange.github.io/docs/v5/otc/margin-product-info
17
+ def get_product_info(**kwargs)
18
+ params = kwargs.dup
19
+ params = Bybit::Utils::WireKeys.camelize(params)
20
+ @session.public_request(path: '/v5/ins-loan/product-infos', params: params)
21
+ end
22
+
23
+ # Get Margin Coin Info (ensure-tokens-convert)
24
+ #
25
+ # GET /v5/ins-loan/ensure-tokens-convert
26
+ #
27
+ # @option kwargs [String] :product_id Product ID
28
+ # @return [Hash] Bybit V5 ApiResponse envelope (retCode / retMsg / result / retExtInfo / time).
29
+ # @see https://bybit-exchange.github.io/docs/v5/otc/margin-coin-convert-info
30
+ def get_margin_coin_info(**kwargs)
31
+ params = kwargs.dup
32
+ params = Bybit::Utils::WireKeys.camelize(params)
33
+ @session.public_request(path: '/v5/ins-loan/ensure-tokens-convert', params: params)
34
+ end
35
+
36
+ # Get Loan Orders
37
+ #
38
+ # GET /v5/ins-loan/loan-order
39
+ #
40
+ # @option kwargs [Integer] :order_id Loan order ID
41
+ # @option kwargs [Integer] :start_time Start timestamp in ms
42
+ # @option kwargs [Integer] :end_time End timestamp in ms
43
+ # @option kwargs [Integer] :limit Records per page (default/max 100)
44
+ # @return [Hash] Bybit V5 ApiResponse envelope (retCode / retMsg / result / retExtInfo / time).
45
+ # @see https://bybit-exchange.github.io/docs/v5/otc/loan-info
46
+ def get_loan_orders(**kwargs)
47
+ params = kwargs.dup
48
+ params = Bybit::Utils::WireKeys.camelize(params)
49
+ @session.sign_request(method: :get, path: '/v5/ins-loan/loan-order', params: params)
50
+ end
51
+
52
+ # Get Repayment Orders
53
+ #
54
+ # GET /v5/ins-loan/repaid-history
55
+ #
56
+ # @option kwargs [Integer] :start_time Start timestamp in ms
57
+ # @option kwargs [Integer] :end_time End timestamp in ms
58
+ # @option kwargs [Integer] :limit Records per page
59
+ # @return [Hash] Bybit V5 ApiResponse envelope (retCode / retMsg / result / retExtInfo / time).
60
+ # @see https://bybit-exchange.github.io/docs/v5/otc/repay-info
61
+ def get_repayment_orders(**kwargs)
62
+ params = kwargs.dup
63
+ params = Bybit::Utils::WireKeys.camelize(params)
64
+ @session.sign_request(method: :get, path: '/v5/ins-loan/repaid-history', params: params)
65
+ end
66
+
67
+ # Get Loan-to-Value
68
+ #
69
+ # GET /v5/ins-loan/ltv-convert
70
+ #
71
+ # @return [Hash] Bybit V5 ApiResponse envelope (retCode / retMsg / result / retExtInfo / time).
72
+ # @see https://bybit-exchange.github.io/docs/v5/otc/ltv-convert
73
+ def get_ltv
74
+ @session.sign_request(method: :get, path: '/v5/ins-loan/ltv-convert')
75
+ end
76
+
77
+ # Bind Or Unbind UID (institutional risk unit)
78
+ #
79
+ # POST /v5/ins-loan/association-uid
80
+ #
81
+ # @param uid [String] Sub UID to bind or unbind
82
+ # @param operate [String] `0` bind, `1` unbind
83
+ # @return [Hash] Bybit V5 ApiResponse envelope (retCode / retMsg / result / retExtInfo / time).
84
+ # @see https://bybit-exchange.github.io/docs/v5/otc/bind-uid
85
+ def bind_or_unbind_uid(uid:, operate:, **kwargs)
86
+ params = kwargs.merge(uid: uid, operate: operate)
87
+ params = Bybit::Utils::WireKeys.camelize(params)
88
+ @session.sign_request(method: :post, path: '/v5/ins-loan/association-uid', body: params)
89
+ end
90
+
91
+ # Repay Loan
92
+ #
93
+ # POST /v5/ins-loan/repay-loan
94
+ #
95
+ # @param order_id [String] Loan order ID to repay
96
+ # @param amount [String] Repayment amount
97
+ # @return [Hash] Bybit V5 ApiResponse envelope (retCode / retMsg / result / retExtInfo / time).
98
+ # @see https://bybit-exchange.github.io/docs/v5/otc/repay-loan
99
+ def repay_loan(order_id:, amount:, **kwargs)
100
+ params = kwargs.merge(order_id: order_id, amount: amount)
101
+ params = Bybit::Utils::WireKeys.camelize(params)
102
+ @session.sign_request(method: :post, path: '/v5/ins-loan/repay-loan', body: params)
103
+ end
104
+ end
105
+ end
106
+ end
@@ -109,7 +109,6 @@ module Bybit
109
109
  # @see https://bybit-exchange.github.io/docs/v5/market/index-kline
110
110
  def get_index_price_kline(symbol:, interval:, **kwargs)
111
111
  params = kwargs.merge(symbol: symbol, interval: interval)
112
- params[:end] = params.delete(:end_) if params.key?(:end_)
113
112
  params = Bybit::Utils::WireKeys.camelize(params)
114
113
  @session.public_request(path: '/v5/market/index-price-kline', params: params)
115
114
  end
@@ -178,7 +177,6 @@ module Bybit
178
177
  # @see https://bybit-exchange.github.io/docs/v5/market/mark-kline
179
178
  def get_mark_price_kline(symbol:, interval:, **kwargs)
180
179
  params = kwargs.merge(symbol: symbol, interval: interval)
181
- params[:end] = params.delete(:end_) if params.key?(:end_)
182
180
  params = Bybit::Utils::WireKeys.camelize(params)
183
181
  @session.public_request(path: '/v5/market/mark-price-kline', params: params)
184
182
  end
@@ -197,7 +195,6 @@ module Bybit
197
195
  # @see https://bybit-exchange.github.io/docs/v5/market/kline
198
196
  def get_kline(symbol:, interval:, **kwargs)
199
197
  params = kwargs.merge(symbol: symbol, interval: interval)
200
- params[:end] = params.delete(:end_) if params.key?(:end_)
201
198
  params = Bybit::Utils::WireKeys.camelize(params)
202
199
  @session.public_request(path: '/v5/market/kline', params: params)
203
200
  end
@@ -278,7 +275,6 @@ module Bybit
278
275
  # @see https://bybit-exchange.github.io/docs/v5/market/premium-index-kline
279
276
  def get_premium_index_price_kline(symbol:, interval:, **kwargs)
280
277
  params = kwargs.merge(symbol: symbol, interval: interval)
281
- params[:end] = params.delete(:end_) if params.key?(:end_)
282
278
  params = Bybit::Utils::WireKeys.camelize(params)
283
279
  @session.public_request(path: '/v5/market/premium-index-price-kline', params: params)
284
280
  end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bybit
4
+ module RestApi
5
+ # Endpoints that don't cleanly belong under a per-domain service:
6
+ # announcement feed and system status.
7
+ class MiscService < BaseService
8
+ # Get Announcements
9
+ #
10
+ # GET /v5/announcements/index
11
+ #
12
+ # @param locale [String] Language code, e.g. `en-US`, `zh-TW`.
13
+ # @option kwargs [String] :type Announcement type filter
14
+ # @option kwargs [String] :tag Tag filter
15
+ # @option kwargs [Integer] :page Page number
16
+ # @option kwargs [Integer] :limit Records per page (max 100)
17
+ # @return [Hash] Bybit V5 ApiResponse envelope (retCode / retMsg / result / retExtInfo / time).
18
+ # @see https://bybit-exchange.github.io/docs/v5/announcement
19
+ def get_announcements(locale:, **kwargs)
20
+ params = kwargs.merge(locale: locale)
21
+ params = Bybit::Utils::WireKeys.camelize(params)
22
+ @session.public_request(path: '/v5/announcements/index', params: params)
23
+ end
24
+
25
+ # Get System Status
26
+ #
27
+ # GET /v5/system/status
28
+ #
29
+ # @return [Hash] Bybit V5 ApiResponse envelope (retCode / retMsg / result / retExtInfo / time).
30
+ # @see https://bybit-exchange.github.io/docs/v5/system-status
31
+ def get_system_status
32
+ @session.public_request(path: '/v5/system/status')
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bybit
4
+ module RestApi
5
+ # /v5/pre-upgrade/* — historical queries scoped to activity that occurred
6
+ # BEFORE a Classic → UTA account upgrade. These records live in the legacy
7
+ # partition and never merge into the UTA tables, so callers upgrading from
8
+ # older SDKs need a dedicated namespace to fetch them.
9
+ class PreUpgradeService < BaseService
10
+ # Get Pre-upgrade Order History
11
+ #
12
+ # GET /v5/pre-upgrade/order/history
13
+ #
14
+ # @param category [String] Product type: `linear`, `inverse`, `option`
15
+ # @option kwargs [String] :symbol Symbol filter
16
+ # @option kwargs [String] :base_coin Base coin filter
17
+ # @option kwargs [String] :order_id Order ID filter
18
+ # @option kwargs [String] :order_link_id User-side order link ID
19
+ # @option kwargs [String] :order_filter Order-type filter
20
+ # @option kwargs [String] :order_status Order status filter
21
+ # @option kwargs [Integer] :start_time Start ms timestamp
22
+ # @option kwargs [Integer] :end_time End ms timestamp
23
+ # @option kwargs [Integer] :limit Records per page
24
+ # @option kwargs [String] :cursor Pagination cursor
25
+ # @return [Hash] Bybit V5 ApiResponse envelope (retCode / retMsg / result / retExtInfo / time).
26
+ # @see https://bybit-exchange.github.io/docs/v5/pre-upgrade/order-list
27
+ def get_order_history(category:, **kwargs)
28
+ params = kwargs.merge(category: category)
29
+ params = Bybit::Utils::WireKeys.camelize(params)
30
+ @session.sign_request(method: :get, path: '/v5/pre-upgrade/order/history', params: params)
31
+ end
32
+
33
+ # Get Pre-upgrade Trade (Execution) History
34
+ #
35
+ # GET /v5/pre-upgrade/execution/list
36
+ #
37
+ # @param category [String] Product type: `linear`, `inverse`, `option`
38
+ # @option kwargs [String] :symbol Symbol filter
39
+ # @option kwargs [String] :order_id Order ID filter
40
+ # @option kwargs [String] :order_link_id User-side order link ID
41
+ # @option kwargs [String] :base_coin Base coin filter
42
+ # @option kwargs [Integer] :start_time Start ms timestamp
43
+ # @option kwargs [Integer] :end_time End ms timestamp
44
+ # @option kwargs [String] :exec_type Execution type filter
45
+ # @option kwargs [Integer] :limit Records per page
46
+ # @option kwargs [String] :cursor Pagination cursor
47
+ # @return [Hash] Bybit V5 ApiResponse envelope (retCode / retMsg / result / retExtInfo / time).
48
+ # @see https://bybit-exchange.github.io/docs/v5/pre-upgrade/execution
49
+ def get_trade_history(category:, **kwargs)
50
+ params = kwargs.merge(category: category)
51
+ params = Bybit::Utils::WireKeys.camelize(params)
52
+ @session.sign_request(method: :get, path: '/v5/pre-upgrade/execution/list', params: params)
53
+ end
54
+
55
+ # Get Pre-upgrade Closed PnL
56
+ #
57
+ # GET /v5/pre-upgrade/position/closed-pnl
58
+ #
59
+ # Bybit V5 only requires `symbol` when `category` is `inverse`; for
60
+ # `linear` it's optional. Keep it as an optional kwarg here — callers
61
+ # querying inverse pass it, callers on linear can omit it.
62
+ #
63
+ # @param category [String] Product type: `linear`, `inverse`
64
+ # @option kwargs [String] :symbol Symbol name (required for `inverse`)
65
+ # @option kwargs [Integer] :start_time Start ms timestamp
66
+ # @option kwargs [Integer] :end_time End ms timestamp
67
+ # @option kwargs [Integer] :limit Records per page
68
+ # @option kwargs [String] :cursor Pagination cursor
69
+ # @return [Hash] Bybit V5 ApiResponse envelope (retCode / retMsg / result / retExtInfo / time).
70
+ # @see https://bybit-exchange.github.io/docs/v5/pre-upgrade/close-pnl
71
+ def get_closed_pnl(category:, **kwargs)
72
+ params = kwargs.merge(category: category)
73
+ params = Bybit::Utils::WireKeys.camelize(params)
74
+ @session.sign_request(method: :get, path: '/v5/pre-upgrade/position/closed-pnl', params: params)
75
+ end
76
+
77
+ # Get Pre-upgrade Transaction Log (USDC Derivatives wallet)
78
+ #
79
+ # GET /v5/pre-upgrade/account/transaction-log
80
+ #
81
+ # @param category [String] Product type: `linear`, `option`
82
+ # @option kwargs [String] :base_coin Base coin filter
83
+ # @option kwargs [String] :type Transaction type filter
84
+ # @option kwargs [Integer] :start_time Start ms timestamp
85
+ # @option kwargs [Integer] :end_time End ms timestamp
86
+ # @option kwargs [Integer] :limit Records per page
87
+ # @option kwargs [String] :cursor Pagination cursor
88
+ # @return [Hash] Bybit V5 ApiResponse envelope (retCode / retMsg / result / retExtInfo / time).
89
+ # @see https://bybit-exchange.github.io/docs/v5/pre-upgrade/transaction-log
90
+ def get_transaction_log(category:, **kwargs)
91
+ params = kwargs.merge(category: category)
92
+ params = Bybit::Utils::WireKeys.camelize(params)
93
+ @session.sign_request(method: :get, path: '/v5/pre-upgrade/account/transaction-log', params: params)
94
+ end
95
+
96
+ # Get Pre-upgrade Option Delivery Record
97
+ #
98
+ # GET /v5/pre-upgrade/asset/delivery-record
99
+ #
100
+ # @param category [String] Product type: `option`
101
+ # @option kwargs [String] :symbol Symbol filter
102
+ # @option kwargs [Integer] :expiry_date Delivery expiry date (YYYYMMDD)
103
+ # @option kwargs [Integer] :limit Records per page
104
+ # @option kwargs [String] :cursor Pagination cursor
105
+ # @return [Hash] Bybit V5 ApiResponse envelope (retCode / retMsg / result / retExtInfo / time).
106
+ # @see https://bybit-exchange.github.io/docs/v5/pre-upgrade/delivery
107
+ def get_option_delivery_record(category:, **kwargs)
108
+ params = kwargs.merge(category: category)
109
+ params = Bybit::Utils::WireKeys.camelize(params)
110
+ @session.sign_request(method: :get, path: '/v5/pre-upgrade/asset/delivery-record', params: params)
111
+ end
112
+
113
+ # Get Pre-upgrade USDC Session Settlement Record
114
+ #
115
+ # GET /v5/pre-upgrade/asset/settlement-record
116
+ #
117
+ # @param category [String] Product type: `linear`
118
+ # @option kwargs [String] :symbol Symbol filter
119
+ # @option kwargs [Integer] :limit Records per page
120
+ # @option kwargs [String] :cursor Pagination cursor
121
+ # @return [Hash] Bybit V5 ApiResponse envelope (retCode / retMsg / result / retExtInfo / time).
122
+ # @see https://bybit-exchange.github.io/docs/v5/pre-upgrade/settlement
123
+ def get_usdc_session_settlement(category:, **kwargs)
124
+ params = kwargs.merge(category: category)
125
+ params = Bybit::Utils::WireKeys.camelize(params)
126
+ @session.sign_request(method: :get, path: '/v5/pre-upgrade/asset/settlement-record', params: params)
127
+ end
128
+ end
129
+ end
130
+ end
data/lib/bybit/session.rb CHANGED
@@ -126,11 +126,21 @@ module Bybit
126
126
  body = normalize_legacy_body(body) if body.is_a?(Hash)
127
127
 
128
128
  # HTTP status wins over retCode when the body isn't a valid ApiResponse.
129
- # 5xx / non-auth 4xx get their own class so retries and pager logic can
130
- # tell them apart from client / auth errors.
129
+ # 401/403/429 get their promised AuthError / RateLimitError classes even
130
+ # when a CDN/WAF returns HTML (no retCode); the README documents this
131
+ # contract. 5xx / other non-auth 4xx keep their generic Server/Client
132
+ # buckets so retry and pager logic can tell them apart.
131
133
  if !body.is_a?(Hash) || !body['retCode'].is_a?(Integer)
132
134
  preview = truncate_for_error(raw)
133
- if status >= 500
135
+ if [401, 403].include?(status)
136
+ raise Bybit::AuthError.new(
137
+ "Bybit auth error (status=#{status}): #{preview}", http_status: status
138
+ )
139
+ elsif status == 429
140
+ raise Bybit::RateLimitError.new(
141
+ "Bybit rate limit (status=#{status}): #{preview}", http_status: status
142
+ )
143
+ elsif status >= 500
134
144
  raise Bybit::ServerError, "Bybit server error (status=#{status}): #{preview}"
135
145
  elsif status >= 400
136
146
  raise Bybit::ClientError, "Bybit client error (status=#{status}): #{preview}"
data/lib/bybit/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Bybit
4
- VERSION = '0.1.0'
4
+ VERSION = '0.1.1'
5
5
  end
@@ -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.1
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-07-22 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: []
@@ -118,9 +160,12 @@ files:
118
160
  - lib/bybit/rest_api/broker_service.rb
119
161
  - lib/bybit/rest_api/crypto_loan_service.rb
120
162
  - lib/bybit/rest_api/earn_service.rb
163
+ - lib/bybit/rest_api/institutional_loan_service.rb
121
164
  - lib/bybit/rest_api/market_service.rb
165
+ - lib/bybit/rest_api/misc_service.rb
122
166
  - lib/bybit/rest_api/p2p_service.rb
123
167
  - lib/bybit/rest_api/position_service.rb
168
+ - lib/bybit/rest_api/pre_upgrade_service.rb
124
169
  - lib/bybit/rest_api/rfq_service.rb
125
170
  - lib/bybit/rest_api/spot_margin_service.rb
126
171
  - lib/bybit/rest_api/trade_service.rb
@@ -128,6 +173,8 @@ files:
128
173
  - lib/bybit/session.rb
129
174
  - lib/bybit/utils/wire_keys.rb
130
175
  - lib/bybit/version.rb
176
+ - lib/bybit/websocket.rb
177
+ - lib/bybit/websocket/client.rb
131
178
  homepage: https://github.com/bybit-exchange/bybit.ruby.api
132
179
  licenses:
133
180
  - MIT
@@ -136,7 +183,7 @@ metadata:
136
183
  documentation_uri: https://bybit-exchange.github.io/docs/v5/intro
137
184
  changelog_uri: https://github.com/bybit-exchange/bybit.ruby.api/blob/main/CHANGELOG.md
138
185
  rubygems_mfa_required: 'true'
139
- post_install_message:
186
+ post_install_message:
140
187
  rdoc_options: []
141
188
  require_paths:
142
189
  - lib
@@ -151,8 +198,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
151
198
  - !ruby/object:Gem::Version
152
199
  version: '0'
153
200
  requirements: []
154
- rubygems_version: 3.0.3.1
155
- signing_key:
201
+ rubygems_version: 3.5.16
202
+ signing_key:
156
203
  specification_version: 4
157
204
  summary: Official Bybit V5 REST API connector for Ruby
158
205
  test_files: []