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