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.
- hypertrader-0.1.0.dist-info/METADATA +416 -0
- hypertrader-0.1.0.dist-info/RECORD +16 -0
- hypertrader-0.1.0.dist-info/WHEEL +5 -0
- hypertrader-0.1.0.dist-info/entry_points.txt +2 -0
- hypertrader-0.1.0.dist-info/top_level.txt +3 -0
- hypertrader.py +741 -0
- modes/auto_trader.py +2141 -0
- modes/market_maker.py +397 -0
- modes/position_management.py +1911 -0
- modes/position_watcher.py +411 -0
- modes/trailing_stop.py +277 -0
- utils/constants.py +31 -0
- utils/helpers.py +988 -0
- utils/style.py +173 -0
- utils/timer.py +14 -0
- utils/worker.py +233 -0
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)
|