hypertrader 0.1.0__py3-none-any.whl

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.
modes/market_maker.py ADDED
@@ -0,0 +1,397 @@
1
+ # ---------------------------------------------------------------------------
2
+ # Event-driven market maker
3
+ # ---------------------------------------------------------------------------
4
+ import asyncio
5
+ import statistics
6
+ from typing import Optional, List, Dict, Any, Tuple
7
+
8
+ from hyperliquid.exchange import Exchange
9
+ from hyperliquid.info import Info
10
+
11
+ from utils.helpers import init_clients, close_clients, extract_order_error
12
+ from utils.constants import INTERVAL_TO_MS, PRICE_EPS
13
+ from utils.helpers import get_all_mids, get_open_orders_for_coin, get_position_size_for_coin, \
14
+ fetch_recent_candles, get_best_bid_ask, round_price_for_hyperliquid, \
15
+ round_size_for_hyperliquid
16
+
17
+ def extract_order_px(order: Dict[str, Any]) -> float:
18
+ """Extract order price across API variants."""
19
+ for key in ("px", "limitPx", "limit_px"):
20
+ value = order.get(key)
21
+ if value is not None:
22
+ return float(value)
23
+ raise KeyError(f"Order has no recognizable price key: {order}")
24
+
25
+ def compute_center_and_sigma_from_candles(candles: List[Dict[str, Any]]) -> Tuple[float, float]:
26
+ """Compute center price and standard deviation from candle closes."""
27
+ closes: List[float] = []
28
+ highs: List[float] = []
29
+ lows: List[float] = []
30
+
31
+ for candle in candles:
32
+ try:
33
+ closes.append(float(candle["c"]))
34
+ highs.append(float(candle["h"]))
35
+ lows.append(float(candle["l"]))
36
+ except (KeyError, TypeError, ValueError):
37
+ continue
38
+
39
+ if not closes:
40
+ raise RuntimeError("No valid close prices in candles.")
41
+
42
+ center = statistics.mean(closes)
43
+ try:
44
+ sigma = statistics.pstdev(closes)
45
+ except statistics.StatisticsError:
46
+ sigma = 0.0
47
+
48
+ if sigma <= 0.0:
49
+ ranges = [high - low for high, low in zip(highs, lows) if high >= low]
50
+ sigma = statistics.mean(ranges) if ranges else 0.0
51
+
52
+ if sigma <= 0.0:
53
+ raise RuntimeError("Standard deviation and average range are zero; market appears flat.")
54
+
55
+ return center, sigma
56
+
57
+ async def cancel_open_orders_for_coin(
58
+ info: Info,
59
+ exchange: Exchange,
60
+ account_address: str,
61
+ coin: str,
62
+ existing_orders: Optional[List[Dict[str, Any]]] = None,
63
+ mid: Optional[float] = None,
64
+ protect_close_pct: float = 0.0,
65
+ ) -> List[Dict[str, Any]]:
66
+ """Cancel open orders for `coin`, optionally protecting orders near current mid."""
67
+ if existing_orders is None:
68
+ coin_orders = await get_open_orders_for_coin(info, account_address, coin)
69
+ else:
70
+ coin_orders = [order for order in existing_orders if str(order.get("coin", "")).lower() == coin.lower()]
71
+
72
+ protected: List[Dict[str, Any]] = []
73
+ cancel_requests: List[Dict[str, Any]] = []
74
+
75
+ for order in coin_orders:
76
+ try:
77
+ px = float(extract_order_px(order))
78
+ except (TypeError, ValueError, KeyError):
79
+ continue
80
+
81
+ if mid is not None and protect_close_pct > 0.0 and mid > 0.0:
82
+ dev = abs(px - mid) / mid
83
+ if dev <= protect_close_pct:
84
+ protected.append(order)
85
+ continue
86
+
87
+ try:
88
+ sz = float(order.get("sz", "0"))
89
+ oid = order.get("oid")
90
+ except (TypeError, ValueError):
91
+ continue
92
+
93
+ if sz > 0.0 and isinstance(oid, int):
94
+ cancel_requests.append({"coin": coin, "oid": oid})
95
+
96
+ if not cancel_requests:
97
+ if protected:
98
+ print(f"[MM] No orders to cancel for {coin}; {len(protected)} orders protected near mid.")
99
+ else:
100
+ print(f"[MM] No existing open orders to cancel for {coin}.")
101
+ return protected
102
+
103
+ print(
104
+ f"[MM] Canceling {len(cancel_requests)} existing open orders for {coin} in bulk; "
105
+ f"protecting {len(protected)} orders near mid."
106
+ )
107
+ try:
108
+ res = await exchange.bulk_cancel(cancel_requests)
109
+ print(f"[CANCEL] {coin} -> {res}")
110
+ except Exception as exc:
111
+ print(f"[ERROR] Bulk cancel for {coin} failed: {exc}")
112
+
113
+ return protected
114
+
115
+ async def place_order_with_retry(
116
+ info: Info,
117
+ exchange: Exchange,
118
+ coin: str,
119
+ is_buy: bool,
120
+ size: float,
121
+ initial_px: float,
122
+ max_retries: int = 3,
123
+ ) -> None:
124
+ """Place a post-only order, retrying slightly adjusted prices if ALO fails."""
125
+ side = "BUY" if is_buy else "SELL"
126
+ px = initial_px
127
+
128
+ for attempt in range(max_retries):
129
+ try:
130
+ rounded_size = await round_size_for_hyperliquid(info, coin, size)
131
+ rounded_px = await round_price_for_hyperliquid(info, coin, px)
132
+ resp = await exchange.order(
133
+ coin,
134
+ is_buy,
135
+ rounded_size,
136
+ rounded_px,
137
+ {"limit": {"tif": "Alo"}},
138
+ )
139
+ except Exception as exc:
140
+ print(f"[ERROR] {side} {size:.8f} {coin} @ {px:.8f} exception: {exc}")
141
+ return
142
+
143
+ err = extract_order_error(resp)
144
+ if err is None:
145
+ print(f"[OK] {side:4} {size:.8f} {coin} @ {px:.8f} -> {resp}")
146
+ return
147
+
148
+ print(f"[WARN] {side} {size:.8f} {coin} @ {px:.8f} rejected: {err}")
149
+
150
+ if "Post only order would have immediately matched" in err:
151
+ best_bid, best_ask = await get_best_bid_ask(info, coin)
152
+ eps = PRICE_EPS * (attempt + 1)
153
+ if is_buy and best_bid is not None:
154
+ px = best_bid - eps
155
+ print(f"[RETRY] Adjusting BUY below best bid: new px={px:.8f}")
156
+ continue
157
+ if (not is_buy) and best_ask is not None:
158
+ px = best_ask + eps
159
+ print(f"[RETRY] Adjusting SELL above best ask: new px={px:.8f}")
160
+ continue
161
+
162
+ print("[RETRY] No valid BBO to adjust against; giving up.")
163
+ return
164
+
165
+ print("[WARN] Non post-only error, not retrying.")
166
+ return
167
+
168
+ print(f"[WARN] Max retries reached for {side} {size:.8f} {coin} (last px={px:.8f}).")
169
+
170
+ async def run_market_maker(
171
+ coin: str,
172
+ interval: str,
173
+ periods: int,
174
+ levels: int,
175
+ base_size: float,
176
+ use_testnet: bool,
177
+ loop_sleep: Optional[float],
178
+ min_edge_pct: float,
179
+ rebalance_threshold_pct: float,
180
+ protect_close_pct: float,
181
+ use_websocket: bool = True,
182
+ account_address: Optional[str] = None,
183
+ info: Optional[Info] = None,
184
+ exchange: Optional[Exchange] = None,
185
+ ) -> None:
186
+ """Market maker that only rebalances when price drifts away from current ladder."""
187
+ if levels <= 0:
188
+ raise RuntimeError("levels must be > 0")
189
+ if base_size <= 0.0:
190
+ raise RuntimeError("base-size must be > 0")
191
+ if interval not in INTERVAL_TO_MS:
192
+ raise RuntimeError(f"Unsupported interval {interval}. Valid: {sorted(INTERVAL_TO_MS.keys())}")
193
+ if min_edge_pct <= 0.0:
194
+ raise RuntimeError("min-edge-pct must be > 0")
195
+ if rebalance_threshold_pct <= 0.0:
196
+ raise RuntimeError("rebalance-threshold-pct must be > 0")
197
+ if protect_close_pct < 0.0:
198
+ raise RuntimeError("protect-close-pct must be >= 0")
199
+ if loop_sleep is None or loop_sleep <= 0.0:
200
+ loop_sleep = 1.0
201
+
202
+ owns_clients = account_address is None and info is None and exchange is None
203
+ if not owns_clients and (account_address is None or info is None or exchange is None):
204
+ raise RuntimeError("Pass account_address, info, and exchange together when reusing initialized clients.")
205
+ try:
206
+ if owns_clients:
207
+ account_address, info, exchange = await init_clients(use_testnet, use_websocket=use_websocket)
208
+ coin = coin.upper()
209
+
210
+ print("============================================================")
211
+ print(" Hyperliquid Async Market Maker")
212
+ print("============================================================")
213
+ print(f"Account: {account_address}")
214
+ print(f"Network: {'TESTNET' if use_testnet else 'MAINNET'}")
215
+ print(f"Websocket: {'ENABLED' if use_websocket else 'DISABLED'}")
216
+ print(f"Coin: {coin}")
217
+ print(f"Interval: {interval}")
218
+ print(f"Lookback N: {periods} candles")
219
+ print(f"Levels/side: {levels}")
220
+ print(f"Base size: {base_size} contracts")
221
+ print(f"Poll interval: {loop_sleep:.2f} seconds")
222
+ print(f"Min edge: {min_edge_pct * 100:.4f}% vs mid")
223
+ print(f"Rebalance thr: {rebalance_threshold_pct * 100:.4f}% mid vs ladder center")
224
+ print("============================================================")
225
+
226
+ loop_idx = 0
227
+ while True:
228
+ loop_idx += 1
229
+ await asyncio.sleep(loop_sleep)
230
+
231
+ try:
232
+ mids = await get_all_mids(info)
233
+ mid = float(mids.get(coin)) if coin in mids else None
234
+ except Exception as exc:
235
+ print(f"[WARN] Failed to fetch mids for {coin}: {exc}")
236
+ mid = None
237
+
238
+ coin_orders = await get_open_orders_for_coin(info, account_address, coin)
239
+ ladder_prices: List[float] = []
240
+ for order in coin_orders:
241
+ try:
242
+ ladder_prices.append(float(extract_order_px(order)))
243
+ except (TypeError, ValueError, KeyError):
244
+ continue
245
+
246
+ need_rebalance = False
247
+ if mid is None or not ladder_prices:
248
+ need_rebalance = True
249
+ if mid is None and not ladder_prices:
250
+ print(f"[LOOP {loop_idx}] No mid price and no ladder -> forcing rebalance.")
251
+ elif mid is None:
252
+ print(f"[LOOP {loop_idx}] No mid price -> forcing rebalance.")
253
+ else:
254
+ print(f"[LOOP {loop_idx}] No existing ladder for {coin} -> forcing rebalance.")
255
+ else:
256
+ ladder_min = min(ladder_prices)
257
+ ladder_max = max(ladder_prices)
258
+ ladder_center = 0.5 * (ladder_min + ladder_max)
259
+ deviation = abs(mid - ladder_center) / ladder_center if ladder_center > 0 else None
260
+ dev_str = "N/A" if deviation is None else f"{deviation * 100:.4f}%"
261
+ print(
262
+ f"[LOOP {loop_idx}] mid={mid:.8f}, ladder_center={ladder_center:.8f}, "
263
+ f"min={ladder_min:.8f}, max={ladder_max:.8f}, dev={dev_str}"
264
+ )
265
+ if deviation is None or deviation >= rebalance_threshold_pct:
266
+ print("[MM] Rebalance condition met (price drifted from ladder).")
267
+ need_rebalance = True
268
+ else:
269
+ print("[MM] No rebalance needed this loop.")
270
+ continue
271
+
272
+ if not need_rebalance:
273
+ continue
274
+
275
+ try:
276
+ pos_size = await get_position_size_for_coin(info, account_address, coin)
277
+ except Exception as exc:
278
+ print(f"[WARN] Failed to fetch current position for {coin}: {exc}")
279
+ pos_size = 0.0
280
+
281
+ pos_abs = abs(pos_size)
282
+ exit_chunk = pos_abs / levels if pos_abs > 0 else 0.0
283
+ if pos_size > 0:
284
+ print(f"[MM] Current position: LONG {pos_size:.8f}, exit chunk per level: {exit_chunk:.8f}")
285
+ elif pos_size < 0:
286
+ print(f"[MM] Current position: SHORT {pos_size:.8f}, exit chunk per level: {exit_chunk:.8f}")
287
+ else:
288
+ print("[MM] Current position: FLAT")
289
+
290
+ try:
291
+ candles = await fetch_recent_candles(info, coin, interval, periods)
292
+ except Exception as exc:
293
+ print(f"[ERROR] Failed to fetch candles for {coin}: {exc}")
294
+ continue
295
+
296
+ print(f"[MM] Fetched {len(candles)} candles for {coin} {interval}.")
297
+ try:
298
+ center, sigma = compute_center_and_sigma_from_candles(candles)
299
+ except Exception as exc:
300
+ print(f"[ERROR] Failed to compute center/sigma: {exc}")
301
+ continue
302
+
303
+ print(f"[MM] New center: {center:.8f}")
304
+ print(f"[MM] New sigma: {sigma:.8f}")
305
+
306
+ try:
307
+ mids = await get_all_mids(info)
308
+ mid = float(mids.get(coin)) if coin in mids else None
309
+ except Exception as exc:
310
+ print(f"[WARN] Failed to fetch mids for edge check: {exc}")
311
+ mid = None
312
+
313
+ if mid is not None:
314
+ print(f"[MM] Current mid: {mid:.8f}")
315
+ else:
316
+ print("[MM] Current mid: N/A")
317
+
318
+ best_bid, best_ask = await get_best_bid_ask(info, coin)
319
+ print(f"[MM] Best bid/ask: {best_bid} / {best_ask}")
320
+
321
+ step = sigma / (levels + 1)
322
+ orders_to_place: List[Dict[str, Any]] = []
323
+
324
+ edge_buy_limit = mid * (1.0 - min_edge_pct) if mid is not None else None
325
+ base_buy_px = center - step
326
+ if edge_buy_limit is not None:
327
+ base_buy_px = min(base_buy_px, edge_buy_limit)
328
+
329
+ last_buy_px = None
330
+ for k in range(levels):
331
+ buy_px = base_buy_px - k * step
332
+ if best_bid is not None and buy_px >= best_bid:
333
+ buy_px = best_bid - PRICE_EPS
334
+ if last_buy_px is not None and buy_px >= last_buy_px:
335
+ buy_px = last_buy_px - PRICE_EPS
336
+ buy_size = exit_chunk if pos_size < 0 and exit_chunk > 0.0 else base_size * (k + 1)
337
+ if buy_px > 0 and buy_size > 0:
338
+ buy_px_rounded = await round_price_for_hyperliquid(info, coin, buy_px)
339
+ buy_size_rounded = await round_size_for_hyperliquid(info, coin, buy_size)
340
+ orders_to_place.append({"coin": coin, "is_buy": True, "px": buy_px_rounded, "sz": buy_size_rounded})
341
+ last_buy_px = buy_px_rounded
342
+
343
+ edge_sell_limit = mid * (1.0 + min_edge_pct) if mid is not None else None
344
+ base_sell_px = center + step
345
+ if edge_sell_limit is not None:
346
+ base_sell_px = max(base_sell_px, edge_sell_limit)
347
+
348
+ last_sell_px = None
349
+ for k in range(levels):
350
+ sell_px = base_sell_px + k * step
351
+ if best_ask is not None and sell_px <= best_ask:
352
+ sell_px = best_ask + PRICE_EPS
353
+ if last_sell_px is not None and sell_px <= last_sell_px:
354
+ sell_px = last_sell_px + PRICE_EPS
355
+ sell_size = exit_chunk if pos_size > 0 and exit_chunk > 0.0 else base_size * (k + 1)
356
+ if sell_px > 0 and sell_size > 0:
357
+ sell_px_rounded = await round_price_for_hyperliquid(info, coin, sell_px)
358
+ sell_size_rounded = await round_size_for_hyperliquid(info, coin, sell_size)
359
+ orders_to_place.append({"coin": coin, "is_buy": False, "px": sell_px_rounded, "sz": sell_size_rounded})
360
+ last_sell_px = sell_px_rounded
361
+
362
+ if not orders_to_place:
363
+ print("[MM] No valid prices generated for orders after spacing/edge checks.")
364
+ continue
365
+
366
+ print("[MM] Planned limit orders (post-only / ALO, spaced ladder, position-based sizing):")
367
+ for order in orders_to_place:
368
+ side = "BUY" if order["is_buy"] else "SELL"
369
+ print(f" {side:4} {order['sz']:.8f} {coin} @ {order['px']:.8f}")
370
+ print("------------------------------------------------------------")
371
+
372
+ await cancel_open_orders_for_coin(
373
+ info,
374
+ exchange,
375
+ account_address,
376
+ coin,
377
+ existing_orders=coin_orders,
378
+ mid=mid,
379
+ protect_close_pct=protect_close_pct,
380
+ )
381
+
382
+ print(f"[MM] Placing {len(orders_to_place)} new orders for {coin}...")
383
+ for order in orders_to_place:
384
+ await place_order_with_retry(
385
+ info,
386
+ exchange,
387
+ order["coin"],
388
+ bool(order["is_buy"]),
389
+ float(order["sz"]),
390
+ float(order["px"]),
391
+ max_retries=3,
392
+ )
393
+ except KeyboardInterrupt:
394
+ print("\n[!] Caught Ctrl+C, stopping market maker loop.")
395
+ finally:
396
+ if owns_clients:
397
+ await close_clients(info, exchange)