coindcx-client 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.
Files changed (93) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/ci.yml +55 -0
  3. data/.github/workflows/release.yml +138 -0
  4. data/.rubocop.yml +56 -0
  5. data/AGENT.md +352 -0
  6. data/README.md +224 -0
  7. data/bin/console +59 -0
  8. data/docs/README.md +29 -0
  9. data/docs/coindcx_docs_gaps.md +3 -0
  10. data/docs/core.md +179 -0
  11. data/docs/rails_integration.md +151 -0
  12. data/docs/standalone_bot.md +159 -0
  13. data/lib/coindcx/auth/signer.rb +48 -0
  14. data/lib/coindcx/client.rb +44 -0
  15. data/lib/coindcx/configuration.rb +108 -0
  16. data/lib/coindcx/contracts/channel_name.rb +23 -0
  17. data/lib/coindcx/contracts/identifiers.rb +36 -0
  18. data/lib/coindcx/contracts/order_request.rb +120 -0
  19. data/lib/coindcx/contracts/socket_backend.rb +19 -0
  20. data/lib/coindcx/contracts/wallet_transfer_request.rb +46 -0
  21. data/lib/coindcx/errors/base_error.rb +54 -0
  22. data/lib/coindcx/logging/null_logger.rb +12 -0
  23. data/lib/coindcx/logging/structured_logger.rb +17 -0
  24. data/lib/coindcx/models/balance.rb +8 -0
  25. data/lib/coindcx/models/base_model.rb +31 -0
  26. data/lib/coindcx/models/instrument.rb +8 -0
  27. data/lib/coindcx/models/market.rb +8 -0
  28. data/lib/coindcx/models/order.rb +8 -0
  29. data/lib/coindcx/models/trade.rb +8 -0
  30. data/lib/coindcx/rest/base_resource.rb +35 -0
  31. data/lib/coindcx/rest/funding/facade.rb +18 -0
  32. data/lib/coindcx/rest/funding/orders.rb +46 -0
  33. data/lib/coindcx/rest/futures/facade.rb +29 -0
  34. data/lib/coindcx/rest/futures/market_data.rb +71 -0
  35. data/lib/coindcx/rest/futures/orders.rb +47 -0
  36. data/lib/coindcx/rest/futures/positions.rb +93 -0
  37. data/lib/coindcx/rest/futures/wallets.rb +44 -0
  38. data/lib/coindcx/rest/margin/facade.rb +17 -0
  39. data/lib/coindcx/rest/margin/orders.rb +57 -0
  40. data/lib/coindcx/rest/public/facade.rb +17 -0
  41. data/lib/coindcx/rest/public/market_data.rb +52 -0
  42. data/lib/coindcx/rest/spot/facade.rb +17 -0
  43. data/lib/coindcx/rest/spot/orders.rb +67 -0
  44. data/lib/coindcx/rest/transfers/facade.rb +17 -0
  45. data/lib/coindcx/rest/transfers/wallets.rb +40 -0
  46. data/lib/coindcx/rest/user/accounts.rb +17 -0
  47. data/lib/coindcx/rest/user/facade.rb +17 -0
  48. data/lib/coindcx/transport/circuit_breaker.rb +65 -0
  49. data/lib/coindcx/transport/http_client.rb +290 -0
  50. data/lib/coindcx/transport/rate_limit_registry.rb +65 -0
  51. data/lib/coindcx/transport/request_policy.rb +152 -0
  52. data/lib/coindcx/transport/response_normalizer.rb +40 -0
  53. data/lib/coindcx/transport/retry_policy.rb +79 -0
  54. data/lib/coindcx/utils/payload.rb +51 -0
  55. data/lib/coindcx/version.rb +5 -0
  56. data/lib/coindcx/ws/connection_manager.rb +423 -0
  57. data/lib/coindcx/ws/connection_state.rb +75 -0
  58. data/lib/coindcx/ws/parsers/order_book_snapshot.rb +42 -0
  59. data/lib/coindcx/ws/private_channels.rb +38 -0
  60. data/lib/coindcx/ws/public_channels.rb +92 -0
  61. data/lib/coindcx/ws/socket_io_client.rb +89 -0
  62. data/lib/coindcx/ws/socket_io_simple_backend.rb +63 -0
  63. data/lib/coindcx/ws/subscription_registry.rb +80 -0
  64. data/lib/coindcx/ws/uri_ruby3_compat.rb +13 -0
  65. data/lib/coindcx.rb +63 -0
  66. data/spec/auth_signer_spec.rb +22 -0
  67. data/spec/client_spec.rb +19 -0
  68. data/spec/contracts/order_request_spec.rb +136 -0
  69. data/spec/contracts/wallet_transfer_request_spec.rb +45 -0
  70. data/spec/models/base_model_spec.rb +18 -0
  71. data/spec/rest/funding/orders_spec.rb +43 -0
  72. data/spec/rest/futures/market_data_spec.rb +49 -0
  73. data/spec/rest/futures/orders_spec.rb +107 -0
  74. data/spec/rest/futures/positions_spec.rb +57 -0
  75. data/spec/rest/futures/wallets_spec.rb +44 -0
  76. data/spec/rest/margin/orders_spec.rb +87 -0
  77. data/spec/rest/public/market_data_spec.rb +31 -0
  78. data/spec/rest/spot/orders_spec.rb +152 -0
  79. data/spec/rest/transfers/wallets_spec.rb +33 -0
  80. data/spec/rest/user/accounts_spec.rb +21 -0
  81. data/spec/spec_helper.rb +11 -0
  82. data/spec/transport/http_client_spec.rb +232 -0
  83. data/spec/transport/rate_limit_registry_spec.rb +28 -0
  84. data/spec/transport/request_policy_spec.rb +67 -0
  85. data/spec/transport/response_normalizer_spec.rb +63 -0
  86. data/spec/ws/connection_manager_spec.rb +339 -0
  87. data/spec/ws/order_book_snapshot_spec.rb +25 -0
  88. data/spec/ws/private_channels_spec.rb +28 -0
  89. data/spec/ws/public_channels_spec.rb +89 -0
  90. data/spec/ws/socket_io_client_spec.rb +229 -0
  91. data/spec/ws/socket_io_simple_backend_spec.rb +41 -0
  92. data/spec/ws/uri_ruby3_compat_spec.rb +12 -0
  93. metadata +164 -0
