Class: Honeymaker::Clients::Hyperliquid

Inherits:
Honeymaker::Client show all
Defined in:
lib/honeymaker/clients/hyperliquid.rb

Constant Summary collapse

URL =
"https://api.hyperliquid.xyz"
RATE_LIMITS =
{ default: 200, orders: 200 }.freeze

Constants inherited from Honeymaker::Client

Honeymaker::Client::OPTIONS

Instance Attribute Summary

Attributes inherited from Honeymaker::Client

#api_key, #api_secret

Instance Method Summary collapse

Methods inherited from Honeymaker::Client

rate_limits, #validate

Constructor Details

#initialize(api_key: nil, api_secret: nil, proxy: nil, logger: nil) ⇒ Hyperliquid

Returns a new instance of Hyperliquid.



9
10
11
# File 'lib/honeymaker/clients/hyperliquid.rb', line 9

def initialize(api_key: nil, api_secret: nil, proxy: nil, logger: nil)
  super
end

Instance Method Details

#all_midsObject



25
26
27
# File 'lib/honeymaker/clients/hyperliquid.rb', line 25

def all_mids
  ({ type: "allMids" })
end

#cancel(coin:, oid:) ⇒ Object



138
139
140
141
142
# File 'lib/honeymaker/clients/hyperliquid.rb', line 138

def cancel(coin:, oid:)
  with_rescue do
    exchange_client.cancel(coin, oid)
  end
end

#candles_snapshot(coin:, interval:, start_time:, end_time:) ⇒ Object



38
39
40
# File 'lib/honeymaker/clients/hyperliquid.rb', line 38

def candles_snapshot(coin:, interval:, start_time:, end_time:)
  ({ type: "candleSnapshot", req: { coin: coin, interval: interval, startTime: start_time, endTime: end_time } })
end

#get_balances(user: nil) ⇒ Object



42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/honeymaker/clients/hyperliquid.rb', line 42

def get_balances(user: nil)
  user ||= @api_key
  result = spot_clearinghouse_state(user: user)
  return result if result.failure?

  balances = {}
  (result.data["balances"] || []).each do |balance|
    symbol = balance["coin"]
    total = BigDecimal((balance["total"] || "0").to_s)
    hold = BigDecimal((balance["hold"] || "0").to_s)
    free = total - hold
    next if free.zero? && hold.zero?
    balances[symbol] = { free: free, locked: hold }
  end

  Result::Success.new(balances)
end

#l2_book(coin:) ⇒ Object



34
35
36
# File 'lib/honeymaker/clients/hyperliquid.rb', line 34

def l2_book(coin:)
  ({ type: "l2Book", coin: coin })
end

#open_orders(user:) ⇒ Object



113
114
115
# File 'lib/honeymaker/clients/hyperliquid.rb', line 113

def open_orders(user:)
  ({ type: "openOrders", user: user })
end

#order(coin:, is_buy:, size:, limit_px:, order_type: { limit: { tif: "Gtc" } }) ⇒ Object

— Trading (requires hyperliquid-rb gem) —



132
133
134
135
136
# File 'lib/honeymaker/clients/hyperliquid.rb', line 132

def order(coin:, is_buy:, size:, limit_px:, order_type: { limit: { tif: "Gtc" } })
  with_rescue do
    exchange_client.order(coin, is_buy: is_buy, sz: size, limit_px: limit_px, order_type: order_type)
  end
end

#order_status(user:, oid:) ⇒ Object

Hyperliquid’s orderStatus body is NESTED:

{ "status" => "order"|"unknownOid",
  "order"  => { "order" => { coin, side, limitPx, sz(remaining), origSz, oid, timestamp, ... },
                "status" => <real order status>, "statusTimestamp" => ... } }

The real status/sizes live under order/order — NOT the top level — and the body carries NO fills. So the ordered amount is origSz, executed is origSz - remaining sz, and the exact cost comes from a bounded userFillsByTime (only fetched when something actually executed, since userFillsByTime is API weight 20 vs orderStatus’s weight 2).



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/honeymaker/clients/hyperliquid.rb', line 68

