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