@@ -0,0 +1,423 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module CoinDCX
6
+ module WS
7
+ # rubocop:disable Metrics/ClassLength
8
+ class ConnectionManager
9
+ MAX_RETRIES = 5
10
+ MAX_BACKOFF_INTERVAL = 30.0
11
+
12
+ def initialize(configuration:, backend:, logger:, sleeper: Kernel, thread_factory: nil, monotonic_clock: nil, randomizer: nil)
13
+ @configuration = configuration
14
+ @backend = backend
15
+ @logger = logger
16
+ @sleeper = sleeper
17
+ @thread_factory = thread_factory || ->(&block) { Thread.new(&block) }
18
+ @monotonic_clock = monotonic_clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
19
+ @randomizer = randomizer || -> { rand }
20
+ @state = ConnectionState.new
21
+ @subscriptions = SubscriptionRegistry.new
22
+ @handlers = Hash.new { |hash, key| hash[key] = [] }
23
+ @registered_event_names = Set.new
24
+ @mutex = Mutex.new
25
+ # Lock-free liveness clock: Socket.IO callbacks and the heartbeat thread both touch this.
26
+ # Using Mutex here caused rare ThreadError (unlock from wrong thread/fiber) under concurrent WS load.
27
+ @last_activity_usec = (monotonic_time * 1_000_000).to_i
28
+ @engine_io_open = false
29
+ end
30
+
31
+ def connect
32
+ return self if state.connected? || state.connecting? || state.reconnecting?
33
+
34
+ transition_to(:connecting)
35
+ connect_with_retries
36
+ start_heartbeat
37
+ self
38
+ end
39
+
40
+ def disconnect
41
+ return self if state.current == :disconnected || state.stopping?
42
+
43
+ transition_to(:stopping)
44
+ stop_heartbeat
45
+ backend.disconnect
46
+ transition_to(:disconnected)
47
+ self
48
+ rescue Errors::SocketError
49
+ transition_to(:disconnected)
50
+ self
51
+ end
52
+
53
+ def on(event_name, &block)
54
+ return self unless block_given?
55
+
56
+ mutex.synchronize do
57
+ handlers[event_name] << block
58
+ register_event_bridge(event_name) if state.connected?
59
+ end
60
+
61
+ self
62
+ end
63
+
64
+ def subscribe(type:, channel_name:, event_name:, payload_builder:, delivery_mode:)
65
+ subscriptions.add(
66
+ type: type,
67
+ channel_name: channel_name,
68
+ event_name: event_name,
69
+ payload_builder: payload_builder,
70
+ delivery_mode: delivery_mode
71
+ )
72
+ register_event_bridge(event_name) if state.connected?
73
+ emit_join(subscription_for(type: type, channel_name: channel_name, event_name: event_name)) if state.connected? && @engine_io_open
74
+ transition_to(:subscribed) if state.connected? && subscriptions.any?
75
+ self
76
+ end
77
+
78
+ def alive?
79
+ return false unless state.connected?
80
+ return true unless subscriptions.any?
81
+
82
+ !stale_connection?
83
+ end
84
+
85
+ private
86
+
87
+ attr_reader :configuration, :backend, :logger, :sleeper, :thread_factory,
88
+ :monotonic_clock, :randomizer, :state, :subscriptions, :handlers, :mutex,
89
+ :registered_event_names
90
+
91
+ # rubocop:disable Metrics/MethodLength
92
+ def connect_with_retries
93
+ attempts = 0
94
+
95
+ begin
96
+ attempts += 1
97
+ establish_connection
98
+ rescue Errors::SocketAuthenticationError => e
99
+ transition_to(:failed)
100
+ log(
101
+ :error,
102
+ event: "ws_failed",
103
+ endpoint: configuration.socket_base_url,
104
+ retries: attempts - 1,
105
+ latency: nil,
106
+ error_class: e.class.name,
107
+ message: e.message,
108
+ subscription_count: subscriptions.count
109
+ )
110
+ raise e
111
+ rescue Errors::SocketConnectionError => e
112
+ handle_reconnect_failure(attempts, e)
113
+ retry
114
+ end
115
+ end
116
+ # rubocop:enable Metrics/MethodLength
117
+
118
+ def establish_connection
119
+ @engine_io_open = false
120
+ backend.connect(configuration.socket_base_url)
121
+ register_runtime_handlers
122
+ backend.start_transport!
123
+ touch_activity!
124
+ transition_to(:authenticated)
125
+ end
126
+
127
+ def register_runtime_handlers
128
+ @registered_event_names = Set.new
129
+ # socket.io-client-simple uses event_emitter's instance_exec(socket, *args) for listeners,
130
+ # so blocks must not rely on implicit self (handle_* would resolve on the socket client).
131
+ manager = self
132
+ backend.on(:connect) { |*_args| manager.send(:handle_connect) }
133
+ backend.on(:disconnect) { |*_args| manager.send(:handle_disconnect) }
134
+ subscriptions.event_names.each { |event_name| register_event_bridge(event_name) }
135
+ end
136
+
137
+ def handle_connect
138
+ touch_activity!
139
+ @engine_io_open = true
140
+ resubscribe_all
141
+ transition_to(subscriptions.any? ? :subscribed : :authenticated)
142
+ end
143
+
144
+ def handle_disconnect
145
+ return if state.stopping? || state.reconnecting?
146
+
147
+ @engine_io_open = false
148
+ transition_to(:disconnected)
149
+ log(
150
+ :warn,
151
+ event: "ws_disconnected",
152
+ endpoint: configuration.socket_base_url,
153
+ retries: 0,
154
+ latency: nil,
155
+ subscription_count: subscriptions.count
156
+ )
157
+ reconnect
158
+ end
159
+
160
+ def reconnect
161
+ return unless begin_reconnect?
162
+
163
+ transition_to(:reconnecting)
164
+ stop_heartbeat
165
+ backend.disconnect
166
+ connect_with_retries
167
+ start_heartbeat
168
+ ensure
169
+ finish_reconnect
170
+ end
171
+
172
+ def begin_reconnect?
173
+ mutex.synchronize do
174
+ return false if @reconnecting
175
+
176
+ @reconnecting = true
177
+ end
178
+
179
+ true
180
+ end
181
+
182
+ def finish_reconnect
183
+ mutex.synchronize { @reconnecting = false }
184
+ end
185
+
186
+ def resubscribe_all
187
+ subscriptions.each { |subscription| emit_join(subscription) }
188
+ end
189
+
190
+ def emit_join(subscription)
191
+ payload = subscription.payload
192
+ backend.emit("join", payload)
193
+ rescue Errors::AuthError => e
194
+ raise Errors::SocketAuthenticationError,
195
+ "private websocket authentication failed for #{subscription.channel_name}: #{e.message}"
196
+ end
197
+
198
+ def register_event_bridge(event_name)
199
+ return if registered_event_names.include?(event_name)
200
+
201
+ manager = self
202
+ # Socket.IO may emit multiple data frames after the event name, e.g. [channelName, payloadHash].
203
+ # Forwarding only the first argument often yields a bare String and drops the price object.
204
+ backend.on(event_name) do |*args|
205
+ manager.send(:touch_activity!)
206
+ coalesced = manager.send(:coalesce_socket_event_payload, args)
207
+ normalized = manager.send(:normalize_coin_dcx_event_payload, coalesced)
208
+ manager.send(:dispatch, event_name, normalized)
209
+ end
210
+
211
+ registered_event_names << event_name
212
+ end
213
+
214
+ def coalesce_socket_event_payload(args)
215
+ parts = Array(args).flatten(1).compact
216
+ return nil if parts.empty?
217
+ return parts.first if parts.size == 1
218
+
219
+ hashes = parts.grep(Hash)
220
+ return hashes.reduce { |acc, h| acc.merge(h) } if hashes.size > 1
221
+ return hashes.first if hashes.size == 1
222
+
223
+ parts.last
224
+ end
225
+
226
+ # CoinDCX often sends { "event" => "...", "data" => "<JSON string of fields>" }. Merge parsed
227
+ # fields into the top-level hash so consumers see p / s / etc. without a second parse.
228
+ # rubocop:disable Metrics/CyclomaticComplexity
229
+ def normalize_coin_dcx_event_payload(payload)
230
+ return payload unless payload.is_a?(Hash)
231
+
232
+ %w[data payload].each do |key|
233
+ raw = payload[key] || payload[key.to_sym]
234
+ next unless raw.is_a?(String) && !raw.strip.empty?
235
+
236
+ parsed = JSON.parse(raw)
237
+ next unless parsed.is_a?(Hash)
238
+
239
+ merged = payload.merge(parsed)
240
+ merged.delete(key)
241
+ merged.delete(key.to_sym)
242
+ return merged
243
+ end
244
+
245
+ payload
246
+ rescue JSON::ParserError
247
+ payload
248
+ end
249
+ # rubocop:enable Metrics/CyclomaticComplexity
250
+
251
+ def dispatch(event_name, payload)
252
+ handlers.fetch(event_name, []).each do |handler|
253
+ handler.call(payload)
254
+ rescue StandardError => e
255
+ log(
256
+ :error,
257
+ event: "ws_handler_error",
258
+ endpoint: event_name,
259
+ retries: 0,
260
+ latency: nil,
261
+ error_class: e.class.name,
262
+ message: e.message
263
+ )
264
+ end
265
+ end
266
+
267
+ def start_heartbeat
268
+ stop_heartbeat
269
+ token = Object.new
270
+ @heartbeat_token = token
271
+ @heartbeat_thread = thread_factory.call { heartbeat_loop(token) }
272
+ end
273
+
274
+ def stop_heartbeat
275
+ @heartbeat_token = nil
276
+ @heartbeat_thread = nil
277
+ end
278
+
279
+ def heartbeat_loop(token)
280
+ loop do
281
+ sleeper.sleep(configuration.socket_heartbeat_interval)
282
+ break unless @heartbeat_token.equal?(token)
283
+
284
+ check_liveness!
285
+ end
286
+ rescue StandardError => e
287
+ log(
288
+ :error,
289
+ event: "ws_heartbeat_failed",
290
+ endpoint: configuration.socket_base_url,
291
+ retries: 0,
292
+ latency: nil,
293
+ error_class: e.class.name,
294
+ message: e.message
295
+ )
296
+ end
297
+
298
+ # rubocop:disable Metrics/MethodLength
299
+ def check_liveness!
300
+ return unless heartbeat_required?
301
+ return unless stale_connection?
302
+
303
+ timeout_error = Errors::SocketHeartbeatTimeoutError.new(
304
+ "CoinDCX websocket heartbeat timed out",
305
+ category: :socket_timeout,
306
+ code: "socket_heartbeat_timeout",
307
+ retryable: true
308
+ )
309
+ log(
310
+ :warn,
311
+ event: "ws_heartbeat_stale",
312
+ endpoint: configuration.socket_base_url,
313
+ retries: 0,
314
+ latency: liveness_age,
315
+ subscription_count: subscriptions.count,
316
+ error_class: timeout_error.class.name,
317
+ message: timeout_error.message
318
+ )
319
+ reconnect
320
+ end
321
+ # rubocop:enable Metrics/MethodLength
322
+
323
+ def heartbeat_required?
324
+ return false unless state.connected?
325
+
326
+ subscriptions.any?
327
+ end
328
+
329
+ def stale_connection?
330
+ liveness_age > configuration.socket_liveness_timeout
331
+ end
332
+
333
+ def liveness_age
334
+ now_usec = (monotonic_time * 1_000_000).to_i
335
+ (now_usec - @last_activity_usec) / 1_000_000.0
336
+ end
337
+
338
+ def touch_activity!
339
+ @last_activity_usec = (monotonic_time * 1_000_000).to_i
340
+ end
341
+
342
+ def monotonic_time
343
+ monotonic_clock.call
344
+ end
345
+
346
+ def reconnect_interval(attempts)
347
+ raw = configuration.socket_reconnect_interval * (2**(attempts - 1))
348
+ base = [raw, MAX_BACKOFF_INTERVAL].min
349
+ base + (base * 0.25 * randomizer.call)
350
+ end
351
+
352
+ def max_retries
353
+ configuration.socket_reconnect_attempts || MAX_RETRIES
354
+ end
355
+
356
+ # rubocop:disable Metrics/MethodLength
357
+ def handle_reconnect_failure(attempts, error)
358
+ if attempts > max_retries
359
+ transition_to(:failed)
360
+ log(
361
+ :error,
362
+ event: "ws_failed",
363
+ endpoint: configuration.socket_base_url,
364
+ retries: attempts - 1,
365
+ latency: nil,
366
+ error_class: error.class.name,
367
+ message: error.message,
368
+ subscription_count: subscriptions.count
369
+ )
370
+ raise error
371
+ end
372
+
373
+ transition_to(:reconnecting)
374
+ sleep_interval = reconnect_interval(attempts)
375
+ log(
376
+ :warn,
377
+ event: "ws_reconnect_retry",
378
+ endpoint: configuration.socket_base_url,
379
+ retries: attempts,
380
+ latency: nil,
381
+ error_class: error.class.name,
382
+ message: error.message,
383
+ sleep_interval: sleep_interval,
384
+ subscription_count: subscriptions.count
385
+ )
386
+ sleeper.sleep(sleep_interval)
387
+ end
388
+ # rubocop:enable Metrics/MethodLength
389
+
390
+ def transition_to(next_state)
391
+ previous_state = state.current
392
+ return if previous_state == next_state
393
+
394
+ state.transition_to(next_state)
395
+ log(
396
+ :info,
397
+ event: "ws_state_transition",
398
+ endpoint: configuration.socket_base_url,
399
+ retries: 0,
400
+ latency: nil,
401
+ from: previous_state,
402
+ to: next_state,
403
+ subscription_count: subscriptions.count
404
+ )
405
+ end
406
+
407
+ def subscription_for(type:, channel_name:, event_name:)
408
+ subscriptions.each do |subscription|
409
+ return subscription if subscription.type == type &&
410
+ subscription.channel_name == channel_name &&
411
+ subscription.event_name == event_name
412
+ end
413
+
414
+ raise Errors::SocketStateError, "subscription intent not registered for #{event_name}"
415
+ end
416
+
417
+ def log(level, payload)
418
+ Logging::StructuredLogger.log(logger, level, payload)
419
+ end
420
+ end
421
+ # rubocop:enable Metrics/ClassLength
422
+ end
423
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module WS
5
+ class ConnectionState
6
+ VALID_STATES = %i[disconnected connecting authenticated subscribed reconnecting failed stopping].freeze
7
+
8
+ # Allowed forward-transitions for each state. Any transition not listed here
9
+ # is a programming error and will raise +SocketStateError+.
10
+ VALID_TRANSITIONS = {
11
+ disconnected: %i[connecting reconnecting],
12
+ connecting: %i[authenticated failed reconnecting stopping],
13
+ authenticated: %i[subscribed reconnecting stopping disconnected],
14
+ subscribed: %i[reconnecting stopping authenticated disconnected],
15
+ reconnecting: %i[authenticated subscribed failed stopping disconnected],
16
+ failed: %i[connecting stopping disconnected],
17
+ stopping: %i[disconnected]
18
+ }.freeze
19
+
20
+ def initialize
21
+ @value = :disconnected
22
+ @mutex = Mutex.new
23
+ end
24
+
25
+ def transition_to(next_state)
26
+ raise Errors::SocketError, "invalid connection state: #{next_state.inspect}" unless VALID_STATES.include?(next_state)
27
+
28
+ @mutex.synchronize do
29
+ return if @value == next_state
30
+
31
+ allowed = VALID_TRANSITIONS.fetch(@value, [])
32
+ unless allowed.include?(next_state)
33
+ raise Errors::SocketStateError,
34
+ "invalid state transition: #{@value} → #{next_state} " \
35
+ "(allowed from #{@value}: #{allowed.inspect})"
36
+ end
37
+
38
+ @value = next_state
39
+ end
40
+ end
41
+
42
+ def current
43
+ @mutex.synchronize { @value }
44
+ end
45
+
46
+ def connected?
47
+ authenticated? || subscribed?
48
+ end
49
+
50
+ def connecting?
51
+ current == :connecting
52
+ end
53
+
54
+ def authenticated?
55
+ current == :authenticated
56
+ end
57
+
58
+ def subscribed?
59
+ current == :subscribed
60
+ end
61
+
62
+ def reconnecting?
63
+ current == :reconnecting
64
+ end
65
+
66
+ def failed?
67
+ current == :failed
68
+ end
69
+
70
+ def stopping?
71
+ current == :stopping
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module WS
5
+ module Parsers
6
+ class OrderBookSnapshot
7
+ MAXIMUM_RECENT_ORDERS = 50
8
+
9
+ def self.parse(payload)
10
+ new(payload).to_h
11
+ end
12
+
13
+ def initialize(payload)
14
+ @payload = Utils::Payload.symbolize_keys(payload || {})
15
+ end
16
+
17
+ def to_h
18
+ {
19
+ source: :snapshot,
20
+ maximum_recent_orders: MAXIMUM_RECENT_ORDERS,
21
+ timestamp: payload[:E] || payload[:ts] || payload[:timestamp],
22
+ version: payload[:vs],
23
+ bids: levels_for(payload[:bids]),
24
+ asks: levels_for(payload[:asks])
25
+ }.compact
26
+ end
27
+
28
+ private
29
+
30
+ attr_reader :payload
31
+
32
+ def levels_for(levels)
33
+ return [] unless levels.is_a?(Hash)
34
+
35
+ levels.map do |price, quantity|
36
+ { price: price.to_s, quantity: quantity.to_s }
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module WS
5
+ class PrivateChannels
6
+ DEFAULT_CHANNEL_NAME = "coindcx"
7
+ BALANCE_UPDATE_EVENT = "balance-update"
8
+ ORDER_UPDATE_EVENT = "order-update"
9
+ TRADE_UPDATE_EVENT = "trade-update"
10
+ # Derivatives (futures) user stream — same authenticated `coindcx` channel as spot private.
11
+ DF_POSITION_UPDATE_EVENT = "df-position-update"
12
+ DF_ORDER_UPDATE_EVENT = "df-order-update"
13
+
14
+ def initialize(configuration:)
15
+ @configuration = configuration
16
+ end
17
+
18
+ def join_payload(channel_name: DEFAULT_CHANNEL_NAME)
19
+ signer.private_channel_join(channel_name: channel_name)
20
+ end
21
+
22
+ private
23
+
24
+ attr_reader :configuration
25
+
26
+ def signer
27
+ @signer ||= Auth::Signer.new(
28
+ api_key: configuration.api_key || missing_configuration!(:api_key),
29
+ api_secret: configuration.api_secret || missing_configuration!(:api_secret)
30
+ )
31
+ end
32
+
33
+ def missing_configuration!(setting_name)
34
+ raise Errors::AuthenticationError, "#{setting_name} is required for private websocket subscriptions"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module WS
5
+ module PublicChannels
6
+ VALID_SPOT_ORDER_BOOK_DEPTHS = [10, 20, 50].freeze
7
+ VALID_FUTURES_ORDER_BOOK_DEPTHS = VALID_SPOT_ORDER_BOOK_DEPTHS
8
+ VALID_CURRENT_PRICES_SPOT_INTERVALS = %w[1s 10s].freeze
9
+ CURRENT_PRICES_SPOT_UPDATE_EVENT = "currentPrices@spot#update"
10
+ CURRENT_PRICES_FUTURES_CHANNEL = "currentPrices@futures@rt"
11
+ CURRENT_PRICES_FUTURES_UPDATE_EVENT = "currentPrices@futures#update"
12
+ PRICE_STATS_SPOT_UPDATE_EVENT = "priceStats@spot#update"
13
+ CANDLESTICK_EVENT = "candlestick"
14
+ DEPTH_SNAPSHOT_EVENT = "depth-snapshot"
15
+ DEPTH_UPDATE_EVENT = "depth-update"
16
+ NEW_TRADE_EVENT = "new-trade"
17
+ PRICE_CHANGE_EVENT = "price-change"
18
+
19
+ module_function
20
+
21
+ def candlestick(pair:, interval:)
22
+ "#{Contracts::Identifiers.validate_pair!(pair)}_#{interval}"
23
+ end
24
+
25
+ def order_book(pair:, depth: 20)
26
+ return "#{Contracts::Identifiers.validate_pair!(pair)}@orderbook@#{depth}" if VALID_SPOT_ORDER_BOOK_DEPTHS.include?(depth)
27
+
28
+ raise Errors::ValidationError,
29
+ "spot order book updates are snapshot-based and only documented for depths #{VALID_SPOT_ORDER_BOOK_DEPTHS.sort.join(', ')}"
30
+ end
31
+
32
+ def current_prices_spot(interval:)
33
+ key = interval.to_s.strip
34
+ unless VALID_CURRENT_PRICES_SPOT_INTERVALS.include?(key)
35
+ raise Errors::ValidationError,
36
+ "currentPrices@spot intervals must be one of #{VALID_CURRENT_PRICES_SPOT_INTERVALS.join(', ')}"
37
+ end
38
+
39
+ "currentPrices@spot@#{key}"
40
+ end
41
+
42
+ def price_stats_spot
43
+ "priceStats@spot@60s"
44
+ end
45
+
46
+ def price_stats(pair:)
47
+ "#{Contracts::Identifiers.validate_pair!(pair)}@prices"
48
+ end
49
+
50
+ def ltp(pair:)
51
+ price_stats(pair: pair)
52
+ end
53
+
54
+ def new_trade(pair:)
55
+ "#{Contracts::Identifiers.validate_pair!(pair)}@trades"
56
+ end
57
+
58
+ def futures_candlestick(instrument:, interval:)
59
+ ins = Contracts::Identifiers.validate_instrument!(instrument)
60
+ iv = interval.to_s.strip
61
+ raise Errors::ValidationError, "futures candlestick interval must be non-empty" if iv.empty?
62
+
63
+ "#{ins}_#{iv}-futures"
64
+ end
65
+
66
+ def futures_order_book(instrument:, depth: 20)
67
+ ins = Contracts::Identifiers.validate_instrument!(instrument)
68
+ return "#{ins}@orderbook@#{depth}-futures" if VALID_FUTURES_ORDER_BOOK_DEPTHS.include?(depth)
69
+
70
+ depths = VALID_FUTURES_ORDER_BOOK_DEPTHS.sort.join(", ")
71
+ raise Errors::ValidationError,
72
+ "futures order book channels are snapshot-based; documented depths are #{depths}"
73
+ end
74
+
75
+ def current_prices_futures
76
+ CURRENT_PRICES_FUTURES_CHANNEL
77
+ end
78
+
79
+ def futures_price_stats(instrument:)
80
+ "#{Contracts::Identifiers.validate_instrument!(instrument)}@prices-futures"
81
+ end
82
+
83
+ def futures_ltp(instrument:)
84
+ futures_price_stats(instrument: instrument)
85
+ end
86
+
87
+ def futures_new_trade(instrument:)
88
+ "#{Contracts::Identifiers.validate_instrument!(instrument)}@trades-futures"
89
+ end
90
+ end
91
+ end
92
+ end