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,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module Transport
5
+ class RequestPolicy
6
+ RETRYABLE_STATUSES = [500, 502, 503, 504].freeze
7
+ VALIDATION_STATUSES = [400, 404, 422].freeze
8
+ ORDER_CREATE_PATHS = [
9
+ "/exchange/v1/orders/create", "/exchange/v1/orders/create_multiple",
10
+ "/exchange/v1/derivatives/futures/orders/create", "/exchange/v1/margin/create"
11
+ ].freeze
12
+ CRITICAL_ORDER_PATHS = ORDER_CREATE_PATHS.freeze
13
+ # Authenticated GETs that mirror private read semantics (signed body + X-AUTH headers).
14
+ FUTURES_AUTH_GET_READ_PATHS = ["/exchange/v1/derivatives/futures/data/instrument"].freeze
15
+
16
+ READ_ONLY_POST_PATHS = [
17
+ "/exchange/v1/orders/status", "/exchange/v1/orders/status_multiple",
18
+ "/exchange/v1/orders/active_orders", "/exchange/v1/orders/active_orders_count",
19
+ "/exchange/v1/orders/trade_history", "/exchange/v1/margin/fetch_orders",
20
+ "/exchange/v1/margin/order", "/exchange/v1/users/balances",
21
+ "/exchange/v1/users/info", "/exchange/v1/derivatives/futures/orders",
22
+ "/exchange/v1/derivatives/futures/trades", "/exchange/v1/derivatives/futures/positions",
23
+ "/exchange/v1/derivatives/futures/positions/transactions",
24
+ "/exchange/v1/derivatives/futures/positions/cross_margin_details",
25
+ "/exchange/v1/derivatives/futures/wallets", "/exchange/v1/funding/fetch_orders"
26
+ ].freeze
27
+ IDEMPOTENCY_KEYS = %w[client_order_id clientOrderId].freeze
28
+
29
+ def self.build(configuration:, method:, path:, body:, auth:, bucket:)
30
+ new(
31
+ operation_name: operation_name_for(method: method, path: path),
32
+ retry_budget: retry_budget_for(configuration: configuration, method: method, path: path, body: body, auth: auth),
33
+ circuit_breaker_key: circuit_breaker_key_for(path: path),
34
+ retry_rate_limits: retry_rate_limits_for?(method: method, path: path),
35
+ requires_idempotency: ORDER_CREATE_PATHS.include?(path),
36
+ idempotency_satisfied: idempotency_contract_met?(path: path, body: body),
37
+ bucket: bucket
38
+ )
39
+ end
40
+
41
+ def self.operation_name_for(method:, path:)
42
+ normalized_path = path.gsub(%r{\A/+}, "").tr("/", "_")
43
+ "#{method}_#{normalized_path}"
44
+ end
45
+
46
+ def self.retry_budget_for(configuration:, method:, path:, body:, auth:)
47
+ return configuration.market_data_retry_budget if method == :get && !auth
48
+ return configuration.private_read_retry_budget if futures_signed_read_get?(method, path, auth)
49
+ return configuration.private_read_retry_budget if READ_ONLY_POST_PATHS.include?(path)
50
+ return configuration.idempotent_order_retry_budget if idempotent_order_retry?(path, body)
51
+
52
+ 0
53
+ end
54
+
55
+ def self.futures_signed_read_get?(method, path, auth)
56
+ method == :get && auth && FUTURES_AUTH_GET_READ_PATHS.include?(path)
57
+ end
58
+
59
+ def self.idempotent_order_retry?(path, body)
60
+ ORDER_CREATE_PATHS.include?(path) && idempotency_contract_met?(path: path, body: body)
61
+ end
62
+
63
+ def self.circuit_breaker_key_for(path:)
64
+ return path if CRITICAL_ORDER_PATHS.include?(path)
65
+
66
+ nil
67
+ end
68
+
69
+ def self.retry_rate_limits_for?(method:, path:)
70
+ method == :get || READ_ONLY_POST_PATHS.include?(path)
71
+ end
72
+
73
+ def self.idempotency_key_present?(body)
74
+ case body
75
+ when Hash
76
+ body.any? do |key, value|
77
+ IDEMPOTENCY_KEYS.include?(key.to_s) || idempotency_key_present?(value)
78
+ end
79
+ when Array
80
+ body.any? { |value| idempotency_key_present?(value) }
81
+ else
82
+ false
83
+ end
84
+ end
85
+
86
+ def self.idempotency_contract_met?(path:, body:)
87
+ return false unless ORDER_CREATE_PATHS.include?(path)
88
+
89
+ if path == "/exchange/v1/orders/create_multiple"
90
+ orders = extract_orders(body)
91
+ return false if orders.empty?
92
+
93
+ return orders.all? { |order| idempotency_key_present?(order) }
94
+ end
95
+
96
+ idempotency_key_present?(body)
97
+ end
98
+
99
+ def self.extract_orders(body)
100
+ return [] unless body.is_a?(Hash)
101
+
102
+ body[:orders] || body["orders"] || []
103
+ end
104
+
105
+ def initialize(operation_name:, retry_budget:, circuit_breaker_key:, retry_rate_limits:, requires_idempotency:,
106
+ idempotency_satisfied:, bucket:)
107
+ @operation_name = operation_name
108
+ @retry_budget = retry_budget
109
+ @circuit_breaker_key = circuit_breaker_key
110
+ @retry_rate_limits = retry_rate_limits
111
+ @requires_idempotency = requires_idempotency
112
+ @idempotency_satisfied = idempotency_satisfied
113
+ @bucket = bucket
114
+ end
115
+
116
+ attr_reader :operation_name, :retry_budget, :circuit_breaker_key, :bucket
117
+
118
+ def retryable_transport_error?
119
+ retry_budget.positive?
120
+ end
121
+
122
+ def retryable_response?(status:, headers:)
123
+ return true if RETRYABLE_STATUSES.include?(status) && retry_budget.positive?
124
+ return false unless status == 429 && retry_budget.positive? && @retry_rate_limits
125
+
126
+ retry_after(headers).to_f.positive?
127
+ end
128
+
129
+ def retry_after(headers)
130
+ return nil unless headers.respond_to?(:[])
131
+
132
+ headers["Retry-After"] || headers["retry-after"]
133
+ end
134
+
135
+ def validation_status?(status)
136
+ VALIDATION_STATUSES.include?(status)
137
+ end
138
+
139
+ def critical_order?
140
+ !circuit_breaker_key.nil?
141
+ end
142
+
143
+ def requires_idempotency?
144
+ @requires_idempotency
145
+ end
146
+
147
+ def idempotency_satisfied?
148
+ @idempotency_satisfied
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module Transport
5
+ module ResponseNormalizer
6
+ module_function
7
+
8
+ def success(data)
9
+ {
10
+ success: true,
11
+ data: data,
12
+ error: nil
13
+ }
14
+ end
15
+
16
+ def failure(status:, body:, fallback_message:, category:, request_context:, retryable:)
17
+ normalized_body = normalize_body(body)
18
+
19
+ {
20
+ success: false,
21
+ data: {},
22
+ error: {
23
+ category: category,
24
+ code: normalized_body[:code] || status,
25
+ message: normalized_body[:message] || normalized_body[:error] || fallback_message,
26
+ request_context: request_context,
27
+ retryable: retryable
28
+ }
29
+ }
30
+ end
31
+
32
+ def normalize_body(body)
33
+ return Utils::Payload.symbolize_keys(body) if body.is_a?(Hash)
34
+ return { message: body } if body.is_a?(String)
35
+
36
+ {}
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module Transport
5
+ class RetryPolicy
6
+ def initialize(max_retries:, base_interval:, logger: Logging::NullLogger.new, sleeper: Kernel)
7
+ @max_retries = max_retries
8
+ @base_interval = base_interval
9
+ @logger = logger
10
+ @sleeper = sleeper
11
+ end
12
+
13
+ def with_retries(context = {}, policy:)
14
+ attempts = 0
15
+
16
+ begin
17
+ attempts += 1
18
+ yield(attempts)
19
+ rescue Errors::ApiError => e
20
+ raise e unless retryable_api_error?(attempts, e, policy)
21
+
22
+ retry_request(context, attempts, e, policy)
23
+ retry
24
+ rescue Faraday::TimeoutError, Faraday::ConnectionFailed => e
25
+ raise transport_error_for(e, context) unless retryable_transport_error?(attempts, policy)
26
+
27
+ retry_request(context, attempts, e, policy)
28
+ retry
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def retryable_api_error?(attempts, error, policy)
35
+ error.retryable && attempts <= retry_limit(policy)
36
+ end
37
+
38
+ def retryable_transport_error?(attempts, policy)
39
+ policy.retryable_transport_error? && attempts <= retry_limit(policy)
40
+ end
41
+
42
+ def retry_request(context, attempts, error, policy)
43
+ sleep_interval = retry_sleep_interval(attempts, error, policy)
44
+ Logging::StructuredLogger.log(
45
+ @logger,
46
+ :warn,
47
+ context.merge(
48
+ event: "api_retry",
49
+ retries: attempts,
50
+ error_class: error.class.name,
51
+ message: error.message,
52
+ sleep_interval: sleep_interval
53
+ )
54
+ )
55
+ @sleeper.sleep(sleep_interval)
56
+ end
57
+
58
+ def retry_sleep_interval(attempts, error, _policy)
59
+ return error.retry_after.to_f if error.respond_to?(:retry_after) && error.retry_after.to_f.positive?
60
+
61
+ @base_interval * (2**(attempts - 1))
62
+ end
63
+
64
+ def retry_limit(policy)
65
+ [policy.retry_budget, @max_retries].min
66
+ end
67
+
68
+ def transport_error_for(error, context)
69
+ Errors::TransportError.new(
70
+ "CoinDCX transport failed for #{context.fetch(:endpoint)}",
71
+ category: :transport,
72
+ code: error.class.name,
73
+ request_context: context,
74
+ retryable: false
75
+ ).tap { |wrapped_error| wrapped_error.set_backtrace(error.backtrace) }
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module Utils
5
+ module Payload
6
+ module_function
7
+
8
+ def compact_hash(object)
9
+ case object
10
+ when Hash
11
+ object.each_with_object({}) do |(key, value), result|
12
+ compact_value = compact_hash(value)
13
+ next if compact_value.nil?
14
+
15
+ result[key] = compact_value
16
+ end
17
+ when Array
18
+ object.filter_map { |value| compact_hash(value) }
19
+ else
20
+ object
21
+ end
22
+ end
23
+
24
+ def stringify_keys(object)
25
+ case object
26
+ when Hash
27
+ object.each_with_object({}) do |(key, value), result|
28
+ result[key.to_s] = stringify_keys(value)
29
+ end
30
+ when Array
31
+ object.map { |value| stringify_keys(value) }
32
+ else
33
+ object
34
+ end
35
+ end
36
+
37
+ def symbolize_keys(object)
38
+ case object
39
+ when Hash
40
+ object.each_with_object({}) do |(key, value), result|
41
+ result[key.to_sym] = symbolize_keys(value)
42
+ end
43
+ when Array
44
+ object.map { |value| symbolize_keys(value) }
45
+ else
46
+ object
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ VERSION = "0.1.0"
5
+ end