hyperliquid-rb 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/LICENSE.txt +21 -0
- data/README.md +460 -0
- data/lib/hyperliquid/cloid.rb +48 -0
- data/lib/hyperliquid/constants.rb +107 -0
- data/lib/hyperliquid/error.rb +27 -0
- data/lib/hyperliquid/exchange.rb +840 -0
- data/lib/hyperliquid/info.rb +486 -0
- data/lib/hyperliquid/signer.rb +147 -0
- data/lib/hyperliquid/transport.rb +69 -0
- data/lib/hyperliquid/utils.rb +78 -0
- data/lib/hyperliquid/version.rb +5 -0
- data/lib/hyperliquid/websocket_manager.rb +197 -0
- data/lib/hyperliquid.rb +15 -0
- metadata +114 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module Hyperliquid
|
|
6
|
+
module Utils
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# Convert float to wire format string: 8 decimal precision, trailing zeros removed.
|
|
10
|
+
# Examples: 100 -> "100", 1670.1 -> "1670.1", 0.0147 -> "0.0147"
|
|
11
|
+
def float_to_wire(x)
|
|
12
|
+
rounded = format("%.8f", x)
|
|
13
|
+
raise SigningError, "float_to_wire causes rounding: #{x}" if (Float(rounded) - x).abs >= 1e-12
|
|
14
|
+
|
|
15
|
+
rounded = "0" if rounded == "-0"
|
|
16
|
+
# Normalize via BigDecimal to strip trailing zeros, format as fixed-point
|
|
17
|
+
normalized = BigDecimal(rounded).to_s("F")
|
|
18
|
+
# BigDecimal("100.00000000").to_s("F") => "100.0", we want "100"
|
|
19
|
+
normalized.sub(/\.0$/, "")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Convert float to integer by multiplying by 10^power, validating precision.
|
|
23
|
+
def float_to_int(x, power)
|
|
24
|
+
with_decimals = x * (10**power)
|
|
25
|
+
rounded = with_decimals.round
|
|
26
|
+
raise SigningError, "float_to_int causes rounding: #{x}" if (rounded - with_decimals).abs >= 1e-3
|
|
27
|
+
|
|
28
|
+
rounded
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Convert float to int * 10^8 for action hashing.
|
|
32
|
+
def float_to_int_for_hashing(x)
|
|
33
|
+
float_to_int(x, 8)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Convert USD float to int * 10^6.
|
|
37
|
+
def float_to_usd_int(x)
|
|
38
|
+
float_to_int(x, 6)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Convert an OrderRequest hash to wire format for signing.
|
|
42
|
+
def order_request_to_order_wire(order, asset)
|
|
43
|
+
wire = {
|
|
44
|
+
"a" => asset,
|
|
45
|
+
"b" => order[:is_buy],
|
|
46
|
+
"p" => float_to_wire(order[:limit_px]),
|
|
47
|
+
"s" => float_to_wire(order[:sz]),
|
|
48
|
+
"r" => order[:reduce_only],
|
|
49
|
+
"t" => order_type_to_wire(order[:order_type])
|
|
50
|
+
}
|
|
51
|
+
wire["c"] = order[:cloid].to_raw if order[:cloid]
|
|
52
|
+
wire
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Convert order type to wire format.
|
|
56
|
+
def order_type_to_wire(order_type)
|
|
57
|
+
if order_type[:limit]
|
|
58
|
+
{ "limit" => order_type[:limit] }
|
|
59
|
+
elsif order_type[:trigger]
|
|
60
|
+
t = order_type[:trigger]
|
|
61
|
+
{
|
|
62
|
+
"trigger" => {
|
|
63
|
+
"isMarket" => t[:isMarket],
|
|
64
|
+
"triggerPx" => float_to_wire(t[:triggerPx]),
|
|
65
|
+
"tpsl" => t[:tpsl]
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
else
|
|
69
|
+
raise SigningError, "Unknown order type: #{order_type}"
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Convert hex address string to 20-byte binary.
|
|
74
|
+
def address_to_bytes(address)
|
|
75
|
+
[address.delete_prefix("0x")].pack("H40")
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "websocket-client-simple"
|
|
5
|
+
|
|
6
|
+
module Hyperliquid
|
|
7
|
+
ActiveSubscription = Struct.new(:callback, :subscription_id)
|
|
8
|
+
|
|
9
|
+
class WebsocketManager
|
|
10
|
+
PING_INTERVAL = 50 # seconds
|
|
11
|
+
|
|
12
|
+
def initialize(base_url)
|
|
13
|
+
@subscription_id_counter = 0
|
|
14
|
+
@ws_ready = false
|
|
15
|
+
@queued_subscriptions = []
|
|
16
|
+
@active_subscriptions = Hash.new { |h, k| h[k] = [] }
|
|
17
|
+
@mutex = Mutex.new
|
|
18
|
+
@stop_event = false
|
|
19
|
+
|
|
20
|
+
ws_url = "ws#{base_url[4..]}/ws"
|
|
21
|
+
@ws_url = ws_url
|
|
22
|
+
@ws = nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def start
|
|
26
|
+
manager = self
|
|
27
|
+
@ws = WebSocket::Client::Simple.connect(@ws_url)
|
|
28
|
+
|
|
29
|
+
@ws.on :message do |msg|
|
|
30
|
+
manager.send(:handle_message, msg.data)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
@ws.on :open do
|
|
34
|
+
manager.send(:handle_open)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
@ping_thread = Thread.new { send_ping }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def stop
|
|
41
|
+
@mutex.synchronize { @stop_event = true }
|
|
42
|
+
@ws&.close
|
|
43
|
+
@ping_thread&.join
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def subscribe(subscription, callback, subscription_id: nil)
|
|
47
|
+
@mutex.synchronize do
|
|
48
|
+
if subscription_id.nil?
|
|
49
|
+
@subscription_id_counter += 1
|
|
50
|
+
subscription_id = @subscription_id_counter
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
if @ws_ready
|
|
54
|
+
identifier = self.class.subscription_to_identifier(subscription)
|
|
55
|
+
if %w[userEvents orderUpdates].include?(identifier) && !@active_subscriptions[identifier].empty?
|
|
56
|
+
raise NotImplementedError, "Cannot subscribe to #{identifier} multiple times"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
@active_subscriptions[identifier] << ActiveSubscription.new(callback, subscription_id)
|
|
60
|
+
@ws.send(JSON.generate({ "method" => "subscribe", "subscription" => subscription }))
|
|
61
|
+
else
|
|
62
|
+
@queued_subscriptions << [subscription, ActiveSubscription.new(callback, subscription_id)]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
subscription_id
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def unsubscribe(subscription, subscription_id)
|
|
70
|
+
@mutex.synchronize do
|
|
71
|
+
raise NotImplementedError, "Can't unsubscribe before websocket connected" unless @ws_ready
|
|
72
|
+
|
|
73
|
+
identifier = self.class.subscription_to_identifier(subscription)
|
|
74
|
+
active = @active_subscriptions[identifier]
|
|
75
|
+
new_active = active.reject { |s| s.subscription_id == subscription_id }
|
|
76
|
+
@ws.send(JSON.generate({ "method" => "unsubscribe", "subscription" => subscription })) if new_active.empty?
|
|
77
|
+
@active_subscriptions[identifier] = new_active
|
|
78
|
+
active.length != new_active.length
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Maps a subscription request to its identifier string.
|
|
83
|
+
def self.subscription_to_identifier(subscription)
|
|
84
|
+
type = subscription["type"]
|
|
85
|
+
case type
|
|
86
|
+
when "allMids"
|
|
87
|
+
"allMids"
|
|
88
|
+
when "l2Book"
|
|
89
|
+
"l2Book:#{subscription["coin"].downcase}"
|
|
90
|
+
when "trades"
|
|
91
|
+
"trades:#{subscription["coin"].downcase}"
|
|
92
|
+
when "userEvents"
|
|
93
|
+
"userEvents"
|
|
94
|
+
when "userFills"
|
|
95
|
+
"userFills:#{subscription["user"].downcase}"
|
|
96
|
+
when "candle"
|
|
97
|
+
"candle:#{subscription["coin"].downcase},#{subscription["interval"]}"
|
|
98
|
+
when "orderUpdates"
|
|
99
|
+
"orderUpdates"
|
|
100
|
+
when "userFundings"
|
|
101
|
+
"userFundings:#{subscription["user"].downcase}"
|
|
102
|
+
when "userNonFundingLedgerUpdates"
|
|
103
|
+
"userNonFundingLedgerUpdates:#{subscription["user"].downcase}"
|
|
104
|
+
when "webData2"
|
|
105
|
+
"webData2:#{subscription["user"].downcase}"
|
|
106
|
+
when "bbo"
|
|
107
|
+
"bbo:#{subscription["coin"].downcase}"
|
|
108
|
+
when "activeAssetCtx"
|
|
109
|
+
"activeAssetCtx:#{subscription["coin"].downcase}"
|
|
110
|
+
when "activeAssetData"
|
|
111
|
+
"activeAssetData:#{subscription["coin"].downcase},#{subscription["user"].downcase}"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Maps an incoming WS message to its identifier string.
|
|
116
|
+
def self.ws_msg_to_identifier(ws_msg)
|
|
117
|
+
channel = ws_msg["channel"]
|
|
118
|
+
case channel
|
|
119
|
+
when "pong"
|
|
120
|
+
"pong"
|
|
121
|
+
when "allMids"
|
|
122
|
+
"allMids"
|
|
123
|
+
when "l2Book"
|
|
124
|
+
"l2Book:#{ws_msg["data"]["coin"].downcase}"
|
|
125
|
+
when "trades"
|
|
126
|
+
trades = ws_msg["data"]
|
|
127
|
+
return nil if trades.empty?
|
|
128
|
+
|
|
129
|
+
"trades:#{trades[0]["coin"].downcase}"
|
|
130
|
+
when "user"
|
|
131
|
+
"userEvents"
|
|
132
|
+
when "userFills"
|
|
133
|
+
"userFills:#{ws_msg["data"]["user"].downcase}"
|
|
134
|
+
when "candle"
|
|
135
|
+
"candle:#{ws_msg["data"]["s"].downcase},#{ws_msg["data"]["i"]}"
|
|
136
|
+
when "orderUpdates"
|
|
137
|
+
"orderUpdates"
|
|
138
|
+
when "userFundings"
|
|
139
|
+
"userFundings:#{ws_msg["data"]["user"].downcase}"
|
|
140
|
+
when "userNonFundingLedgerUpdates"
|
|
141
|
+
"userNonFundingLedgerUpdates:#{ws_msg["data"]["user"].downcase}"
|
|
142
|
+
when "webData2"
|
|
143
|
+
"webData2:#{ws_msg["data"]["user"].downcase}"
|
|
144
|
+
when "bbo"
|
|
145
|
+
"bbo:#{ws_msg["data"]["coin"].downcase}"
|
|
146
|
+
when "activeAssetCtx", "activeSpotAssetCtx"
|
|
147
|
+
"activeAssetCtx:#{ws_msg["data"]["coin"].downcase}"
|
|
148
|
+
when "activeAssetData"
|
|
149
|
+
"activeAssetData:#{ws_msg["data"]["coin"].downcase},#{ws_msg["data"]["user"].downcase}"
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def handle_message(data)
|
|
156
|
+
return if data == "Websocket connection established."
|
|
157
|
+
|
|
158
|
+
ws_msg = JSON.parse(data)
|
|
159
|
+
identifier = self.class.ws_msg_to_identifier(ws_msg)
|
|
160
|
+
return if identifier == "pong"
|
|
161
|
+
return if identifier.nil?
|
|
162
|
+
|
|
163
|
+
active = @mutex.synchronize { @active_subscriptions[identifier].dup }
|
|
164
|
+
if active.empty?
|
|
165
|
+
warn "Websocket message from an unexpected subscription: #{data} #{identifier}"
|
|
166
|
+
else
|
|
167
|
+
active.each { |sub| sub.callback.call(ws_msg) }
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def handle_open
|
|
172
|
+
@mutex.synchronize do
|
|
173
|
+
@ws_ready = true
|
|
174
|
+
@queued_subscriptions.each do |subscription, active_sub|
|
|
175
|
+
subscribe(subscription, active_sub.callback, subscription_id: active_sub.subscription_id)
|
|
176
|
+
end
|
|
177
|
+
@queued_subscriptions.clear
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def send_ping
|
|
182
|
+
loop do
|
|
183
|
+
sleep 1
|
|
184
|
+
elapsed = 0
|
|
185
|
+
loop do
|
|
186
|
+
break if @mutex.synchronize { @stop_event } || elapsed >= PING_INTERVAL
|
|
187
|
+
|
|
188
|
+
sleep 1
|
|
189
|
+
elapsed += 1
|
|
190
|
+
end
|
|
191
|
+
break if @mutex.synchronize { @stop_event }
|
|
192
|
+
|
|
193
|
+
@ws&.send(JSON.generate({ "method" => "ping" }))
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
data/lib/hyperliquid.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "hyperliquid/version"
|
|
4
|
+
require_relative "hyperliquid/error"
|
|
5
|
+
require_relative "hyperliquid/constants"
|
|
6
|
+
require_relative "hyperliquid/utils"
|
|
7
|
+
require_relative "hyperliquid/cloid"
|
|
8
|
+
require_relative "hyperliquid/transport"
|
|
9
|
+
require_relative "hyperliquid/signer"
|
|
10
|
+
require_relative "hyperliquid/websocket_manager"
|
|
11
|
+
require_relative "hyperliquid/info"
|
|
12
|
+
require_relative "hyperliquid/exchange"
|
|
13
|
+
|
|
14
|
+
module Hyperliquid
|
|
15
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: hyperliquid-rb
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Deltabadger
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: eth
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.5.17
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: 0.5.17
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: faraday
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '2.0'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '2.0'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: msgpack
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.7'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.7'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: websocket-client-simple
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0.9'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0.9'
|
|
68
|
+
description: Complete Ruby SDK for Hyperliquid — trading, market data, EIP-712 signing,
|
|
69
|
+
and WebSocket subscriptions.
|
|
70
|
+
email:
|
|
71
|
+
- hello@deltabadger.com
|
|
72
|
+
executables: []
|
|
73
|
+
extensions: []
|
|
74
|
+
extra_rdoc_files: []
|
|
75
|
+
files:
|
|
76
|
+
- LICENSE.txt
|
|
77
|
+
- README.md
|
|
78
|
+
- lib/hyperliquid.rb
|
|
79
|
+
- lib/hyperliquid/cloid.rb
|
|
80
|
+
- lib/hyperliquid/constants.rb
|
|
81
|
+
- lib/hyperliquid/error.rb
|
|
82
|
+
- lib/hyperliquid/exchange.rb
|
|
83
|
+
- lib/hyperliquid/info.rb
|
|
84
|
+
- lib/hyperliquid/signer.rb
|
|
85
|
+
- lib/hyperliquid/transport.rb
|
|
86
|
+
- lib/hyperliquid/utils.rb
|
|
87
|
+
- lib/hyperliquid/version.rb
|
|
88
|
+
- lib/hyperliquid/websocket_manager.rb
|
|
89
|
+
homepage: https://github.com/deltabadger/hyperliquid-rb
|
|
90
|
+
licenses:
|
|
91
|
+
- MIT
|
|
92
|
+
metadata:
|
|
93
|
+
homepage_uri: https://github.com/deltabadger/hyperliquid-rb
|
|
94
|
+
source_code_uri: https://github.com/deltabadger/hyperliquid-rb
|
|
95
|
+
changelog_uri: https://github.com/deltabadger/hyperliquid-rb/commits/main
|
|
96
|
+
rubygems_mfa_required: 'true'
|
|
97
|
+
rdoc_options: []
|
|
98
|
+
require_paths:
|
|
99
|
+
- lib
|
|
100
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
101
|
+
requirements:
|
|
102
|
+
- - ">="
|
|
103
|
+
- !ruby/object:Gem::Version
|
|
104
|
+
version: '3.1'
|
|
105
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - ">="
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '0'
|
|
110
|
+
requirements: []
|
|
111
|
+
rubygems_version: 4.0.3
|
|
112
|
+
specification_version: 4
|
|
113
|
+
summary: Ruby SDK for the Hyperliquid DEX API
|
|
114
|
+
test_files: []
|