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,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
|
data/spec/client_spec.rb
ADDED
|
@@ -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
|