bybit-connector-ruby 0.1.0

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,199 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'json'
5
+ require 'timeout'
6
+ require 'uri'
7
+
8
+ module Bybit
9
+ # Session owns the Faraday connection + auth-header assembly. Every service
10
+ # class receives a Session instance and dispatches through public_request /
11
+ # sign_request. Modeled after binance-connector-ruby's Session pattern.
12
+ #
13
+ # Signing invariant: the payload signed and the query string sent on the
14
+ # wire MUST be byte-identical. We enforce this by building ONE query_str
15
+ # (via encode_query after key-sort) and using it verbatim for both
16
+ # the signature payload AND the request URL — never letting Faraday's
17
+ # NestedParamsEncoder re-serialize the params. This avoids the classic
18
+ # array-value / nested-hash reorder bug.
19
+ class Session
20
+ PAYLOAD_QUERY_METHODS = %i[get delete].freeze
21
+
22
+ def initialize(config)
23
+ @config = config
24
+ @conn = config.faraday_connection || build_connection
25
+ end
26
+
27
+ # Public unsigned endpoint — no X-BAPI-* headers attached.
28
+ # session.public_request(path: '/v5/market/kline', params: {...})
29
+ def public_request(path:, method: :get, params: nil, body: nil)
30
+ dispatch(method: method, path: path, signed: false, params: params, body: body)
31
+ end
32
+
33
+ # Signed endpoint — X-BAPI-* headers computed via Authentication.
34
+ def sign_request(method:, path:, params: nil, body: nil)
35
+ dispatch(method: method, path: path, signed: true, params: params, body: body)
36
+ end
37
+
38
+ private
39
+
40
+ def dispatch(method:, path:, signed:, params:, body:)
41
+ # Only GET / DELETE carry params on the query string. Silently dropping
42
+ # params on POST / PUT / PATCH used to leave codegen typos undiagnosed
43
+ # for weeks — now they raise loudly.
44
+ if params && !PAYLOAD_QUERY_METHODS.include?(method) && !body.nil?
45
+ raise Bybit::ConfigurationError,
46
+ "params: is only valid on GET/DELETE — #{method.to_s.upcase} must pass data via body:"
47
+ end
48
+ clean_params = compact(params)
49
+ clean_body = compact(body)
50
+ query_str = clean_params ? encode_query(clean_params) : ''
51
+ body_str = clean_body ? JSON.generate(clean_body) : ''
52
+ headers = build_headers(signed: signed, method: method, query_str: query_str, body_str: body_str)
53
+
54
+ # Build URL manually with our own query_str so the wire bytes match
55
+ # what we signed. `req.params =` would re-serialize through
56
+ # Faraday::NestedParamsEncoder which key-orders and array-brackets
57
+ # differently, breaking the signature.
58
+ full_url = if !query_str.empty? && PAYLOAD_QUERY_METHODS.include?(method)
59
+ "#{path}?#{query_str}"
60
+ else
61
+ path
62
+ end
63
+
64
+ resp = @conn.send(method) do |req|
65
+ req.url full_url
66
+ req.headers.update(headers)
67
+ req.body = body_str if clean_body
68
+ end
69
+ parse_response(resp)
70
+ rescue Faraday::TimeoutError => e
71
+ raise Bybit::TimeoutError, e.message
72
+ rescue Faraday::ConnectionFailed => e
73
+ raise Bybit::TimeoutError, e.message if connect_timeout?(e)
74
+
75
+ raise Bybit::NetworkError, e.message
76
+ rescue Faraday::SSLError => e
77
+ raise Bybit::NetworkError, e.message
78
+ rescue Faraday::Error => e
79
+ # Catch-all for Faraday::ParsingError / ClientError / ServerError etc.
80
+ # that surface when the caller wires their own error-raising middleware.
81
+ raise Bybit::TransportError, e.message
82
+ end
83
+
84
+ # Faraday 2's net_http adapter wraps Net::OpenTimeout / Timeout::Error /
85
+ # Errno::ETIMEDOUT in ConnectionFailed (only Net::ReadTimeout becomes
86
+ # Faraday::TimeoutError). Callers still want these surfaced as TimeoutError.
87
+ def connect_timeout?(err)
88
+ cause = err.respond_to?(:wrapped_exception) ? err.wrapped_exception : nil
89
+ cause.is_a?(Timeout::Error) || cause.is_a?(Errno::ETIMEDOUT)
90
+ end
91
+
92
+ # Deterministic `&`-joined encoding, keys sorted, values URI-escaped.
93
+ # Arrays become repeated keys (`symbol=BTCUSDT&symbol=ETHUSDT`) — this
94
+ # matches Bybit V5's flat-list expectation.
95
+ def encode_query(params)
96
+ params.sort_by { |k, _| k.to_s }.flat_map do |k, v|
97
+ key_enc = URI.encode_www_form_component(k.to_s)
98
+ Array(v).map { |single| "#{key_enc}=#{URI.encode_www_form_component(single.to_s)}" }
99
+ end.join('&')
100
+ end
101
+
102
+ def build_headers(signed:, method:, query_str:, body_str:)
103
+ h = {}
104
+ h['Content-Type'] = 'application/json' unless body_str.empty?
105
+ return h unless signed
106
+ raise Bybit::ConfigurationError, 'signed endpoint requires api_key + api_secret' if @config.api_key.nil? || @config.api_secret.nil?
107
+
108
+ ts = (Time.now.to_f * 1000).to_i.to_s
109
+ # payload for GET/DELETE is query string; for POST/PUT/PATCH it's the
110
+ # JSON body. Both branches use the SAME predicate as dispatch above.
111
+ payload = PAYLOAD_QUERY_METHODS.include?(method) ? query_str : body_str
112
+ h['X-BAPI-API-KEY'] = @config.api_key
113
+ h['X-BAPI-TIMESTAMP'] = ts
114
+ h['X-BAPI-RECV-WINDOW'] = @config.recv_window.to_s
115
+ h['X-BAPI-SIGN'] = Authentication.sign_v5(
116
+ @config.api_secret, ts, @config.api_key, @config.recv_window.to_s, payload
117
+ )
118
+ h['X-BAPI-SIGN-TYPE'] = '2'
119
+ h
120
+ end
121
+
122
+ def parse_response(response)
123
+ status = response.status
124
+ raw = response.body
125
+ body = raw.is_a?(String) ? safe_parse_json(raw) : raw
126
+ body = normalize_legacy_body(body) if body.is_a?(Hash)
127
+
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.
131
+ if !body.is_a?(Hash) || !body['retCode'].is_a?(Integer)
132
+ preview = truncate_for_error(raw)
133
+ if status >= 500
134
+ raise Bybit::ServerError, "Bybit server error (status=#{status}): #{preview}"
135
+ elsif status >= 400
136
+ raise Bybit::ClientError, "Bybit client error (status=#{status}): #{preview}"
137
+ else
138
+ raise Bybit::ParseError.new(
139
+ "Response is not a valid Bybit V5 ApiResponse (status=#{status}): #{preview}",
140
+ body: raw, http_status: status
141
+ )
142
+ end
143
+ end
144
+ return body if body['retCode'].zero?
145
+
146
+ raise Bybit.api_error_from(body, http_status: status)
147
+ end
148
+
149
+ # P2P endpoints (and a few legacy V3-derived paths) return the pre-V5
150
+ # envelope: { ret_code, ret_msg, ext_code, ext_info, time_now, result }.
151
+ # Alias the legacy keys into the V5 shape so the rest of the parser and
152
+ # error mapper works uniformly. If the response is already V5, no-op.
153
+ LEGACY_KEY_MAP = {
154
+ 'ret_code' => 'retCode',
155
+ 'ret_msg' => 'retMsg',
156
+ 'ext_info' => 'retExtInfo',
157
+ 'time_now' => 'time'
158
+ }.freeze
159
+
160
+ def normalize_legacy_body(body)
161
+ return body if body.key?('retCode')
162
+ return body unless body.key?('ret_code')
163
+
164
+ LEGACY_KEY_MAP.each do |legacy, v5|
165
+ body[v5] = body.delete(legacy) if body.key?(legacy)
166
+ end
167
+ # `time_now` is a Bybit-legacy float-string; V5 exposes `time` as an
168
+ # integer millisecond epoch. Best-effort coerce so downstream code that
169
+ # compares against V5 `time` doesn't hit a type mismatch.
170
+ body['time'] = (body['time'].to_f * 1000).to_i if body['time'].is_a?(String)
171
+ body
172
+ end
173
+
174
+ def safe_parse_json(str)
175
+ JSON.parse(str)
176
+ rescue JSON::ParserError
177
+ nil
178
+ end
179
+
180
+ def truncate_for_error(raw)
181
+ return '(nil body)' if raw.nil?
182
+
183
+ s = raw.is_a?(String) ? raw : raw.inspect
184
+ s.length > 2048 ? "#{s[0, 2048]}…(truncated)" : s
185
+ end
186
+
187
+ # Shallow compact only — inner Hash / Array-of-Hash values pass through.
188
+ # Recursion happens on the wire-key side via WireKeys.camelize.
189
+ def compact(hash)
190
+ return nil if hash.nil?
191
+
192
+ hash.reject { |_, v| v.nil? }
193
+ end
194
+
195
+ def build_connection
196
+ Faraday.new(url: @config.resolved_base_url, request: { timeout: @config.timeout })
197
+ end
198
+ end
199
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bybit
4
+ module Utils
5
+ # snake_case <-> camelCase key translation for wire payloads.
6
+ # Bot-family endpoints keep snake_case (identity mapping) — call sites
7
+ # skip WireKeys.camelize. Everyone else runs camelize on the params/body
8
+ # hash right before dispatch.
9
+ #
10
+ # Recursion: batch endpoints (trade#batch_amend_orders,
11
+ # account#batch_set_collateral, etc.) carry Array<Hash> or nested Hash
12
+ # payloads whose inner keys must ALSO be camelized. A non-recursive
13
+ # camelize was the PR#1 blocker (retCode 10001/10004 on every batch call).
14
+ module WireKeys
15
+ # Ruby-reserved-word arg aliases: :end_ → :end on the wire. Codegen
16
+ # emits arg names with the trailing '_'; camelize normalizes them back
17
+ # to the spec name so service methods no longer need per-method
18
+ # `params[:end] = params.delete(:end_)` shims.
19
+ RESERVED_ALIASES = {
20
+ end_: :end, begin_: :begin, class_: :class, next_: :next,
21
+ return_: :return, do_: :do, if_: :if, else_: :else
22
+ }.freeze
23
+
24
+ module_function
25
+
26
+ # Convert every key of a hash from :some_key / 'some_key' to camelCase.
27
+ # Recurses into Hash and Array-of-Hash values. Non-hash/array values
28
+ # pass through unchanged. Reserved-word aliases (:end_ etc.) get
29
+ # rewritten to their bare form.
30
+ def camelize(hash)
31
+ return hash if hash.nil?
32
+
33
+ hash.each_with_object({}) do |(k, v), out|
34
+ out[to_camel(unalias(k))] = camelize_value(v)
35
+ end
36
+ end
37
+
38
+ def camelize_value(value)
39
+ case value
40
+ when Hash then camelize(value)
41
+ when Array then value.map { |el| el.is_a?(Hash) ? camelize(el) : el }
42
+ else value
43
+ end
44
+ end
45
+
46
+ def unalias(key)
47
+ return key unless key.is_a?(Symbol) || key.is_a?(String)
48
+
49
+ aliased = RESERVED_ALIASES[key.to_s.to_sym]
50
+ return key unless aliased
51
+
52
+ key.is_a?(Symbol) ? aliased : aliased.to_s
53
+ end
54
+
55
+ def to_camel(key)
56
+ return key unless key.is_a?(Symbol) || key.is_a?(String)
57
+
58
+ parts = key.to_s.split('_')
59
+ return key if parts.size < 2
60
+
61
+ camel = parts[0] + parts[1..].reject(&:empty?).map(&:capitalize).join
62
+ key.is_a?(Symbol) ? camel.to_sym : camel
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Bybit
4
+ VERSION = '0.1.0'
5
+ end
data/lib/bybit.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bybit/version'
4
+ require 'bybit/error'
5
+ require 'bybit/configuration'
6
+ require 'bybit/authentication'
7
+ require 'bybit/session'
8
+ require 'bybit/utils/wire_keys'
9
+ require 'bybit/rest_api/base_service'
10
+ require 'bybit/client'
metadata ADDED
@@ -0,0 +1,158 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: bybit-connector-ruby
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Bybit
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2026-07-20 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: faraday
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '2.0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '2.0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: rspec
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '3.12'
34
+ type: :development
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '3.12'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rubocop
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '1.60'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '1.60'
55
+ - !ruby/object:Gem::Dependency
56
+ name: simplecov
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '0.22'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '0.22'
69
+ - !ruby/object:Gem::Dependency
70
+ name: webmock
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.0'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: yard
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.9'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '0.9'
97
+ description: Ruby connector for the Bybit V5 REST API — keyword-arg method signatures,
98
+ HMAC-SHA256 signing, typed error hierarchy, Faraday-based transport.
99
+ email:
100
+ executables: []
101
+ extensions: []
102
+ extra_rdoc_files: []
103
+ files:
104
+ - CHANGELOG.md
105
+ - LICENSE
106
+ - README.md
107
+ - examples/quickstart.rb
108
+ - lib/bybit.rb
109
+ - lib/bybit/authentication.rb
110
+ - lib/bybit/client.rb
111
+ - lib/bybit/configuration.rb
112
+ - lib/bybit/error.rb
113
+ - lib/bybit/rest_api/account_service.rb
114
+ - lib/bybit/rest_api/affiliate_service.rb
115
+ - lib/bybit/rest_api/asset_service.rb
116
+ - lib/bybit/rest_api/base_service.rb
117
+ - lib/bybit/rest_api/bot_service.rb
118
+ - lib/bybit/rest_api/broker_service.rb
119
+ - lib/bybit/rest_api/crypto_loan_service.rb
120
+ - lib/bybit/rest_api/earn_service.rb
121
+ - lib/bybit/rest_api/market_service.rb
122
+ - lib/bybit/rest_api/p2p_service.rb
123
+ - lib/bybit/rest_api/position_service.rb
124
+ - lib/bybit/rest_api/rfq_service.rb
125
+ - lib/bybit/rest_api/spot_margin_service.rb
126
+ - lib/bybit/rest_api/trade_service.rb
127
+ - lib/bybit/rest_api/user_service.rb
128
+ - lib/bybit/session.rb
129
+ - lib/bybit/utils/wire_keys.rb
130
+ - lib/bybit/version.rb
131
+ homepage: https://github.com/bybit-exchange/bybit.ruby.api
132
+ licenses:
133
+ - MIT
134
+ metadata:
135
+ source_code_uri: https://github.com/bybit-exchange/bybit.ruby.api
136
+ documentation_uri: https://bybit-exchange.github.io/docs/v5/intro
137
+ changelog_uri: https://github.com/bybit-exchange/bybit.ruby.api/blob/main/CHANGELOG.md
138
+ rubygems_mfa_required: 'true'
139
+ post_install_message:
140
+ rdoc_options: []
141
+ require_paths:
142
+ - lib
143
+ required_ruby_version: !ruby/object:Gem::Requirement
144
+ requirements:
145
+ - - ">="
146
+ - !ruby/object:Gem::Version
147
+ version: '3.0'
148
+ required_rubygems_version: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
+ requirements: []
154
+ rubygems_version: 3.0.3.1
155
+ signing_key:
156
+ specification_version: 4
157
+ summary: Official Bybit V5 REST API connector for Ruby
158
+ test_files: []