hyperliquid 0.2.0 → 0.4.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 +25 -2
- data/CHANGELOG.md +25 -5
- data/CLAUDE.md +202 -0
- data/README.md +410 -61
- data/example.rb +131 -28
- data/lib/hyperliquid/cloid.rb +102 -0
- data/lib/hyperliquid/constants.rb +1 -0
- data/lib/hyperliquid/exchange.rb +410 -0
- data/lib/hyperliquid/info.rb +257 -19
- data/lib/hyperliquid/signing/eip712.rb +56 -0
- data/lib/hyperliquid/signing/signer.rb +122 -0
- data/lib/hyperliquid/version.rb +1 -1
- data/lib/hyperliquid.rb +33 -5
- data/test_integration.rb +255 -0
- metadata +35 -1
data/example.rb
CHANGED
|
@@ -5,54 +5,157 @@ require_relative 'lib/hyperliquid'
|
|
|
5
5
|
|
|
6
6
|
# Example usage of the Hyperliquid Ruby SDK
|
|
7
7
|
|
|
8
|
+
puts 'Hyperliquid Ruby SDK v0.4.0 - API Examples'
|
|
9
|
+
puts '=' * 50
|
|
10
|
+
|
|
11
|
+
# =============================================================================
|
|
12
|
+
# INFO API (Read-only, no authentication required)
|
|
13
|
+
# =============================================================================
|
|
14
|
+
|
|
8
15
|
# Create a new SDK instance (defaults to mainnet)
|
|
9
16
|
sdk = Hyperliquid.new
|
|
10
17
|
|
|
11
|
-
puts
|
|
12
|
-
puts '=' * 50
|
|
18
|
+
puts "\n--- INFO API EXAMPLES ---\n"
|
|
13
19
|
|
|
14
20
|
# Example 1: Get all market mid prices
|
|
15
21
|
begin
|
|
16
|
-
puts
|
|
22
|
+
puts '1. Getting all market mid prices...'
|
|
17
23
|
mids = sdk.info.all_mids
|
|
18
|
-
puts "Found #{mids.length} markets" if mids.is_a?(Hash)
|
|
24
|
+
puts " Found #{mids.length} markets" if mids.is_a?(Hash)
|
|
25
|
+
puts " BTC mid: #{mids['BTC']}" if mids['BTC']
|
|
19
26
|
rescue Hyperliquid::Error => e
|
|
20
|
-
puts "Error
|
|
27
|
+
puts " Error: #{e.message}"
|
|
21
28
|
end
|
|
22
29
|
|
|
23
|
-
# Example 2: Get metadata for all assets
|
|
30
|
+
# Example 2: Get metadata for all perpetual assets
|
|
24
31
|
begin
|
|
25
|
-
puts "\n2. Getting asset metadata..."
|
|
32
|
+
puts "\n2. Getting perpetual asset metadata..."
|
|
26
33
|
meta = sdk.info.meta
|
|
27
|
-
|
|
34
|
+
universe = meta['universe'] || []
|
|
35
|
+
puts " Found #{universe.length} perpetual assets"
|
|
36
|
+
puts " First asset: #{universe.first['name']}" if universe.any?
|
|
37
|
+
rescue Hyperliquid::Error => e
|
|
38
|
+
puts " Error: #{e.message}"
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Example 3: Get L2 order book
|
|
42
|
+
begin
|
|
43
|
+
puts "\n3. Getting L2 order book for BTC..."
|
|
44
|
+
book = sdk.info.l2_book('BTC')
|
|
45
|
+
levels = book['levels'] || []
|
|
46
|
+
puts " Bid levels: #{levels[0]&.length || 0}, Ask levels: #{levels[1]&.length || 0}"
|
|
28
47
|
rescue Hyperliquid::Error => e
|
|
29
|
-
puts "Error
|
|
48
|
+
puts " Error: #{e.message}"
|
|
30
49
|
end
|
|
31
50
|
|
|
32
|
-
# Example
|
|
33
|
-
|
|
51
|
+
# Example 4: Get spot metadata
|
|
52
|
+
begin
|
|
53
|
+
puts "\n4. Getting spot asset metadata..."
|
|
54
|
+
spot_meta = sdk.info.spot_meta
|
|
55
|
+
tokens = spot_meta['tokens'] || []
|
|
56
|
+
puts " Found #{tokens.length} spot tokens"
|
|
57
|
+
rescue Hyperliquid::Error => e
|
|
58
|
+
puts " Error: #{e.message}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Example 5: Use testnet
|
|
62
|
+
puts "\n5. Creating testnet SDK..."
|
|
34
63
|
testnet_sdk = Hyperliquid.new(testnet: true)
|
|
35
|
-
puts "Testnet
|
|
64
|
+
puts " Testnet base URL: #{testnet_sdk.base_url}"
|
|
36
65
|
|
|
37
|
-
# Example
|
|
38
|
-
wallet_address = "0x#{'0' * 40}" #
|
|
66
|
+
# Example 6: User-specific endpoints (requires valid wallet address)
|
|
67
|
+
wallet_address = "0x#{'0' * 40}" # Placeholder address
|
|
39
68
|
|
|
40
69
|
begin
|
|
41
|
-
puts "\
|
|
42
|
-
|
|
43
|
-
puts "
|
|
70
|
+
puts "\n6. Getting user state for #{wallet_address[0..13]}..."
|
|
71
|
+
state = sdk.info.user_state(wallet_address)
|
|
72
|
+
puts " Account value: #{state.dig('marginSummary', 'accountValue') || 'N/A'}"
|
|
44
73
|
rescue Hyperliquid::Error => e
|
|
45
|
-
puts "Error
|
|
74
|
+
puts " Error: #{e.message}"
|
|
46
75
|
end
|
|
47
76
|
|
|
77
|
+
# =============================================================================
|
|
78
|
+
# EXCHANGE API (Authenticated, requires private key)
|
|
79
|
+
# =============================================================================
|
|
80
|
+
|
|
81
|
+
puts "\n--- EXCHANGE API EXAMPLES ---\n"
|
|
82
|
+
puts "(These examples are commented out to prevent accidental execution)\n"
|
|
83
|
+
|
|
84
|
+
# To use the Exchange API, create an SDK with your private key:
|
|
85
|
+
#
|
|
86
|
+
# sdk = Hyperliquid.new(
|
|
87
|
+
# testnet: true, # Use testnet for testing!
|
|
88
|
+
# private_key: ENV['HYPERLIQUID_PRIVATE_KEY']
|
|
89
|
+
# )
|
|
90
|
+
#
|
|
91
|
+
# Your wallet address:
|
|
92
|
+
# puts sdk.exchange.address
|
|
93
|
+
#
|
|
94
|
+
# Place a limit order:
|
|
95
|
+
# result = sdk.exchange.order(
|
|
96
|
+
# coin: 'BTC',
|
|
97
|
+
# is_buy: true,
|
|
98
|
+
# size: 0.001,
|
|
99
|
+
# limit_px: 50000.0,
|
|
100
|
+
# order_type: { limit: { tif: 'Gtc' } },
|
|
101
|
+
# reduce_only: false
|
|
102
|
+
# )
|
|
103
|
+
#
|
|
104
|
+
# Place a market order (uses slippage):
|
|
105
|
+
# result = sdk.exchange.market_order(
|
|
106
|
+
# coin: 'BTC',
|
|
107
|
+
# is_buy: true,
|
|
108
|
+
# size: 0.001,
|
|
109
|
+
# slippage: 0.01 # 1% slippage
|
|
110
|
+
# )
|
|
111
|
+
#
|
|
112
|
+
# Place a stop loss order:
|
|
113
|
+
# result = sdk.exchange.order(
|
|
114
|
+
# coin: 'BTC',
|
|
115
|
+
# is_buy: false,
|
|
116
|
+
# size: 0.001,
|
|
117
|
+
# limit_px: 48000.0,
|
|
118
|
+
# order_type: {
|
|
119
|
+
# trigger: {
|
|
120
|
+
# trigger_px: 49000.0,
|
|
121
|
+
# is_market: false,
|
|
122
|
+
# tpsl: 'sl'
|
|
123
|
+
# }
|
|
124
|
+
# },
|
|
125
|
+
# reduce_only: true
|
|
126
|
+
# )
|
|
127
|
+
#
|
|
128
|
+
# Cancel an order by OID:
|
|
129
|
+
# result = sdk.exchange.cancel(coin: 'BTC', oid: 123456789)
|
|
130
|
+
#
|
|
131
|
+
# Cancel an order by client order ID:
|
|
132
|
+
# result = sdk.exchange.cancel_by_cloid(coin: 'BTC', cloid: '0x...')
|
|
133
|
+
#
|
|
134
|
+
# Bulk cancel multiple orders:
|
|
135
|
+
# result = sdk.exchange.bulk_cancel([
|
|
136
|
+
# { coin: 'BTC', oid: 123 },
|
|
137
|
+
# { coin: 'ETH', oid: 456 }
|
|
138
|
+
# ])
|
|
139
|
+
#
|
|
140
|
+
# Use client order IDs for tracking:
|
|
141
|
+
# cloid = Hyperliquid::Cloid.random
|
|
142
|
+
# result = sdk.exchange.order(
|
|
143
|
+
# coin: 'BTC',
|
|
144
|
+
# is_buy: true,
|
|
145
|
+
# size: 0.001,
|
|
146
|
+
# limit_px: 50000.0,
|
|
147
|
+
# cloid: cloid
|
|
148
|
+
# )
|
|
149
|
+
# puts "Order placed with cloid: #{cloid}"
|
|
150
|
+
#
|
|
151
|
+
# Trade on behalf of a vault:
|
|
152
|
+
# result = sdk.exchange.order(
|
|
153
|
+
# coin: 'BTC',
|
|
154
|
+
# is_buy: true,
|
|
155
|
+
# size: 0.001,
|
|
156
|
+
# limit_px: 50000.0,
|
|
157
|
+
# vault_address: '0x...'
|
|
158
|
+
# )
|
|
159
|
+
|
|
160
|
+
puts 'See commented code above for Exchange API usage patterns.'
|
|
48
161
|
puts "\nSDK examples completed!"
|
|
49
|
-
puts "\nAvailable Info methods:"
|
|
50
|
-
puts '- all_mids() - Get all market mid prices'
|
|
51
|
-
puts "- open_orders(user) - Get user's open orders"
|
|
52
|
-
puts "- user_fills(user) - Get user's fill history"
|
|
53
|
-
puts '- order_status(user, oid) - Get order status'
|
|
54
|
-
puts "- user_state(user) - Get user's trading state"
|
|
55
|
-
puts '- meta() - Get asset metadata'
|
|
56
|
-
puts '- meta_and_asset_ctxs() - Get extended asset metadata'
|
|
57
|
-
puts '- l2_book(coin) - Get L2 order book'
|
|
58
|
-
puts '- candles_snapshot(coin, interval, start_time, end_time) - Get candlestick data'
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Hyperliquid
|
|
6
|
+
# Client Order ID for tracking orders
|
|
7
|
+
# Must be a 16-byte hex string in format: 0x + 32 hex characters
|
|
8
|
+
class Cloid
|
|
9
|
+
# Initialize a new Cloid from a raw hex string
|
|
10
|
+
# @param raw_cloid [String] Hex string in format 0x + 32 hex characters
|
|
11
|
+
# @raise [ArgumentError] If format is invalid
|
|
12
|
+
def initialize(raw_cloid)
|
|
13
|
+
@raw_cloid = raw_cloid.downcase
|
|
14
|
+
validate!
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Get the raw hex string representation
|
|
18
|
+
# @return [String] The raw cloid string
|
|
19
|
+
def to_raw
|
|
20
|
+
@raw_cloid
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# String representation
|
|
24
|
+
# @return [String] The raw cloid string
|
|
25
|
+
def to_s
|
|
26
|
+
@raw_cloid
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Inspect representation
|
|
30
|
+
# @return [String] The raw cloid string
|
|
31
|
+
def inspect
|
|
32
|
+
@raw_cloid
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Equality check
|
|
36
|
+
# @param other [Cloid, String] Another Cloid or string to compare
|
|
37
|
+
# @return [Boolean] True if equal
|
|
38
|
+
def ==(other)
|
|
39
|
+
case other
|
|
40
|
+
when Cloid
|
|
41
|
+
@raw_cloid == other.to_raw
|
|
42
|
+
when String
|
|
43
|
+
@raw_cloid == other.downcase
|
|
44
|
+
else
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
alias eql? ==
|
|
50
|
+
|
|
51
|
+
# Hash for use in Hash keys
|
|
52
|
+
# @return [Integer] Hash value
|
|
53
|
+
def hash
|
|
54
|
+
@raw_cloid.hash
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
class << self
|
|
58
|
+
# Create a Cloid from an integer
|
|
59
|
+
# @param value [Integer] Integer value (0 to 2^128-1)
|
|
60
|
+
# @return [Cloid] New Cloid instance
|
|
61
|
+
# @raise [ArgumentError] If value is out of range
|
|
62
|
+
def from_int(value)
|
|
63
|
+
raise ArgumentError, 'cloid integer must be non-negative' if value.negative?
|
|
64
|
+
raise ArgumentError, 'cloid integer must be less than 2^128' if value >= 2**128
|
|
65
|
+
|
|
66
|
+
new(format('0x%032x', value))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Create a Cloid from a hex string
|
|
70
|
+
# @param value [String] Hex string in format 0x + 32 hex characters
|
|
71
|
+
# @return [Cloid] New Cloid instance
|
|
72
|
+
def from_str(value)
|
|
73
|
+
new(value)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Generate a random Cloid
|
|
77
|
+
# @return [Cloid] New random Cloid instance
|
|
78
|
+
def random
|
|
79
|
+
from_int(SecureRandom.random_number(2**128))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Create a Cloid from a UUID string
|
|
83
|
+
# @param uuid [String] UUID string (with or without dashes)
|
|
84
|
+
# @return [Cloid] New Cloid instance
|
|
85
|
+
def from_uuid(uuid)
|
|
86
|
+
hex = uuid.delete('-').downcase
|
|
87
|
+
raise ArgumentError, 'UUID must be 32 hex characters' unless hex.match?(/\A[0-9a-f]{32}\z/)
|
|
88
|
+
|
|
89
|
+
new("0x#{hex}")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def validate!
|
|
96
|
+
return if @raw_cloid.match?(/\A0x[0-9a-f]{32}\z/)
|
|
97
|
+
|
|
98
|
+
raise ArgumentError,
|
|
99
|
+
"cloid must be '0x' followed by 32 hex characters (16 bytes). Got: #{@raw_cloid.inspect}"
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bigdecimal'
|
|
4
|
+
|
|
5
|
+
module Hyperliquid
|
|
6
|
+
# Exchange API client for write operations (orders, cancels, etc.)
|
|
7
|
+
# Requires a private key for signing transactions
|
|
8
|
+
class Exchange
|
|
9
|
+
# Default slippage for market orders (5%)
|
|
10
|
+
DEFAULT_SLIPPAGE = 0.05
|
|
11
|
+
|
|
12
|
+
# Spot assets have indices >= 10000
|
|
13
|
+
SPOT_ASSET_THRESHOLD = 10_000
|
|
14
|
+
|
|
15
|
+
# Initialize the exchange client
|
|
16
|
+
# @param client [Hyperliquid::Client] HTTP client
|
|
17
|
+
# @param signer [Hyperliquid::Signing::Signer] EIP-712 signer
|
|
18
|
+
# @param info [Hyperliquid::Info] Info API client for metadata
|
|
19
|
+
# @param expires_after [Integer, nil] Optional global expiration timestamp
|
|
20
|
+
def initialize(client:, signer:, info:, expires_after: nil)
|
|
21
|
+
@client = client
|
|
22
|
+
@signer = signer
|
|
23
|
+
@info = info
|
|
24
|
+
@expires_after = expires_after
|
|
25
|
+
@asset_cache = nil
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Get the wallet address
|
|
29
|
+
# @return [String] Checksummed Ethereum address
|
|
30
|
+
def address
|
|
31
|
+
@signer.address
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Place a single order
|
|
35
|
+
# @param coin [String] Asset symbol (e.g., "BTC")
|
|
36
|
+
# @param is_buy [Boolean] True for buy, false for sell
|
|
37
|
+
# @param size [String, Numeric] Order size
|
|
38
|
+
# @param limit_px [String, Numeric] Limit price
|
|
39
|
+
# @param order_type [Hash] Order type config (default: { limit: { tif: "Gtc" } })
|
|
40
|
+
# @param reduce_only [Boolean] Reduce-only flag (default: false)
|
|
41
|
+
# @param cloid [Cloid, String, nil] Client order ID (optional)
|
|
42
|
+
# @param vault_address [String, nil] Vault address for vault trading (optional)
|
|
43
|
+
# @return [Hash] Order response
|
|
44
|
+
def order(coin:, is_buy:, size:, limit_px:, order_type: { limit: { tif: 'Gtc' } },
|
|
45
|
+
reduce_only: false, cloid: nil, vault_address: nil)
|
|
46
|
+
nonce = timestamp_ms
|
|
47
|
+
|
|
48
|
+
order_wire = build_order_wire(
|
|
49
|
+
coin: coin,
|
|
50
|
+
is_buy: is_buy,
|
|
51
|
+
size: size,
|
|
52
|
+
limit_px: limit_px,
|
|
53
|
+
order_type: order_type,
|
|
54
|
+
reduce_only: reduce_only,
|
|
55
|
+
cloid: cloid
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
action = {
|
|
59
|
+
type: 'order',
|
|
60
|
+
orders: [order_wire],
|
|
61
|
+
grouping: 'na'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
signature = @signer.sign_l1_action(
|
|
65
|
+
action, nonce,
|
|
66
|
+
vault_address: vault_address,
|
|
67
|
+
expires_after: @expires_after
|
|
68
|
+
)
|
|
69
|
+
post_action(action, signature, nonce, vault_address)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Place multiple orders in a batch
|
|
73
|
+
# @param orders [Array<Hash>] Array of order hashes with keys:
|
|
74
|
+
# :coin, :is_buy, :size, :limit_px, :order_type, :reduce_only, :cloid
|
|
75
|
+
# @param grouping [String] Order grouping ("na", "normalTpsl", "positionTpsl")
|
|
76
|
+
# @param vault_address [String, nil] Vault address for vault trading (optional)
|
|
77
|
+
# @return [Hash] Bulk order response
|
|
78
|
+
def bulk_orders(orders:, grouping: 'na', vault_address: nil)
|
|
79
|
+
nonce = timestamp_ms
|
|
80
|
+
|
|
81
|
+
order_wires = orders.map do |o|
|
|
82
|
+
build_order_wire(
|
|
83
|
+
coin: o[:coin],
|
|
84
|
+
is_buy: o[:is_buy],
|
|
85
|
+
size: o[:size],
|
|
86
|
+
limit_px: o[:limit_px],
|
|
87
|
+
order_type: o[:order_type] || { limit: { tif: 'Gtc' } },
|
|
88
|
+
reduce_only: o[:reduce_only] || false,
|
|
89
|
+
cloid: o[:cloid]
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
action = {
|
|
94
|
+
type: 'order',
|
|
95
|
+
orders: order_wires,
|
|
96
|
+
grouping: grouping
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
signature = @signer.sign_l1_action(
|
|
100
|
+
action, nonce,
|
|
101
|
+
vault_address: vault_address,
|
|
102
|
+
expires_after: @expires_after
|
|
103
|
+
)
|
|
104
|
+
post_action(action, signature, nonce, vault_address)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Place a market order (aggressive limit IoC with slippage)
|
|
108
|
+
# @param coin [String] Asset symbol
|
|
109
|
+
# @param is_buy [Boolean] True for buy, false for sell
|
|
110
|
+
# @param size [String, Numeric] Order size
|
|
111
|
+
# @param slippage [Float] Slippage tolerance (default: 0.05 = 5%)
|
|
112
|
+
# @param vault_address [String, nil] Vault address for vault trading (optional)
|
|
113
|
+
# @return [Hash] Order response
|
|
114
|
+
def market_order(coin:, is_buy:, size:, slippage: DEFAULT_SLIPPAGE, vault_address: nil)
|
|
115
|
+
# Get current mid price
|
|
116
|
+
mids = @info.all_mids
|
|
117
|
+
mid = mids[coin]&.to_f
|
|
118
|
+
raise ArgumentError, "Unknown asset or no price available: #{coin}" unless mid&.positive?
|
|
119
|
+
|
|
120
|
+
# Apply slippage and round to appropriate precision
|
|
121
|
+
slippage_price = calculate_slippage_price(coin, mid, is_buy, slippage)
|
|
122
|
+
|
|
123
|
+
order(
|
|
124
|
+
coin: coin,
|
|
125
|
+
is_buy: is_buy,
|
|
126
|
+
size: size,
|
|
127
|
+
limit_px: slippage_price,
|
|
128
|
+
order_type: { limit: { tif: 'Ioc' } },
|
|
129
|
+
vault_address: vault_address
|
|
130
|
+
)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Cancel a single order by order ID
|
|
134
|
+
# @param coin [String] Asset symbol
|
|
135
|
+
# @param oid [Integer] Order ID
|
|
136
|
+
# @param vault_address [String, nil] Vault address for vault trading (optional)
|
|
137
|
+
# @return [Hash] Cancel response
|
|
138
|
+
def cancel(coin:, oid:, vault_address: nil)
|
|
139
|
+
nonce = timestamp_ms
|
|
140
|
+
|
|
141
|
+
action = {
|
|
142
|
+
type: 'cancel',
|
|
143
|
+
cancels: [{ a: asset_index(coin), o: oid }]
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
signature = @signer.sign_l1_action(
|
|
147
|
+
action, nonce,
|
|
148
|
+
vault_address: vault_address,
|
|
149
|
+
expires_after: @expires_after
|
|
150
|
+
)
|
|
151
|
+
post_action(action, signature, nonce, vault_address)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Cancel a single order by client order ID
|
|
155
|
+
# @param coin [String] Asset symbol
|
|
156
|
+
# @param cloid [Cloid, String] Client order ID
|
|
157
|
+
# @param vault_address [String, nil] Vault address for vault trading (optional)
|
|
158
|
+
# @return [Hash] Cancel response
|
|
159
|
+
def cancel_by_cloid(coin:, cloid:, vault_address: nil)
|
|
160
|
+
nonce = timestamp_ms
|
|
161
|
+
cloid_raw = normalize_cloid(cloid)
|
|
162
|
+
|
|
163
|
+
action = {
|
|
164
|
+
type: 'cancelByCloid',
|
|
165
|
+
cancels: [{ asset: asset_index(coin), cloid: cloid_raw }]
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
signature = @signer.sign_l1_action(
|
|
169
|
+
action, nonce,
|
|
170
|
+
vault_address: vault_address,
|
|
171
|
+
expires_after: @expires_after
|
|
172
|
+
)
|
|
173
|
+
post_action(action, signature, nonce, vault_address)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Cancel multiple orders by order ID
|
|
177
|
+
# @param cancels [Array<Hash>] Array of cancel hashes with :coin and :oid
|
|
178
|
+
# @param vault_address [String, nil] Vault address for vault trading (optional)
|
|
179
|
+
# @return [Hash] Bulk cancel response
|
|
180
|
+
def bulk_cancel(cancels:, vault_address: nil)
|
|
181
|
+
nonce = timestamp_ms
|
|
182
|
+
|
|
183
|
+
cancel_wires = cancels.map do |c|
|
|
184
|
+
{ a: asset_index(c[:coin]), o: c[:oid] }
|
|
185
|
+
end
|
|
186
|
+
action = { type: 'cancel', cancels: cancel_wires }
|
|
187
|
+
|
|
188
|
+
signature = @signer.sign_l1_action(
|
|
189
|
+
action, nonce,
|
|
190
|
+
vault_address: vault_address,
|
|
191
|
+
expires_after: @expires_after
|
|
192
|
+
)
|
|
193
|
+
post_action(action, signature, nonce, vault_address)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Cancel multiple orders by client order ID
|
|
197
|
+
# @param cancels [Array<Hash>] Array of cancel hashes with :coin and :cloid
|
|
198
|
+
# @param vault_address [String, nil] Vault address for vault trading (optional)
|
|
199
|
+
# @return [Hash] Bulk cancel by cloid response
|
|
200
|
+
def bulk_cancel_by_cloid(cancels:, vault_address: nil)
|
|
201
|
+
nonce = timestamp_ms
|
|
202
|
+
|
|
203
|
+
cancel_wires = cancels.map do |c|
|
|
204
|
+
{ asset: asset_index(c[:coin]), cloid: normalize_cloid(c[:cloid]) }
|
|
205
|
+
end
|
|
206
|
+
action = { type: 'cancelByCloid', cancels: cancel_wires }
|
|
207
|
+
|
|
208
|
+
signature = @signer.sign_l1_action(
|
|
209
|
+
action, nonce,
|
|
210
|
+
vault_address: vault_address,
|
|
211
|
+
expires_after: @expires_after
|
|
212
|
+
)
|
|
213
|
+
post_action(action, signature, nonce, vault_address)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Clear the asset metadata cache
|
|
217
|
+
# Call this if metadata has been updated
|
|
218
|
+
def reload_metadata!
|
|
219
|
+
@asset_cache = nil
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
private
|
|
223
|
+
|
|
224
|
+
# Build order wire format
|
|
225
|
+
def build_order_wire(coin:, is_buy:, size:, limit_px:, order_type:, reduce_only:, cloid:)
|
|
226
|
+
wire = {
|
|
227
|
+
a: asset_index(coin),
|
|
228
|
+
b: is_buy,
|
|
229
|
+
p: float_to_wire(limit_px),
|
|
230
|
+
s: float_to_wire(size),
|
|
231
|
+
r: reduce_only,
|
|
232
|
+
t: order_type_to_wire(order_type)
|
|
233
|
+
}
|
|
234
|
+
wire[:c] = normalize_cloid(cloid) if cloid
|
|
235
|
+
wire
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Get current timestamp in milliseconds
|
|
239
|
+
def timestamp_ms
|
|
240
|
+
(Time.now.to_f * 1000).to_i
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Get asset index for a coin symbol
|
|
244
|
+
# @param coin [String] Asset symbol
|
|
245
|
+
# @return [Integer] Asset index
|
|
246
|
+
def asset_index(coin)
|
|
247
|
+
load_asset_cache unless @asset_cache
|
|
248
|
+
@asset_cache[:indices][coin] || raise(ArgumentError, "Unknown asset: #{coin}")
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Get asset metadata for a coin symbol
|
|
252
|
+
# @param coin [String] Asset symbol
|
|
253
|
+
# @return [Hash] Asset metadata with :sz_decimals and :is_spot
|
|
254
|
+
def asset_metadata(coin)
|
|
255
|
+
load_asset_cache unless @asset_cache
|
|
256
|
+
@asset_cache[:metadata][coin] || raise(ArgumentError, "Unknown asset: #{coin}")
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Load asset metadata from Info API (perps and spot)
|
|
260
|
+
def load_asset_cache
|
|
261
|
+
@asset_cache = { indices: {}, metadata: {} }
|
|
262
|
+
|
|
263
|
+
# Load perpetual assets
|
|
264
|
+
meta = @info.meta
|
|
265
|
+
meta['universe'].each_with_index do |asset, index|
|
|
266
|
+
name = asset['name']
|
|
267
|
+
@asset_cache[:indices][name] = index
|
|
268
|
+
@asset_cache[:metadata][name] = {
|
|
269
|
+
sz_decimals: asset['szDecimals'],
|
|
270
|
+
is_spot: false
|
|
271
|
+
}
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Load spot assets (index starts at SPOT_ASSET_THRESHOLD)
|
|
275
|
+
spot_meta = @info.spot_meta
|
|
276
|
+
spot_meta['universe'].each_with_index do |pair, index|
|
|
277
|
+
name = pair['name']
|
|
278
|
+
spot_index = index + SPOT_ASSET_THRESHOLD
|
|
279
|
+
@asset_cache[:indices][name] = spot_index
|
|
280
|
+
@asset_cache[:metadata][name] = {
|
|
281
|
+
sz_decimals: pair['szDecimals'] || 0,
|
|
282
|
+
is_spot: true
|
|
283
|
+
}
|
|
284
|
+
end
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# Convert float to wire format (string representation)
|
|
288
|
+
# Maintains parity with official Python SDK
|
|
289
|
+
# - 8 decimal precision
|
|
290
|
+
# - Rounding tolerance validation (1e-12)
|
|
291
|
+
# - Decimal normalization (remove trailing zeros)
|
|
292
|
+
# @param value [String, Numeric] Value to convert
|
|
293
|
+
# @return [String] Wire format string
|
|
294
|
+
def float_to_wire(value)
|
|
295
|
+
decimal = BigDecimal(value.to_s)
|
|
296
|
+
|
|
297
|
+
# Format to 8 decimal places
|
|
298
|
+
rounded_str = format('%.8f', decimal)
|
|
299
|
+
rounded = BigDecimal(rounded_str)
|
|
300
|
+
|
|
301
|
+
# Validate rounding tolerance
|
|
302
|
+
raise ArgumentError, "float_to_wire causes rounding: #{value}" if (rounded - decimal).abs >= BigDecimal('1e-12')
|
|
303
|
+
|
|
304
|
+
# Negative zero edge case
|
|
305
|
+
rounded_str = '0.00000000' if rounded_str == '-0.00000000'
|
|
306
|
+
|
|
307
|
+
# Normalize: remove trailing zeros and unnecessary decimal point
|
|
308
|
+
# BigDecimal#to_s('F') gives fixed-point notation
|
|
309
|
+
normalized = BigDecimal(rounded_str).to_s('F')
|
|
310
|
+
# Remove trailing zeros after decimal point, and trailing decimal point
|
|
311
|
+
normalized.sub(/(\.\d*?)0+\z/, '\1').sub(/\.\z/, '')
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Calculate slippage price for market orders
|
|
315
|
+
# Maintains parity with official Python SDK
|
|
316
|
+
# 1. Apply slippage to mid price
|
|
317
|
+
# 2. Round to 5 significant figures
|
|
318
|
+
# 3. Round to asset-specific decimal places
|
|
319
|
+
# @param coin [String] Asset symbol
|
|
320
|
+
# @param mid [Float] Current mid price
|
|
321
|
+
# @param is_buy [Boolean] True for buy
|
|
322
|
+
# @param slippage [Float] Slippage tolerance
|
|
323
|
+
# @return [String] Formatted price string
|
|
324
|
+
def calculate_slippage_price(coin, mid, is_buy, slippage)
|
|
325
|
+
# Apply slippage
|
|
326
|
+
px = is_buy ? mid * (1 + slippage) : mid * (1 - slippage)
|
|
327
|
+
|
|
328
|
+
# Get asset metadata
|
|
329
|
+
metadata = asset_metadata(coin)
|
|
330
|
+
sz_decimals = metadata[:sz_decimals]
|
|
331
|
+
is_spot = metadata[:is_spot]
|
|
332
|
+
|
|
333
|
+
# Round to 5 significant figures first
|
|
334
|
+
sig_figs_str = format('%.5g', px)
|
|
335
|
+
sig_figs_price = sig_figs_str.to_f
|
|
336
|
+
|
|
337
|
+
# Calculate decimal places: (6 for perp, 8 for spot) - szDecimals
|
|
338
|
+
base_decimals = is_spot ? 8 : 6
|
|
339
|
+
decimal_places = [base_decimals - sz_decimals, 0].max
|
|
340
|
+
|
|
341
|
+
# Round to decimal places
|
|
342
|
+
rounded = sig_figs_price.round(decimal_places)
|
|
343
|
+
|
|
344
|
+
# Format with fixed decimal places
|
|
345
|
+
format("%.#{decimal_places}f", rounded)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# Convert cloid to raw string format
|
|
349
|
+
# @param cloid [Cloid, String, nil] Client order ID
|
|
350
|
+
# @return [String, nil] Raw cloid string
|
|
351
|
+
def normalize_cloid(cloid)
|
|
352
|
+
case cloid
|
|
353
|
+
when nil
|
|
354
|
+
nil
|
|
355
|
+
when Cloid
|
|
356
|
+
cloid.to_raw
|
|
357
|
+
when String
|
|
358
|
+
# Validate format
|
|
359
|
+
unless cloid.match?(/\A0x[0-9a-fA-F]{32}\z/i)
|
|
360
|
+
raise ArgumentError,
|
|
361
|
+
"cloid must be '0x' followed by 32 hex characters (16 bytes). Got: #{cloid.inspect}"
|
|
362
|
+
end
|
|
363
|
+
cloid.downcase
|
|
364
|
+
else
|
|
365
|
+
raise ArgumentError, "cloid must be Cloid, String, or nil. Got: #{cloid.class}"
|
|
366
|
+
end
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Convert order type to wire format
|
|
370
|
+
# @param order_type [Hash] Order type configuration
|
|
371
|
+
# @return [Hash] Wire format order type
|
|
372
|
+
def order_type_to_wire(order_type)
|
|
373
|
+
if order_type[:limit]
|
|
374
|
+
{ limit: { tif: order_type[:limit][:tif] || 'Gtc' } }
|
|
375
|
+
elsif order_type[:trigger]
|
|
376
|
+
trigger = order_type[:trigger]
|
|
377
|
+
|
|
378
|
+
# Validate required fields
|
|
379
|
+
raise ArgumentError, 'Trigger orders require :trigger_px' unless trigger[:trigger_px]
|
|
380
|
+
raise ArgumentError, 'Trigger orders require :tpsl' unless trigger[:tpsl]
|
|
381
|
+
unless %w[tp sl].include?(trigger[:tpsl])
|
|
382
|
+
raise ArgumentError, "tpsl must be 'tp' or 'sl', got: #{trigger[:tpsl]}"
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
{
|
|
386
|
+
trigger: {
|
|
387
|
+
isMarket: trigger[:is_market] || false,
|
|
388
|
+
triggerPx: float_to_wire(trigger[:trigger_px]),
|
|
389
|
+
tpsl: trigger[:tpsl]
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
else
|
|
393
|
+
raise ArgumentError, 'order_type must specify :limit or :trigger'
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Post an action to the exchange endpoint
|
|
398
|
+
def post_action(action, signature, nonce, vault_address)
|
|
399
|
+
payload = {
|
|
400
|
+
action: action,
|
|
401
|
+
nonce: nonce,
|
|
402
|
+
signature: signature
|
|
403
|
+
}
|
|
404
|
+
payload[:vaultAddress] = vault_address if vault_address
|
|
405
|
+
payload[:expiresAfter] = @expires_after if @expires_after
|
|
406
|
+
|
|
407
|
+
@client.post(Constants::EXCHANGE_ENDPOINT, payload)
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
end
|