def order_status(user:, oid:)
  result = ({ type: "orderStatus", user: user, oid: oid })
  return result if result.failure?

  raw = result.data
  # A distinct not-found signal — aged-out orders are normal; the caller recovers fills / abandons.
  return Result::Failure.new("unknownOid", data: { not_found: true }) if raw["status"] == "unknownOid"

  wrapper = raw["order"] || {}
  order = wrapper["order"] || {}
  status_str = wrapper["status"]

  coin = order["coin"]
  side = order["side"] == "B" ? :buy : :sell
  limit_price = BigDecimal((order["limitPx"] || "0").to_s)
  ordered_size = BigDecimal((order["origSz"] || "0").to_s)
  remaining_size = BigDecimal((order["sz"] || "0").to_s)
  amount_exec = [ordered_size - remaining_size, BigDecimal("0")].max

  quote_amount_exec = BigDecimal("0")
  price = limit_price
  if amount_exec.positive?
    fills_result = order["timestamp"] ? user_fills_by_time(user: user, start_time: order["timestamp"]) : nil
    # A FAILED exact-cost lookup (timeout / rate-limit) is PROPAGATED so the consumer's typed-error
    # retry runs — never record an executed order with an estimated cost just because userFills
    # blipped (that would silently corrupt accounting and skip the retry).
    return fills_result if fills_result&.failure?

    matched = Array(fills_result&.data).select { |f| f["oid"].to_s == oid.to_s }
    matched_quote = matched.sum(BigDecimal("0")) { |f| BigDecimal(f["px"].to_s) * BigDecimal(f["sz"].to_s) }
    # userFills SUCCEEDED but has no matching fill (aged out of the window) → estimate from the
    # limit price so a filled order never reports quote_amount_exec 0. Only on success, never failure.
    quote_amount_exec = matched_quote.positive? ? matched_quote : (limit_price * amount_exec)
    price = quote_amount_exec / amount_exec
  end
  price = nil if price.nil? || price.zero?

  Result::Success.new({
    order_id: "#{coin}-#{oid}", coin: coin,
    status: parse_order_status(status_str), side: side, order_type: :limit,
    price: price, amount: ordered_size, quote_amount: nil,
    amount_exec: amount_exec, quote_amount_exec: quote_amount_exec, raw: raw
  })
end

#spot_balances(user: nil) ⇒ Object



29
30
31
32
# File 'lib/honeymaker/clients/hyperliquid.rb', line 29

def spot_balances(user: nil)
  user ||= @api_key
  spot_clearinghouse_state(user: user)
end

#spot_clearinghouse_state(user:) ⇒ Object



21
22
23
# File 'lib/honeymaker/clients/hyperliquid.rb', line 21

def spot_clearinghouse_state(user:)
  ({ type: "spotClearinghouseState", user: user })
end

#spot_metaObject



13
14
15
# File 'lib/honeymaker/clients/hyperliquid.rb', line 13

def spot_meta
  ({ type: "spotMeta" })
end

#spot_meta_and_asset_ctxsObject



17
18
19
# File 'lib/honeymaker/clients/hyperliquid.rb', line 17

def spot_meta_and_asset_ctxs
  ({ type: "spotMetaAndAssetCtxs" })
end

#user_fills(user:, start_time: nil, end_time: nil) ⇒ Object



117
118
119
120
121
122
# File 'lib/honeymaker/clients/hyperliquid.rb', line 117

def user_fills(user:, start_time: nil, end_time: nil)
  body = { type: "userFills", user: user }
  body[:startTime] = start_time if start_time
  body[:endTime] = end_time if end_time
  (body)
end

#user_fills_by_time(user:, start_time:, end_time: nil) ⇒ Object



124
125
126
127
128
# File 'lib/honeymaker/clients/hyperliquid.rb', line 124

def user_fills_by_time(user:, start_time:, end_time: nil)
  body = { type: "userFillsByTime", user: user, startTime: start_time }
  body[:endTime] = end_time if end_time
  (body)
end

#user_funding(user:, start_time:, end_time: nil) ⇒ Object

— Futures —



146
147
148
149
150
# File 'lib/honeymaker/clients/hyperliquid.rb', line 146

def user_funding(user:, start_time:, end_time: nil)
  body = { type: "userFunding", user: user, startTime: start_time }
  body[:endTime] = end_time if end_time
  (body)
end

#user_non_funding_ledger_updates(user:, start_time:, end_time: nil) ⇒ Object



152
153
154
155
156
# File 'lib/honeymaker/clients/hyperliquid.rb', line 152

def user_non_funding_ledger_updates(user:, start_time:, end_time: nil)
  body = { type: "userNonFundingLedgerUpdates", user: user, startTime: start_time }
  body[:endTime] = end_time if end_time
  (body)
end