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.
- checksums.yaml +7 -0
- data/.github/workflows/ci.yml +55 -0
- data/.github/workflows/release.yml +138 -0
- data/.rubocop.yml +56 -0
- data/AGENT.md +352 -0
- data/README.md +224 -0
- data/bin/console +59 -0
- data/docs/README.md +29 -0
- data/docs/coindcx_docs_gaps.md +3 -0
- data/docs/core.md +179 -0
- data/docs/rails_integration.md +151 -0
- data/docs/standalone_bot.md +159 -0
- data/lib/coindcx/auth/signer.rb +48 -0
- data/lib/coindcx/client.rb +44 -0
- data/lib/coindcx/configuration.rb +108 -0
- data/lib/coindcx/contracts/channel_name.rb +23 -0
- data/lib/coindcx/contracts/identifiers.rb +36 -0
- data/lib/coindcx/contracts/order_request.rb +120 -0
- data/lib/coindcx/contracts/socket_backend.rb +19 -0
- data/lib/coindcx/contracts/wallet_transfer_request.rb +46 -0
- data/lib/coindcx/errors/base_error.rb +54 -0
- data/lib/coindcx/logging/null_logger.rb +12 -0
- data/lib/coindcx/logging/structured_logger.rb +17 -0
- data/lib/coindcx/models/balance.rb +8 -0
- data/lib/coindcx/models/base_model.rb +31 -0
- data/lib/coindcx/models/instrument.rb +8 -0
- data/lib/coindcx/models/market.rb +8 -0
- data/lib/coindcx/models/order.rb +8 -0
- data/lib/coindcx/models/trade.rb +8 -0
- data/lib/coindcx/rest/base_resource.rb +35 -0
- data/lib/coindcx/rest/funding/facade.rb +18 -0
- data/lib/coindcx/rest/funding/orders.rb +46 -0
- data/lib/coindcx/rest/futures/facade.rb +29 -0
- data/lib/coindcx/rest/futures/market_data.rb +71 -0
- data/lib/coindcx/rest/futures/orders.rb +47 -0
- data/lib/coindcx/rest/futures/positions.rb +93 -0
- data/lib/coindcx/rest/futures/wallets.rb +44 -0
- data/lib/coindcx/rest/margin/facade.rb +17 -0
- data/lib/coindcx/rest/margin/orders.rb +57 -0
- data/lib/coindcx/rest/public/facade.rb +17 -0
- data/lib/coindcx/rest/public/market_data.rb +52 -0
- data/lib/coindcx/rest/spot/facade.rb +17 -0
- data/lib/coindcx/rest/spot/orders.rb +67 -0
- data/lib/coindcx/rest/transfers/facade.rb +17 -0
- data/lib/coindcx/rest/transfers/wallets.rb +40 -0
- data/lib/coindcx/rest/user/accounts.rb +17 -0
- data/lib/coindcx/rest/user/facade.rb +17 -0
- data/lib/coindcx/transport/circuit_breaker.rb +65 -0
- data/lib/coindcx/transport/http_client.rb +290 -0
- data/lib/coindcx/transport/rate_limit_registry.rb +65 -0
- data/lib/coindcx/transport/request_policy.rb +152 -0
- data/lib/coindcx/transport/response_normalizer.rb +40 -0
- data/lib/coindcx/transport/retry_policy.rb +79 -0
- data/lib/coindcx/utils/payload.rb +51 -0
- data/lib/coindcx/version.rb +5 -0
- data/lib/coindcx/ws/connection_manager.rb +423 -0
- data/lib/coindcx/ws/connection_state.rb +75 -0
- data/lib/coindcx/ws/parsers/order_book_snapshot.rb +42 -0
- data/lib/coindcx/ws/private_channels.rb +38 -0
- data/lib/coindcx/ws/public_channels.rb +92 -0
- data/lib/coindcx/ws/socket_io_client.rb +89 -0
- data/lib/coindcx/ws/socket_io_simple_backend.rb +63 -0
- data/lib/coindcx/ws/subscription_registry.rb +80 -0
- data/lib/coindcx/ws/uri_ruby3_compat.rb +13 -0
- data/lib/coindcx.rb +63 -0
- data/spec/auth_signer_spec.rb +22 -0
- data/spec/client_spec.rb +19 -0
- data/spec/contracts/order_request_spec.rb +136 -0
- data/spec/contracts/wallet_transfer_request_spec.rb +45 -0
- data/spec/models/base_model_spec.rb +18 -0
- data/spec/rest/funding/orders_spec.rb +43 -0
- data/spec/rest/futures/market_data_spec.rb +49 -0
- data/spec/rest/futures/orders_spec.rb +107 -0
- data/spec/rest/futures/positions_spec.rb +57 -0
- data/spec/rest/futures/wallets_spec.rb +44 -0
- data/spec/rest/margin/orders_spec.rb +87 -0
- data/spec/rest/public/market_data_spec.rb +31 -0
- data/spec/rest/spot/orders_spec.rb +152 -0
- data/spec/rest/transfers/wallets_spec.rb +33 -0
- data/spec/rest/user/accounts_spec.rb +21 -0
- data/spec/spec_helper.rb +11 -0
- data/spec/transport/http_client_spec.rb +232 -0
- data/spec/transport/rate_limit_registry_spec.rb +28 -0
- data/spec/transport/request_policy_spec.rb +67 -0
- data/spec/transport/response_normalizer_spec.rb +63 -0
- data/spec/ws/connection_manager_spec.rb +339 -0
- data/spec/ws/order_book_snapshot_spec.rb +25 -0
- data/spec/ws/private_channels_spec.rb +28 -0
- data/spec/ws/public_channels_spec.rb +89 -0
- data/spec/ws/socket_io_client_spec.rb +229 -0
- data/spec/ws/socket_io_simple_backend_spec.rb +41 -0
- data/spec/ws/uri_ruby3_compat_spec.rb +12 -0
- 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
|