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,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module WS
5
+ class SocketIOClient
6
+ def initialize(configuration:, backend: nil, sleeper: Kernel, thread_factory: nil, monotonic_clock: nil, randomizer: nil)
7
+ @configuration = configuration
8
+ @logger = configuration.logger || Logging::NullLogger.new
9
+ @backend = Contracts::SocketBackend.validate!(backend || build_backend)
10
+ @private_channels = PrivateChannels.new(configuration: configuration)
11
+ @connection_manager = ConnectionManager.new(
12
+ configuration: configuration,
13
+ backend: @backend,
14
+ logger: @logger,
15
+ sleeper: sleeper,
16
+ thread_factory: thread_factory,
17
+ monotonic_clock: monotonic_clock,
18
+ randomizer: randomizer
19
+ )
20
+ end
21
+
22
+ def connect
23
+ connection_manager.connect
24
+ self
25
+ end
26
+
27
+ def disconnect
28
+ connection_manager.disconnect
29
+ self
30
+ end
31
+
32
+ def on(event_name, &block)
33
+ connection_manager.on(event_name, &block)
34
+ self
35
+ end
36
+
37
+ def subscribe_public(channel_name:, event_name:, &block)
38
+ register_subscription(type: :public, channel_name: channel_name, event_name: event_name, &block)
39
+ end
40
+
41
+ def subscribe_private(event_name:, channel_name: PrivateChannels::DEFAULT_CHANNEL_NAME, &block)
42
+ register_subscription(type: :private, channel_name: channel_name, event_name: event_name, &block)
43
+ end
44
+
45
+ def alive?
46
+ connection_manager.alive?
47
+ end
48
+
49
+ # Emits Socket.IO `leave` per CoinDCX docs. Does not remove handlers or registry entries; after a
50
+ # reconnect, {#subscribe_public} / {#subscribe_private} intents are still re-joined automatically.
51
+ def leave_channel(channel_name:)
52
+ backend.emit(
53
+ "leave",
54
+ { "channelName" => Contracts::ChannelName.validate!(channel_name) }
55
+ )
56
+ self
57
+ end
58
+
59
+ private
60
+
61
+ attr_reader :configuration, :backend, :private_channels, :connection_manager
62
+
63
+ def register_subscription(type:, channel_name:, event_name:, &block)
64
+ on(event_name, &block) if block_given?
65
+ connection_manager.subscribe(
66
+ type: type,
67
+ channel_name: channel_name,
68
+ event_name: event_name,
69
+ payload_builder: -> { join_payload(type: type, channel_name: channel_name) },
70
+ delivery_mode: :at_least_once
71
+ )
72
+ self
73
+ end
74
+
75
+ def join_payload(type:, channel_name:)
76
+ return { "channelName" => Contracts::ChannelName.validate!(channel_name) } if type == :public
77
+
78
+ private_channels.join_payload(channel_name: channel_name)
79
+ end
80
+
81
+ def build_backend
82
+ factory = configuration.socket_io_backend_factory
83
+ return Contracts::SocketBackend.validate!(factory.call) if factory
84
+
85
+ SocketIOSimpleBackend.new(connect_options: configuration.socket_io_connect_options)
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "uri_ruby3_compat"
4
+ require "socket.io-client-simple"
5
+
6
+ module CoinDCX
7
+ module WS
8
+ class SocketIOSimpleBackend
9
+ def initialize(socket_client_class: ::SocketIO::Client::Simple::Client, connect_options: nil)
10
+ @socket_client_class = socket_client_class
11
+ @connect_options = connect_options || { EIO: 3 }
12
+ end
13
+
14
+ # Engine.IO version must match the server (`EIO` query param). CoinDCX stream uses Engine.IO v3
15
+ # (same as socket.io-client 2.3.x in their docs). This gem's default backend only supports v3;
16
+ # use `socket_io_backend_factory` if you need a different stack.
17
+ #
18
+ # Two-step setup so ConnectionManager can register Socket.IO listeners before the WebSocket
19
+ # opens; otherwise the Engine.IO "open" packet can fire :connect before we listen, and join
20
+ # emits run while the client still rejects emit (pre-handshake).
21
+ def connect(url)
22
+ disconnect
23
+ @socket = @socket_client_class.new(url, @connect_options.dup)
24
+ self
25
+ rescue StandardError => e
26
+ @socket = nil
27
+ raise Errors::SocketConnectionError, e.message
28
+ end
29
+
30
+ def start_transport!
31
+ raise Errors::SocketError, "socket client missing before start_transport!" if @socket.nil?
32
+
33
+ @socket.connect
34
+ self
35
+ end
36
+
37
+ def emit(event_name, payload)
38
+ socket.emit(event_name, payload)
39
+ end
40
+
41
+ def on(event_name, &block)
42
+ socket.on(event_name, &block)
43
+ end
44
+
45
+ def disconnect
46
+ return if @socket.nil?
47
+
48
+ if @socket.respond_to?(:disconnect)
49
+ @socket.disconnect
50
+ elsif @socket.respond_to?(:close)
51
+ @socket.close
52
+ end
53
+ @socket = nil
54
+ end
55
+
56
+ private
57
+
58
+ def socket
59
+ @socket || raise(Errors::SocketError, "socket connection has not been established")
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CoinDCX
4
+ module WS
5
+ class SubscriptionRegistry
6
+ SubscriptionIntent = Struct.new(
7
+ :type,
8
+ :channel_name,
9
+ :event_name,
10
+ :payload_builder,
11
+ :delivery_mode
12
+ ) do
13
+ def payload
14
+ payload_builder.call
15
+ end
16
+
17
+ def private_channel?
18
+ type == :private
19
+ end
20
+ end
21
+
22
+ def initialize
23
+ @subscriptions = []
24
+ @mutex = Mutex.new
25
+ end
26
+
27
+ def add(type:, channel_name:, event_name:, payload_builder:, delivery_mode:)
28
+ @mutex.synchronize do
29
+ subscription = SubscriptionIntent.new(
30
+ type: type,
31
+ channel_name: channel_name,
32
+ event_name: event_name,
33
+ payload_builder: payload_builder,
34
+ delivery_mode: delivery_mode
35
+ )
36
+
37
+ @subscriptions << subscription unless include?(subscription)
38
+ end
39
+ end
40
+
41
+ def each(&block)
42
+ snapshot.each(&block)
43
+ end
44
+
45
+ def any?
46
+ snapshot.any?
47
+ end
48
+
49
+ def count
50
+ snapshot.count
51
+ end
52
+
53
+ def event_names
54
+ snapshot.map(&:event_name).uniq
55
+ end
56
+
57
+ def private_subscriptions?
58
+ snapshot.any?(&:private_channel?)
59
+ end
60
+
61
+ def public_subscriptions?
62
+ snapshot.any? { |subscription| !subscription.private_channel? }
63
+ end
64
+
65
+ private
66
+
67
+ def include?(candidate)
68
+ @subscriptions.any? do |subscription|
69
+ subscription.type == candidate.type &&
70
+ subscription.channel_name == candidate.channel_name &&
71
+ subscription.event_name == candidate.event_name
72
+ end
73
+ end
74
+
75
+ def snapshot
76
+ @mutex.synchronize { @subscriptions.dup }
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ # socket.io-client-simple 1.2.x builds the Engine.IO query string with URI.encode, which was
6
+ # removed in Ruby 3.0 (use URI::DEFAULT_PARSER.escape instead). Load this before socket.io-client-simple.
7
+ unless URI.respond_to?(:encode)
8
+ module URI
9
+ def self.encode(str)
10
+ DEFAULT_PARSER.escape(str.to_s)
11
+ end
12
+ end
13
+ end
data/lib/coindcx.rb ADDED
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ require_relative "coindcx/version"
6
+ require_relative "coindcx/utils/payload"
7
+ require_relative "coindcx/errors/base_error"
8
+ require_relative "coindcx/logging/null_logger"
9
+ require_relative "coindcx/logging/structured_logger"
10
+ require_relative "coindcx/contracts/channel_name"
11
+ require_relative "coindcx/contracts/identifiers"
12
+ require_relative "coindcx/contracts/order_request"
13
+ require_relative "coindcx/contracts/wallet_transfer_request"
14
+ require_relative "coindcx/contracts/socket_backend"
15
+ require_relative "coindcx/models/base_model"
16
+
17
+ Dir[File.join(__dir__, "coindcx/models/*.rb")].each do |file|
18
+ require file unless file.end_with?("base_model.rb")
19
+ end
20
+
21
+ require_relative "coindcx/auth/signer"
22
+ require_relative "coindcx/transport/circuit_breaker"
23
+ require_relative "coindcx/transport/rate_limit_registry"
24
+ require_relative "coindcx/transport/request_policy"
25
+ require_relative "coindcx/transport/retry_policy"
26
+ require_relative "coindcx/transport/response_normalizer"
27
+ require_relative "coindcx/transport/http_client"
28
+ require_relative "coindcx/rest/base_resource"
29
+
30
+ Dir[File.join(__dir__, "coindcx/rest/**/*.rb")].each do |file|
31
+ require file unless file.end_with?("base_resource.rb")
32
+ end
33
+
34
+ Dir[File.join(__dir__, "coindcx/ws/**/*.rb")].each do |file|
35
+ require file
36
+ end
37
+
38
+ require_relative "coindcx/configuration"
39
+ require_relative "coindcx/client"
40
+
41
+ module CoinDCX
42
+ class << self
43
+ def configuration
44
+ @configuration ||= Configuration.new
45
+ end
46
+
47
+ def configure
48
+ yield(configuration)
49
+ end
50
+
51
+ def client
52
+ Client.new(configuration: configuration)
53
+ end
54
+
55
+ def generate_client_order_id
56
+ SecureRandom.uuid
57
+ end
58
+
59
+ def reset_configuration!
60
+ @configuration = Configuration.new
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+ require "spec_helper"
6
+
7
+ RSpec.describe CoinDCX::Auth::Signer do
8
+ subject(:signer) { described_class.new(api_key: "key-123", api_secret: "secret-456") }
9
+
10
+ describe "#authenticated_request" do
11
+ it "adds the documented authentication headers" do
12
+ request_body, headers = signer.authenticated_request(market: "SNTBTC", timestamp: 1234)
13
+ expected_signature = OpenSSL::HMAC.hexdigest("SHA256", "secret-456", JSON.generate("market" => "SNTBTC", "timestamp" => 1234))
14
+
15
+ expect(request_body).to eq(market: "SNTBTC", timestamp: 1234)
16
+ expect(headers).to eq(
17
+ "X-AUTH-APIKEY" => "key-123",
18
+ "X-AUTH-SIGNATURE" => expected_signature
19
+ )
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CoinDCX::Client do
6
+ subject(:client) { described_class.new(configuration: configuration) }
7
+
8
+ let(:configuration) { CoinDCX::Configuration.new }
9
+
10
+ it 'builds facades for each API namespace' do
11
+ expect(client.public).to be_a(CoinDCX::REST::Public::Facade)
12
+ expect(client.spot).to be_a(CoinDCX::REST::Spot::Facade)
13
+ expect(client.margin).to be_a(CoinDCX::REST::Margin::Facade)
14
+ expect(client.user).to be_a(CoinDCX::REST::User::Facade)
15
+ expect(client.transfers).to be_a(CoinDCX::REST::Transfers::Facade)
16
+ expect(client.futures).to be_a(CoinDCX::REST::Futures::Facade)
17
+ expect(client.funding).to be_a(CoinDCX::REST::Funding::Facade)
18
+ end
19
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe CoinDCX::Contracts::OrderRequest do
6
+ describe ".validate_spot_create!" do
7
+ it "accepts a valid spot order" do
8
+ attributes = described_class.validate_spot_create!(
9
+ side: "buy",
10
+ order_type: "limit_order",
11
+ market: "SNTBTC",
12
+ total_quantity: 2,
13
+ client_order_id: "spot-order-1",
14
+ price_per_unit: "0.03244"
15
+ )
16
+
17
+ expect(attributes).to include(
18
+ side: "buy",
19
+ order_type: "limit_order",
20
+ market: "SNTBTC",
21
+ total_quantity: 2,
22
+ client_order_id: "spot-order-1"
23
+ )
24
+ end
25
+
26
+ it "rejects unsupported sides" do
27
+ expect do
28
+ described_class.validate_spot_create!(
29
+ side: "hold",
30
+ order_type: "limit_order",
31
+ market: "SNTBTC",
32
+ total_quantity: 2
33
+ )
34
+ end.to raise_error(CoinDCX::Errors::ValidationError, /side/)
35
+ end
36
+
37
+ it "rejects non-positive quantities" do
38
+ expect do
39
+ described_class.validate_spot_create!(
40
+ side: "buy",
41
+ order_type: "limit_order",
42
+ market: "SNTBTC",
43
+ total_quantity: 0,
44
+ client_order_id: "spot-order-3"
45
+ )
46
+ end.to raise_error(CoinDCX::Errors::ValidationError, /total_quantity/)
47
+ end
48
+
49
+ it "rejects invalid market symbols" do
50
+ expect do
51
+ described_class.validate_spot_create!(
52
+ side: "buy",
53
+ order_type: "limit_order",
54
+ market: "btc-usdt",
55
+ total_quantity: 1,
56
+ client_order_id: "spot-order-4"
57
+ )
58
+ end.to raise_error(CoinDCX::Errors::ValidationError, /market/)
59
+ end
60
+
61
+ it "requires a client_order_id for safety" do
62
+ expect do
63
+ described_class.validate_spot_create!(
64
+ side: "buy",
65
+ order_type: "market_order",
66
+ market: "SNTBTC",
67
+ total_quantity: 1
68
+ )
69
+ end.to raise_error(CoinDCX::Errors::ValidationError, /client_order_id/)
70
+ end
71
+ end
72
+
73
+ describe ".validate_futures_create!" do
74
+ it "accepts a valid futures order" do
75
+ order = described_class.validate_futures_create!(
76
+ side: "sell",
77
+ order_type: "limit_order",
78
+ pair: "B-BTC_USDT",
79
+ quantity: 1,
80
+ client_order_id: "futures-order-1"
81
+ )
82
+
83
+ expect(order).to include(side: "sell", pair: "B-BTC_USDT", quantity: 1)
84
+ end
85
+
86
+ it "accepts total_quantity (CoinDCX futures create payload style)" do
87
+ order = described_class.validate_futures_create!(
88
+ side: "buy",
89
+ pair: "B-SOL_USDT",
90
+ total_quantity: "0.01",
91
+ order_type: "market_order",
92
+ client_order_id: "futures-order-2",
93
+ margin_currency_short_name: "USDT",
94
+ leverage: 3
95
+ )
96
+
97
+ expect(order).to include(side: "buy", pair: "B-SOL_USDT", total_quantity: "0.01")
98
+ end
99
+
100
+ it "rejects malformed pair identifiers" do
101
+ expect do
102
+ described_class.validate_futures_create!(
103
+ side: "sell",
104
+ pair: "BTCUSDT",
105
+ quantity: 1,
106
+ client_order_id: "futures-order-3"
107
+ )
108
+ end.to raise_error(CoinDCX::Errors::ValidationError, /pair/)
109
+ end
110
+ end
111
+
112
+ describe ".validate_margin_create!" do
113
+ it "accepts a valid margin order" do
114
+ attributes = described_class.validate_margin_create!(
115
+ side: "buy",
116
+ order_type: "market_order",
117
+ market: "SNTBTC",
118
+ quantity: 1,
119
+ client_order_id: "margin-order-1"
120
+ )
121
+
122
+ expect(attributes).to include(side: "buy", market: "SNTBTC", quantity: 1)
123
+ end
124
+
125
+ it "rejects missing quantities" do
126
+ expect do
127
+ described_class.validate_margin_create!(
128
+ side: "buy",
129
+ order_type: "market_order",
130
+ market: "SNTBTC",
131
+ client_order_id: "margin-order-2"
132
+ )
133
+ end.to raise_error(CoinDCX::Errors::ValidationError, /quantity/)
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ RSpec.describe CoinDCX::Contracts::WalletTransferRequest do
6
+ describe ".validate_transfer!" do
7
+ it "accepts a valid wallet transfer request" do
8
+ attributes = described_class.validate_transfer!(
9
+ source_wallet_type: "spot",
10
+ destination_wallet_type: "futures",
11
+ currency_short_name: "USDT",
12
+ amount: 1
13
+ )
14
+
15
+ expect(attributes).to include(
16
+ source_wallet_type: "spot",
17
+ destination_wallet_type: "futures",
18
+ currency_short_name: "USDT",
19
+ amount: 1
20
+ )
21
+ end
22
+
23
+ it "rejects unsupported wallet types" do
24
+ expect do
25
+ described_class.validate_transfer!(
26
+ source_wallet_type: "vault",
27
+ destination_wallet_type: "futures",
28
+ currency_short_name: "USDT",
29
+ amount: 1
30
+ )
31
+ end.to raise_error(CoinDCX::Errors::ValidationError, /source_wallet_type/)
32
+ end
33
+
34
+ it "rejects non-positive amounts" do
35
+ expect do
36
+ described_class.validate_transfer!(
37
+ source_wallet_type: "spot",
38
+ destination_wallet_type: "futures",
39
+ currency_short_name: "USDT",
40
+ amount: 0
41
+ )
42
+ end.to raise_error(CoinDCX::Errors::ValidationError, /amount/)
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CoinDCX::Models::BaseModel do
6
+ subject(:model) { described_class.new('status' => 'open', 'nested' => { 'count' => 2 }) }
7
+
8
+ it 'provides hash-like and method-style access to attributes' do
9
+ expect(model[:status]).to eq('open')
10
+ expect(model.status).to eq('open')
11
+ expect(model.to_h).to eq(status: 'open', nested: { count: 2 })
12
+ end
13
+
14
+ it 'reports dynamic methods through respond_to?' do
15
+ expect(model).to respond_to(:status)
16
+ expect(model).not_to respond_to(:missing_attribute)
17
+ end
18
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CoinDCX::REST::Funding::Orders do
6
+ subject(:resource) { described_class.new(http_client: http_client) }
7
+
8
+ let(:http_client) { instance_double(CoinDCX::Transport::HttpClient) }
9
+
10
+ before do
11
+ allow(http_client).to receive(:post).and_return({})
12
+ end
13
+
14
+ it 'routes funding endpoints through authenticated transport calls' do
15
+ resource.list
16
+ resource.lend(currency: 'USDT', amount: '10')
17
+ resource.settle(id: 'abc123')
18
+
19
+ expect(http_client).to have_received(:post).with(
20
+ '/exchange/v1/funding/fetch_orders',
21
+ body: {},
22
+ auth: true,
23
+ base: :api,
24
+ bucket: :funding_fetch_orders
25
+ )
26
+
27
+ expect(http_client).to have_received(:post).with(
28
+ '/exchange/v1/funding/lend',
29
+ body: { currency: 'USDT', amount: '10' },
30
+ auth: true,
31
+ base: :api,
32
+ bucket: :funding_lend
33
+ )
34
+
35
+ expect(http_client).to have_received(:post).with(
36
+ '/exchange/v1/funding/settle',
37
+ body: { id: 'abc123' },
38
+ auth: true,
39
+ base: :api,
40
+ bucket: :funding_settle
41
+ )
42
+ end
43
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe CoinDCX::REST::Futures::MarketData do
6
+ subject(:resource) { described_class.new(http_client: http_client) }
7
+
8
+ let(:http_client) { instance_double(CoinDCX::Transport::HttpClient) }
9
+
10
+ before do
11
+ allow(http_client).to receive(:get).and_return({})
12
+ end
13
+
14
+ it 'routes futures market data requests to the expected endpoints' do
15
+ resource.list_active_instruments
16
+ resource.fetch_instrument(pair: 'B-BTC_USDT', margin_currency_short_name: 'USDT')
17
+ resource.list_trades(pair: 'B-BTC_USDT')
18
+ resource.fetch_order_book(instrument: 'B-BTC_USDT', depth: 50)
19
+ resource.list_candlesticks(pair: 'B-BTC_USDT', from: 1, to: 2, resolution: '1D')
20
+ resource.current_prices
21
+ resource.stats(pair: 'B-BTC_USDT')
22
+ resource.conversions
23
+
24
+ expect(http_client).to have_received(:get).with('/exchange/v1/derivatives/futures/data/active_instruments',
25
+ params: { 'margin_currency_short_name[]': ['USDT'] }, body: {}, auth: false, base: :api, bucket: nil)
26
+ expect(http_client).to have_received(:get).with('/exchange/v1/derivatives/futures/data/instrument',
27
+ params: { pair: 'B-BTC_USDT', margin_currency_short_name: 'USDT' }, body: {}, auth: true,
28
+ base: :api, bucket: :futures_instrument_detail)
29
+ expect(http_client).to have_received(:get).with('/exchange/v1/derivatives/futures/data/trades', params: { pair: 'B-BTC_USDT' }, body: {},
30
+ auth: false, base: :api, bucket: nil)
31
+ expect(http_client).to have_received(:get).with('/market_data/v3/orderbook/B-BTC_USDT-futures/50', params: {}, body: {}, auth: false,
32
+ base: :public, bucket: nil)
33
+ expect(http_client).to have_received(:get).with('/market_data/candlesticks',
34
+ params: { pair: 'B-BTC_USDT', from: 1, to: 2, resolution: '1D', pcode: 'f' }, body: {},
35
+ auth: false, base: :public, bucket: nil)
36
+ expect(http_client).to have_received(:get).with('/market_data/v3/current_prices/futures/rt', params: {}, body: {}, auth: false,
37
+ base: :public, bucket: :public_market_data)
38
+ expect(http_client).to have_received(:get).with('/api/v1/derivatives/futures/data/stats', params: { pair: 'B-BTC_USDT' }, body: {},
39
+ auth: false, base: :api, bucket: nil)
40
+ expect(http_client).to have_received(:get).with('/api/v1/derivatives/futures/data/conversions', params: {}, body: {}, auth: false,
41
+ base: :api, bucket: nil)
42
+ end
43
+
44
+ it 'rejects unsupported order book depths' do
45
+ expect do
46
+ resource.fetch_order_book(instrument: 'B-BTC_USDT', depth: 5)
47
+ end.to raise_error(CoinDCX::Errors::ValidationError)
48
+ end
49
+ end