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,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