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,339 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe CoinDCX::WS::ConnectionManager do
|
|
6
|
+
let(:configuration) do
|
|
7
|
+
CoinDCX::Configuration.new.tap do |config|
|
|
8
|
+
config.socket_reconnect_attempts = 2
|
|
9
|
+
config.socket_reconnect_interval = 0.01
|
|
10
|
+
config.socket_heartbeat_interval = 0.5
|
|
11
|
+
config.socket_liveness_timeout = 1.0
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
let(:backend) { instance_double("SocketBackend") }
|
|
16
|
+
let(:logger) { instance_double("Logger", info: nil, warn: nil, error: nil) }
|
|
17
|
+
let(:sleeper) { class_double(Kernel, sleep: nil) }
|
|
18
|
+
let(:now) { { value: 100.0 } }
|
|
19
|
+
let(:clock) { -> { now.fetch(:value) } }
|
|
20
|
+
let(:thread_factory) { ->(&_) {} }
|
|
21
|
+
let(:backend_listeners) { {} }
|
|
22
|
+
|
|
23
|
+
# Zero-jitter randomizer keeps sleep assertions deterministic.
|
|
24
|
+
let(:randomizer) { -> { 0.0 } }
|
|
25
|
+
|
|
26
|
+
subject(:manager) do
|
|
27
|
+
described_class.new(
|
|
28
|
+
configuration: configuration,
|
|
29
|
+
backend: backend,
|
|
30
|
+
logger: logger,
|
|
31
|
+
sleeper: sleeper,
|
|
32
|
+
thread_factory: thread_factory,
|
|
33
|
+
monotonic_clock: clock,
|
|
34
|
+
randomizer: randomizer
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
before do
|
|
39
|
+
allow(backend).to receive(:connect)
|
|
40
|
+
allow(backend).to receive(:start_transport!)
|
|
41
|
+
allow(backend).to receive(:on) do |event, &block|
|
|
42
|
+
backend_listeners[event] = block
|
|
43
|
+
end
|
|
44
|
+
allow(backend).to receive(:emit)
|
|
45
|
+
allow(backend).to receive(:disconnect)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def fire_engine_io_open!
|
|
49
|
+
backend_listeners[:connect]&.call
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
describe "subscription recovery" do
|
|
53
|
+
it "reconnects and resubscribes when heartbeat liveness goes stale" do
|
|
54
|
+
manager.connect
|
|
55
|
+
fire_engine_io_open!
|
|
56
|
+
manager.subscribe(
|
|
57
|
+
type: :public,
|
|
58
|
+
channel_name: "B-BTC_USDT@prices",
|
|
59
|
+
event_name: "price-change",
|
|
60
|
+
payload_builder: -> { { "channelName" => "B-BTC_USDT@prices" } },
|
|
61
|
+
delivery_mode: :at_least_once
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
now[:value] += 2.0
|
|
65
|
+
|
|
66
|
+
manager.send(:check_liveness!)
|
|
67
|
+
|
|
68
|
+
fire_engine_io_open!
|
|
69
|
+
|
|
70
|
+
expect(backend).to have_received(:disconnect).once
|
|
71
|
+
expect(backend).to have_received(:connect).twice
|
|
72
|
+
expect(backend).to have_received(:emit).with("join", { "channelName" => "B-BTC_USDT@prices" }).twice
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it "rebuilds private subscription auth payloads after reconnect" do
|
|
76
|
+
payload_sequence = [
|
|
77
|
+
{ "channelName" => "coindcx", "authSignature" => "first", "apiKey" => "api-key" },
|
|
78
|
+
{ "channelName" => "coindcx", "authSignature" => "second", "apiKey" => "api-key" }
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
manager.connect
|
|
82
|
+
fire_engine_io_open!
|
|
83
|
+
# Include a public subscription alongside private so both reconnect paths are covered.
|
|
84
|
+
manager.subscribe(
|
|
85
|
+
type: :public,
|
|
86
|
+
channel_name: "B-BTC_USDT@prices",
|
|
87
|
+
event_name: "price-change",
|
|
88
|
+
payload_builder: -> { { "channelName" => "B-BTC_USDT@prices" } },
|
|
89
|
+
delivery_mode: :at_least_once
|
|
90
|
+
)
|
|
91
|
+
manager.subscribe(
|
|
92
|
+
type: :private,
|
|
93
|
+
channel_name: "coindcx",
|
|
94
|
+
event_name: "order-update",
|
|
95
|
+
payload_builder: -> { payload_sequence.shift },
|
|
96
|
+
delivery_mode: :at_least_once
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
now[:value] += 2.0
|
|
100
|
+
|
|
101
|
+
manager.send(:check_liveness!)
|
|
102
|
+
|
|
103
|
+
fire_engine_io_open!
|
|
104
|
+
|
|
105
|
+
expect(backend).to have_received(:emit).with(
|
|
106
|
+
"join",
|
|
107
|
+
{ "channelName" => "B-BTC_USDT@prices" }
|
|
108
|
+
).twice
|
|
109
|
+
expect(backend).to have_received(:emit).with(
|
|
110
|
+
"join",
|
|
111
|
+
{ "channelName" => "coindcx", "authSignature" => "first", "apiKey" => "api-key" }
|
|
112
|
+
).once
|
|
113
|
+
expect(backend).to have_received(:emit).with(
|
|
114
|
+
"join",
|
|
115
|
+
{ "channelName" => "coindcx", "authSignature" => "second", "apiKey" => "api-key" }
|
|
116
|
+
).once
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
it "reconnects a private-only subscription that exceeds the liveness timeout" do
|
|
120
|
+
manager.connect
|
|
121
|
+
fire_engine_io_open!
|
|
122
|
+
manager.subscribe(
|
|
123
|
+
type: :private,
|
|
124
|
+
channel_name: "coindcx",
|
|
125
|
+
event_name: "order-update",
|
|
126
|
+
payload_builder: -> { { "channelName" => "coindcx", "authSignature" => "fresh", "apiKey" => "api-key" } },
|
|
127
|
+
delivery_mode: :at_least_once
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
now[:value] += 2.0 # exceed liveness_timeout of 1.0s
|
|
131
|
+
|
|
132
|
+
manager.send(:check_liveness!)
|
|
133
|
+
fire_engine_io_open!
|
|
134
|
+
|
|
135
|
+
expect(backend).to have_received(:disconnect).once
|
|
136
|
+
expect(backend).to have_received(:connect).twice
|
|
137
|
+
expect(backend).to have_received(:emit).with(
|
|
138
|
+
"join",
|
|
139
|
+
{ "channelName" => "coindcx", "authSignature" => "fresh", "apiKey" => "api-key" }
|
|
140
|
+
).twice
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it "does not reconnect a private subscription still within the liveness window" do
|
|
144
|
+
manager.connect
|
|
145
|
+
fire_engine_io_open!
|
|
146
|
+
manager.subscribe(
|
|
147
|
+
type: :private,
|
|
148
|
+
channel_name: "coindcx",
|
|
149
|
+
event_name: "order-update",
|
|
150
|
+
payload_builder: -> { { "channelName" => "coindcx", "authSignature" => "first", "apiKey" => "api-key" } },
|
|
151
|
+
delivery_mode: :at_least_once
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
now[:value] += 0.5 # well within liveness_timeout of 1.0s
|
|
155
|
+
|
|
156
|
+
manager.send(:check_liveness!)
|
|
157
|
+
|
|
158
|
+
expect(backend).to have_received(:connect).once
|
|
159
|
+
expect(backend).not_to have_received(:disconnect)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
describe "socket.io listener binding (event_emitter uses instance_exec on the client)" do
|
|
164
|
+
it "runs disconnect without NameError when the block executes with the socket as self" do
|
|
165
|
+
manager.connect
|
|
166
|
+
manager.define_singleton_method(:reconnect) { nil }
|
|
167
|
+
|
|
168
|
+
disconnect_block = backend_listeners[:disconnect]
|
|
169
|
+
expect(disconnect_block).to be_a(Proc)
|
|
170
|
+
|
|
171
|
+
bogus_socket = Object.new
|
|
172
|
+
expect { bogus_socket.instance_exec(&disconnect_block) }.not_to raise_error
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
it "delivers public payloads to registered handlers when the bridge runs under instance_exec" do
|
|
176
|
+
received = []
|
|
177
|
+
manager.connect
|
|
178
|
+
fire_engine_io_open!
|
|
179
|
+
manager.on("price-change") { |payload| received << payload }
|
|
180
|
+
manager.subscribe(
|
|
181
|
+
type: :public,
|
|
182
|
+
channel_name: "B-BTC_USDT@prices",
|
|
183
|
+
event_name: "price-change",
|
|
184
|
+
payload_builder: -> { { "channelName" => "B-BTC_USDT@prices" } },
|
|
185
|
+
delivery_mode: :at_least_once
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
bridge = backend_listeners["price-change"]
|
|
189
|
+
expect(bridge).to be_a(Proc)
|
|
190
|
+
|
|
191
|
+
tick = { "p" => "1.0" }
|
|
192
|
+
Object.new.instance_exec(tick, &bridge)
|
|
193
|
+
|
|
194
|
+
expect(received).to eq([tick])
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
it "coalesces Socket.IO multi-arg frames into the hash payload for handlers" do
|
|
198
|
+
received = []
|
|
199
|
+
manager.connect
|
|
200
|
+
fire_engine_io_open!
|
|
201
|
+
manager.on("new-trade") { |payload| received << payload }
|
|
202
|
+
manager.subscribe(
|
|
203
|
+
type: :public,
|
|
204
|
+
channel_name: "B-BTC_USDT@trades-futures",
|
|
205
|
+
event_name: "new-trade",
|
|
206
|
+
payload_builder: -> { { "channelName" => "B-BTC_USDT@trades-futures" } },
|
|
207
|
+
delivery_mode: :at_least_once
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
bridge = backend_listeners["new-trade"]
|
|
211
|
+
channel = "B-BTC_USDT@trades-futures"
|
|
212
|
+
trade = { "p" => "2.0", "s" => "BTCUSDT" }
|
|
213
|
+
Object.new.instance_exec(channel, trade, &bridge)
|
|
214
|
+
|
|
215
|
+
expect(received).to eq([trade])
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
it "unwraps CoinDCX envelope hashes whose data field is a JSON string" do
|
|
219
|
+
received = []
|
|
220
|
+
manager.connect
|
|
221
|
+
fire_engine_io_open!
|
|
222
|
+
manager.on("price-change") { |payload| received << payload }
|
|
223
|
+
manager.subscribe(
|
|
224
|
+
type: :public,
|
|
225
|
+
channel_name: "B-BTC_USDT@prices-futures",
|
|
226
|
+
event_name: "price-change",
|
|
227
|
+
payload_builder: -> { { "channelName" => "B-BTC_USDT@prices-futures" } },
|
|
228
|
+
delivery_mode: :at_least_once
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
bridge = backend_listeners["price-change"]
|
|
232
|
+
envelope = {
|
|
233
|
+
"event" => "price-change",
|
|
234
|
+
"data" => "{\"T\":1775667421370,\"p\":\"71585.4\",\"pr\":\"f\"}"
|
|
235
|
+
}
|
|
236
|
+
Object.new.instance_exec(envelope, &bridge)
|
|
237
|
+
|
|
238
|
+
expect(received.size).to eq(1)
|
|
239
|
+
expect(received.first["p"]).to eq("71585.4")
|
|
240
|
+
expect(received.first).not_to have_key("data")
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
describe "#alive?" do
|
|
245
|
+
it "reports false after the liveness timeout elapses" do
|
|
246
|
+
manager.connect
|
|
247
|
+
fire_engine_io_open!
|
|
248
|
+
manager.subscribe(
|
|
249
|
+
type: :public,
|
|
250
|
+
channel_name: "B-BTC_USDT@prices",
|
|
251
|
+
event_name: "price-change",
|
|
252
|
+
payload_builder: -> { { "channelName" => "B-BTC_USDT@prices" } },
|
|
253
|
+
delivery_mode: :at_least_once
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
expect(manager.alive?).to be(true)
|
|
257
|
+
|
|
258
|
+
now[:value] += 2.0
|
|
259
|
+
|
|
260
|
+
expect(manager.alive?).to be(false)
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
describe "reconnect backoff" do
|
|
265
|
+
it "applies exponential backoff bounded by MAX_BACKOFF_INTERVAL with zero jitter" do
|
|
266
|
+
base = configuration.socket_reconnect_interval # 0.01
|
|
267
|
+
|
|
268
|
+
# attempts 1..3 → intervals 0.01, 0.02, 0.04 (well under ceiling)
|
|
269
|
+
expect(manager.send(:reconnect_interval, 1)).to eq(base * 1)
|
|
270
|
+
expect(manager.send(:reconnect_interval, 2)).to eq(base * 2)
|
|
271
|
+
expect(manager.send(:reconnect_interval, 3)).to eq(base * 4)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
it "caps the backoff at MAX_BACKOFF_INTERVAL" do
|
|
275
|
+
configuration.socket_reconnect_interval = 1.0
|
|
276
|
+
|
|
277
|
+
# 2^30 would be enormous; ceiling at 30s
|
|
278
|
+
result = manager.send(:reconnect_interval, 30)
|
|
279
|
+
expect(result).to eq(CoinDCX::WS::ConnectionManager::MAX_BACKOFF_INTERVAL)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
it "adds jitter proportional to the base interval" do
|
|
283
|
+
jitter_randomizer = -> { 1.0 } # max jitter: base * 0.25
|
|
284
|
+
jittery_manager = described_class.new(
|
|
285
|
+
configuration: configuration,
|
|
286
|
+
backend: backend,
|
|
287
|
+
logger: logger,
|
|
288
|
+
sleeper: sleeper,
|
|
289
|
+
thread_factory: thread_factory,
|
|
290
|
+
monotonic_clock: clock,
|
|
291
|
+
randomizer: jitter_randomizer
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
base = configuration.socket_reconnect_interval
|
|
295
|
+
expected = base + (base * 0.25)
|
|
296
|
+
expect(jittery_manager.send(:reconnect_interval, 1)).to be_within(0.0001).of(expected)
|
|
297
|
+
end
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
describe "state transition guards" do
|
|
301
|
+
it "raises SocketStateError on an illegal transition" do
|
|
302
|
+
# :disconnected may only go to :connecting or :reconnecting
|
|
303
|
+
expect do
|
|
304
|
+
manager.send(:state).transition_to(:subscribed)
|
|
305
|
+
end.to raise_error(CoinDCX::Errors::SocketStateError, /disconnected.*subscribed/)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
it "is a no-op when transitioning to the current state" do
|
|
309
|
+
expect { manager.send(:state).transition_to(:disconnected) }.not_to raise_error
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
it "allows :failed state after exhausting reconnect retries" do
|
|
313
|
+
allow(backend).to receive(:connect).and_raise(CoinDCX::Errors::SocketConnectionError, "refused")
|
|
314
|
+
|
|
315
|
+
expect { manager.connect }.to raise_error(CoinDCX::Errors::SocketConnectionError)
|
|
316
|
+
expect(manager.send(:state).current).to eq(:failed)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
it "raises SocketStateError when the transition map is violated" do
|
|
320
|
+
state = CoinDCX::WS::ConnectionState.new
|
|
321
|
+
state.transition_to(:connecting) # disconnected → connecting ✓
|
|
322
|
+
state.transition_to(:authenticated) # connecting → authenticated ✓
|
|
323
|
+
|
|
324
|
+
expect do
|
|
325
|
+
state.transition_to(:connecting) # authenticated → connecting ✗
|
|
326
|
+
end.to raise_error(CoinDCX::Errors::SocketStateError)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
describe "#disconnect" do
|
|
331
|
+
it "is idempotent — calling disconnect twice does not raise" do
|
|
332
|
+
manager.connect
|
|
333
|
+
fire_engine_io_open!
|
|
334
|
+
|
|
335
|
+
expect { manager.disconnect }.not_to raise_error
|
|
336
|
+
expect { manager.disconnect }.not_to raise_error
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe CoinDCX::WS::Parsers::OrderBookSnapshot do
|
|
6
|
+
describe ".parse" do
|
|
7
|
+
it "normalizes the snapshot payload without pretending it is a diff stream" do
|
|
8
|
+
parsed_snapshot = described_class.parse(
|
|
9
|
+
"ts" => 1_705_483_019_891,
|
|
10
|
+
"vs" => 27_570_132,
|
|
11
|
+
"bids" => { "1995" => "2.618" },
|
|
12
|
+
"asks" => { "2001" => "2.145" }
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
expect(parsed_snapshot).to eq(
|
|
16
|
+
source: :snapshot,
|
|
17
|
+
maximum_recent_orders: 50,
|
|
18
|
+
timestamp: 1_705_483_019_891,
|
|
19
|
+
version: 27_570_132,
|
|
20
|
+
bids: [{ price: "1995", quantity: "2.618" }],
|
|
21
|
+
asks: [{ price: "2001", quantity: "2.145" }]
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "openssl"
|
|
5
|
+
require "spec_helper"
|
|
6
|
+
|
|
7
|
+
RSpec.describe CoinDCX::WS::PrivateChannels do
|
|
8
|
+
subject(:private_channels) do
|
|
9
|
+
described_class.new(
|
|
10
|
+
configuration: CoinDCX::Configuration.new.tap do |config|
|
|
11
|
+
config.api_key = "api-key"
|
|
12
|
+
config.api_secret = "api-secret"
|
|
13
|
+
end
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
describe "#join_payload" do
|
|
18
|
+
it "signs the fixed private channel payload" do
|
|
19
|
+
expected_signature = OpenSSL::HMAC.hexdigest("SHA256", "api-secret", JSON.generate("channel" => "coindcx"))
|
|
20
|
+
|
|
21
|
+
expect(private_channels.join_payload).to eq(
|
|
22
|
+
"channelName" => "coindcx",
|
|
23
|
+
"authSignature" => expected_signature,
|
|
24
|
+
"apiKey" => "api-key"
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "spec_helper"
|
|
4
|
+
|
|
5
|
+
RSpec.describe CoinDCX::WS::PublicChannels do
|
|
6
|
+
describe "event constants" do
|
|
7
|
+
it "exposes documented public event names" do
|
|
8
|
+
expect(described_class::CANDLESTICK_EVENT).to eq("candlestick")
|
|
9
|
+
expect(described_class::DEPTH_SNAPSHOT_EVENT).to eq("depth-snapshot")
|
|
10
|
+
expect(described_class::DEPTH_UPDATE_EVENT).to eq("depth-update")
|
|
11
|
+
expect(described_class::NEW_TRADE_EVENT).to eq("new-trade")
|
|
12
|
+
expect(described_class::PRICE_CHANGE_EVENT).to eq("price-change")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
describe ".order_book" do
|
|
17
|
+
it "builds documented snapshot channels for depths 10, 20, and 50" do
|
|
18
|
+
[10, 20, 50].each do |depth|
|
|
19
|
+
expect(described_class.order_book(pair: "B-BTC_USDT", depth: depth)).to eq("B-BTC_USDT@orderbook@#{depth}")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it "rejects unsupported spot depths" do
|
|
24
|
+
expect do
|
|
25
|
+
described_class.order_book(pair: "B-BTC_USDT", depth: 5)
|
|
26
|
+
end.to raise_error(CoinDCX::Errors::ValidationError, /snapshot-based/)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
describe ".current_prices_spot" do
|
|
31
|
+
it "builds currentPrices@spot channels for 1s and 10s" do
|
|
32
|
+
expect(described_class.current_prices_spot(interval: "1s")).to eq("currentPrices@spot@1s")
|
|
33
|
+
expect(described_class.current_prices_spot(interval: "10s")).to eq("currentPrices@spot@10s")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it "rejects unsupported intervals" do
|
|
37
|
+
expect do
|
|
38
|
+
described_class.current_prices_spot(interval: "30s")
|
|
39
|
+
end.to raise_error(CoinDCX::Errors::ValidationError, /currentPrices@spot/)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
describe ".price_stats_spot" do
|
|
44
|
+
it "builds the 60s priceStats@spot channel" do
|
|
45
|
+
expect(described_class.price_stats_spot).to eq("priceStats@spot@60s")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
describe ".futures_candlestick" do
|
|
50
|
+
it "suffixes the instrument channel with interval and -futures per CoinDCX docs" do
|
|
51
|
+
expect(described_class.futures_candlestick(instrument: "B-BTC_USDT", interval: "1m")).to eq("B-BTC_USDT_1m-futures")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "rejects a blank interval" do
|
|
55
|
+
expect do
|
|
56
|
+
described_class.futures_candlestick(instrument: "B-BTC_USDT", interval: " ")
|
|
57
|
+
end.to raise_error(CoinDCX::Errors::ValidationError, /interval/)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
describe ".futures_order_book" do
|
|
62
|
+
it "builds documented futures snapshot channels for depths 10, 20, and 50" do
|
|
63
|
+
[10, 20, 50].each do |depth|
|
|
64
|
+
expect(described_class.futures_order_book(instrument: "B-BTC_USDT", depth: depth)).to eq(
|
|
65
|
+
"B-BTC_USDT@orderbook@#{depth}-futures"
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it "rejects unsupported futures depths" do
|
|
71
|
+
expect do
|
|
72
|
+
described_class.futures_order_book(instrument: "B-BTC_USDT", depth: 5)
|
|
73
|
+
end.to raise_error(CoinDCX::Errors::ValidationError, /documented depths/)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
describe ".current_prices_futures" do
|
|
78
|
+
it "returns the currentPrices@futures@rt channel" do
|
|
79
|
+
expect(described_class.current_prices_futures).to eq("currentPrices@futures@rt")
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
describe "futures LTP and trade channels" do
|
|
84
|
+
it "builds @prices-futures and @trades-futures" do
|
|
85
|
+
expect(described_class.futures_ltp(instrument: "B-ETH_USDT")).to eq("B-ETH_USDT@prices-futures")
|
|
86
|
+
expect(described_class.futures_new_trade(instrument: "B-ETH_USDT")).to eq("B-ETH_USDT@trades-futures")
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|