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
|
@@ -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)
|