hyperliquid 0.3.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.
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 'Hyperliquid Ruby SDK v0.1 - Info API Examples'
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 "\n1. Getting all market mid prices..."
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 getting market mids: #{e.message}"
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
- puts 'Got metadata for universe' if meta.is_a?(Hash)
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 getting metadata: #{e.message}"
48
+ puts " Error: #{e.message}"
30
49
  end
31
50
 
32
- # Example 3: Use testnet
33
- puts "\n3. Using testnet..."
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 SDK created, base URL: #{testnet_sdk.base_url}"
64
+ puts " Testnet base URL: #{testnet_sdk.base_url}"
36
65
 
37
- # Example 4: User-specific endpoints (requires valid wallet address)
38
- wallet_address = "0x#{'0' * 40}" # Example placeholder address
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 "\n4. Getting open orders for wallet #{wallet_address[0..10]}..."
42
- orders = sdk.info.open_orders(wallet_address)
43
- puts "Open orders: #{orders}"
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 getting open orders: #{e.message}"
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
@@ -9,6 +9,7 @@ module Hyperliquid
9
9
 
10
10
  # API endpoints
11
11
  INFO_ENDPOINT = '/info'
12
+ EXCHANGE_ENDPOINT = '/exchange'
12
13
 
13
14
  # Request timeouts (seconds)
14
15
  DEFAULT_TIMEOUT = 30
@@ -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