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.
@@ -0,0 +1,411 @@
1
+ import asyncio
2
+ import logging
3
+ import time
4
+ from typing import Optional, List, Dict, Any
5
+
6
+ from hyperliquid.exchange import Exchange
7
+ from hyperliquid.info import Info
8
+
9
+ from modes.auto_trader import compute_default_stop_loss_pct
10
+ from modes.position_management import monitor_bracket_position, rebuild_bracket_orders
11
+ from utils.constants import WATCH_RETRY_SLEEP_SECONDS
12
+ from utils.helpers import init_clients, get_all_open_positions, get_all_mids, compute_position_unrealized_pnl, \
13
+ get_account_runtime_metrics, format_account_metrics, is_rate_limit_error, close_clients
14
+
15
+
16
+ async def attach_bracket_to_existing_position(
17
+ info: Info,
18
+ exchange: Exchange,
19
+ account_address: str,
20
+ position: Dict[str, Any],
21
+ take_profit_pct: Optional[float],
22
+ stop_loss_pct: Optional[float],
23
+ take_profit_levels: int,
24
+ use_trailing_tp: bool,
25
+ trailing_tp_trigger_level: int,
26
+ trailing_tp_profit_pct: float,
27
+ poll_interval: float,
28
+ tp_reversal_pct: Optional[float],
29
+ tp_tif: str,
30
+ market_slippage: float,
31
+ cancel_existing_tpsl: bool,
32
+ tp_reversal_limit_exit: bool,
33
+ tp_reversal_stop_buffer_pct: Optional[float],
34
+ hide_orders: bool = False,
35
+ metrics_start_time_ms: Optional[int] = None,
36
+ ) -> None:
37
+ """Attach TP/SL orders to an already-open position and monitor it."""
38
+ coin = str(position.get("coin", "")).upper().strip()
39
+ if not coin:
40
+ raise RuntimeError(f"Cannot manage position without coin field: {position}")
41
+
42
+ try:
43
+ current_size = float(position["szi"])
44
+ entry_px = float(position["entryPx"])
45
+ except (KeyError, TypeError, ValueError) as exc:
46
+ raise RuntimeError(f"Could not parse open position for bracket attachment: {position}") from exc
47
+
48
+ if current_size == 0.0:
49
+ print(f"[WATCH] {coin} is already flat; nothing to attach.")
50
+ return
51
+
52
+ side = "long" if current_size > 0.0 else "short"
53
+ pos_abs = abs(current_size)
54
+
55
+ if take_profit_pct is None and stop_loss_pct is None:
56
+ raise RuntimeError("Specify --take-profit-pct, --stop-loss-pct, or both.")
57
+ if take_profit_pct is not None and not (0.0 < take_profit_pct < 1.0):
58
+ raise RuntimeError("--take-profit-pct must be a decimal fraction between 0 and 1.")
59
+ stop_loss_pct = compute_default_stop_loss_pct(take_profit_pct, stop_loss_pct)
60
+ if stop_loss_pct is not None and not (0.0 < stop_loss_pct < 1.0):
61
+ raise RuntimeError("--stop-loss-pct must be a decimal fraction between 0 and 1.")
62
+ if take_profit_levels <= 0:
63
+ raise RuntimeError("--take-profit-levels must be > 0.")
64
+ if trailing_tp_trigger_level <= 0:
65
+ raise RuntimeError("--trailing-tp-trigger-level must be > 0.")
66
+ if trailing_tp_trigger_level > take_profit_levels:
67
+ raise RuntimeError("--trailing-tp-trigger-level cannot exceed --take-profit-levels.")
68
+ if not (0.0 < trailing_tp_profit_pct < 1.0):
69
+ raise RuntimeError("--trailing-tp-profit-pct must be a decimal fraction between 0 and 1.")
70
+ if tp_tif not in ("Alo", "Gtc"):
71
+ raise RuntimeError("--tp-tif must be Alo or Gtc.")
72
+ if market_slippage < 0.0:
73
+ raise RuntimeError("--market-slippage must be >= 0.")
74
+
75
+ print("============================================================")
76
+ print(" Async Bracket Attach")
77
+ print("============================================================")
78
+ print(f"Account: {account_address}")
79
+ print(f"Coin: {coin}")
80
+ print(f"Side: {side}")
81
+ print(f"Signed size: {current_size:.8f}")
82
+ print(f"Managed size: {pos_abs:.8f}")
83
+ print(f"Entry: {entry_px:.8f}")
84
+ print(f"Hide orders: {hide_orders}")
85
+ print(f"Take profit pct: {take_profit_pct * 100:.4f}%" if take_profit_pct is not None else "Take profit pct: N/A")
86
+ print(f"Trailing TP: {use_trailing_tp}")
87
+ if use_trailing_tp:
88
+ print(f"Trailing TP level: {trailing_tp_trigger_level}")
89
+ print(f"Trailing TP pct: {trailing_tp_profit_pct * 100:.4f}%")
90
+ print(f"Stop loss pct: {stop_loss_pct * 100:.4f}%" if stop_loss_pct is not None else "Stop loss pct: N/A")
91
+ print(f"TP levels: {take_profit_levels}")
92
+ print("============================================================")
93
+
94
+ if not cancel_existing_tpsl:
95
+ print("[WATCH] Existing reduce-only TP/SL orders will be left untouched before placing this bracket.")
96
+
97
+ stop_oid, tp_orders, tp_oids, _ = await rebuild_bracket_orders(
98
+ info=info,
99
+ exchange=exchange,
100
+ account_address=account_address,
101
+ coin=coin,
102
+ side=side,
103
+ position_size_abs=pos_abs,
104
+ entry_px=entry_px,
105
+ take_profit_pct=take_profit_pct,
106
+ stop_loss_pct=stop_loss_pct,
107
+ stop_loss_trigger_px=None,
108
+ take_profit_levels=take_profit_levels,
109
+ tp_tif=tp_tif,
110
+ market_slippage=market_slippage,
111
+ cancel_existing_reduce_only=cancel_existing_tpsl,
112
+ hide_orders=hide_orders,
113
+ use_trailing_tp=use_trailing_tp,
114
+ trailing_tp_profit_pct=trailing_tp_profit_pct,
115
+ )
116
+
117
+ await monitor_bracket_position(
118
+ info=info,
119
+ exchange=exchange,
120
+ account_address=account_address,
121
+ coin=coin,
122
+ side=side,
123
+ entry_px=entry_px,
124
+ take_profit_pct=take_profit_pct,
125
+ tp_orders=tp_orders,
126
+ tp_oids=tp_oids,
127
+ stop_oid=stop_oid,
128
+ managed_signed_size=current_size,
129
+ poll_interval=poll_interval,
130
+ reversal_pct=tp_reversal_pct,
131
+ market_slippage=market_slippage,
132
+ stop_loss_pct=stop_loss_pct,
133
+ stop_loss_trigger_px=None,
134
+ take_profit_levels=take_profit_levels,
135
+ tp_tif=tp_tif,
136
+ tp_reversal_limit_exit=tp_reversal_limit_exit,
137
+ tp_reversal_stop_buffer_pct=tp_reversal_stop_buffer_pct,
138
+ hide_orders=hide_orders,
139
+ use_trailing_tp=use_trailing_tp,
140
+ trailing_tp_trigger_level=trailing_tp_trigger_level,
141
+ trailing_tp_profit_pct=trailing_tp_profit_pct,
142
+ metrics_start_time_ms=metrics_start_time_ms,
143
+ )
144
+
145
+ async def _watched_position_task(
146
+ info: Info,
147
+ exchange: Exchange,
148
+ account_address: str,
149
+ position: Dict[str, Any],
150
+ take_profit_pct: Optional[float],
151
+ stop_loss_pct: Optional[float],
152
+ take_profit_levels: int,
153
+ use_trailing_tp: bool,
154
+ trailing_tp_trigger_level: int,
155
+ trailing_tp_profit_pct: float,
156
+ poll_interval: float,
157
+ tp_reversal_pct: Optional[float],
158
+ tp_tif: str,
159
+ market_slippage: float,
160
+ cancel_existing_tpsl: bool,
161
+ tp_reversal_limit_exit: bool,
162
+ tp_reversal_stop_buffer_pct: Optional[float],
163
+ hide_orders: bool = False,
164
+ metrics_start_time_ms: Optional[int] = None,
165
+ ) -> None:
166
+ """Task wrapper for one watched position so watcher loop keeps running after errors."""
167
+ coin = str(position.get("coin", "UNKNOWN")).upper()
168
+ try:
169
+ while True:
170
+ try:
171
+ await attach_bracket_to_existing_position(
172
+ info=info,
173
+ exchange=exchange,
174
+ account_address=account_address,
175
+ position=position,
176
+ take_profit_pct=take_profit_pct,
177
+ stop_loss_pct=stop_loss_pct,
178
+ take_profit_levels=take_profit_levels,
179
+ use_trailing_tp=use_trailing_tp,
180
+ trailing_tp_trigger_level=trailing_tp_trigger_level,
181
+ trailing_tp_profit_pct=trailing_tp_profit_pct,
182
+ poll_interval=poll_interval,
183
+ tp_reversal_pct=tp_reversal_pct,
184
+ tp_tif=tp_tif,
185
+ market_slippage=market_slippage,
186
+ cancel_existing_tpsl=cancel_existing_tpsl,
187
+ tp_reversal_limit_exit=tp_reversal_limit_exit,
188
+ tp_reversal_stop_buffer_pct=tp_reversal_stop_buffer_pct,
189
+ hide_orders=hide_orders,
190
+ metrics_start_time_ms=metrics_start_time_ms,
191
+ )
192
+ print(f"[WATCH] Management task for {coin} finished.")
193
+ return
194
+ except Exception as exc:
195
+ if not is_rate_limit_error(exc):
196
+ raise
197
+ print(
198
+ f"[WATCH-RETRY] Management task for {coin} hit Hyperliquid rate limit ({exc}). "
199
+ f"Sleeping {WATCH_RETRY_SLEEP_SECONDS:.1f}s before retry."
200
+ )
201
+ await asyncio.sleep(WATCH_RETRY_SLEEP_SECONDS)
202
+ except asyncio.CancelledError:
203
+ print(f"[WATCH] Management task for {coin} canceled; leaving position/orders untouched.")
204
+ raise
205
+ except Exception:
206
+ logging.getLogger("hypertrader").exception("[WATCH-ERROR] Management task for %s failed.", coin)
207
+
208
+
209
+ async def run_position_watcher(
210
+ only_coin: Optional[str],
211
+ take_profit_pct: Optional[float],
212
+ stop_loss_pct: Optional[float],
213
+ take_profit_levels: int,
214
+ use_trailing_tp: bool,
215
+ trailing_tp_trigger_level: int,
216
+ trailing_tp_profit_pct: float,
217
+ poll_interval: float,
218
+ tp_reversal_pct: Optional[float],
219
+ tp_tif: str,
220
+ market_slippage: float,
221
+ cancel_existing_tpsl: bool,
222
+ manage_existing: bool,
223
+ tp_reversal_limit_exit: bool,
224
+ tp_reversal_stop_buffer_pct: Optional[float],
225
+ use_testnet: bool,
226
+ use_websocket: bool = True,
227
+ hide_orders: bool = False,
228
+ account_address: Optional[str] = None,
229
+ info: Optional[Info] = None,
230
+ exchange: Optional[Exchange] = None,
231
+ ) -> None:
232
+ """Watch account positions and attach TP/SL management to new open positions."""
233
+ if take_profit_pct is None and stop_loss_pct is None:
234
+ raise RuntimeError("Specify --take-profit-pct, --stop-loss-pct, or both.")
235
+ if take_profit_pct is not None and not (0.0 < take_profit_pct < 1.0):
236
+ raise RuntimeError("--take-profit-pct must be a decimal fraction between 0 and 1.")
237
+ stop_loss_pct = compute_default_stop_loss_pct(take_profit_pct, stop_loss_pct)
238
+ if stop_loss_pct is not None and not (0.0 < stop_loss_pct < 1.0):
239
+ raise RuntimeError("--stop-loss-pct must be a decimal fraction between 0 and 1.")
240
+ if take_profit_levels <= 0:
241
+ raise RuntimeError("--take-profit-levels must be > 0.")
242
+ if trailing_tp_trigger_level <= 0:
243
+ raise RuntimeError("--trailing-tp-trigger-level must be > 0.")
244
+ if trailing_tp_trigger_level > take_profit_levels:
245
+ raise RuntimeError("--trailing-tp-trigger-level cannot exceed --take-profit-levels.")
246
+ if not (0.0 < trailing_tp_profit_pct < 1.0):
247
+ raise RuntimeError("--trailing-tp-profit-pct must be a decimal fraction between 0 and 1.")
248
+ if poll_interval <= 0.0:
249
+ raise RuntimeError("--poll-interval must be > 0.")
250
+ if tp_tif not in ("Alo", "Gtc"):
251
+ raise RuntimeError("--tp-tif must be Alo or Gtc.")
252
+ if market_slippage < 0.0:
253
+ raise RuntimeError("--market-slippage must be >= 0.")
254
+
255
+ normalized_only_coin = only_coin.upper() if only_coin else None
256
+ owns_clients = account_address is None and info is None and exchange is None
257
+ if not owns_clients and (account_address is None or info is None or exchange is None):
258
+ raise RuntimeError("Pass account_address, info, and exchange together when reusing initialized clients.")
259
+ managed_tasks: Dict[str, asyncio.Task[None]] = {}
260
+ ignored_initial_coins: set[str] = set()
261
+
262
+ try:
263
+ if owns_clients:
264
+ account_address, info, exchange = await init_clients(use_testnet, use_websocket=use_websocket)
265
+ metrics_start_time_ms = int(time.time() * 1000)
266
+ initial_positions = await get_all_open_positions(info, account_address)
267
+ if normalized_only_coin is not None:
268
+ initial_positions = [
269
+ pos for pos in initial_positions
270
+ if str(pos.get("coin", "")).upper() == normalized_only_coin
271
+ ]
272
+ ignored_initial_coins = {
273
+ str(pos.get("coin", "")).upper()
274
+ for pos in initial_positions
275
+ if str(pos.get("coin", "")).strip()
276
+ }
277
+
278
+ print("============================================================")
279
+ print(" Hyperliquid Async Position Watcher")
280
+ print("============================================================")
281
+ print(f"Account: {account_address}")
282
+ print(f"Network: {'TESTNET' if use_testnet else 'MAINNET'}")
283
+ print(f"Websocket: {'ENABLED' if use_websocket else 'DISABLED'}")
284
+ print(f"Hide orders: {hide_orders}")
285
+ print(f"Coin filter: {normalized_only_coin if normalized_only_coin else 'ALL'}")
286
+ print(f"Take profit pct: {take_profit_pct * 100:.4f}%" if take_profit_pct is not None else "Take profit pct: N/A")
287
+ print(f"Trailing TP: {use_trailing_tp}")
288
+ if use_trailing_tp:
289
+ print(f"Trailing TP level: {trailing_tp_trigger_level}")
290
+ print(f"Trailing TP pct: {trailing_tp_profit_pct * 100:.4f}%")
291
+ print(f"Stop loss pct: {stop_loss_pct * 100:.4f}%" if stop_loss_pct is not None else "Stop loss pct: N/A")
292
+ print(f"TP levels: {take_profit_levels}")
293
+ print(f"Poll interval: {poll_interval:.2f}s")
294
+ print(f"Manage existing: {manage_existing}")
295
+ print("------------------------------------------------------------")
296
+ if ignored_initial_coins and not manage_existing:
297
+ print(
298
+ "[WATCH] Existing positions at startup will be ignored until they are flat and reopened: "
299
+ + ", ".join(sorted(ignored_initial_coins))
300
+ )
301
+ elif ignored_initial_coins and manage_existing:
302
+ print("[WATCH] Existing positions will be managed immediately: " + ", ".join(sorted(ignored_initial_coins)))
303
+ else:
304
+ print("[WATCH] No matching open positions at startup.")
305
+ print("Press Ctrl+C to stop watcher. Active positions/orders are left untouched.")
306
+ print("============================================================")
307
+
308
+ while True:
309
+ finished = [coin for coin, task in managed_tasks.items() if task.done()]
310
+ for coin in finished:
311
+ managed_tasks.pop(coin, None)
312
+
313
+ try:
314
+ positions = await get_all_open_positions(info, account_address)
315
+ except Exception as exc:
316
+ print(f"[WATCH-WARN] Failed to fetch open positions: {exc}")
317
+ await asyncio.sleep(poll_interval)
318
+ continue
319
+
320
+ mids: Dict[str, Any] = {}
321
+ try:
322
+ mids = await get_all_mids(info)
323
+ except Exception as exc:
324
+ print(f"[WATCH-WARN] Failed to fetch mids for uPnL display: {exc}")
325
+
326
+ current_open_coins: set[str] = set()
327
+ for pos in positions:
328
+ coin = str(pos.get("coin", "")).upper().strip()
329
+ if not coin:
330
+ continue
331
+ if normalized_only_coin is not None and coin != normalized_only_coin:
332
+ continue
333
+ current_open_coins.add(coin)
334
+
335
+ try:
336
+ signed_size = float(pos.get("szi", "0"))
337
+ entry_px = float(pos.get("entryPx", "0"))
338
+ except (TypeError, ValueError):
339
+ print(f"[WATCH-WARN] Could not parse position for {coin}: {pos}")
340
+ continue
341
+ side = "LONG" if signed_size > 0.0 else "SHORT"
342
+ upnl_str = "N/A"
343
+ mid_price_raw = mids.get(coin)
344
+ if mid_price_raw is not None:
345
+ try:
346
+ unrealized_pnl = compute_position_unrealized_pnl(pos, float(mid_price_raw))
347
+ except (TypeError, ValueError):
348
+ unrealized_pnl = None
349
+ if unrealized_pnl is not None:
350
+ upnl_str = f"{unrealized_pnl:.8f}"
351
+ metrics = await get_account_runtime_metrics(info, account_address, metrics_start_time_ms, coin=coin)
352
+ print(
353
+ f"[WATCH] {coin} {side} signed_size={signed_size:.8f} "
354
+ f"entry={entry_px:.8f} upnl={upnl_str} {format_account_metrics(metrics)}"
355
+ )
356
+
357
+ existing_task = managed_tasks.get(coin)
358
+ if existing_task is not None and not existing_task.done():
359
+ continue
360
+
361
+ if not manage_existing and coin in ignored_initial_coins:
362
+ continue
363
+
364
+ print(
365
+ f"[WATCH] New unmanaged position detected: {coin} {side} "
366
+ f"signed_size={signed_size:.8f}, entry={entry_px:.8f}. Attaching TP/SL."
367
+ )
368
+ managed_tasks[coin] = asyncio.create_task(
369
+ _watched_position_task(
370
+ info=info,
371
+ exchange=exchange,
372
+ account_address=account_address,
373
+ position=dict(pos),
374
+ take_profit_pct=take_profit_pct,
375
+ stop_loss_pct=stop_loss_pct,
376
+ take_profit_levels=take_profit_levels,
377
+ use_trailing_tp=use_trailing_tp,
378
+ trailing_tp_trigger_level=trailing_tp_trigger_level,
379
+ trailing_tp_profit_pct=trailing_tp_profit_pct,
380
+ poll_interval=poll_interval,
381
+ tp_reversal_pct=tp_reversal_pct,
382
+ tp_tif=tp_tif,
383
+ market_slippage=market_slippage,
384
+ cancel_existing_tpsl=cancel_existing_tpsl,
385
+ tp_reversal_limit_exit=tp_reversal_limit_exit,
386
+ tp_reversal_stop_buffer_pct=tp_reversal_stop_buffer_pct,
387
+ hide_orders=hide_orders,
388
+ metrics_start_time_ms=metrics_start_time_ms,
389
+ ),
390
+ name=f"bracket-watch-{coin}",
391
+ )
392
+
393
+ for coin in list(ignored_initial_coins):
394
+ if coin not in current_open_coins:
395
+ ignored_initial_coins.remove(coin)
396
+ print(f"[WATCH] Startup position {coin} is now flat; a future reopened position will be managed.")
397
+
398
+ managed_label = ", ".join(sorted(managed_tasks)) if managed_tasks else "none"
399
+ ignored_label = ", ".join(sorted(ignored_initial_coins)) if ignored_initial_coins else "none"
400
+ print(f"[WATCH] open={sorted(current_open_coins)} managed={managed_label} ignored_startup={ignored_label}")
401
+ await asyncio.sleep(poll_interval)
402
+ except KeyboardInterrupt:
403
+ print("\n[!] Caught Ctrl+C, stopping position watcher.")
404
+ finally:
405
+ if managed_tasks:
406
+ print(f"[WATCH] Canceling {len(managed_tasks)} local monitor task(s); exchange orders are left untouched.")
407
+ for task in managed_tasks.values():
408
+ task.cancel()
409
+ await asyncio.gather(*managed_tasks.values(), return_exceptions=True)
410
+ if owns_clients:
411
+ await close_clients(info, exchange)
modes/trailing_stop.py ADDED
@@ -0,0 +1,277 @@
1
+ # ---------------------------------------------------------------------------
2
+ # Trailing stop manager
3
+ # ---------------------------------------------------------------------------
4
+ import asyncio
5
+ import time
6
+ from typing import Optional, Dict, Any
7
+
8
+ from hyperliquid.exchange import Exchange
9
+ from hyperliquid.info import Info
10
+
11
+ from utils.helpers import init_clients, get_all_open_positions, get_all_mids, get_position_for_coin, \
12
+ parse_position_snapshot, compute_position_unrealized_pnl, get_account_runtime_metrics, position_is_directional_add, \
13
+ format_account_metrics, close_clients
14
+
15
+
16
+ async def trailing_stop_for_all_positions(
17
+ trail_pct: float,
18
+ poll_interval: float,
19
+ use_testnet: bool,
20
+ only_coin: Optional[str] = None,
21
+ use_websocket: bool = True,
22
+ hide_orders: bool = False,
23
+ account_address: Optional[str] = None,
24
+ info: Optional[Info] = None,
25
+ exchange: Optional[Exchange] = None,
26
+ ) -> None:
27
+ """Trailing stop manager for all open positions or one coin.
28
+
29
+ If a managed position is increased in the same direction, the trailing
30
+ anchor and entry are rebased to the new average entry. If the add makes the
31
+ position no longer profitable, trailing is paused until price is back beyond
32
+ the new entry; this avoids closing immediately after scaling in.
33
+ """
34
+ if not (0.0 < trail_pct < 1.0):
35
+ raise ValueError(f"trail_pct must be between 0 and 1. Got: {trail_pct}")
36
+ if poll_interval <= 0:
37
+ raise ValueError(f"poll_interval must be greater than 0. Got: {poll_interval}")
38
+
39
+ def in_profit(side: str, price: float, entry: float) -> bool:
40
+ return price > entry if side == "long" else price < entry
41
+
42
+ def reset_trailing_state(st: Dict[str, Any], price: float) -> None:
43
+ side = str(st["side"])
44
+ entry = float(st["entry"])
45
+ st["highest"] = price
46
+ st["lowest"] = price
47
+ st["armed"] = in_profit(side, price, entry)
48
+ if st["armed"]:
49
+ if side == "long":
50
+ st["stop"] = max(entry, price * (1.0 - trail_pct))
51
+ else:
52
+ st["stop"] = min(entry, price * (1.0 + trail_pct))
53
+ else:
54
+ st["stop"] = entry
55
+
56
+ owns_clients = account_address is None and info is None and exchange is None
57
+ if not owns_clients and (account_address is None or info is None or exchange is None):
58
+ raise RuntimeError("Pass account_address, info, and exchange together when reusing initialized clients.")
59
+ try:
60
+ if owns_clients:
61
+ account_address, info, exchange = await init_clients(use_testnet, use_websocket=use_websocket)
62
+ metrics_start_time_ms = int(time.time() * 1000)
63
+ open_positions = await get_all_open_positions(info, account_address)
64
+ if only_coin is not None:
65
+ only_coin_upper = only_coin.upper()
66
+ open_positions = [
67
+ pos for pos in open_positions if str(pos.get("coin", "")).upper() == only_coin_upper
68
+ ]
69
+
70
+ if not open_positions:
71
+ if only_coin:
72
+ print(f"[!] No open positions found for {only_coin} on this account.")
73
+ else:
74
+ print("[!] No open perp positions found on this account.")
75
+ return
76
+
77
+ try:
78
+ mids = await get_all_mids(info)
79
+ except Exception as exc:
80
+ print(f"[ERROR] Failed to fetch mids before starting trailing stop manager: {exc}")
81
+ return
82
+
83
+ state: Dict[str, Dict[str, Any]] = {}
84
+
85
+ for pos in open_positions:
86
+ coin = pos.get("coin")
87
+ if not coin:
88
+ print(f"[WARN] Position missing coin field, skipping: {pos}")
89
+ continue
90
+ coin = str(coin)
91
+
92
+ if coin not in mids:
93
+ print(f"[WARN] No mid price for {coin}, skipping.")
94
+ continue
95
+
96
+ try:
97
+ size = float(pos["szi"])
98
+ entry_px = float(pos["entryPx"])
99
+ current_price = float(mids[coin])
100
+ except KeyError as exc:
101
+ print(f"[WARN] Position for {coin} missing field {exc}, skipping: {pos}")
102
+ continue
103
+ except (TypeError, ValueError) as exc:
104
+ print(f"[WARN] Could not parse position/mid values for {coin}: {exc}. Position: {pos}")
105
+ continue
106
+
107
+ if size == 0.0:
108
+ continue
109
+
110
+ side = "long" if size > 0.0 else "short"
111
+ entry_px_str = pos.get("entryPx", "N/A")
112
+ st = {
113
+ "coin": coin,
114
+ "size": size,
115
+ "side": side,
116
+ "entry": entry_px,
117
+ "entryPx": entry_px_str,
118
+ "highest": current_price,
119
+ "lowest": current_price,
120
+ "stop": entry_px,
121
+ "active": True,
122
+ "armed": False,
123
+ }
124
+ reset_trailing_state(st, current_price)
125
+ if not st["armed"]:
126
+ print(
127
+ f"[!] {side.upper()} {coin} is not in profit yet: current={current_price:.8f}, "
128
+ f"entry={entry_px:.8f}. It will be armed once price is profitable."
129
+ )
130
+ state[coin] = st
131
+
132
+ if not state:
133
+ print("[!] No valid positions to manage trailing stops for.")
134
+ return
135
+
136
+ print("============================================================")
137
+ print(" Hyperliquid Async Trailing Stop Manager")
138
+ print("============================================================")
139
+ print(f"Account: {account_address}")
140
+ print(f"Network: {'TESTNET' if use_testnet else 'MAINNET'}")
141
+ print(f"Websocket: {'ENABLED' if use_websocket else 'DISABLED'}")
142
+ print(f"Hide orders: {hide_orders} (trailing is always local; no TP/SL orders are placed)")
143
+ print(f"Trail percent: {trail_pct * 100:.4f}%")
144
+ print(f"Poll interval: {poll_interval:.2f} s")
145
+ print("------------------------------------------------------------")
146
+
147
+ for coin, st in state.items():
148
+ armed = "YES" if st.get("armed") else "NO"
149
+ print(
150
+ f"Coin: {coin:>8} | Side: {st['side']:>5} | Size: {st['size']:.8f} | "
151
+ f"Entry: {st['entryPx']} | Initial mid: {float(mids.get(coin, 0.0)):.8f} | "
152
+ f"Initial stop: {st['stop']:.8f} | Armed: {armed}"
153
+ )
154
+
155
+ print("------------------------------------------------------------")
156
+ print("Press Ctrl+C to exit without closing remaining positions.")
157
+ print("============================================================")
158
+
159
+ while True:
160
+ if not any(st["active"] for st in state.values()):
161
+ print("[DONE] All managed positions have been closed.")
162
+ break
163
+
164
+ await asyncio.sleep(poll_interval)
165
+
166
+ try:
167
+ mids = await get_all_mids(info)
168
+ except Exception as exc:
169
+ print(f"[WARN] Failed to fetch mids: {exc}. Retrying...")
170
+ continue
171
+
172
+ for coin, st in list(state.items()):
173
+ if not st["active"]:
174
+ continue
175
+ if coin not in mids:
176
+ print(f"[WARN] Mid price for {coin} missing in this poll, skipping.")
177
+ continue
178
+
179
+ pos = await get_position_for_coin(info, account_address, coin)
180
+ if pos is None:
181
+ print(f"[DONE] {coin} is now flat; trailing state cleared.")
182
+ st["active"] = False
183
+ continue
184
+
185
+ try:
186
+ _, live_size, live_entry_px, live_side, _ = parse_position_snapshot(pos)
187
+ except RuntimeError as exc:
188
+ print(f"[WARN] {exc}")
189
+ continue
190
+
191
+ if live_side != st["side"]:
192
+ print(
193
+ f"[DONE] {coin} position direction changed from {st['side']} to {live_side}; "
194
+ "stopping this trailing state."
195
+ )
196
+ st["active"] = False
197
+ continue
198
+
199
+ try:
200
+ price = float(mids[coin])
201
+ except (TypeError, ValueError) as exc:
202
+ print(f"[WARN] Could not parse mid price for {coin}: {mids[coin]!r}. Error: {exc}")
203
+ continue
204
+
205
+ unrealized_pnl = compute_position_unrealized_pnl(pos, price)
206
+ metrics = await get_account_runtime_metrics(info, account_address, metrics_start_time_ms, coin=coin)
207
+ if position_is_directional_add(float(st["size"]), live_size):
208
+ st["size"] = live_size
209
+ st["entry"] = live_entry_px
210
+ st["entryPx"] = f"{live_entry_px:.8f}"
211
+ reset_trailing_state(st, price)
212
+ print(
213
+ f"[TRAIL-REBASE] {coin} position increased to {live_size:.8f}; "
214
+ f"entry rebased to {live_entry_px:.8f}, trailing anchor reset to {price:.8f}, "
215
+ f"stop recalculated to {st['stop']:.8f}, armed={st['armed']}."
216
+ )
217
+ else:
218
+ st["size"] = live_size
219
+ st["entry"] = live_entry_px
220
+ st["entryPx"] = f"{live_entry_px:.8f}"
221
+
222
+ side = st["side"]
223
+ if not st.get("armed", True):
224
+ if in_profit(side, price, float(st["entry"])):
225
+ reset_trailing_state(st, price)
226
+ print(
227
+ f"[TRAIL-ARM] {coin} is profitable again; trailing armed with stop {st['stop']:.8f}."
228
+ )
229
+ else:
230
+ pnl_str = f"{unrealized_pnl:.8f}" if unrealized_pnl is not None else "N/A"
231
+ print(
232
+ f"[INFO] {coin} | Side: {side} | Size: {st['size']:.8f} | Last: {price:.8f} | "
233
+ f"Entry: {st['entry']:.8f} | uPnL: {pnl_str} | {format_account_metrics(metrics)} | Stop: PAUSED | Active: {st['active']}"
234
+ )
235
+ continue
236
+
237
+ if side == "long":
238
+ if price > st["highest"]:
239
+ st["highest"] = price
240
+ st["stop"] = max(st["entry"], st["highest"] * (1.0 - trail_pct))
241
+ print(f"[LONG] {coin} new high {st['highest']:.8f}, moved stop to {st['stop']:.8f}")
242
+
243
+ if price <= st["stop"]:
244
+ print(f"[LONG] {coin} stop hit! Price {price:.8f} <= stop {st['stop']:.8f}. Closing...")
245
+ try:
246
+ result = await exchange.market_close(coin)
247
+ print(f"[RESULT] {coin} market_close response: {result}")
248
+ st["active"] = False
249
+ except Exception as exc:
250
+ print(f"[ERROR] Failed to close {coin} position: {exc}")
251
+ st["active"] = True
252
+ else:
253
+ if price < st["lowest"]:
254
+ st["lowest"] = price
255
+ st["stop"] = min(st["entry"], st["lowest"] * (1.0 + trail_pct))
256
+ print(f"[SHORT] {coin} new low {st['lowest']:.8f}, moved stop to {st['stop']:.8f}")
257
+
258
+ if price >= st["stop"]:
259
+ print(f"[SHORT] {coin} stop hit! Price {price:.8f} >= stop {st['stop']:.8f}. Closing...")
260
+ try:
261
+ result = await exchange.market_close(coin)
262
+ print(f"[RESULT] {coin} market_close response: {result}")
263
+ st["active"] = False
264
+ except Exception as exc:
265
+ print(f"[ERROR] Failed to close {coin} position: {exc}")
266
+ st["active"] = True
267
+
268
+ pnl_str = f"{unrealized_pnl:.8f}" if unrealized_pnl is not None else "N/A"
269
+ print(
270
+ f"[INFO] {coin} | Side: {side} | Size: {st['size']:.8f} | Last: {price:.8f} | "
271
+ f"Entry: {st['entry']:.8f} | uPnL: {pnl_str} | {format_account_metrics(metrics)} | Stop: {st['stop']:.8f} | Active: {st['active']}"
272
+ )
273
+ except KeyboardInterrupt:
274
+ print("\n[!] Caught Ctrl+C, exiting without closing remaining positions.")
275
+ finally:
276
+ if owns_clients:
277
+ await close_clients(info, exchange)