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,159 @@
1
+ # Standalone Trading Bot
2
+
3
+ ## Architecture
4
+
5
+ ```
6
+ WS Feed -> Strategy Engine -> Command -> Execution -> Risk -> Exit
7
+ ```
8
+
9
+ ## 1. Boot
10
+
11
+ ```ruby
12
+ require 'logger'
13
+ require 'coindcx'
14
+
15
+ CoinDCX.configure do |config|
16
+ config.api_key = ENV.fetch('COINDCX_API_KEY')
17
+ config.api_secret = ENV.fetch('COINDCX_API_SECRET')
18
+ config.logger = Logger.new($stdout)
19
+ config.socket_reconnect_attempts = 5
20
+ config.socket_heartbeat_interval = 10.0
21
+ config.socket_liveness_timeout = 60.0
22
+ end
23
+
24
+ client = CoinDCX.client
25
+ ws = client.ws
26
+ ```
27
+
28
+ Keep the event bus in the bot process, not in the gem:
29
+
30
+ ```ruby
31
+ class EventBus
32
+ def initialize
33
+ @listeners = Hash.new { |hash, key| hash[key] = [] }
34
+ end
35
+
36
+ def subscribe(event, &block)
37
+ @listeners[event] << block
38
+ end
39
+
40
+ def publish(event, payload)
41
+ @listeners[event].each { |listener| listener.call(payload) }
42
+ end
43
+ end
44
+
45
+ event_bus = EventBus.new
46
+ ```
47
+
48
+ ## 2. Subscribe Market
49
+
50
+ ```ruby
51
+ channel = CoinDCX::WS::PublicChannels.price_stats(pair: 'B-BTC_USDT')
52
+
53
+ ws.connect
54
+ ws.subscribe_public(channel_name: channel, event_name: 'price-change') do |message|
55
+ event_bus.publish(:tick, message)
56
+ end
57
+ ```
58
+
59
+ ## 3. Strategy Engine
60
+
61
+ ```ruby
62
+ class Strategy
63
+ def initialize(event_bus:, execution:)
64
+ @execution = execution
65
+ event_bus.subscribe(:tick) { |data| on_tick(data) }
66
+ end
67
+
68
+ def on_tick(data)
69
+ price = BigDecimal(data.fetch('p', data.fetch('last_price')).to_s)
70
+ execute_trade(price) if breakout?(price)
71
+ end
72
+
73
+ private
74
+
75
+ def breakout?(price)
76
+ price > BigDecimal('100.0')
77
+ end
78
+
79
+ def execute_trade(price)
80
+ @execution.call(price: price)
81
+ end
82
+ end
83
+ ```
84
+
85
+ ## 4. Execution (Command Pattern in the Bot)
86
+
87
+ ```ruby
88
+ class ExecuteTrade
89
+ def initialize(client:)
90
+ @client = client
91
+ end
92
+
93
+ def call(price:)
94
+ client_order_id = SecureRandom.uuid
95
+
96
+ @client.spot.orders.create(
97
+ side: 'buy',
98
+ order_type: 'limit_order',
99
+ market: 'SNTBTC',
100
+ price_per_unit: price.to_s,
101
+ total_quantity: 1,
102
+ client_order_id: client_order_id
103
+ )
104
+ end
105
+ end
106
+ ```
107
+
108
+ ## 5. Risk Manager
109
+
110
+ ```ruby
111
+ class RiskManager
112
+ def initialize(entry_price)
113
+ @entry_price = entry_price
114
+ end
115
+
116
+ def stop_loss
117
+ @entry_price * BigDecimal('0.98')
118
+ end
119
+ end
120
+ ```
121
+
122
+ ## 6. Exit Logic
123
+
124
+ ```ruby
125
+ risk_manager = RiskManager.new(BigDecimal('100.0'))
126
+
127
+ event_bus.subscribe(:tick) do |data|
128
+ price = BigDecimal(data.fetch('p', data.fetch('last_price')).to_s)
129
+ exit_position if price <= risk_manager.stop_loss
130
+ end
131
+ ```
132
+
133
+ ## 7. Resilience
134
+
135
+ Your bot loop should explicitly handle:
136
+
137
+ - websocket reconnects
138
+ - duplicate ticks
139
+ - order failures after the gem exhausts its bounded retry policy
140
+ - stale data timeouts
141
+
142
+ The gem delivers websocket subscriptions with at-least-once semantics after reconnect. Treat duplicate ticks as normal and de-duplicate at your event boundary when your strategy needs exactly-once behavior.
143
+
144
+ The gem will reconnect sockets, renew private-channel auth, enforce endpoint throttles, and normalize transport errors, but your bot still owns strategy-safe recovery.
145
+
146
+ Operator intervention is still required when:
147
+
148
+ - the websocket reaches the `failed` state after bounded reconnect attempts
149
+ - the order circuit breaker opens on repeated create-order failures
150
+ - upstream validation errors indicate your request contract is wrong
151
+
152
+ ## 8. Minimal Loop
153
+
154
+ ```ruby
155
+ execution = ExecuteTrade.new(client: client)
156
+ Strategy.new(event_bus: event_bus, execution: execution)
157
+
158
+ sleep
159
+ ```
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+
6
+ module CoinDCX
7
+ module Auth
8
+ class Signer
9
+ def initialize(api_key:, api_secret:)
10
+ @api_key = api_key
11
+ @api_secret = api_secret
12
+ end
13
+
14
+ attr_reader :api_key, :api_secret
15
+
16
+ def authenticated_request(body = {})
17
+ normalized_body = Utils::Payload.compact_hash(body || {})
18
+ normalized_body[:timestamp] ||= (Time.now.to_f * 1000).floor
19
+ payload = JSON.generate(Utils::Payload.stringify_keys(normalized_body))
20
+ [normalized_body, authentication_headers(payload)]
21
+ end
22
+
23
+ def private_channel_join(channel_name: "coindcx")
24
+ channel = Contracts::ChannelName.validate!(channel_name)
25
+ payload = JSON.generate("channel" => channel)
26
+
27
+ {
28
+ "channelName" => channel,
29
+ "authSignature" => signature_for(payload),
30
+ "apiKey" => api_key
31
+ }
32
+ end
33
+
34
+ def signature_for(payload)
35
+ OpenSSL::HMAC.hexdigest("SHA256", api_secret, payload)
36
+ end
37
+
38
+ private
39
+
40
+ def authentication_headers(payload)
41
+ {
42
+ "X-AUTH-APIKEY" => api_key,
43
+ "X-AUTH-SIGNATURE" => signature_for(payload)
44
+ }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ class Client
5
+ def initialize(configuration:)
6
+ @configuration = configuration
7
+ @http_client = Transport::HttpClient.new(configuration: configuration)
8
+ end
9
+
10
+ attr_reader :configuration
11
+
12
+ def public
13
+ @public ||= REST::Public::Facade.new(http_client: @http_client)
14
+ end
15
+
16
+ def spot
17
+ @spot ||= REST::Spot::Facade.new(http_client: @http_client)
18
+ end
19
+
20
+ def margin
21
+ @margin ||= REST::Margin::Facade.new(http_client: @http_client)
22
+ end
23
+
24
+ def user
25
+ @user ||= REST::User::Facade.new(http_client: @http_client)
26
+ end
27
+
28
+ def transfers
29
+ @transfers ||= REST::Transfers::Facade.new(http_client: @http_client)
30
+ end
31
+
32
+ def futures
33
+ @futures ||= REST::Futures::Facade.new(http_client: @http_client)
34
+ end
35
+
36
+ def funding
37
+ @funding ||= REST::Funding::Facade.new(http_client: @http_client)
38
+ end
39
+
40
+ def ws
41
+ @ws ||= WS::SocketIOClient.new(configuration: configuration)
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ class Configuration
5
+ DEFAULT_API_BASE_URL = "https://api.coindcx.com"
6
+ DEFAULT_PUBLIC_BASE_URL = "https://public.coindcx.com"
7
+ DEFAULT_SOCKET_BASE_URL = "wss://stream.coindcx.com"
8
+ DEFAULT_USER_AGENT = "coindcx-client/#{VERSION}".freeze
9
+ DEFAULT_PRIVATE_RATE_LIMIT = { limit: 60, period: 60 }.freeze
10
+ DEFAULT_ENDPOINT_RATE_LIMITS = {
11
+ # Public (unauthenticated) endpoints — shared bucket keeps burst bursts off the exchange.
12
+ public_market_data: { limit: 30, period: 1 },
13
+ public_ticker: { limit: 10, period: 1 },
14
+ public_order_book: { limit: 10, period: 1 },
15
+ public_trades: { limit: 10, period: 1 },
16
+ public_candles: { limit: 10, period: 1 },
17
+ spot_create_order_multiple: { limit: 2000, period: 60 },
18
+ spot_create_order: { limit: 2000, period: 60 },
19
+ spot_cancel_all: { limit: 30, period: 60 },
20
+ spot_order_status_multiple: { limit: 2000, period: 60 },
21
+ spot_order_status: { limit: 2000, period: 60 },
22
+ spot_cancel_multiple_by_id: { limit: 300, period: 60 },
23
+ spot_cancel_order: { limit: 2000, period: 60 },
24
+ spot_active_order: { limit: 300, period: 60 },
25
+ spot_active_order_count: DEFAULT_PRIVATE_RATE_LIMIT,
26
+ spot_trade_history: DEFAULT_PRIVATE_RATE_LIMIT,
27
+ spot_edit_price: { limit: 2000, period: 60 },
28
+ futures_list_orders: DEFAULT_PRIVATE_RATE_LIMIT,
29
+ futures_create_order: DEFAULT_PRIVATE_RATE_LIMIT,
30
+ futures_cancel_order: DEFAULT_PRIVATE_RATE_LIMIT,
31
+ futures_edit_order: DEFAULT_PRIVATE_RATE_LIMIT,
32
+ futures_positions_list: DEFAULT_PRIVATE_RATE_LIMIT,
33
+ futures_positions_update_leverage: DEFAULT_PRIVATE_RATE_LIMIT,
34
+ futures_positions_add_margin: DEFAULT_PRIVATE_RATE_LIMIT,
35
+ futures_positions_remove_margin: DEFAULT_PRIVATE_RATE_LIMIT,
36
+ futures_positions_cancel_all_open_orders: DEFAULT_PRIVATE_RATE_LIMIT,
37
+ futures_positions_cancel_all_open_orders_for_position: DEFAULT_PRIVATE_RATE_LIMIT,
38
+ futures_positions_exit: DEFAULT_PRIVATE_RATE_LIMIT,
39
+ futures_positions_create_tpsl: DEFAULT_PRIVATE_RATE_LIMIT,
40
+ futures_positions_transactions: DEFAULT_PRIVATE_RATE_LIMIT,
41
+ futures_positions_cross_margin_details: DEFAULT_PRIVATE_RATE_LIMIT,
42
+ futures_positions_margin_type: DEFAULT_PRIVATE_RATE_LIMIT,
43
+ futures_wallet_transfer: DEFAULT_PRIVATE_RATE_LIMIT,
44
+ futures_wallet_details: DEFAULT_PRIVATE_RATE_LIMIT,
45
+ futures_wallet_transactions: DEFAULT_PRIVATE_RATE_LIMIT,
46
+ futures_trades: DEFAULT_PRIVATE_RATE_LIMIT,
47
+ futures_instrument_detail: DEFAULT_PRIVATE_RATE_LIMIT,
48
+ funding_fetch_orders: DEFAULT_PRIVATE_RATE_LIMIT,
49
+ funding_lend: DEFAULT_PRIVATE_RATE_LIMIT,
50
+ funding_settle: DEFAULT_PRIVATE_RATE_LIMIT,
51
+ margin_create_order: DEFAULT_PRIVATE_RATE_LIMIT,
52
+ margin_list_orders: DEFAULT_PRIVATE_RATE_LIMIT,
53
+ margin_fetch_order: DEFAULT_PRIVATE_RATE_LIMIT,
54
+ margin_cancel_order: DEFAULT_PRIVATE_RATE_LIMIT,
55
+ margin_exit_order: DEFAULT_PRIVATE_RATE_LIMIT,
56
+ margin_edit_target: DEFAULT_PRIVATE_RATE_LIMIT,
57
+ margin_edit_stop_loss: DEFAULT_PRIVATE_RATE_LIMIT,
58
+ margin_edit_trailing_stop_loss: DEFAULT_PRIVATE_RATE_LIMIT,
59
+ margin_edit_target_order_price: DEFAULT_PRIVATE_RATE_LIMIT,
60
+ margin_add_margin: DEFAULT_PRIVATE_RATE_LIMIT,
61
+ margin_remove_margin: DEFAULT_PRIVATE_RATE_LIMIT,
62
+ user_balances: DEFAULT_PRIVATE_RATE_LIMIT,
63
+ user_info: DEFAULT_PRIVATE_RATE_LIMIT,
64
+ wallets_transfer: DEFAULT_PRIVATE_RATE_LIMIT,
65
+ wallets_sub_account_transfer: DEFAULT_PRIVATE_RATE_LIMIT
66
+ }.freeze
67
+
68
+ attr_accessor :api_key, :api_secret, :api_base_url, :public_base_url,
69
+ :socket_base_url, :socket_io_connect_options, :open_timeout, :read_timeout, :max_retries,
70
+ :retry_base_interval, :user_agent, :socket_io_backend_factory,
71
+ :endpoint_rate_limits, :logger, :socket_reconnect_attempts,
72
+ :socket_reconnect_interval, :socket_heartbeat_interval,
73
+ :socket_liveness_timeout, :market_data_retry_budget,
74
+ :private_read_retry_budget, :idempotent_order_retry_budget,
75
+ :circuit_breaker_threshold, :circuit_breaker_cooldown
76
+
77
+ # rubocop:disable Metrics/MethodLength
78
+ def initialize
79
+ @api_base_url = DEFAULT_API_BASE_URL
80
+ @public_base_url = DEFAULT_PUBLIC_BASE_URL
81
+ @socket_base_url = DEFAULT_SOCKET_BASE_URL
82
+ # CoinDCX stream matches official socket.io-client 2.x (Engine.IO v3). The default backend
83
+ # `socket.io-client-simple` only parses that protocol; `EIO: 4` breaks the handshake/payloads.
84
+ @socket_io_connect_options = { EIO: 3 }
85
+ @open_timeout = 5
86
+ @read_timeout = 30
87
+ @max_retries = 2
88
+ @retry_base_interval = 0.25
89
+ @user_agent = DEFAULT_USER_AGENT
90
+ @endpoint_rate_limits = DEFAULT_ENDPOINT_RATE_LIMITS.transform_values(&:dup)
91
+ @logger = nil
92
+ @socket_reconnect_attempts = 5
93
+ @socket_reconnect_interval = 1.0
94
+ @socket_heartbeat_interval = 10.0
95
+ @socket_liveness_timeout = 60.0
96
+ @market_data_retry_budget = 2
97
+ @private_read_retry_budget = 1
98
+ @idempotent_order_retry_budget = 1
99
+ @circuit_breaker_threshold = 3
100
+ @circuit_breaker_cooldown = 30.0
101
+ end
102
+ # rubocop:enable Metrics/MethodLength
103
+
104
+ def rate_limit_for(bucket_name)
105
+ endpoint_rate_limits[bucket_name.to_sym]
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module Contracts
5
+ module ChannelName
6
+ module_function
7
+
8
+ def validate!(channel_name)
9
+ normalized_channel_name = channel_name.to_s.strip
10
+ raise Errors::ValidationError, "channel_name must be provided" if normalized_channel_name.empty?
11
+ return normalized_channel_name if valid_format?(normalized_channel_name)
12
+
13
+ raise Errors::ValidationError, "channel_name must match a CoinDCX channel name"
14
+ end
15
+
16
+ def valid_format?(channel_name)
17
+ channel_name == "coindcx" ||
18
+ channel_name.include?("@") ||
19
+ channel_name.include?("_")
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module Contracts
5
+ module Identifiers
6
+ MARKET_PATTERN = /\A[A-Z0-9]+\z/
7
+ PAIR_PATTERN = /\A[A-Z]+-[A-Z0-9]+_[A-Z0-9]+\z/
8
+ CURRENCY_PATTERN = /\A[A-Z0-9]+\z/
9
+
10
+ module_function
11
+
12
+ def validate_market!(market)
13
+ validate!(market, MARKET_PATTERN, "market must be a CoinDCX symbol like SNTBTC")
14
+ end
15
+
16
+ def validate_pair!(pair)
17
+ validate!(pair, PAIR_PATTERN, "pair must be a CoinDCX pair like B-BTC_USDT")
18
+ end
19
+
20
+ def validate_instrument!(instrument)
21
+ validate_pair!(instrument)
22
+ end
23
+
24
+ def validate_currency!(currency)
25
+ validate!(currency, CURRENCY_PATTERN, "currency_short_name must be a CoinDCX currency code")
26
+ end
27
+
28
+ def validate!(value, pattern, message)
29
+ string_value = value.to_s.strip
30
+ raise Errors::ValidationError, message if string_value.empty? || string_value.match?(pattern) == false
31
+
32
+ string_value
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module Contracts
5
+ module OrderRequest
6
+ VALID_SIDES = %w[buy sell].freeze
7
+ VALID_SPOT_ORDER_TYPES = %w[market_order limit_order stop_limit take_profit].freeze
8
+ VALID_MARGIN_ORDER_TYPES = %w[market_order limit_order stop_limit take_profit].freeze
9
+ IDEMPOTENCY_KEYS = %i[client_order_id clientOrderId].freeze
10
+
11
+ module_function
12
+
13
+ def validate_spot_create!(attributes)
14
+ validate_side!(attributes)
15
+ validate_order_type!(attributes, VALID_SPOT_ORDER_TYPES)
16
+ validate_market!(attributes)
17
+ validate_positive_quantity!(attributes, :total_quantity)
18
+ validate_positive_number!(attributes, :price_per_unit) if present?(attributes, :price_per_unit)
19
+ validate_idempotency_key!(attributes)
20
+ attributes
21
+ end
22
+
23
+ def validate_spot_create_many!(orders)
24
+ Array(orders).each { |order| validate_spot_create!(order) }
25
+ orders
26
+ end
27
+
28
+ def validate_futures_create!(attributes)
29
+ validate_side!(attributes)
30
+ validate_pair!(attributes, :pair) if present?(attributes, :pair)
31
+ validate_pair!(attributes, :instrument) if present?(attributes, :instrument)
32
+ validate_positive_quantity!(attributes, :quantity, :size, :total_quantity)
33
+ validate_idempotency_key!(attributes)
34
+ attributes
35
+ end
36
+
37
+ def validate_margin_create!(attributes)
38
+ validate_side!(attributes)
39
+ validate_order_type!(attributes, VALID_MARGIN_ORDER_TYPES)
40
+ validate_market!(attributes)
41
+ validate_positive_quantity!(attributes, :quantity, :total_quantity)
42
+ validate_positive_number!(attributes, :price_per_unit) if present?(attributes, :price_per_unit)
43
+ validate_idempotency_key!(attributes)
44
+ attributes
45
+ end
46
+
47
+ def validate_idempotency_key!(attributes)
48
+ IDEMPOTENCY_KEYS.each do |key|
49
+ value = fetch_optional(attributes, key)
50
+ return value if value && !value.to_s.strip.empty?
51
+ end
52
+
53
+ raise Errors::ValidationError, "client_order_id is required for order placement safety"
54
+ end
55
+
56
+ def validate_side!(attributes)
57
+ side = fetch_required(attributes, :side)
58
+ return side if VALID_SIDES.include?(side.to_s)
59
+
60
+ raise Errors::ValidationError, "side must be one of: #{VALID_SIDES.join(', ')}"
61
+ end
62
+
63
+ def validate_positive_quantity!(attributes, *keys)
64
+ quantity = fetch_quantity(attributes, keys)
65
+ return quantity if quantity.to_f.positive?
66
+
67
+ raise Errors::ValidationError, "#{keys.join(' or ')} must be greater than 0"
68
+ end
69
+
70
+ def validate_positive_number!(attributes, key)
71
+ value = fetch_required(attributes, key)
72
+ return value if value.to_f.positive?
73
+
74
+ raise Errors::ValidationError, "#{key} must be greater than 0"
75
+ end
76
+
77
+ def validate_order_type!(attributes, valid_order_types)
78
+ order_type = fetch_required(attributes, :order_type)
79
+ return order_type if valid_order_types.include?(order_type.to_s)
80
+
81
+ raise Errors::ValidationError, "order_type must be one of: #{valid_order_types.join(', ')}"
82
+ end
83
+
84
+ def validate_market!(attributes)
85
+ Identifiers.validate_market!(fetch_required(attributes, :market))
86
+ end
87
+
88
+ def validate_pair!(attributes, key)
89
+ Identifiers.validate_pair!(fetch_required(attributes, key))
90
+ end
91
+
92
+ def fetch_quantity(attributes, keys)
93
+ keys.each do |key|
94
+ value = fetch_optional(attributes, key)
95
+ return value unless value.nil?
96
+ end
97
+
98
+ raise Errors::ValidationError, "#{keys.join(' or ')} is required"
99
+ end
100
+
101
+ def fetch_required(attributes, key)
102
+ value = fetch_optional(attributes, key)
103
+ return value unless value.nil?
104
+
105
+ raise Errors::ValidationError, "#{key} is required"
106
+ end
107
+
108
+ def fetch_optional(attributes, key)
109
+ return attributes[key] if attributes.key?(key)
110
+ return attributes[key.to_s] if attributes.key?(key.to_s)
111
+
112
+ nil
113
+ end
114
+
115
+ def present?(attributes, key)
116
+ !fetch_optional(attributes, key).nil?
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module Contracts
5
+ module SocketBackend
6
+ REQUIRED_METHODS = %i[connect start_transport! emit on disconnect].freeze
7
+
8
+ module_function
9
+
10
+ def validate!(backend)
11
+ missing_methods = REQUIRED_METHODS.reject { |method_name| backend.respond_to?(method_name) }
12
+ return backend if missing_methods.empty?
13
+
14
+ raise Errors::ConfigurationError,
15
+ "socket backend must respond to #{REQUIRED_METHODS.join(', ')}; missing #{missing_methods.join(', ')}"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module Contracts
5
+ module WalletTransferRequest
6
+ VALID_WALLET_TYPES = %w[spot futures margin].freeze
7
+
8
+ module_function
9
+
10
+ def validate_transfer!(attributes)
11
+ validate_wallet_type!(attributes, :source_wallet_type)
12
+ validate_wallet_type!(attributes, :destination_wallet_type)
13
+ validate_positive_number!(attributes, :amount)
14
+ validate_currency!(attributes, :currency_short_name)
15
+ attributes
16
+ end
17
+
18
+ def validate_wallet_type!(attributes, key)
19
+ wallet_type = fetch_required(attributes, key)
20
+ return wallet_type if VALID_WALLET_TYPES.include?(wallet_type.to_s)
21
+
22
+ raise Errors::ValidationError, "#{key} must be one of: #{VALID_WALLET_TYPES.join(', ')}"
23
+ end
24
+
25
+ def validate_positive_number!(attributes, key)
26
+ number = fetch_required(attributes, key)
27
+ return number if number.to_f.positive?
28
+
29
+ raise Errors::ValidationError, "#{key} must be greater than 0"
30
+ end
31
+
32
+ def validate_currency!(attributes, key)
33
+ Identifiers.validate_currency!(fetch_required(attributes, key))
34
+ end
35
+
36
+ def fetch_required(attributes, key)
37
+ return attributes[key] if attributes.key?(key)
38
+ return attributes[key.to_s] if attributes.key?(key.to_s)
39
+
40
+ raise Errors::ValidationError, "#{key} is required"
41
+ end
42
+
43
+ private_class_method :validate_wallet_type!, :validate_positive_number!, :validate_currency!, :fetch_required
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module Errors
5
+ class Error < StandardError
6
+ attr_reader :category, :code, :request_context, :retryable
7
+
8
+ def initialize(message, category: nil, code: nil, request_context: nil, retryable: false)
9
+ super(message)
10
+ @category = category
11
+ @code = code
12
+ @request_context = request_context
13
+ @retryable = retryable
14
+ end
15
+ end
16
+
17
+ class ConfigurationError < Error; end
18
+ class ValidationError < Error; end
19
+ class MissingDependencyError < Error; end
20
+
21
+ class SocketError < Error; end
22
+ class SocketConnectionError < SocketError; end
23
+ class SocketAuthenticationError < SocketError; end
24
+ class SocketStateError < SocketError; end
25
+ class SocketHeartbeatTimeoutError < SocketError; end
26
+
27
+ class ApiError < Error
28
+ attr_reader :status, :body, :retry_after
29
+
30
+ # rubocop:disable Metrics/ParameterLists
31
+ def initialize(message, status: nil, body: nil, category: nil, code: nil, request_context: nil, retryable: false, retry_after: nil)
32
+ super(message, category: category, code: code, request_context: request_context, retryable: retryable)
33
+ @status = status
34
+ @body = body
35
+ @retry_after = retry_after
36
+ end
37
+ # rubocop:enable Metrics/ParameterLists
38
+ end
39
+
40
+ class RequestError < ApiError; end
41
+ class RateLimitError < ApiError; end
42
+ class AuthError < ApiError; end
43
+ class RemoteValidationError < RequestError; end
44
+ class TransportError < RequestError; end
45
+ class UpstreamServerError < RequestError; end
46
+ class RetryableRateLimitError < RateLimitError; end
47
+ class CircuitOpenError < RequestError; end
48
+
49
+ AuthenticationError = AuthError
50
+ # Semantic aliases used in documentation and consumer code.
51
+ NetworkError = TransportError
52
+ ExchangeError = UpstreamServerError
53
+ end
54
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module Logging
5
+ class NullLogger
6
+ def debug(*) = nil
7
+ def info(*) = nil
8
+ def warn(*) = nil
9
+ def error(*) = nil
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module Logging
5
+ module StructuredLogger
6
+ module_function
7
+
8
+ def log(logger, level, payload)
9
+ return unless logger.respond_to?(level)
10
+
11
+ logger.public_send(level, payload)
12
+ rescue ArgumentError, TypeError
13
+ logger.public_send(level, payload.inspect)
14
+ end
15
+ end
16
+ end
17
+ end