hyperliquid 0.6.0 → 1.0.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 +4 -4
- data/.rubocop.yml +2 -1
- data/CHANGELOG.md +50 -0
- data/README.md +2 -1
- data/docs/API.md +68 -2
- data/docs/EXAMPLES.md +247 -0
- data/docs/WS.md +49 -0
- data/lib/hyperliquid/constants.rb +5 -0
- data/lib/hyperliquid/errors.rb +3 -0
- data/lib/hyperliquid/exchange.rb +203 -19
- data/lib/hyperliquid/info.rb +26 -2
- data/lib/hyperliquid/signing/eip712.rb +37 -0
- data/lib/hyperliquid/signing/signer.rb +6 -0
- data/lib/hyperliquid/version.rb +1 -1
- data/lib/hyperliquid/ws/client.rb +340 -0
- data/lib/hyperliquid.rb +4 -1
- data/scripts/test_03_perp_market_roundtrip.rb +32 -26
- data/scripts/test_05_update_leverage.rb +11 -0
- data/scripts/test_07_market_close.rb +28 -22
- data/scripts/test_08_usd_class_transfer.rb +2 -0
- data/scripts/test_10_vault.rb +68 -0
- data/scripts/test_11_builder_fee.rb +71 -0
- data/scripts/test_12_staking.rb +70 -0
- data/scripts/test_13_ws_l2_book.rb +83 -0
- data/scripts/test_14_ws_candle.rb +81 -0
- data/scripts/test_all.rb +5 -1
- data/scripts/test_helpers.rb +131 -1
- metadata +22 -2
- data/scripts/test_10_vault_transfer.rb +0 -41
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ws_lite'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Hyperliquid
|
|
7
|
+
module WS
|
|
8
|
+
# Managed WebSocket client for subscribing to real-time data channels
|
|
9
|
+
class Client
|
|
10
|
+
attr_reader :dropped_message_count
|
|
11
|
+
|
|
12
|
+
def initialize(testnet: false, max_queue_size: Constants::WS_MAX_QUEUE_SIZE, reconnect: true)
|
|
13
|
+
base_url = testnet ? Constants::TESTNET_API_URL : Constants::MAINNET_API_URL
|
|
14
|
+
@url = base_url.sub(%r{^https?://}, 'wss://') + Constants::WS_ENDPOINT
|
|
15
|
+
@max_queue_size = max_queue_size
|
|
16
|
+
@reconnect_enabled = reconnect
|
|
17
|
+
|
|
18
|
+
@subscriptions = {} # identifier => [{ id:, callback: }]
|
|
19
|
+
@subscription_msgs = {} # subscription_id => { subscription:, identifier: }
|
|
20
|
+
@next_id = 0
|
|
21
|
+
@mutex = Mutex.new
|
|
22
|
+
@queue = Queue.new
|
|
23
|
+
@dropped_message_count = 0
|
|
24
|
+
|
|
25
|
+
@ws = nil
|
|
26
|
+
@connected = false
|
|
27
|
+
@closing = false
|
|
28
|
+
@dispatch_thread = nil
|
|
29
|
+
@ping_thread = nil
|
|
30
|
+
@pending_subscriptions = []
|
|
31
|
+
|
|
32
|
+
@lifecycle_callbacks = {}
|
|
33
|
+
@reconnect_attempts = 0
|
|
34
|
+
@connection_id = 0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def connect
|
|
38
|
+
@closing = false
|
|
39
|
+
@reconnect_attempts = 0
|
|
40
|
+
establish_connection
|
|
41
|
+
start_dispatch_thread
|
|
42
|
+
start_ping_thread
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def subscribe(subscription, &callback)
|
|
47
|
+
raise ArgumentError, 'Block required for subscribe' unless block_given?
|
|
48
|
+
|
|
49
|
+
identifier = subscription_identifier(subscription)
|
|
50
|
+
sub_id = nil
|
|
51
|
+
|
|
52
|
+
@mutex.synchronize do
|
|
53
|
+
sub_id = @next_id
|
|
54
|
+
@next_id += 1
|
|
55
|
+
|
|
56
|
+
@subscriptions[identifier] ||= []
|
|
57
|
+
@subscriptions[identifier] << { id: sub_id, callback: callback }
|
|
58
|
+
@subscription_msgs[sub_id] = { subscription: subscription, identifier: identifier }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
if @connected
|
|
62
|
+
send_subscribe(subscription)
|
|
63
|
+
else
|
|
64
|
+
@mutex.synchronize { @pending_subscriptions << subscription }
|
|
65
|
+
connect unless @ws
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
sub_id
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def unsubscribe(subscription_id)
|
|
72
|
+
sub_msg = nil
|
|
73
|
+
should_send = false
|
|
74
|
+
|
|
75
|
+
@mutex.synchronize do
|
|
76
|
+
sub_msg = @subscription_msgs.delete(subscription_id)
|
|
77
|
+
return unless sub_msg
|
|
78
|
+
|
|
79
|
+
identifier = sub_msg[:identifier]
|
|
80
|
+
callbacks = @subscriptions[identifier]
|
|
81
|
+
return unless callbacks
|
|
82
|
+
|
|
83
|
+
callbacks.reject! { |entry| entry[:id] == subscription_id }
|
|
84
|
+
|
|
85
|
+
if callbacks.empty?
|
|
86
|
+
@subscriptions.delete(identifier)
|
|
87
|
+
should_send = true
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
send_unsubscribe(sub_msg[:subscription]) if should_send && @connected
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def close
|
|
95
|
+
@closing = true
|
|
96
|
+
@connected = false
|
|
97
|
+
|
|
98
|
+
@ping_thread&.kill
|
|
99
|
+
@ping_thread = nil
|
|
100
|
+
|
|
101
|
+
@queue&.close if @queue.respond_to?(:close)
|
|
102
|
+
@dispatch_thread&.join(5)
|
|
103
|
+
@dispatch_thread = nil
|
|
104
|
+
|
|
105
|
+
@ws&.close
|
|
106
|
+
@ws = nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def connected?
|
|
110
|
+
@connected
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def on(event, &callback)
|
|
114
|
+
@lifecycle_callbacks[event] = callback
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
private
|
|
118
|
+
|
|
119
|
+
def establish_connection
|
|
120
|
+
client = self
|
|
121
|
+
url = @url
|
|
122
|
+
@connection_id += 1
|
|
123
|
+
active_id = @connection_id
|
|
124
|
+
|
|
125
|
+
@ws = ::WSLite.connect(url) do |ws|
|
|
126
|
+
ws.on :open do
|
|
127
|
+
next if client.send(:stale_connection?, active_id)
|
|
128
|
+
|
|
129
|
+
client.send(:handle_open)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
ws.on :message do |msg|
|
|
133
|
+
next if client.send(:stale_connection?, active_id)
|
|
134
|
+
|
|
135
|
+
client.send(:handle_message, msg.data)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
ws.on :error do |e|
|
|
139
|
+
next if client.send(:stale_connection?, active_id)
|
|
140
|
+
|
|
141
|
+
client.send(:handle_error, e)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
ws.on :close do |e|
|
|
145
|
+
next if client.send(:stale_connection?, active_id)
|
|
146
|
+
|
|
147
|
+
client.send(:handle_close, e)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def stale_connection?(id)
|
|
153
|
+
id != @connection_id
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def handle_open
|
|
157
|
+
@connected = true
|
|
158
|
+
@reconnect_attempts = 0
|
|
159
|
+
flush_pending_subscriptions
|
|
160
|
+
replay_subscriptions
|
|
161
|
+
@lifecycle_callbacks[:open]&.call
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def handle_message(raw)
|
|
165
|
+
return if raw.nil? || raw.empty?
|
|
166
|
+
|
|
167
|
+
return if raw.start_with?('Websocket connection established')
|
|
168
|
+
|
|
169
|
+
data = parse_json(raw)
|
|
170
|
+
return unless data
|
|
171
|
+
|
|
172
|
+
channel = data['channel']
|
|
173
|
+
return if channel == 'pong'
|
|
174
|
+
return unless channel
|
|
175
|
+
|
|
176
|
+
identifier = compute_identifier(channel, data['data'])
|
|
177
|
+
return unless identifier
|
|
178
|
+
|
|
179
|
+
enqueue_message(identifier, data['data'])
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def handle_error(error)
|
|
183
|
+
@lifecycle_callbacks[:error]&.call(error)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def handle_close(_event)
|
|
187
|
+
was_connected = @connected
|
|
188
|
+
@connected = false
|
|
189
|
+
@lifecycle_callbacks[:close]&.call
|
|
190
|
+
|
|
191
|
+
attempt_reconnect if was_connected && !@closing && @reconnect_enabled
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def parse_json(raw)
|
|
195
|
+
JSON.parse(raw)
|
|
196
|
+
rescue JSON::ParserError => e
|
|
197
|
+
warn "[Hyperliquid::WS] Failed to parse message: #{e.message}"
|
|
198
|
+
nil
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def compute_identifier(channel, data)
|
|
202
|
+
case channel
|
|
203
|
+
when 'l2Book' then "l2Book:#{data['coin'].downcase}"
|
|
204
|
+
when 'trades' then data.is_a?(Array) && data[0] ? "trades:#{data[0]['coin'].downcase}" : nil
|
|
205
|
+
when 'bbo' then "bbo:#{data['coin'].downcase}"
|
|
206
|
+
when 'candle' then "candle:#{data['s'].downcase}:#{data['i']}"
|
|
207
|
+
when 'allMids' then 'allMids'
|
|
208
|
+
when 'orderUpdates' then 'orderUpdates'
|
|
209
|
+
when 'userEvents' then "userEvents:#{data['user'].downcase}"
|
|
210
|
+
when 'userFills' then "userFills:#{data['user'].downcase}"
|
|
211
|
+
when 'userFundings' then "userFundings:#{data['user'].downcase}"
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def subscription_identifier(subscription)
|
|
216
|
+
type = sub_field(subscription, 'type')
|
|
217
|
+
case type
|
|
218
|
+
when 'l2Book' then "l2Book:#{sub_field(subscription, 'coin').downcase}"
|
|
219
|
+
when 'trades' then "trades:#{sub_field(subscription, 'coin').downcase}"
|
|
220
|
+
when 'bbo' then "bbo:#{sub_field(subscription, 'coin').downcase}"
|
|
221
|
+
when 'candle'
|
|
222
|
+
"candle:#{sub_field(subscription, 'coin').downcase}:#{sub_field(subscription, 'interval')}"
|
|
223
|
+
when 'allMids' then 'allMids'
|
|
224
|
+
when 'orderUpdates' then 'orderUpdates'
|
|
225
|
+
when 'userEvents' then "userEvents:#{sub_field(subscription, 'user').downcase}"
|
|
226
|
+
when 'userFills' then "userFills:#{sub_field(subscription, 'user').downcase}"
|
|
227
|
+
when 'userFundings' then "userFundings:#{sub_field(subscription, 'user').downcase}"
|
|
228
|
+
else
|
|
229
|
+
raise Hyperliquid::WebSocketError, "Unsupported subscription type: #{type}"
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def sub_field(subscription, key)
|
|
234
|
+
subscription[key.to_sym] || subscription[key]
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def enqueue_message(identifier, data)
|
|
238
|
+
@mutex.synchronize do
|
|
239
|
+
if @queue.size >= @max_queue_size
|
|
240
|
+
@dropped_message_count += 1
|
|
241
|
+
if @dropped_message_count == 1 || (@dropped_message_count % 100).zero?
|
|
242
|
+
warn "[Hyperliquid::WS] Queue full (#{@max_queue_size}). " \
|
|
243
|
+
"Dropped #{@dropped_message_count} message(s). Callbacks may be too slow."
|
|
244
|
+
end
|
|
245
|
+
return
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
@queue.push({ identifier: identifier, data: data })
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def start_dispatch_thread
|
|
252
|
+
@dispatch_thread = Thread.new do
|
|
253
|
+
loop do
|
|
254
|
+
msg = begin
|
|
255
|
+
@queue.pop
|
|
256
|
+
rescue ClosedQueueError
|
|
257
|
+
break
|
|
258
|
+
end
|
|
259
|
+
break if msg.nil?
|
|
260
|
+
|
|
261
|
+
callbacks = @mutex.synchronize { @subscriptions[msg[:identifier]]&.dup }
|
|
262
|
+
next unless callbacks
|
|
263
|
+
|
|
264
|
+
callbacks.each do |entry|
|
|
265
|
+
entry[:callback].call(msg[:data])
|
|
266
|
+
rescue StandardError => e
|
|
267
|
+
warn "[Hyperliquid::WS] Callback error: #{e.message}"
|
|
268
|
+
end
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
@dispatch_thread.name = 'hl-ws-dispatch'
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def start_ping_thread
|
|
275
|
+
@ping_thread = Thread.new do
|
|
276
|
+
loop do
|
|
277
|
+
sleep Constants::WS_PING_INTERVAL
|
|
278
|
+
break if @closing
|
|
279
|
+
|
|
280
|
+
send_json({ method: 'ping' }) if @connected
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
@ping_thread.name = 'hl-ws-ping'
|
|
284
|
+
@ping_thread.report_on_exception = false
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
def flush_pending_subscriptions
|
|
288
|
+
pending = @mutex.synchronize do
|
|
289
|
+
subs = @pending_subscriptions.dup
|
|
290
|
+
@pending_subscriptions.clear
|
|
291
|
+
subs
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
pending.each { |sub| send_subscribe(sub) }
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def send_subscribe(subscription)
|
|
298
|
+
send_json({ method: 'subscribe', subscription: subscription })
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def send_unsubscribe(subscription)
|
|
302
|
+
send_json({ method: 'unsubscribe', subscription: subscription })
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def send_json(hash)
|
|
306
|
+
@ws&.send(JSON.generate(hash))
|
|
307
|
+
rescue StandardError => e
|
|
308
|
+
warn "[Hyperliquid::WS] Send error: #{e.message}"
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
def attempt_reconnect
|
|
312
|
+
Thread.new do
|
|
313
|
+
loop do
|
|
314
|
+
break if @closing
|
|
315
|
+
|
|
316
|
+
delay = [2**@reconnect_attempts, 30].min
|
|
317
|
+
@reconnect_attempts += 1
|
|
318
|
+
sleep delay
|
|
319
|
+
|
|
320
|
+
break if @closing
|
|
321
|
+
|
|
322
|
+
begin
|
|
323
|
+
establish_connection
|
|
324
|
+
break
|
|
325
|
+
rescue StandardError => e
|
|
326
|
+
warn "[Hyperliquid::WS] Reconnect failed: #{e.message}"
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def replay_subscriptions
|
|
333
|
+
subs = @mutex.synchronize do
|
|
334
|
+
@subscription_msgs.values.map { |v| v[:subscription] }.uniq
|
|
335
|
+
end
|
|
336
|
+
subs.each { |sub| send_subscribe(sub) }
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
end
|
|
340
|
+
end
|
data/lib/hyperliquid.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative 'hyperliquid/cloid'
|
|
|
9
9
|
require_relative 'hyperliquid/signing/eip712'
|
|
10
10
|
require_relative 'hyperliquid/signing/signer'
|
|
11
11
|
require_relative 'hyperliquid/exchange'
|
|
12
|
+
require_relative 'hyperliquid/ws/client'
|
|
12
13
|
|
|
13
14
|
# Ruby SDK for Hyperliquid API
|
|
14
15
|
# Provides access to Hyperliquid's decentralized exchange API
|
|
@@ -34,7 +35,7 @@ module Hyperliquid
|
|
|
34
35
|
|
|
35
36
|
# Main SDK class
|
|
36
37
|
class SDK
|
|
37
|
-
attr_reader :info, :exchange
|
|
38
|
+
attr_reader :info, :exchange, :ws
|
|
38
39
|
|
|
39
40
|
# Initialize the SDK
|
|
40
41
|
# @param testnet [Boolean] Whether to use testnet (default: false for mainnet)
|
|
@@ -50,6 +51,7 @@ module Hyperliquid
|
|
|
50
51
|
@info = Info.new(client)
|
|
51
52
|
@testnet = testnet
|
|
52
53
|
@exchange = nil
|
|
54
|
+
@ws = WS::Client.new(testnet: testnet)
|
|
53
55
|
|
|
54
56
|
return unless private_key
|
|
55
57
|
|
|
@@ -58,6 +60,7 @@ module Hyperliquid
|
|
|
58
60
|
client: client,
|
|
59
61
|
signer: signer,
|
|
60
62
|
info: @info,
|
|
63
|
+
testnet: testnet,
|
|
61
64
|
expires_after: expires_after
|
|
62
65
|
)
|
|
63
66
|
end
|
|
@@ -1,49 +1,55 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
# Test 3: Perp Market Roundtrip (
|
|
5
|
-
# Open a long
|
|
4
|
+
# Test 3: Perp Market Roundtrip (Long)
|
|
5
|
+
# Open a long perp position, then close it.
|
|
6
6
|
|
|
7
7
|
require_relative 'test_helpers'
|
|
8
8
|
|
|
9
9
|
sdk = build_sdk
|
|
10
|
-
|
|
10
|
+
perp_coin = 'ETH'
|
|
11
|
+
separator("TEST 3: Perp Market Roundtrip (#{perp_coin} Long)")
|
|
11
12
|
|
|
12
|
-
perp_coin = 'BTC'
|
|
13
13
|
mids = sdk.info.all_mids
|
|
14
|
-
|
|
14
|
+
perp_coin_price = mids[perp_coin]&.to_f
|
|
15
15
|
|
|
16
|
-
if
|
|
16
|
+
if perp_coin_price&.positive?
|
|
17
17
|
meta = sdk.info.meta
|
|
18
|
-
|
|
19
|
-
sz_decimals =
|
|
18
|
+
perp_coin_meta = meta['universe'].find { |a| a['name'] == perp_coin }
|
|
19
|
+
sz_decimals = perp_coin_meta['szDecimals']
|
|
20
20
|
|
|
21
|
-
perp_size = (20.0 /
|
|
21
|
+
perp_size = (20.0 / perp_coin_price).ceil(sz_decimals)
|
|
22
22
|
|
|
23
|
-
puts "#{perp_coin} mid: $#{
|
|
24
|
-
puts "Size: #{perp_size}
|
|
25
|
-
puts "Slippage: #{(PERP_SLIPPAGE * 100).to_i}%"
|
|
23
|
+
puts "#{perp_coin} mid: $#{perp_coin_price.round(2)}"
|
|
24
|
+
puts "Size: #{perp_size} #{perp_coin} (~$#{(perp_size * perp_coin_price).round(2)})"
|
|
25
|
+
puts "Slippage: #{(PERP_SLIPPAGE * 100).to_i}% (with retry up to #{((PERP_SLIPPAGE + ORACLE_SLIPPAGE_INCREMENT * (ORACLE_RETRY_ATTEMPTS - 1)) * 100).to_i}%)"
|
|
26
26
|
puts
|
|
27
27
|
|
|
28
28
|
puts 'Opening LONG position (market buy)...'
|
|
29
|
-
result =
|
|
29
|
+
result = market_order_with_retry(
|
|
30
|
+
sdk,
|
|
30
31
|
coin: perp_coin,
|
|
31
32
|
is_buy: true,
|
|
32
33
|
size: perp_size,
|
|
33
|
-
|
|
34
|
+
base_slippage: PERP_SLIPPAGE
|
|
34
35
|
)
|
|
35
|
-
check_result(result, 'Long open')
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
36
|
+
open_success = check_result(result, 'Long open')
|
|
37
|
+
|
|
38
|
+
if open_success
|
|
39
|
+
wait_with_countdown(WAIT_SECONDS, 'Position open. Waiting before close...')
|
|
40
|
+
|
|
41
|
+
puts 'Closing LONG position (market sell)...'
|
|
42
|
+
result = market_order_with_retry(
|
|
43
|
+
sdk,
|
|
44
|
+
coin: perp_coin,
|
|
45
|
+
is_buy: false,
|
|
46
|
+
size: perp_size,
|
|
47
|
+
base_slippage: PERP_SLIPPAGE
|
|
48
|
+
)
|
|
49
|
+
check_result(result, 'Long close')
|
|
50
|
+
else
|
|
51
|
+
puts red('Skipping close - position was not opened')
|
|
52
|
+
end
|
|
47
53
|
else
|
|
48
54
|
puts red("SKIPPED: Could not get #{perp_coin} price")
|
|
49
55
|
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
# Test 5: Update Leverage (BTC)
|
|
5
5
|
# Set cross, isolated, then reset leverage.
|
|
6
|
+
# Requires no open BTC position (cannot switch leverage type with open position).
|
|
6
7
|
|
|
7
8
|
require_relative 'test_helpers'
|
|
8
9
|
|
|
@@ -14,8 +15,16 @@ mids = sdk.info.all_mids
|
|
|
14
15
|
btc_price = mids[perp_coin]&.to_f
|
|
15
16
|
|
|
16
17
|
if btc_price&.positive?
|
|
18
|
+
# Check for open position - cannot switch leverage type with open position
|
|
19
|
+
unless check_position_and_prompt(sdk, perp_coin, timeout: 10)
|
|
20
|
+
puts
|
|
21
|
+
puts green('Test 5 Update Leverage skipped (open position).')
|
|
22
|
+
exit 0
|
|
23
|
+
end
|
|
24
|
+
|
|
17
25
|
puts 'Setting BTC to 5x cross leverage...'
|
|
18
26
|
result = sdk.exchange.update_leverage(coin: perp_coin, leverage: 5, is_cross: true)
|
|
27
|
+
dump_status(result)
|
|
19
28
|
api_error?(result) || puts(green('5x cross leverage set'))
|
|
20
29
|
puts
|
|
21
30
|
|
|
@@ -23,6 +32,7 @@ if btc_price&.positive?
|
|
|
23
32
|
|
|
24
33
|
puts 'Setting BTC to 3x isolated leverage...'
|
|
25
34
|
result = sdk.exchange.update_leverage(coin: perp_coin, leverage: 3, is_cross: false)
|
|
35
|
+
dump_status(result)
|
|
26
36
|
api_error?(result) || puts(green('3x isolated leverage set'))
|
|
27
37
|
puts
|
|
28
38
|
|
|
@@ -30,6 +40,7 @@ if btc_price&.positive?
|
|
|
30
40
|
|
|
31
41
|
puts 'Resetting BTC to 1x cross leverage...'
|
|
32
42
|
result = sdk.exchange.update_leverage(coin: perp_coin, leverage: 1, is_cross: true)
|
|
43
|
+
dump_status(result)
|
|
33
44
|
api_error?(result) || puts(green('1x cross leverage set'))
|
|
34
45
|
else
|
|
35
46
|
puts red("SKIPPED: Could not get #{perp_coin} price")
|
|
@@ -1,46 +1,52 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
-
# Test 7: Market Close (
|
|
4
|
+
# Test 7: Market Close (PERP)
|
|
5
5
|
# Open a long position, then close it using market_close (auto-detect size).
|
|
6
6
|
|
|
7
7
|
require_relative 'test_helpers'
|
|
8
8
|
|
|
9
9
|
sdk = build_sdk
|
|
10
|
-
|
|
10
|
+
perp_coin = 'ETH'
|
|
11
|
+
separator("TEST 7: Market Close (#{perp_coin})")
|
|
11
12
|
|
|
12
|
-
perp_coin = 'BTC'
|
|
13
13
|
mids = sdk.info.all_mids
|
|
14
|
-
|
|
14
|
+
perp_coin_price = mids[perp_coin]&.to_f
|
|
15
15
|
|
|
16
|
-
if
|
|
16
|
+
if perp_coin_price&.positive?
|
|
17
17
|
meta = sdk.info.meta
|
|
18
|
-
|
|
19
|
-
sz_decimals =
|
|
18
|
+
perp_coin_meta = meta['universe'].find { |a| a['name'] == perp_coin }
|
|
19
|
+
sz_decimals = perp_coin_meta['szDecimals']
|
|
20
20
|
|
|
21
|
-
perp_size = (20.0 /
|
|
21
|
+
perp_size = (20.0 / perp_coin_price).ceil(sz_decimals)
|
|
22
22
|
|
|
23
|
-
puts "#{perp_coin} mid: $#{
|
|
24
|
-
puts "Size: #{perp_size}
|
|
23
|
+
puts "#{perp_coin} mid: $#{perp_coin_price.round(2)}"
|
|
24
|
+
puts "Size: #{perp_size} #{perp_coin}"
|
|
25
|
+
puts "Slippage: #{(PERP_SLIPPAGE * 100).to_i}% (with retry up to #{((PERP_SLIPPAGE + ORACLE_SLIPPAGE_INCREMENT * (ORACLE_RETRY_ATTEMPTS - 1)) * 100).to_i}%)"
|
|
25
26
|
puts
|
|
26
27
|
|
|
27
28
|
puts 'Opening LONG position (market buy)...'
|
|
28
|
-
result =
|
|
29
|
+
result = market_order_with_retry(
|
|
30
|
+
sdk,
|
|
29
31
|
coin: perp_coin,
|
|
30
32
|
is_buy: true,
|
|
31
33
|
size: perp_size,
|
|
32
|
-
|
|
34
|
+
base_slippage: PERP_SLIPPAGE
|
|
33
35
|
)
|
|
34
|
-
check_result(result, 'Long open')
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
36
|
+
open_success = check_result(result, 'Long open')
|
|
37
|
+
|
|
38
|
+
if open_success
|
|
39
|
+
wait_with_countdown(WAIT_SECONDS, 'Position open. Waiting before market_close...')
|
|
40
|
+
|
|
41
|
+
puts 'Closing position using market_close (auto-detect size)...'
|
|
42
|
+
result = sdk.exchange.market_close(
|
|
43
|
+
coin: perp_coin,
|
|
44
|
+
slippage: PERP_SLIPPAGE + ORACLE_SLIPPAGE_INCREMENT # Use higher slippage for close
|
|
45
|
+
)
|
|
46
|
+
check_result(result, 'Market close')
|
|
47
|
+
else
|
|
48
|
+
puts red('Skipping market_close - position was not opened')
|
|
49
|
+
end
|
|
44
50
|
else
|
|
45
51
|
puts red("SKIPPED: Could not get #{perp_coin} price")
|
|
46
52
|
end
|
|
@@ -11,6 +11,7 @@ separator('TEST 8: USD Class Transfer (Perp <-> Spot)')
|
|
|
11
11
|
|
|
12
12
|
puts 'Transferring $10 from perp to spot...'
|
|
13
13
|
result = sdk.exchange.usd_class_transfer(amount: '10', to_perp: false)
|
|
14
|
+
dump_status(result)
|
|
14
15
|
api_error?(result) || puts(green('Transfer to spot successful!'))
|
|
15
16
|
puts
|
|
16
17
|
|
|
@@ -18,6 +19,7 @@ wait_with_countdown(WAIT_SECONDS, 'Waiting before transferring back...')
|
|
|
18
19
|
|
|
19
20
|
puts 'Transferring $10 from spot to perp...'
|
|
20
21
|
result = sdk.exchange.usd_class_transfer(amount: '10', to_perp: true)
|
|
22
|
+
dump_status(result)
|
|
21
23
|
api_error?(result) || puts(green('Transfer to perp successful!'))
|
|
22
24
|
|
|
23
25
|
test_passed('Test 8 USD Class Transfer')
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Test 10: Vault Status / Deposit / Withdraw
|
|
5
|
+
#
|
|
6
|
+
# Default: Show vault status (equity, entry date, unlock date)
|
|
7
|
+
# Options: ruby test_10_vault_transfer.rb deposit
|
|
8
|
+
# ruby test_10_vault_transfer.rb withdraw
|
|
9
|
+
|
|
10
|
+
require_relative 'test_helpers'
|
|
11
|
+
|
|
12
|
+
sdk = build_sdk
|
|
13
|
+
separator('TEST 10: Vault Status / Deposit / Withdraw')
|
|
14
|
+
|
|
15
|
+
vault_addr = '0xa15099a30bbf2e68942d6f4c43d70d04faeab0a0'
|
|
16
|
+
action = ARGV[0] # nil, "deposit", or "withdraw"
|
|
17
|
+
|
|
18
|
+
puts "Vault: #{vault_addr}"
|
|
19
|
+
puts
|
|
20
|
+
|
|
21
|
+
vault = sdk.info.vault_details(vault_addr, sdk.exchange.address)
|
|
22
|
+
follower = vault['followerState']
|
|
23
|
+
|
|
24
|
+
if follower
|
|
25
|
+
equity = follower['vaultEquity']
|
|
26
|
+
entry_time = follower['vaultEntryTime']
|
|
27
|
+
lockup_until = follower['lockupUntil']
|
|
28
|
+
|
|
29
|
+
puts "Vault equity: $#{equity}"
|
|
30
|
+
puts "Entry date: #{Time.at(entry_time / 1000.0).utc}" if entry_time
|
|
31
|
+
puts "Unlock date: #{Time.at(lockup_until / 1000.0).utc}" if lockup_until
|
|
32
|
+
else
|
|
33
|
+
puts 'No position in this vault.'
|
|
34
|
+
end
|
|
35
|
+
puts
|
|
36
|
+
|
|
37
|
+
case action
|
|
38
|
+
when 'deposit'
|
|
39
|
+
puts 'Depositing $10 to vault...'
|
|
40
|
+
result = sdk.exchange.vault_transfer(
|
|
41
|
+
vault_address: vault_addr,
|
|
42
|
+
is_deposit: true,
|
|
43
|
+
usd: 10
|
|
44
|
+
)
|
|
45
|
+
dump_status(result)
|
|
46
|
+
api_error?(result) || puts(green('Vault deposit successful!'))
|
|
47
|
+
when 'withdraw'
|
|
48
|
+
equity_f = follower&.dig('vaultEquity')&.to_f || 0
|
|
49
|
+
if equity_f > 1
|
|
50
|
+
withdraw_amount = equity_f.floor
|
|
51
|
+
puts "Withdrawing $#{withdraw_amount} from vault..."
|
|
52
|
+
result = sdk.exchange.vault_transfer(
|
|
53
|
+
vault_address: vault_addr,
|
|
54
|
+
is_deposit: false,
|
|
55
|
+
usd: withdraw_amount
|
|
56
|
+
)
|
|
57
|
+
dump_status(result)
|
|
58
|
+
api_error?(result) || puts(green('Vault withdrawal successful!'))
|
|
59
|
+
else
|
|
60
|
+
puts red("Insufficient vault equity to withdraw ($#{equity_f})")
|
|
61
|
+
end
|
|
62
|
+
else
|
|
63
|
+
puts 'Pass "deposit" or "withdraw" as an argument to perform a transfer.'
|
|
64
|
+
puts ' ruby scripts/test_10_vault_transfer.rb deposit'
|
|
65
|
+
puts ' ruby scripts/test_10_vault_transfer.rb withdraw'
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
test_passed('Test 10 Vault Status')
|