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,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "spec_helper"
5
+
6
+ RSpec.describe CoinDCX::Transport::HttpClient do
7
+ let(:configuration) do
8
+ CoinDCX::Configuration.new.tap do |config|
9
+ config.api_key = "api-key"
10
+ config.api_secret = "api-secret"
11
+ config.logger = logger
12
+ config.retry_base_interval = 0.01
13
+ end
14
+ end
15
+
16
+ let(:logger) { instance_double("Logger", info: nil, warn: nil, error: nil) }
17
+ let(:sleeper) { class_double(Kernel, sleep: nil) }
18
+ let(:stubs) { Faraday::Adapter::Test::Stubs.new }
19
+ subject(:http_client) { described_class.new(configuration: configuration, stubs: stubs, sleeper: sleeper) }
20
+
21
+ describe "#get" do
22
+ context "when authenticated with a JSON body (CoinDCX futures wallet-style GET)" do
23
+ it "sends signed JSON in the request body and query params on the URL" do
24
+ stubs.get("/exchange/v1/derivatives/futures/wallets/transactions") do |env|
25
+ expect(env.request_headers["X-AUTH-APIKEY"]).to eq("api-key")
26
+ expect(env.request_headers["X-AUTH-SIGNATURE"]).not_to be_nil
27
+ expect(env.body).to match(/"timestamp":\d+/)
28
+ expect(env.url.query).to eq("page=1&size=10")
29
+ [200, { "Content-Type" => "application/json" }, "[]"]
30
+ end
31
+
32
+ response_body = http_client.get(
33
+ "/exchange/v1/derivatives/futures/wallets/transactions",
34
+ params: { page: 1, size: 10 },
35
+ body: {},
36
+ auth: true,
37
+ bucket: :futures_wallet_transactions
38
+ )
39
+
40
+ expect(response_body).to eq([])
41
+ stubs.verify_stubbed_calls
42
+ end
43
+ end
44
+ end
45
+
46
+ describe "#post" do
47
+ context "when the response is successful" do
48
+ it "returns the parsed response data and logs the request metadata" do
49
+ stubs.post("/exchange/v1/orders/create") do |env|
50
+ expect(env.request_headers["X-AUTH-APIKEY"]).to eq("api-key")
51
+ expect(env.request_headers["X-AUTH-SIGNATURE"]).not_to be_nil
52
+ expect(env.body).to include('"market":"SNTBTC"')
53
+ [200, { "Content-Type" => "application/json" }, '{"id":"123"}']
54
+ end
55
+
56
+ response_body = http_client.post(
57
+ "/exchange/v1/orders/create",
58
+ auth: true,
59
+ bucket: :spot_create_order,
60
+ body: { market: "SNTBTC", client_order_id: "spot-create-1" }
61
+ )
62
+
63
+ expect(response_body).to eq("id" => "123")
64
+ expect(logger).to have_received(:info).with(
65
+ hash_including(
66
+ event: "api_call",
67
+ endpoint: "/exchange/v1/orders/create",
68
+ request_id: a_string_matching(/\A[0-9a-f-]{36}\z/),
69
+ retries: 0
70
+ )
71
+ )
72
+ stubs.verify_stubbed_calls
73
+ end
74
+ end
75
+
76
+ context "when CoinDCX responds with a rate limit error" do
77
+ it "raises a rate limit error" do
78
+ stubs.post("/exchange/v1/orders/create") do
79
+ [429, { "Content-Type" => "application/json" }, '{"message":"too many requests"}']
80
+ end
81
+
82
+ request_call = lambda do
83
+ http_client.post(
84
+ "/exchange/v1/orders/create",
85
+ auth: true,
86
+ bucket: :spot_create_order,
87
+ body: { market: "SNTBTC", client_order_id: "spot-create-2" }
88
+ )
89
+ end
90
+
91
+ expect(&request_call).to raise_error(CoinDCX::Errors::RateLimitError) do |error|
92
+ expect(error.status).to eq(429)
93
+ expect(error.body).to include(success: false, data: {})
94
+ expect(error.body[:error]).to include(
95
+ category: :rate_limit,
96
+ code: 429,
97
+ message: "too many requests",
98
+ retryable: false
99
+ )
100
+ expect(error.body.dig(:error, :request_context)).to include(
101
+ base: :api,
102
+ endpoint: "/exchange/v1/orders/create",
103
+ method: "POST",
104
+ operation: "post_exchange_v1_orders_create"
105
+ )
106
+ expect(error.body.dig(:error, :request_context, :request_id)).to match(/\A[0-9a-f-]{36}\z/)
107
+ end
108
+ end
109
+ end
110
+
111
+ context "when CoinDCX responds with a retryable rate limit error" do
112
+ it "retries when Retry-After is present and the endpoint budget allows it" do
113
+ attempts = 0
114
+ stubs.post("/exchange/v1/orders/status") do
115
+ attempts += 1
116
+ if attempts == 1
117
+ [429, { "Content-Type" => "application/json", "Retry-After" => "0.5" }, '{"message":"slow down"}']
118
+ else
119
+ [200, { "Content-Type" => "application/json" }, '{"id":"123"}']
120
+ end
121
+ end
122
+
123
+ response_body = http_client.post("/exchange/v1/orders/status", auth: true, body: { id: "123" }, bucket: :spot_order_status)
124
+
125
+ expect(response_body).to eq("id" => "123")
126
+ expect(attempts).to eq(2)
127
+ expect(sleeper).to have_received(:sleep).with(0.5).once
128
+ end
129
+ end
130
+
131
+ context "when CoinDCX responds with a retryable upstream error" do
132
+ it "retries a private read endpoint within its retry budget" do
133
+ attempts = 0
134
+ stubs.post("/exchange/v1/orders/status") do
135
+ attempts += 1
136
+ if attempts == 1
137
+ [503, { "Content-Type" => "application/json" }, '{"message":"temporary outage"}']
138
+ else
139
+ [200, { "Content-Type" => "application/json" }, '{"id":"123"}']
140
+ end
141
+ end
142
+
143
+ response_body = http_client.post("/exchange/v1/orders/status", auth: true, body: { id: "123" }, bucket: :spot_order_status)
144
+
145
+ expect(response_body).to eq("id" => "123")
146
+ expect(attempts).to eq(2)
147
+ expect(logger).to have_received(:warn).with(
148
+ hash_including(
149
+ event: "api_retry",
150
+ endpoint: "/exchange/v1/orders/status",
151
+ retries: 1
152
+ )
153
+ )
154
+ end
155
+ end
156
+
157
+ context "when an order create request does not include client_order_id" do
158
+ it "rejects the request before transport execution" do
159
+ expect do
160
+ http_client.post(
161
+ "/exchange/v1/orders/create",
162
+ auth: true,
163
+ bucket: :spot_create_order,
164
+ body: { market: "SNTBTC" }
165
+ )
166
+ end.to raise_error(CoinDCX::Errors::ValidationError, /client_order_id/)
167
+ end
168
+ end
169
+
170
+ context "when an order create request times out with an idempotency key" do
171
+ it "retries the request" do
172
+ attempts = 0
173
+ stubs.post("/exchange/v1/orders/create") do
174
+ attempts += 1
175
+ raise Faraday::TimeoutError, "timed out" if attempts == 1
176
+
177
+ [200, { "Content-Type" => "application/json" }, '{"id":"123"}']
178
+ end
179
+
180
+ response_body = http_client.post(
181
+ "/exchange/v1/orders/create",
182
+ auth: true,
183
+ bucket: :spot_create_order,
184
+ body: { market: "SNTBTC", client_order_id: "client-123" }
185
+ )
186
+
187
+ expect(response_body).to eq("id" => "123")
188
+ expect(attempts).to eq(2)
189
+ expect(sleeper).to have_received(:sleep).with(0.01).once
190
+ expect(logger).to have_received(:warn).with(
191
+ hash_including(
192
+ event: "api_retry",
193
+ endpoint: "/exchange/v1/orders/create",
194
+ retries: 1
195
+ )
196
+ )
197
+ end
198
+ end
199
+
200
+ context "when an upstream error repeatedly hits a critical order endpoint" do
201
+ it "opens the circuit breaker for subsequent calls" do
202
+ configuration.circuit_breaker_threshold = 2
203
+ configuration.idempotent_order_retry_budget = 0
204
+
205
+ failing_client = described_class.new(configuration: configuration, stubs: stubs, sleeper: sleeper)
206
+ stubs.post("/exchange/v1/orders/create") do
207
+ [503, { "Content-Type" => "application/json" }, '{"message":"temporary outage"}']
208
+ end
209
+
210
+ 2.times do
211
+ expect do
212
+ failing_client.post(
213
+ "/exchange/v1/orders/create",
214
+ auth: true,
215
+ bucket: :spot_create_order,
216
+ body: { market: "SNTBTC", client_order_id: "client-123" }
217
+ )
218
+ end.to raise_error(CoinDCX::Errors::UpstreamServerError)
219
+ end
220
+
221
+ expect do
222
+ failing_client.post(
223
+ "/exchange/v1/orders/create",
224
+ auth: true,
225
+ bucket: :spot_create_order,
226
+ body: { market: "SNTBTC", client_order_id: "client-123" }
227
+ )
228
+ end.to raise_error(CoinDCX::Errors::CircuitOpenError)
229
+ end
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CoinDCX::Transport::RateLimitRegistry do
6
+ let(:definitions) { { spot_create_order: { limit: 1, period: 60 } } }
7
+ subject(:registry) { described_class.new(definitions) }
8
+
9
+ it 'returns immediately for unknown buckets when throttling is optional' do
10
+ expect { registry.throttle(:unknown_bucket) }.not_to raise_error
11
+ end
12
+
13
+ it 'raises when a required bucket has no rate limit definition' do
14
+ expect { registry.throttle(:unknown_bucket, required: true) }
15
+ .to raise_error(CoinDCX::Errors::ConfigurationError, 'missing rate limit definition for unknown_bucket')
16
+ end
17
+
18
+ it 'sleeps when a bucket is exhausted' do
19
+ allow(registry).to receive(:sleep)
20
+ monotonic_times = [0.0, 5.0, 61.0]
21
+ allow(registry).to receive(:monotonic_time) { monotonic_times.shift }
22
+
23
+ registry.throttle(:spot_create_order)
24
+ registry.throttle(:spot_create_order)
25
+
26
+ expect(registry).to have_received(:sleep).with(55.0).once
27
+ end
28
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe CoinDCX::Transport::RequestPolicy do
6
+ let(:configuration) { CoinDCX::Configuration.new }
7
+
8
+ describe ".build" do
9
+ it "marks order create endpoints as idempotency-required" do
10
+ policy = described_class.build(
11
+ configuration: configuration,
12
+ method: :post,
13
+ path: "/exchange/v1/orders/create",
14
+ body: { market: "SNTBTC", client_order_id: "spot-1" },
15
+ auth: true,
16
+ bucket: :spot_create_order
17
+ )
18
+
19
+ expect(policy.requires_idempotency?).to be(true)
20
+ expect(policy.idempotency_satisfied?).to be(true)
21
+ expect(policy.retry_budget).to eq(configuration.idempotent_order_retry_budget)
22
+ end
23
+
24
+ it "disables retries when idempotency is missing on unsafe endpoints" do
25
+ policy = described_class.build(
26
+ configuration: configuration,
27
+ method: :post,
28
+ path: "/exchange/v1/orders/create",
29
+ body: { market: "SNTBTC" },
30
+ auth: true,
31
+ bucket: :spot_create_order
32
+ )
33
+
34
+ expect(policy.requires_idempotency?).to be(true)
35
+ expect(policy.idempotency_satisfied?).to be(false)
36
+ expect(policy.retry_budget).to eq(0)
37
+ end
38
+
39
+ it "requires every batch order to carry an idempotency key" do
40
+ policy = described_class.build(
41
+ configuration: configuration,
42
+ method: :post,
43
+ path: "/exchange/v1/orders/create_multiple",
44
+ body: { orders: [{ market: "SNTBTC", client_order_id: "spot-1" }, { market: "ETHBTC" }] },
45
+ auth: true,
46
+ bucket: :spot_create_order_multiple
47
+ )
48
+
49
+ expect(policy.requires_idempotency?).to be(true)
50
+ expect(policy.idempotency_satisfied?).to be(false)
51
+ expect(policy.retry_budget).to eq(0)
52
+ end
53
+
54
+ it "uses the private read retry budget for authenticated futures instrument GET" do
55
+ policy = described_class.build(
56
+ configuration: configuration,
57
+ method: :get,
58
+ path: "/exchange/v1/derivatives/futures/data/instrument",
59
+ body: {},
60
+ auth: true,
61
+ bucket: :futures_instrument_detail
62
+ )
63
+
64
+ expect(policy.retry_budget).to eq(configuration.private_read_retry_budget)
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe CoinDCX::Transport::ResponseNormalizer do
6
+ describe ".success" do
7
+ it "wraps response data in a consistent success shape" do
8
+ expect(described_class.success("id" => "123")).to eq(
9
+ success: true,
10
+ data: { "id" => "123" },
11
+ error: nil
12
+ )
13
+ end
14
+ end
15
+
16
+ describe ".failure" do
17
+ it "extracts normalized error details from a hash body" do
18
+ normalized_error = described_class.failure(
19
+ status: 429,
20
+ body: { "message" => "too many requests", "code" => "rate_limit" },
21
+ fallback_message: "request failed",
22
+ category: :rate_limit,
23
+ request_context: { endpoint: "/exchange/v1/orders/create", request_id: "request-123" },
24
+ retryable: true
25
+ )
26
+
27
+ expect(normalized_error).to eq(
28
+ success: false,
29
+ data: {},
30
+ error: {
31
+ category: :rate_limit,
32
+ code: "rate_limit",
33
+ message: "too many requests",
34
+ request_context: { endpoint: "/exchange/v1/orders/create", request_id: "request-123" },
35
+ retryable: true
36
+ }
37
+ )
38
+ end
39
+
40
+ it "falls back to a readable message for string bodies" do
41
+ normalized_error = described_class.failure(
42
+ status: 500,
43
+ body: "upstream failure",
44
+ fallback_message: "request failed",
45
+ category: :upstream,
46
+ request_context: { endpoint: "/exchange/v1/orders/create" },
47
+ retryable: false
48
+ )
49
+
50
+ expect(normalized_error).to eq(
51
+ success: false,
52
+ data: {},
53
+ error: {
54
+ category: :upstream,
55
+ code: 500,
56
+ message: "upstream failure",
57
+ request_context: { endpoint: "/exchange/v1/orders/create" },
58
+ retryable: false
59
+ }
60
+ )
61
+ end
62
+ end
63
+ end