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/auto_trader.py ADDED
@@ -0,0 +1,2141 @@
1
+ import asyncio
2
+ import decimal
3
+ import json
4
+ import logging
5
+ import math
6
+ import os
7
+ import time
8
+ import numpy as np
9
+ import talib
10
+ from dataclasses import dataclass
11
+ from typing import Optional, List, Dict, Any, Tuple
12
+
13
+ from hyperliquid.exchange import Exchange
14
+ from hyperliquid.info import Info
15
+
16
+ from modes.position_management import run_bracket_entry
17
+ from utils.constants import AUTO_TRADES_LOG_FILE, INTERVAL_TO_MS, cp
18
+ from utils.helpers import parse_interval_list, init_clients, _try_float, parse_fractional_pct, \
19
+ extract_account_balance_from_user_state, round_size_for_hyperliquid, get_user_state_with_retry, get_all_mids, \
20
+ fetch_recent_candles, compute_position_unrealized_pnl, close_clients, compute_default_stop_loss_pct, \
21
+ extract_closed_pnl_from_fill
22
+
23
+ logger = logging.getLogger(__name__)
24
+ _AUTO_TRADES_LOGGER: Optional[logging.Logger] = None
25
+
26
+
27
+ def get_auto_trades_logger() -> logging.Logger:
28
+ """Return the dedicated auto-trade completion logger."""
29
+ global _AUTO_TRADES_LOGGER
30
+
31
+ if _AUTO_TRADES_LOGGER is not None:
32
+ return _AUTO_TRADES_LOGGER
33
+
34
+ resolved_path = os.path.abspath(AUTO_TRADES_LOG_FILE)
35
+ os.makedirs(os.path.dirname(resolved_path), exist_ok=True)
36
+
37
+ auto_logger = logging.getLogger("hypertrader.auto_trades")
38
+ auto_logger.setLevel(logging.INFO)
39
+ auto_logger.propagate = False
40
+
41
+ if not any(
42
+ isinstance(handler, logging.FileHandler) and getattr(handler, "baseFilename", None) == resolved_path
43
+ for handler in auto_logger.handlers
44
+ ):
45
+ file_handler = logging.FileHandler(resolved_path, encoding="utf-8")
46
+ file_handler.setLevel(logging.INFO)
47
+ file_handler.setFormatter(logging.Formatter("%(asctime)s %(message)s"))
48
+ auto_logger.addHandler(file_handler)
49
+
50
+ _AUTO_TRADES_LOGGER = auto_logger
51
+ return auto_logger
52
+
53
+ def require_talib_available() -> None:
54
+ """Fail early with an actionable message when auto-mode dependencies are missing."""
55
+ if np is None or talib is None:
56
+ raise RuntimeError(
57
+ "The auto command requires numpy and TA-Lib. Install them with:\n"
58
+ " pip install numpy TA-Lib\n\n"
59
+ "On Debian/Ubuntu, if the wheel is unavailable, install the TA-Lib C library first, for example:\n"
60
+ " sudo apt-get update && sudo apt-get install -y build-essential ta-lib\n"
61
+ )
62
+
63
+ async def get_top_perp_markets_by_volume(info: Info, limit: int) -> List[Tuple[str, float]]:
64
+ """Return the top perp markets ranked by reported day notional volume."""
65
+ if limit <= 0:
66
+ raise RuntimeError("--top-markets must be > 0.")
67
+
68
+ meta_and_ctxs = await info.meta_and_asset_ctxs()
69
+ if not isinstance(meta_and_ctxs, (list, tuple)) or len(meta_and_ctxs) < 2:
70
+ raise RuntimeError(f"Unexpected metaAndAssetCtxs response shape: {type(meta_and_ctxs).__name__}")
71
+
72
+ meta = meta_and_ctxs[0]
73
+ asset_ctxs = meta_and_ctxs[1]
74
+ universe = meta.get("universe", []) if isinstance(meta, dict) else []
75
+ if not isinstance(universe, list) or not isinstance(asset_ctxs, list):
76
+ raise RuntimeError("metaAndAssetCtxs response did not include perp universe and asset contexts lists.")
77
+
78
+ ranked: List[Tuple[str, float]] = []
79
+ for asset_info, ctx in zip(universe, asset_ctxs):
80
+ if not isinstance(asset_info, dict) or not isinstance(ctx, dict):
81
+ continue
82
+ coin = str(asset_info.get("name") or ctx.get("coin") or "").upper()
83
+ if not coin:
84
+ continue
85
+ volume = _try_float(ctx.get("dayNtlVlm"))
86
+ if volume is None or volume <= 0.0:
87
+ continue
88
+ ranked.append((coin, volume))
89
+
90
+ ranked.sort(key=lambda item: item[1], reverse=True)
91
+ return ranked[:limit]
92
+
93
+
94
+ # ---------------------------------------------------------------------------
95
+ # TA-Lib auto trader
96
+ # ---------------------------------------------------------------------------
97
+
98
+ @dataclass
99
+ class AutoIntervalSignal:
100
+ """TA-Lib indicator snapshot for one interval."""
101
+ interval: str
102
+ closes: List[float]
103
+ close: float
104
+ macd: float
105
+ macd_signal: float
106
+ macd_hist: float
107
+ sar: float
108
+ adx: float
109
+ bb_upper: float
110
+ bb_middle: float
111
+ bb_lower: float
112
+ direction: str
113
+ reason: str
114
+
115
+
116
+ @dataclass
117
+ class AutoTradeDecision:
118
+ """Aggregated multi-timeframe trade decision."""
119
+ direction: Optional[str]
120
+ current_px: float
121
+ target_px: Optional[float]
122
+ take_profit_pct: Optional[float]
123
+ stop_loss_pct: Optional[float]
124
+ stop_loss_trigger_px: Optional[float]
125
+ long_votes: int
126
+ short_votes: int
127
+ required_votes: int
128
+ snapshots: List[AutoIntervalSignal]
129
+ reason: str
130
+
131
+
132
+ @dataclass
133
+ class BollingerState:
134
+ """Computed Bollinger confirmation state for one selected interval."""
135
+ interval: str
136
+ basis: float
137
+ upper: float
138
+ lower: float
139
+ percent_b: float
140
+ basis_slope: float
141
+ bandwidth: float
142
+ previous_bandwidth: float
143
+ bandwidth_expanding: bool
144
+ latest_close: float
145
+
146
+
147
+ @dataclass
148
+ class CoinTradeSessionState:
149
+ coin: str
150
+ realized_pnl: float = 0.0
151
+ peak_pnl: float = 0.0
152
+ completed_cycles: int = 0
153
+ winning_cycles: int = 0
154
+ losing_cycles: int = 0
155
+ consecutive_wins: int = 0
156
+ consecutive_losses: int = 0
157
+ cooldown_until_ms: int = 0
158
+ cooldown_reason: str = ""
159
+ last_cycle_pnl: float = 0.0
160
+ last_cycle_started_ms: int = 0
161
+ last_cycle_closed_ms: int = 0
162
+
163
+
164
+ @dataclass
165
+ class AutoRiskSessionState:
166
+ started_ms: int
167
+ realized_pnl: float = 0.0
168
+ peak_pnl: float = 0.0
169
+ completed_cycles: int = 0
170
+ stopped: bool = False
171
+ stop_reason: str = ""
172
+
173
+
174
+ @dataclass
175
+ class AutoScanLoopSnapshot:
176
+ """Per-scan shared state reused across all market candidates."""
177
+ mids: Dict[str, float]
178
+ positions_by_coin: Dict[str, Dict[str, Any]]
179
+ account_balance: Optional[float]
180
+ realized_pnl: Optional[float]
181
+
182
+
183
+ @dataclass
184
+ class AutoScanCandidate:
185
+ """Outcome of scanning one market during a shared scan pass."""
186
+ coin: str
187
+ decision: Optional["AutoTradeDecision"]
188
+ existing_position: Optional[Dict[str, Any]]
189
+ rejection_reason: Optional[str] = None
190
+
191
+
192
+ def now_ms() -> int:
193
+ return int(time.time() * 1000)
194
+
195
+
196
+ def get_or_create_coin_session(
197
+ coin_sessions: Dict[str, CoinTradeSessionState],
198
+ coin: str,
199
+ ) -> CoinTradeSessionState:
200
+ coin_key = str(coin).upper()
201
+ existing = coin_sessions.get(coin_key)
202
+ if existing is not None:
203
+ return existing
204
+ created = CoinTradeSessionState(coin=coin_key)
205
+ coin_sessions[coin_key] = created
206
+ return created
207
+
208
+
209
+ def _append_risk_event_log(path: str, payload: Dict[str, Any]) -> None:
210
+ if not path:
211
+ return
212
+ resolved_path = os.path.abspath(path)
213
+ os.makedirs(os.path.dirname(resolved_path) or ".", exist_ok=True)
214
+ with open(resolved_path, "a", encoding="utf-8") as handle:
215
+ handle.write(json.dumps(payload, sort_keys=True) + "\n")
216
+
217
+
218
+ def reset_coin_session_after_cooldown_if_needed(
219
+ coin_state: CoinTradeSessionState,
220
+ current_time_ms: int,
221
+ risk_session_log: str = "",
222
+ session_pnl: Optional[float] = None,
223
+ ) -> bool:
224
+ if coin_state.cooldown_until_ms <= 0 or current_time_ms < coin_state.cooldown_until_ms:
225
+ return False
226
+
227
+ print(
228
+ f"[AUTO-RISK] {coin_state.coin} cooldown expired; resetting coin session state "
229
+ f'(previous_reason="{coin_state.cooldown_reason}")'
230
+ )
231
+ _append_risk_event_log(
232
+ risk_session_log,
233
+ {
234
+ "ts_ms": current_time_ms,
235
+ "event": "coin_cooldown_expired",
236
+ "coin": coin_state.coin,
237
+ "reason": coin_state.cooldown_reason,
238
+ "coin_pnl": coin_state.realized_pnl,
239
+ "session_pnl": session_pnl,
240
+ },
241
+ )
242
+ coin_state.realized_pnl = 0.0
243
+ coin_state.peak_pnl = 0.0
244
+ coin_state.completed_cycles = 0
245
+ coin_state.winning_cycles = 0
246
+ coin_state.losing_cycles = 0
247
+ coin_state.consecutive_wins = 0
248
+ coin_state.consecutive_losses = 0
249
+ coin_state.cooldown_until_ms = 0
250
+ coin_state.cooldown_reason = ""
251
+ coin_state.last_cycle_pnl = 0.0
252
+ coin_state.last_cycle_started_ms = 0
253
+ coin_state.last_cycle_closed_ms = 0
254
+ return True
255
+
256
+
257
+ def coin_is_blocked_by_risk(
258
+ coin_state: CoinTradeSessionState,
259
+ current_time_ms: int,
260
+ ) -> Tuple[bool, str]:
261
+ if coin_state.cooldown_until_ms > current_time_ms:
262
+ remaining_seconds = (coin_state.cooldown_until_ms - current_time_ms) / 1000.0
263
+ return (
264
+ True,
265
+ f'cooldown active {remaining_seconds:.1f}s remaining reason="{coin_state.cooldown_reason}"',
266
+ )
267
+ return False, ""
268
+
269
+
270
+ def set_coin_cooldown(
271
+ coin_state: CoinTradeSessionState,
272
+ cooldown_seconds: float,
273
+ reason: str,
274
+ ) -> None:
275
+ coin_state.cooldown_reason = reason
276
+ if cooldown_seconds > 0.0:
277
+ coin_state.cooldown_until_ms = now_ms() + int(cooldown_seconds * 1000)
278
+ else:
279
+ coin_state.cooldown_until_ms = now_ms()
280
+
281
+
282
+ async def fetch_trade_cycle_net_pnl(
283
+ info: Info,
284
+ account_address: str,
285
+ coin: str,
286
+ start_time_ms: int,
287
+ end_time_ms: int,
288
+ ) -> Tuple[float, List[Dict[str, Any]], str]:
289
+ request_start_ms = start_time_ms - 5000
290
+ request_end_ms = end_time_ms + 5000
291
+ fills: Any
292
+
293
+ try:
294
+ try:
295
+ fills = await info.user_fills_by_time(
296
+ account_address,
297
+ request_start_ms,
298
+ request_end_ms,
299
+ aggregate_by_time=False,
300
+ )
301
+ except TypeError:
302
+ fills = await info.post(
303
+ "/info",
304
+ {
305
+ "type": "userFillsByTime",
306
+ "user": account_address,
307
+ "startTime": request_start_ms,
308
+ "endTime": request_end_ms,
309
+ "aggregateByTime": False,
310
+ },
311
+ )
312
+ except Exception as exc:
313
+ return 0.0, [], f"pnl_unavailable: {exc}"
314
+
315
+ if not isinstance(fills, list):
316
+ return 0.0, [], f"pnl_unavailable: unexpected fills response type {type(fills).__name__}"
317
+
318
+ coin_upper = str(coin).upper()
319
+ filtered_fills: List[Dict[str, Any]] = []
320
+ net_pnl = 0.0
321
+ for fill in fills:
322
+ if not isinstance(fill, dict):
323
+ continue
324
+ if str(fill.get("coin", "")).upper() != coin_upper:
325
+ continue
326
+ fill_time_ms = int(_try_float(fill.get("time")) or _try_float(fill.get("timestamp")) or 0.0)
327
+ if fill_time_ms < request_start_ms or fill_time_ms > request_end_ms:
328
+ continue
329
+ closed_pnl = _try_float(fill.get("closedPnl"))
330
+ if closed_pnl is None:
331
+ closed_pnl = _try_float(fill.get("closedPnL"))
332
+ if closed_pnl is None:
333
+ closed_pnl = _try_float(fill.get("realizedPnl"))
334
+ if closed_pnl is None:
335
+ closed_pnl = _try_float(fill.get("realizedPnL"))
336
+ if closed_pnl is None:
337
+ closed_pnl = _try_float(fill.get("realized_pnl"))
338
+ fee = _try_float(fill.get("fee")) or 0.0
339
+ net_pnl += (closed_pnl or 0.0) - fee
340
+ filtered_fills.append(fill)
341
+
342
+ if not filtered_fills:
343
+ return 0.0, [], "pnl_unavailable: no matching fills in trade cycle window"
344
+
345
+ return net_pnl, filtered_fills, ""
346
+
347
+
348
+ def update_risk_after_trade_cycle(
349
+ coin_state: CoinTradeSessionState,
350
+ session_state: AutoRiskSessionState,
351
+ cycle_pnl: float,
352
+ cycle_started_ms: int,
353
+ cycle_closed_ms: int,
354
+ max_coin_trades_per_session: int,
355
+ coin_session_cooldown_seconds: float,
356
+ coin_session_profit_target: float,
357
+ coin_session_min_profit_to_lock: float,
358
+ coin_session_giveback_pct: float,
359
+ cooldown_after_loss_following_wins: int,
360
+ session_profit_target: float,
361
+ session_max_loss: float,
362
+ session_giveback_pct: float,
363
+ ) -> Tuple[Optional[str], Optional[str]]:
364
+ previous_consecutive_wins = coin_state.consecutive_wins
365
+
366
+ coin_state.realized_pnl += cycle_pnl
367
+ coin_state.peak_pnl = max(coin_state.peak_pnl, coin_state.realized_pnl)
368
+ coin_state.completed_cycles += 1
369
+ coin_state.last_cycle_pnl = cycle_pnl
370
+ coin_state.last_cycle_started_ms = cycle_started_ms
371
+ coin_state.last_cycle_closed_ms = cycle_closed_ms
372
+
373
+ if cycle_pnl > 0.0:
374
+ coin_state.winning_cycles += 1
375
+ coin_state.consecutive_wins += 1
376
+ coin_state.consecutive_losses = 0
377
+ elif cycle_pnl < 0.0:
378
+ coin_state.losing_cycles += 1
379
+ coin_state.consecutive_losses += 1
380
+ coin_state.consecutive_wins = 0
381
+ else:
382
+ coin_state.consecutive_wins = 0
383
+ coin_state.consecutive_losses = 0
384
+
385
+ session_state.realized_pnl += cycle_pnl
386
+ session_state.peak_pnl = max(session_state.peak_pnl, session_state.realized_pnl)
387
+ session_state.completed_cycles += 1
388
+
389
+ coin_reason: Optional[str] = None
390
+ if 0 < max_coin_trades_per_session <= coin_state.completed_cycles:
391
+ coin_reason = (
392
+ "max_coin_trades_per_session reached: "
393
+ f"completed_cycles={coin_state.completed_cycles} limit={max_coin_trades_per_session}"
394
+ )
395
+ elif 0.0 < coin_session_profit_target <= coin_state.realized_pnl:
396
+ coin_reason = (
397
+ "coin profit target reached: "
398
+ f"pnl={coin_state.realized_pnl:.6f} target={coin_session_profit_target:.6f}"
399
+ )
400
+ elif (
401
+ 0 < cooldown_after_loss_following_wins <= previous_consecutive_wins
402
+ and cycle_pnl < 0.0
403
+ ):
404
+ coin_reason = (
405
+ "loss after win streak: "
406
+ f"previous_wins={previous_consecutive_wins} loss={cycle_pnl:.6f}"
407
+ )
408
+ elif (
409
+ coin_session_giveback_pct > 0.0
410
+ and coin_state.peak_pnl > 0.0
411
+ and coin_state.peak_pnl >= coin_session_min_profit_to_lock
412
+ ):
413
+ threshold = coin_state.peak_pnl * (1.0 - coin_session_giveback_pct)
414
+ if coin_state.realized_pnl <= threshold:
415
+ coin_reason = (
416
+ "coin giveback stop: "
417
+ f"pnl={coin_state.realized_pnl:.6f} peak={coin_state.peak_pnl:.6f} "
418
+ f"giveback_pct={coin_session_giveback_pct:.6f} threshold={threshold:.6f}"
419
+ )
420
+
421
+ if coin_reason is not None:
422
+ set_coin_cooldown(
423
+ coin_state=coin_state,
424
+ cooldown_seconds=coin_session_cooldown_seconds,
425
+ reason=coin_reason,
426
+ )
427
+
428
+ session_reason: Optional[str] = None
429
+ if session_max_loss > 0.0 and session_state.realized_pnl <= -abs(session_max_loss):
430
+ session_reason = (
431
+ "session max loss reached "
432
+ f"pnl={session_state.realized_pnl:.6f} limit=-{abs(session_max_loss):.6f}"
433
+ )
434
+ elif 0.0 < session_profit_target <= session_state.realized_pnl:
435
+ session_reason = (
436
+ "session profit target reached "
437
+ f"pnl={session_state.realized_pnl:.6f} target={session_profit_target:.6f}"
438
+ )
439
+ elif (
440
+ session_giveback_pct > 0.0
441
+ and session_state.peak_pnl > 0.0
442
+ ):
443
+ threshold = session_state.peak_pnl * (1.0 - session_giveback_pct)
444
+ if session_state.realized_pnl <= threshold:
445
+ session_reason = (
446
+ "session giveback stop "
447
+ f"pnl={session_state.realized_pnl:.6f} peak={session_state.peak_pnl:.6f} "
448
+ f"giveback_pct={session_giveback_pct:.6f} threshold={threshold:.6f}"
449
+ )
450
+
451
+ if session_reason is not None:
452
+ session_state.stopped = True
453
+ session_state.stop_reason = session_reason
454
+
455
+ return coin_reason, session_reason
456
+
457
+ def _normalize_state_key(key: Any) -> str:
458
+ """Normalize account-state keys so minor API casing/name shifts still match."""
459
+ return "".join(ch for ch in str(key).lower() if ch.isalnum())
460
+
461
+ def _decimal_from_margin_value(value: Any, *, field_name: str) -> decimal.Decimal:
462
+ """Convert Hyperliquid numeric payload fields into Decimal safely."""
463
+ if value is None:
464
+ raise RuntimeError(f"{field_name} is missing.")
465
+
466
+ if isinstance(value, decimal.Decimal):
467
+ return value
468
+
469
+ raw = str(value).strip()
470
+ if raw.endswith("%"):
471
+ raw = raw[:-1].strip()
472
+ if not raw or raw.lower() in {"nan", "none", "null"}:
473
+ raise RuntimeError(f"{field_name} is not a usable number: {value!r}")
474
+
475
+ try:
476
+ return decimal.Decimal(raw)
477
+ except decimal.InvalidOperation as exc:
478
+ raise RuntimeError(f"{field_name} is not a valid decimal: {value!r}") from exc
479
+
480
+ def _find_first_numeric_field(
481
+ payload: Any,
482
+ candidate_keys: Tuple[str, ...],
483
+ path: Tuple[str, ...] = (),
484
+ ) -> Tuple[Optional[float], Optional[str]]:
485
+ """Depth-first search for the first matching numeric field in nested account state."""
486
+ if isinstance(payload, dict):
487
+ normalized_map = {_normalize_state_key(key): key for key in payload.keys()}
488
+ for candidate_key in candidate_keys:
489
+ actual_key = normalized_map.get(_normalize_state_key(candidate_key))
490
+ if actual_key is None:
491
+ continue
492
+ value = _try_float(payload.get(actual_key))
493
+ if value is not None and value >= 0.0:
494
+ return value, ".".join(path + (str(actual_key),))
495
+
496
+ for key, value in payload.items():
497
+ nested_value, nested_path = _find_first_numeric_field(value, candidate_keys, path + (str(key),))
498
+ if nested_value is not None and nested_path is not None:
499
+ return nested_value, nested_path
500
+ return None, None
501
+
502
+ if isinstance(payload, list):
503
+ for idx, value in enumerate(payload):
504
+ nested_value, nested_path = _find_first_numeric_field(value, candidate_keys, path + (f"[{idx}]",))
505
+ if nested_value is not None and nested_path is not None:
506
+ return nested_value, nested_path
507
+ return None, None
508
+
509
+
510
+ def extract_available_collateral_from_user_state(user_state: Dict[str, Any]) -> Tuple[Optional[float], str]:
511
+ """Extract available collateral with the same priority order as testpct.py."""
512
+ with decimal.localcontext() as ctx:
513
+ ctx.prec = 28
514
+
515
+ withdrawable = user_state.get("withdrawable")
516
+ if withdrawable is not None:
517
+ try:
518
+ available = _decimal_from_margin_value(withdrawable, field_name="withdrawable")
519
+ except RuntimeError:
520
+ available = None
521
+ if available is not None and available >= 0:
522
+ return float(available), "withdrawable"
523
+
524
+ summary = user_state.get("crossMarginSummary")
525
+ summary_name = "crossMarginSummary"
526
+ if not isinstance(summary, dict):
527
+ summary = user_state.get("marginSummary")
528
+ summary_name = "marginSummary"
529
+ if isinstance(summary, dict):
530
+ account_value_raw = summary.get("accountValue")
531
+ total_margin_used_raw = summary.get("totalMarginUsed")
532
+ if account_value_raw is not None and total_margin_used_raw is not None:
533
+ try:
534
+ account_value = _decimal_from_margin_value(
535
+ account_value_raw,
536
+ field_name=f"{summary_name}.accountValue",
537
+ )
538
+ total_margin_used = _decimal_from_margin_value(
539
+ total_margin_used_raw,
540
+ field_name=f"{summary_name}.totalMarginUsed",
541
+ )
542
+ available = max(decimal.Decimal("0"), account_value - total_margin_used)
543
+ return float(available), f"{summary_name}.accountValue-totalMarginUsed"
544
+ except RuntimeError:
545
+ pass
546
+
547
+ candidate_paths = (
548
+ ("availableToWithdraw",),
549
+ ("availableBalance",),
550
+ ("availableCollateral",),
551
+ ("freeCollateral",),
552
+ ("usableBalance",),
553
+ ("totalWithdrawable",),
554
+ ("crossMarginSummary", "withdrawable"),
555
+ ("crossMarginSummary", "availableToWithdraw"),
556
+ ("crossMarginSummary", "availableBalance"),
557
+ ("crossMarginSummary", "availableCollateral"),
558
+ ("crossMarginSummary", "freeCollateral"),
559
+ ("marginSummary", "withdrawable"),
560
+ ("marginSummary", "availableToWithdraw"),
561
+ ("marginSummary", "availableBalance"),
562
+ ("marginSummary", "availableCollateral"),
563
+ ("marginSummary", "freeCollateral"),
564
+ ("marginSummary", "accountValue"),
565
+ ("crossMarginSummary", "accountValue"),
566
+ ("portfolio", "accountValue"),
567
+ ("accountValue",),
568
+ ("totalAccountValue",),
569
+ ("balance",),
570
+ )
571
+ for path in candidate_paths:
572
+ current: Any = user_state
573
+ valid = True
574
+ for key in path:
575
+ if not isinstance(current, dict):
576
+ valid = False
577
+ break
578
+ current = current.get(key)
579
+ if not valid:
580
+ continue
581
+ value = _try_float(current)
582
+ if value is not None and value >= 0.0:
583
+ return value, ".".join(path)
584
+
585
+ recursive_available, recursive_available_path = _find_first_numeric_field(
586
+ user_state,
587
+ (
588
+ "withdrawable",
589
+ "availableToWithdraw",
590
+ "availableBalance",
591
+ "availableCollateral",
592
+ "freeCollateral",
593
+ "usableBalance",
594
+ "totalWithdrawable",
595
+ ),
596
+ )
597
+ if recursive_available is not None and recursive_available_path is not None:
598
+ return recursive_available, recursive_available_path
599
+
600
+ recursive_balance, recursive_balance_path = _find_first_numeric_field(
601
+ user_state,
602
+ ("accountValue", "totalAccountValue", "balance"),
603
+ )
604
+ if recursive_balance is not None and recursive_balance_path is not None:
605
+ return recursive_balance, recursive_balance_path
606
+
607
+ fallback_balance = extract_account_balance_from_user_state(user_state)
608
+ if fallback_balance is not None and fallback_balance >= 0.0:
609
+ return fallback_balance, "account_balance_fallback"
610
+ return None, "unknown"
611
+
612
+
613
+ def extract_open_positions_by_coin(user_state: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
614
+ """Build a coin->position map from one user_state snapshot."""
615
+ positions_by_coin: Dict[str, Dict[str, Any]] = {}
616
+ asset_positions = user_state.get("assetPositions", [])
617
+ if not isinstance(asset_positions, list):
618
+ return positions_by_coin
619
+
620
+ for asset_pos in asset_positions:
621
+ if not isinstance(asset_pos, dict):
622
+ continue
623
+ position = asset_pos.get("position", {})
624
+ if not isinstance(position, dict):
625
+ continue
626
+ coin = str(position.get("coin", "")).upper()
627
+ if not coin:
628
+ continue
629
+ try:
630
+ if float(position.get("szi", "0")) == 0.0:
631
+ continue
632
+ except (TypeError, ValueError):
633
+ continue
634
+ positions_by_coin[coin] = position
635
+ return positions_by_coin
636
+
637
+
638
+ def extract_realized_pnl_by_coin_since(fills: Any) -> Dict[str, float]:
639
+ """Aggregate closed PnL by coin from one fills response."""
640
+ realized_by_coin: Dict[str, float] = {}
641
+ if not isinstance(fills, list):
642
+ return realized_by_coin
643
+
644
+ for fill in fills:
645
+ if not isinstance(fill, dict):
646
+ continue
647
+ coin = str(fill.get("coin", "")).upper()
648
+ if not coin:
649
+ continue
650
+ realized_by_coin[coin] = realized_by_coin.get(coin, 0.0) + extract_closed_pnl_from_fill(fill)
651
+ return realized_by_coin
652
+
653
+
654
+ async def build_auto_scan_loop_snapshot(
655
+ info: Info,
656
+ account_address: str,
657
+ metrics_start_time_ms: int,
658
+ ) -> AutoScanLoopSnapshot:
659
+ """Fetch shared scan-loop state once to avoid per-market REST duplication."""
660
+ user_state = await get_user_state_with_retry(
661
+ info,
662
+ account_address,
663
+ context_label="auto_scan_loop_snapshot",
664
+ )
665
+ positions_by_coin = extract_open_positions_by_coin(user_state)
666
+ account_balance = extract_account_balance_from_user_state(user_state)
667
+
668
+ mids_task = asyncio.create_task(get_all_mids(info))
669
+ fills_task = asyncio.create_task(info.user_fills_by_time(account_address, metrics_start_time_ms))
670
+
671
+ mids = await mids_task
672
+
673
+ try:
674
+ fills = await fills_task
675
+ except Exception as exc:
676
+ cp.warning(f"[AUTO-WARN] Failed to fetch user fills for session metrics: {exc}")
677
+ fills = []
678
+ realized_by_coin = extract_realized_pnl_by_coin_since(fills)
679
+
680
+ return AutoScanLoopSnapshot(
681
+ mids=mids,
682
+ positions_by_coin=positions_by_coin,
683
+ account_balance=account_balance,
684
+ realized_pnl=sum(realized_by_coin.values()) if realized_by_coin or isinstance(fills, list) else None,
685
+ )
686
+
687
+
688
+ def format_auto_scan_metrics(snapshot: AutoScanLoopSnapshot) -> str:
689
+ """Format the shared auto-scan account metrics display."""
690
+ realized = f"{snapshot.realized_pnl:.8f}" if snapshot.realized_pnl is not None else "N/A"
691
+ balance = f"{snapshot.account_balance:.8f}" if snapshot.account_balance is not None else "N/A"
692
+ return f"rpnl={realized} balance={balance}"
693
+
694
+
695
+ def _active_asset_side_index(side: str) -> int:
696
+ normalized = str(side).strip().lower()
697
+ if normalized in {"long", "buy", "b", "bid"}:
698
+ return 0
699
+ if normalized in {"short", "sell", "s", "ask"}:
700
+ return 1
701
+ raise RuntimeError(f"Unsupported side for activeAssetData sizing: {side!r}")
702
+
703
+
704
+ def normalize_bollinger_side(side: str) -> str:
705
+ """Map project side aliases onto long/short confirmation rules."""
706
+ normalized = str(side).strip().lower()
707
+ if normalized in {"long", "buy", "b", "bid"}:
708
+ return "long"
709
+ if normalized in {"short", "sell", "s", "a", "ask"}:
710
+ return "short"
711
+ raise RuntimeError(f"Unsupported side for Bollinger confirmation: {side!r}")
712
+
713
+
714
+ def interval_to_seconds(interval: str) -> int:
715
+ """Convert supported interval strings to seconds for dynamic ordering."""
716
+ normalized = str(interval).strip()
717
+ if normalized in INTERVAL_TO_MS:
718
+ return int(INTERVAL_TO_MS[normalized] / 1000)
719
+
720
+ lower = normalized.lower()
721
+ if lower in INTERVAL_TO_MS:
722
+ return int(INTERVAL_TO_MS[lower] / 1000)
723
+ if lower.endswith("m"):
724
+ return int(lower[:-1]) * 60
725
+ if lower.endswith("h"):
726
+ return int(lower[:-1]) * 60 * 60
727
+ if lower.endswith("d"):
728
+ return int(lower[:-1]) * 24 * 60 * 60
729
+ if lower.endswith("w"):
730
+ return int(lower[:-1]) * 7 * 24 * 60 * 60
731
+ raise ValueError(f"Unsupported interval: {interval}")
732
+
733
+
734
+ def select_bollinger_intervals(active_intervals: List[str], scalp: bool) -> Dict[str, str]:
735
+ """Select dynamic Bollinger confirmation intervals from the current auto run."""
736
+ if not active_intervals:
737
+ raise RuntimeError("No active intervals were configured for Bollinger confirmation.")
738
+
739
+ ordered = sorted(active_intervals, key=interval_to_seconds)
740
+ if scalp:
741
+ return {"entry": ordered[0]}
742
+
743
+ if len(ordered) == 1:
744
+ return {"entry": ordered[0], "regime": ordered[0]}
745
+ if len(ordered) == 2:
746
+ return {"entry": ordered[0], "regime": ordered[1]}
747
+ if len(ordered) == 3:
748
+ return {"entry": ordered[0], "setup": ordered[1], "regime": ordered[2]}
749
+
750
+ entry_index = 1 if ordered[0] == "1m" else 0
751
+ return {
752
+ "entry": ordered[entry_index],
753
+ "setup": ordered[len(ordered) // 2],
754
+ "regime": ordered[-1],
755
+ }
756
+
757
+
758
+ def compute_bollinger_state(
759
+ interval: str,
760
+ closes: List[float],
761
+ period: int = 20,
762
+ stddev_multiplier: float = 2.0,
763
+ ) -> BollingerState:
764
+ """Compute current and previous Bollinger state for one interval."""
765
+ require_talib_available()
766
+ if period <= 1:
767
+ raise RuntimeError(f"Bollinger period must be > 1, got {period}.")
768
+ if len(closes) < period + 1:
769
+ raise RuntimeError(
770
+ f"{interval} has insufficient close data for Bollinger confirmation: "
771
+ f"got {len(closes)}, need at least {period + 1}."
772
+ )
773
+
774
+ close_arr = np.asarray(closes, dtype=float) # type: ignore[union-attr]
775
+ upper_arr, basis_arr, lower_arr = talib.BBANDS( # type: ignore[union-attr]
776
+ close_arr,
777
+ timeperiod=period,
778
+ nbdevup=stddev_multiplier,
779
+ nbdevdn=stddev_multiplier,
780
+ matype=0,
781
+ )
782
+
783
+ current_idx = last_finite_index(close_arr, upper_arr, basis_arr, lower_arr)
784
+ previous_idx: Optional[int] = None
785
+ for idx in range(current_idx - 1, -1, -1):
786
+ values = (close_arr[idx], upper_arr[idx], basis_arr[idx], lower_arr[idx])
787
+ if all(math.isfinite(float(value)) for value in values):
788
+ previous_idx = idx
789
+ break
790
+ if previous_idx is None:
791
+ raise RuntimeError(f"{interval} does not have enough completed Bollinger windows for slope confirmation.")
792
+
793
+ latest_close = float(close_arr[current_idx])
794
+ upper = float(upper_arr[current_idx])
795
+ basis = float(basis_arr[current_idx])
796
+ lower = float(lower_arr[current_idx])
797
+ previous_upper = float(upper_arr[previous_idx])
798
+ previous_basis = float(basis_arr[previous_idx])
799
+ previous_lower = float(lower_arr[previous_idx])
800
+
801
+ band_span = upper - lower
802
+ if abs(band_span) <= 0.0:
803
+ raise RuntimeError(f"{interval} Bollinger band span is zero; skipping confirmation safely.")
804
+ if basis == 0.0:
805
+ raise RuntimeError(f"{interval} Bollinger basis is zero; skipping confirmation safely.")
806
+ previous_band_span = previous_upper - previous_lower
807
+ if previous_basis == 0.0:
808
+ raise RuntimeError(f"{interval} previous Bollinger basis is zero; skipping confirmation safely.")
809
+
810
+ percent_b = (latest_close - lower) / band_span
811
+ basis_slope = basis - previous_basis
812
+ bandwidth = band_span / basis
813
+ previous_bandwidth = previous_band_span / previous_basis
814
+
815
+ return BollingerState(
816
+ interval=interval,
817
+ basis=basis,
818
+ upper=upper,
819
+ lower=lower,
820
+ percent_b=percent_b,
821
+ basis_slope=basis_slope,
822
+ bandwidth=bandwidth,
823
+ previous_bandwidth=previous_bandwidth,
824
+ bandwidth_expanding=bandwidth > previous_bandwidth,
825
+ latest_close=latest_close,
826
+ )
827
+
828
+
829
+ def _scalp_quality_label(side: str, percent_b: float) -> str:
830
+ """Return scalp-mode quality label for logs."""
831
+ if side == "long":
832
+ if percent_b <= 0.25:
833
+ return "ideal"
834
+ if percent_b <= 0.35:
835
+ return "good"
836
+ if percent_b <= 0.50:
837
+ return "valid"
838
+ return "extended"
839
+ if percent_b >= 0.75:
840
+ return "ideal"
841
+ if percent_b >= 0.65:
842
+ return "good"
843
+ if percent_b >= 0.50:
844
+ return "valid"
845
+ return "extended"
846
+
847
+
848
+ def confirm_signal_with_bollinger(
849
+ side: str,
850
+ interval_to_closes: Dict[str, List[float]],
851
+ active_intervals: List[str],
852
+ scalp: bool,
853
+ period: int = 20,
854
+ stddev_multiplier: float = 2.0,
855
+ ) -> Tuple[bool, str, Dict[str, BollingerState]]:
856
+ """Confirm an already-generated directional auto signal with Bollinger context."""
857
+ try:
858
+ normalized_side = normalize_bollinger_side(side)
859
+ selected_intervals = select_bollinger_intervals(active_intervals, scalp)
860
+ except Exception as exc:
861
+ return False, str(exc), {}
862
+
863
+ states: Dict[str, BollingerState] = {}
864
+ for role, interval in selected_intervals.items():
865
+ closes = interval_to_closes.get(interval)
866
+ if not closes:
867
+ return False, f"{role} interval {interval} has no usable close data for Bollinger confirmation.", states
868
+ try:
869
+ states[role] = compute_bollinger_state(
870
+ interval=interval,
871
+ closes=closes,
872
+ period=period,
873
+ stddev_multiplier=stddev_multiplier,
874
+ )
875
+ except Exception as exc:
876
+ return False, f"{role} interval {interval} Bollinger confirmation failed: {exc}", states
877
+
878
+ if scalp:
879
+ entry_state = states["entry"]
880
+ quality = _scalp_quality_label(normalized_side, entry_state.percent_b)
881
+ if normalized_side == "long":
882
+ allowed = entry_state.percent_b <= 0.50
883
+ if not allowed:
884
+ return False, (
885
+ f"mode=scalp side=long entry_tf={entry_state.interval} %B={entry_state.percent_b:.3f} "
886
+ f"result=REJECT reason=\"long rejected: entry interval above middle band\" quality={quality}"
887
+ ), states
888
+ else:
889
+ allowed = entry_state.percent_b >= 0.50
890
+ if not allowed:
891
+ return False, (
892
+ f"mode=scalp side=short entry_tf={entry_state.interval} %B={entry_state.percent_b:.3f} "
893
+ f"result=REJECT reason=\"short rejected: entry interval below middle band\" quality={quality}"
894
+ ), states
895
+
896
+ return True, (
897
+ f"mode=scalp side={normalized_side} entry_tf={entry_state.interval} %B={entry_state.percent_b:.3f} "
898
+ f"basis={entry_state.basis:.8f} upper={entry_state.upper:.8f} lower={entry_state.lower:.8f} "
899
+ f"result=PASS quality={quality}"
900
+ ), states
901
+
902
+ regime_state = states.get("regime")
903
+ setup_state = states.get("setup")
904
+ entry_state = states["entry"]
905
+
906
+ if normalized_side == "long":
907
+ if regime_state is not None:
908
+ if regime_state.percent_b < 0.25 and regime_state.basis_slope < 0.0:
909
+ return False, (
910
+ f"mode=non_scalp side=long regime_tf={regime_state.interval} setup_tf="
911
+ f"{setup_state.interval if setup_state is not None else 'N/A'} entry_tf={entry_state.interval} "
912
+ f"result=REJECT reason=\"long rejected: regime interval is in bearish breakdown\""
913
+ ), states
914
+ if regime_state.percent_b < 0.0 and regime_state.bandwidth_expanding:
915
+ return False, (
916
+ f"mode=non_scalp side=long regime_tf={regime_state.interval} setup_tf="
917
+ f"{setup_state.interval if setup_state is not None else 'N/A'} entry_tf={entry_state.interval} "
918
+ f"result=REJECT reason=\"long rejected: regime interval is below lower band with expanding bandwidth\""
919
+ ), states
920
+ if setup_state is not None and setup_state.percent_b > 0.90:
921
+ return False, (
922
+ f"mode=non_scalp side=long regime_tf={regime_state.interval if regime_state is not None else 'N/A'} "
923
+ f"setup_tf={setup_state.interval} entry_tf={entry_state.interval} "
924
+ f"result=REJECT reason=\"long rejected: setup interval is already overextended high\""
925
+ ), states
926
+ if entry_state.percent_b > 0.50:
927
+ return False, (
928
+ f"mode=non_scalp side=long regime_tf={regime_state.interval if regime_state is not None else 'N/A'} "
929
+ f"setup_tf={setup_state.interval if setup_state is not None else 'N/A'} entry_tf={entry_state.interval} "
930
+ f"result=REJECT reason=\"long rejected: entry interval above middle band\""
931
+ ), states
932
+ else:
933
+ if regime_state is not None:
934
+ if regime_state.percent_b > 0.75 and regime_state.basis_slope > 0.0:
935
+ return False, (
936
+ f"mode=non_scalp side=short regime_tf={regime_state.interval} setup_tf="
937
+ f"{setup_state.interval if setup_state is not None else 'N/A'} entry_tf={entry_state.interval} "
938
+ f"result=REJECT reason=\"short rejected: regime interval is in bullish breakout\""
939
+ ), states
940
+ if regime_state.percent_b > 1.0 and regime_state.bandwidth_expanding:
941
+ return False, (
942
+ f"mode=non_scalp side=short regime_tf={regime_state.interval} setup_tf="
943
+ f"{setup_state.interval if setup_state is not None else 'N/A'} entry_tf={entry_state.interval} "
944
+ f"result=REJECT reason=\"short rejected: regime interval is above upper band with expanding bandwidth\""
945
+ ), states
946
+ if setup_state is not None and setup_state.percent_b < 0.10:
947
+ return False, (
948
+ f"mode=non_scalp side=short regime_tf={regime_state.interval if regime_state is not None else 'N/A'} "
949
+ f"setup_tf={setup_state.interval} entry_tf={entry_state.interval} "
950
+ f"result=REJECT reason=\"short rejected: setup interval is already overextended low\""
951
+ ), states
952
+ if entry_state.percent_b < 0.50:
953
+ return False, (
954
+ f"mode=non_scalp side=short regime_tf={regime_state.interval if regime_state is not None else 'N/A'} "
955
+ f"setup_tf={setup_state.interval if setup_state is not None else 'N/A'} entry_tf={entry_state.interval} "
956
+ f"result=REJECT reason=\"short rejected: entry interval below middle band\""
957
+ ), states
958
+
959
+ reason = (
960
+ f"mode=non_scalp side={normalized_side} "
961
+ f"regime_tf={regime_state.interval if regime_state is not None else 'N/A'} "
962
+ f"setup_tf={setup_state.interval if setup_state is not None else 'N/A'} "
963
+ f"entry_tf={entry_state.interval} result=PASS "
964
+ )
965
+ if regime_state is not None:
966
+ reason += f"regime_b={regime_state.percent_b:.3f} "
967
+ if setup_state is not None:
968
+ reason += f"setup_b={setup_state.percent_b:.3f} "
969
+ reason += f"entry_b={entry_state.percent_b:.3f}"
970
+ return True, reason, states
971
+
972
+
973
+ async def resolve_size_pct_from_active_asset_data(
974
+ info: Info,
975
+ account_address: str,
976
+ coin: str,
977
+ side: str,
978
+ size_pct_fraction: float,
979
+ ) -> Tuple[Optional[float], str]:
980
+ """Resolve size from Hyperliquid activeAssetData when available.
981
+
982
+ Hyperliquid's side-specific `maxTradeSzs` value already reflects the
983
+ leveraged trade-size ceiling for the asset. `availableToTrade` is useful
984
+ telemetry, but using `min(availableToTrade, maxTradeSzs)` here incorrectly
985
+ turns `--size-pct` into a percent of the unlevered side capacity.
986
+ """
987
+ try:
988
+ active_asset_data = await info.post("/info", {"type": "activeAssetData", "user": account_address, "coin": coin})
989
+ except Exception as exc:
990
+ return None, f"activeAssetData request failed: {exc}"
991
+
992
+ if not isinstance(active_asset_data, dict):
993
+ return None, f"activeAssetData returned {type(active_asset_data).__name__}, expected dict"
994
+
995
+ side_idx = _active_asset_side_index(side)
996
+ available_to_trade = active_asset_data.get("availableToTrade")
997
+ # available_to_trade = active_asset_data.get("max_trade_notional_usd")
998
+ max_trade_szs = active_asset_data.get("maxTradeSzs")
999
+ if not isinstance(available_to_trade, (list, tuple)) or len(available_to_trade) < 2:
1000
+ return None, "activeAssetData.availableToTrade missing long/short capacity"
1001
+ if not isinstance(max_trade_szs, (list, tuple)) or len(max_trade_szs) < 2:
1002
+ return None, "activeAssetData.maxTradeSzs missing long/short capacity"
1003
+
1004
+ try:
1005
+ available_size = _decimal_from_margin_value(
1006
+ available_to_trade[side_idx],
1007
+ field_name=f"activeAssetData.availableToTrade[{side_idx}]",
1008
+ )
1009
+ max_trade_size = _decimal_from_margin_value(
1010
+ max_trade_szs[side_idx],
1011
+ field_name=f"activeAssetData.maxTradeSzs[{side_idx}]",
1012
+ )
1013
+ mark_price = _decimal_from_margin_value(
1014
+ active_asset_data.get("markPx"),
1015
+ field_name="activeAssetData.markPx",
1016
+ )
1017
+ except RuntimeError as exc:
1018
+ return None, f"activeAssetData numeric parse failed: {exc}"
1019
+
1020
+ if mark_price <= 0:
1021
+ return None, f"activeAssetData.markPx must be > 0, got {mark_price}"
1022
+
1023
+ size_pct_dec = decimal.Decimal(str(size_pct_fraction))
1024
+ capacity_size = max_trade_size
1025
+ derived_size = capacity_size * size_pct_dec
1026
+ rounded_size = await round_size_for_hyperliquid(info, coin, float(derived_size))
1027
+ if rounded_size <= 0.0:
1028
+ raise RuntimeError(
1029
+ f"--size-pct resolved to {float(derived_size):.12f} {coin} from activeAssetData, "
1030
+ "which rounds to zero for Hyperliquid precision."
1031
+ )
1032
+ return (
1033
+ rounded_size,
1034
+ (
1035
+ f"size_pct={size_pct_fraction:.4f} source=activeAssetData side={side} "
1036
+ f"available_size={available_size:.8f} max_trade_size={max_trade_size:.8f} "
1037
+ f"capacity_size={capacity_size:.8f} derived_size={derived_size:.8f} mark={mark_price:.8f}"
1038
+ ),
1039
+ )
1040
+
1041
+
1042
+ def _extract_active_asset_data_leverage(active_asset_data: Dict[str, Any]) -> Tuple[Optional[decimal.Decimal], str]:
1043
+ """Extract the currently configured leverage from activeAssetData when present."""
1044
+ leverage = active_asset_data.get("leverage")
1045
+ if not isinstance(leverage, dict):
1046
+ return None, "activeAssetData.leverage missing"
1047
+ try:
1048
+ leverage_value = _decimal_from_margin_value(
1049
+ leverage.get("value"),
1050
+ field_name="activeAssetData.leverage.value",
1051
+ )
1052
+ except RuntimeError as exc:
1053
+ return None, f"activeAssetData leverage parse failed: {exc}"
1054
+ if leverage_value <= 0:
1055
+ return None, f"activeAssetData leverage must be > 0, got {leverage_value}"
1056
+ return leverage_value, "activeAssetData.leverage.value"
1057
+
1058
+
1059
+ async def get_instrument_leverage_for_size_pct(
1060
+ info: Info,
1061
+ account_address: str,
1062
+ coin: str,
1063
+ ) -> Tuple[Optional[decimal.Decimal], str]:
1064
+ """Return leverage for manual --size-pct fallback sizing."""
1065
+ try:
1066
+ active_asset_data = await info.post("/info", {"type": "activeAssetData", "user": account_address, "coin": coin})
1067
+ except Exception as exc:
1068
+ active_asset_data = None
1069
+ active_asset_reason = f"activeAssetData request failed: {exc}"
1070
+ else:
1071
+ if isinstance(active_asset_data, dict):
1072
+ leverage_value, leverage_source = _extract_active_asset_data_leverage(active_asset_data)
1073
+ if leverage_value is not None:
1074
+ return leverage_value, leverage_source
1075
+ active_asset_reason = leverage_source
1076
+ else:
1077
+ active_asset_reason = f"activeAssetData returned {type(active_asset_data).__name__}, expected dict"
1078
+
1079
+ try:
1080
+ meta = await info.meta()
1081
+ except Exception as exc:
1082
+ return None, f"{active_asset_reason}; meta request failed: {exc}"
1083
+
1084
+ universe = meta.get("universe", []) if isinstance(meta, dict) else []
1085
+ if not isinstance(universe, list):
1086
+ return None, f"{active_asset_reason}; meta.universe missing"
1087
+
1088
+ normalized_coin = str(coin).upper()
1089
+ for asset_info in universe:
1090
+ if not isinstance(asset_info, dict):
1091
+ continue
1092
+ asset_name = str(asset_info.get("name") or "").upper()
1093
+ if asset_name != normalized_coin:
1094
+ continue
1095
+ for key in ("maxLeverage", "maxLev", "max_leverage", "leverage"):
1096
+ raw_value = asset_info.get(key)
1097
+ if raw_value is None:
1098
+ continue
1099
+ try:
1100
+ leverage_value = _decimal_from_margin_value(raw_value, field_name=f"meta.universe[{normalized_coin}].{key}")
1101
+ except RuntimeError:
1102
+ continue
1103
+ if leverage_value > 0:
1104
+ return leverage_value, f"meta.universe.{key}"
1105
+ return None, f"{active_asset_reason}; no leverage field found in meta for {normalized_coin}"
1106
+
1107
+ return None, f"{active_asset_reason}; {normalized_coin} missing from meta universe"
1108
+
1109
+
1110
+
1111
+
1112
+
1113
+ async def resolve_auto_trade_size(
1114
+ info: Info,
1115
+ account_address: str,
1116
+ coin: str,
1117
+ side: str,
1118
+ size: Optional[float],
1119
+ size_pct: Optional[Any],
1120
+ ) -> Tuple[float, str]:
1121
+ """Resolve auto-trade size from explicit size or available-collateral percentage."""
1122
+ if size is not None:
1123
+ if size <= 0.0:
1124
+ raise RuntimeError("--size must be > 0.")
1125
+ return size, f"fixed size={size:.8f}"
1126
+
1127
+ if size_pct is None:
1128
+ raise RuntimeError("Specify either --size or --size-pct.")
1129
+ size_pct_fraction = parse_fractional_pct(size_pct, field_name="--size-pct")
1130
+
1131
+ active_asset_size, active_asset_reason = await resolve_size_pct_from_active_asset_data(
1132
+ info=info,
1133
+ account_address=account_address,
1134
+ coin=coin,
1135
+ side=side,
1136
+ size_pct_fraction=size_pct_fraction,
1137
+ )
1138
+ if active_asset_size is not None:
1139
+ return active_asset_size, active_asset_reason
1140
+
1141
+ user_state = await get_user_state_with_retry(
1142
+ info,
1143
+ account_address,
1144
+ context_label="resolve_auto_trade_size",
1145
+ coin=coin,
1146
+ )
1147
+ available_collateral, collateral_source = extract_available_collateral_from_user_state(user_state)
1148
+ if available_collateral is None or available_collateral <= 0.0:
1149
+ raise RuntimeError("Could not determine available collateral for --size-pct sizing.")
1150
+
1151
+ mids = await get_all_mids(info)
1152
+ current_px = _try_float(mids.get(coin))
1153
+ if current_px is None or current_px <= 0.0:
1154
+ raise RuntimeError(f"No mid price available for {coin}; cannot derive size from --size-pct.")
1155
+
1156
+ available_collateral_dec = decimal.Decimal(str(available_collateral))
1157
+ size_pct_dec = decimal.Decimal(str(size_pct_fraction))
1158
+ current_px_dec = decimal.Decimal(str(current_px))
1159
+ leverage_dec, leverage_source = await get_instrument_leverage_for_size_pct(
1160
+ info=info,
1161
+ account_address=account_address,
1162
+ coin=coin,
1163
+ )
1164
+ if leverage_dec is None or leverage_dec <= 0:
1165
+ raise RuntimeError(
1166
+ f"Could not determine leverage for {coin} during --size-pct sizing. "
1167
+ f"fallback_reason={active_asset_reason}; leverage_reason={leverage_source}"
1168
+ )
1169
+
1170
+ usd_notional = available_collateral_dec * leverage_dec * size_pct_dec
1171
+ derived_size = usd_notional / current_px_dec
1172
+ rounded_size = await round_size_for_hyperliquid(info, coin, float(derived_size))
1173
+ if rounded_size <= 0.0:
1174
+ raise RuntimeError(
1175
+ f"--size-pct resolved to {float(derived_size):.12f} {coin}, which rounds to zero for Hyperliquid precision."
1176
+ )
1177
+ return (
1178
+ rounded_size,
1179
+ (
1180
+ f"size_pct={size_pct_fraction:.4f} collateral={available_collateral_dec:.8f} "
1181
+ f"source={collateral_source} leverage={leverage_dec:.8f} leverage_source={leverage_source} "
1182
+ f"notional={usd_notional:.8f} mid={current_px_dec:.8f} fallback_reason={active_asset_reason}"
1183
+ ),
1184
+ )
1185
+
1186
+
1187
+ def last_finite_index(*arrays: Any) -> int:
1188
+ """Return the latest index where every TA-Lib array has a finite value."""
1189
+ if not arrays:
1190
+ raise RuntimeError("No arrays supplied for finite-index scan.")
1191
+ length = min(len(array) for array in arrays)
1192
+ for idx in range(length - 1, -1, -1):
1193
+ ok = True
1194
+ for array in arrays:
1195
+ try:
1196
+ value = float(array[idx])
1197
+ except (TypeError, ValueError):
1198
+ ok = False
1199
+ break
1200
+ if not math.isfinite(value):
1201
+ ok = False
1202
+ break
1203
+ if ok:
1204
+ return idx
1205
+ raise RuntimeError("No finite TA-Lib indicator row is available yet; fetch more candles.")
1206
+
1207
+
1208
+ async def compute_auto_interval_signal(
1209
+ info: Info,
1210
+ coin: str,
1211
+ interval: str,
1212
+ periods: int,
1213
+ adx_threshold: float,
1214
+ macd_fast: int,
1215
+ macd_slow: int,
1216
+ macd_signal: int,
1217
+ sar_acceleration: float,
1218
+ sar_maximum: float,
1219
+ adx_timeperiod: int,
1220
+ bb_timeperiod: int,
1221
+ bb_dev: float,
1222
+ use_last_closed_candle: bool,
1223
+ use_websocket_candles: bool = False,
1224
+ ) -> AutoIntervalSignal:
1225
+ """Fetch candles for one interval and compute MACD/SAR/ADX/Bollinger signal state."""
1226
+ require_talib_available()
1227
+ minimum_periods = max(macd_slow + macd_signal + 5, adx_timeperiod + 5, bb_timeperiod + 5, 40)
1228
+ if periods < minimum_periods:
1229
+ raise RuntimeError(f"--auto-periods must be at least {minimum_periods} for the chosen indicator settings.")
1230
+ # TODO: Determine - is it possible to retrieve candles for multiple coins with one API call? Or is this data \
1231
+ # TODO: that is available over the websocket?
1232
+ candles = await fetch_recent_candles(
1233
+ info,
1234
+ coin,
1235
+ interval,
1236
+ periods,
1237
+ use_websocket_candles=use_websocket_candles,
1238
+ )
1239
+ usable_candles = candles[:-1] if use_last_closed_candle and len(candles) > 1 else candles
1240
+
1241
+ highs: List[float] = []
1242
+ lows: List[float] = []
1243
+ closes: List[float] = []
1244
+ for candle in usable_candles:
1245
+ try:
1246
+ highs.append(float(candle["h"]))
1247
+ lows.append(float(candle["l"]))
1248
+ closes.append(float(candle["c"]))
1249
+ except (KeyError, TypeError, ValueError):
1250
+ continue
1251
+
1252
+ if len(closes) < minimum_periods:
1253
+ raise RuntimeError(
1254
+ f"Not enough valid candles for {coin} {interval}: got {len(closes)}, need at least {minimum_periods}."
1255
+ )
1256
+
1257
+ high_arr = np.asarray(highs, dtype=float) # type: ignore[union-attr]
1258
+ low_arr = np.asarray(lows, dtype=float) # type: ignore[union-attr]
1259
+ close_arr = np.asarray(closes, dtype=float) # type: ignore[union-attr]
1260
+
1261
+ macd_arr, macd_sig_arr, macd_hist_arr = talib.MACD( # type: ignore[union-attr]
1262
+ close_arr,
1263
+ fastperiod=macd_fast,
1264
+ slowperiod=macd_slow,
1265
+ signalperiod=macd_signal,
1266
+ )
1267
+ sar_arr = talib.SAR( # type: ignore[union-attr]
1268
+ high_arr,
1269
+ low_arr,
1270
+ acceleration=sar_acceleration,
1271
+ maximum=sar_maximum,
1272
+ )
1273
+ adx_arr = talib.ADX( # type: ignore[union-attr]
1274
+ high_arr,
1275
+ low_arr,
1276
+ close_arr,
1277
+ timeperiod=adx_timeperiod,
1278
+ )
1279
+ bb_upper_arr, bb_middle_arr, bb_lower_arr = talib.BBANDS( # type: ignore[union-attr]
1280
+ close_arr,
1281
+ timeperiod=bb_timeperiod,
1282
+ nbdevup=bb_dev,
1283
+ nbdevdn=bb_dev,
1284
+ matype=0,
1285
+ )
1286
+
1287
+ idx = last_finite_index(close_arr, macd_arr, macd_sig_arr, macd_hist_arr, sar_arr, adx_arr, bb_upper_arr, bb_middle_arr, bb_lower_arr)
1288
+ close_px = float(close_arr[idx])
1289
+ macd_value = float(macd_arr[idx])
1290
+ macd_signal_value = float(macd_sig_arr[idx])
1291
+ macd_hist_value = float(macd_hist_arr[idx])
1292
+ sar_value = float(sar_arr[idx])
1293
+ adx_value = float(adx_arr[idx])
1294
+ bb_upper = float(bb_upper_arr[idx])
1295
+ bb_middle = float(bb_middle_arr[idx])
1296
+ bb_lower = float(bb_lower_arr[idx])
1297
+
1298
+ macd_bullish = macd_value > macd_signal_value and macd_hist_value > 0.0
1299
+ macd_bearish = macd_value < macd_signal_value and macd_hist_value < 0.0
1300
+ sar_bullish = close_px > sar_value
1301
+ sar_bearish = close_px < sar_value
1302
+ trend_ok = adx_value >= adx_threshold
1303
+
1304
+ if trend_ok and macd_bullish and sar_bullish:
1305
+ direction = "long"
1306
+ elif trend_ok and macd_bearish and sar_bearish:
1307
+ direction = "short"
1308
+ else:
1309
+ direction = "neutral"
1310
+
1311
+ reason = (
1312
+ f"macd={'bull' if macd_bullish else 'bear' if macd_bearish else 'flat'} "
1313
+ f"sar={'bull' if sar_bullish else 'bear' if sar_bearish else 'flat'} "
1314
+ f"adx={adx_value:.2f}/{adx_threshold:.2f}"
1315
+ )
1316
+
1317
+ return AutoIntervalSignal(
1318
+ interval=interval,
1319
+ closes=list(closes),
1320
+ close=close_px,
1321
+ macd=macd_value,
1322
+ macd_signal=macd_signal_value,
1323
+ macd_hist=macd_hist_value,
1324
+ sar=sar_value,
1325
+ adx=adx_value,
1326
+ bb_upper=bb_upper,
1327
+ bb_middle=bb_middle,
1328
+ bb_lower=bb_lower,
1329
+ direction=direction,
1330
+ reason=reason,
1331
+ )
1332
+
1333
+
1334
+ def clamp_pct(value: float, minimum: float, maximum: float) -> float:
1335
+ """Clamp a percentage fraction to configured bounds."""
1336
+ return min(max(value, minimum), maximum)
1337
+
1338
+
1339
+ def get_shortest_interval_snapshot(snapshots: List[AutoIntervalSignal]) -> Optional[AutoIntervalSignal]:
1340
+ """Return the snapshot for the shortest configured interval."""
1341
+ if not snapshots:
1342
+ return None
1343
+ return min(snapshots, key=lambda snapshot: INTERVAL_TO_MS.get(snapshot.interval, math.inf))
1344
+
1345
+
1346
+
1347
+
1348
+ async def evaluate_auto_trade_decision(
1349
+ info: Info,
1350
+ coin: str,
1351
+ intervals: List[str],
1352
+ periods: int,
1353
+ min_agreement: int,
1354
+ adx_threshold: float,
1355
+ take_profit_pct_override: Optional[float],
1356
+ stop_loss_pct_override: Optional[float],
1357
+ min_take_profit_pct: float,
1358
+ max_take_profit_pct: float,
1359
+ macd_fast: int,
1360
+ macd_slow: int,
1361
+ macd_signal_period: int,
1362
+ sar_acceleration: float,
1363
+ sar_maximum: float,
1364
+ adx_timeperiod: int,
1365
+ bb_timeperiod: int,
1366
+ bb_dev: float,
1367
+ use_last_closed_candle: bool,
1368
+ use_sar_stop_on_shortest_interval: bool,
1369
+ use_websocket_candles: bool = False,
1370
+ current_px: Optional[float] = None,
1371
+ ) -> AutoTradeDecision:
1372
+ """Evaluate all configured intervals and return an aggregated trade decision."""
1373
+ snapshots: List[AutoIntervalSignal] = []
1374
+ errors: List[str] = []
1375
+ for interval in intervals:
1376
+ try:
1377
+ snapshots.append(
1378
+ await compute_auto_interval_signal(
1379
+ info=info,
1380
+ coin=coin,
1381
+ interval=interval,
1382
+ periods=periods,
1383
+ adx_threshold=adx_threshold,
1384
+ macd_fast=macd_fast,
1385
+ macd_slow=macd_slow,
1386
+ macd_signal=macd_signal_period,
1387
+ sar_acceleration=sar_acceleration,
1388
+ sar_maximum=sar_maximum,
1389
+ adx_timeperiod=adx_timeperiod,
1390
+ bb_timeperiod=bb_timeperiod,
1391
+ bb_dev=bb_dev,
1392
+ use_last_closed_candle=use_last_closed_candle,
1393
+ use_websocket_candles=use_websocket_candles,
1394
+ )
1395
+ )
1396
+ except Exception as exc:
1397
+ errors.append(f"{interval}: {exc}")
1398
+ cp.warning(f"[AUTO-WARN] Failed to compute signal for {coin} {interval}: {exc}")
1399
+
1400
+ if not snapshots:
1401
+ return AutoTradeDecision(
1402
+ direction=None,
1403
+ current_px=0.0,
1404
+ target_px=None,
1405
+ take_profit_pct=None,
1406
+ stop_loss_pct=None,
1407
+ stop_loss_trigger_px=None,
1408
+ long_votes=0,
1409
+ short_votes=0,
1410
+ required_votes=max(1, min_agreement),
1411
+ snapshots=[],
1412
+ reason="No usable interval snapshots. " + "; ".join(errors),
1413
+ )
1414
+
1415
+ required_votes = len(intervals) if min_agreement <= 0 else min_agreement
1416
+ long_votes = sum(1 for snapshot in snapshots if snapshot.direction == "long")
1417
+ short_votes = sum(1 for snapshot in snapshots if snapshot.direction == "short")
1418
+
1419
+ if current_px is None or current_px <= 0.0:
1420
+ current_px = float(snapshots[-1].close)
1421
+
1422
+ if len(snapshots) < required_votes:
1423
+ return AutoTradeDecision(
1424
+ direction=None,
1425
+ current_px=current_px,
1426
+ target_px=None,
1427
+ take_profit_pct=None,
1428
+ stop_loss_pct=None,
1429
+ stop_loss_trigger_px=None,
1430
+ long_votes=long_votes,
1431
+ short_votes=short_votes,
1432
+ required_votes=required_votes,
1433
+ snapshots=snapshots,
1434
+ reason=f"Only {len(snapshots)} usable snapshots; required {required_votes}.",
1435
+ )
1436
+
1437
+ direction: Optional[str]
1438
+ if long_votes >= required_votes and long_votes > short_votes:
1439
+ direction = "long"
1440
+ elif short_votes >= required_votes and short_votes > long_votes:
1441
+ direction = "short"
1442
+ else:
1443
+ direction = None
1444
+
1445
+ if direction is None:
1446
+ return AutoTradeDecision(
1447
+ direction=None,
1448
+ current_px=current_px,
1449
+ target_px=None,
1450
+ take_profit_pct=None,
1451
+ stop_loss_pct=None,
1452
+ stop_loss_trigger_px=None,
1453
+ long_votes=long_votes,
1454
+ short_votes=short_votes,
1455
+ required_votes=required_votes,
1456
+ snapshots=snapshots,
1457
+ reason=f"No trade: long_votes={long_votes}, short_votes={short_votes}, required={required_votes}.",
1458
+ )
1459
+
1460
+ if direction == "long":
1461
+ candidate_targets = sorted(snapshot.bb_upper for snapshot in snapshots if snapshot.bb_upper > current_px)
1462
+ raw_target_px = candidate_targets[0] if candidate_targets else current_px * (1.0 + min_take_profit_pct)
1463
+ raw_take_profit_pct = max(0.0, (raw_target_px - current_px) / current_px)
1464
+ else:
1465
+ candidate_targets = sorted(
1466
+ (snapshot.bb_lower for snapshot in snapshots if snapshot.bb_lower < current_px),
1467
+ reverse=True,
1468
+ )
1469
+ raw_target_px = candidate_targets[0] if candidate_targets else current_px * (1.0 - min_take_profit_pct)
1470
+ raw_take_profit_pct = max(0.0, (current_px - raw_target_px) / current_px)
1471
+
1472
+ if take_profit_pct_override is not None:
1473
+ take_profit_pct = take_profit_pct_override
1474
+ else:
1475
+ take_profit_pct = clamp_pct(raw_take_profit_pct, min_take_profit_pct, max_take_profit_pct)
1476
+
1477
+ if direction == "long":
1478
+ target_px = current_px * (1.0 + take_profit_pct)
1479
+ else:
1480
+ target_px = current_px * (1.0 - take_profit_pct)
1481
+
1482
+ stop_loss_pct = compute_default_stop_loss_pct(take_profit_pct, stop_loss_pct_override)
1483
+ stop_loss_trigger_px: Optional[float] = None
1484
+ shortest_snapshot = get_shortest_interval_snapshot(snapshots)
1485
+ if use_sar_stop_on_shortest_interval and shortest_snapshot is not None:
1486
+ candidate_stop = float(shortest_snapshot.sar)
1487
+ if direction == "long" and candidate_stop < current_px:
1488
+ stop_loss_trigger_px = candidate_stop
1489
+ elif direction == "short" and candidate_stop > current_px:
1490
+ stop_loss_trigger_px = candidate_stop
1491
+ return AutoTradeDecision(
1492
+ direction=direction,
1493
+ current_px=current_px,
1494
+ target_px=target_px,
1495
+ take_profit_pct=take_profit_pct,
1496
+ stop_loss_pct=stop_loss_pct,
1497
+ stop_loss_trigger_px=stop_loss_trigger_px,
1498
+ long_votes=long_votes,
1499
+ short_votes=short_votes,
1500
+ required_votes=required_votes,
1501
+ snapshots=snapshots,
1502
+ reason=(
1503
+ f"{direction.upper()} signal: long_votes={long_votes}, short_votes={short_votes}, "
1504
+ f"required={required_votes}, bb_target={raw_target_px:.8f}, tp_pct={take_profit_pct * 100:.4f}%"
1505
+ ),
1506
+ )
1507
+
1508
+
1509
+ def print_auto_decision(decision: AutoTradeDecision, instrument: str) -> None:
1510
+ """Print a compact multi-timeframe signal summary."""
1511
+ cp.normal(f"{instrument.upper()} Interval signals:", 'AUTO')
1512
+ if not decision.snapshots:
1513
+ print(" no usable snapshots")
1514
+ for snapshot in decision.snapshots:
1515
+ print(
1516
+ f" {snapshot.interval:>4} | {snapshot.direction:>7} | close={snapshot.close:.8f} "
1517
+ f"macd={snapshot.macd:.8f}/{snapshot.macd_signal:.8f} hist={snapshot.macd_hist:.8f} "
1518
+ f"sar={snapshot.sar:.8f} adx={snapshot.adx:.2f} "
1519
+ f"bb=({snapshot.bb_lower:.8f}, {snapshot.bb_middle:.8f}, {snapshot.bb_upper:.8f}) "
1520
+ f"{snapshot.reason}"
1521
+ )
1522
+ target = f"{decision.target_px:.8f}" if decision.target_px is not None else "N/A"
1523
+ tp_pct = f"{decision.take_profit_pct * 100:.4f}%" if decision.take_profit_pct is not None else "N/A"
1524
+ sl_pct = f"{decision.stop_loss_pct * 100:.4f}%" if decision.stop_loss_pct is not None else "N/A"
1525
+ sl_trigger = f"{decision.stop_loss_trigger_px:.8f}" if decision.stop_loss_trigger_px is not None else "N/A"
1526
+ data = f"decision={decision.direction or 'none'} current={decision.current_px:.8f} "
1527
+ f"target={target} tp={tp_pct} sl={sl_pct} sl_trigger={sl_trigger} reason={decision.reason}"
1528
+ if decision.direction is None:
1529
+ cp.warning(data, 'AUTO')
1530
+ else:
1531
+ if decision.direction.lower() == "long":
1532
+ cp.good(
1533
+ data, 'AUTO'
1534
+ )
1535
+ elif decision.direction.lower() == "short":
1536
+ cp.error(
1537
+ data, 'AUTO'
1538
+ )
1539
+
1540
+
1541
+ async def scan_auto_trade_candidate(
1542
+ info: Info,
1543
+ scan_coin: str,
1544
+ intervals: List[str],
1545
+ periods: int,
1546
+ min_agreement: int,
1547
+ adx_threshold: float,
1548
+ take_profit_pct: Optional[float],
1549
+ stop_loss_pct: Optional[float],
1550
+ min_take_profit_pct: float,
1551
+ max_take_profit_pct: float,
1552
+ macd_fast: int,
1553
+ macd_slow: int,
1554
+ macd_signal_period: int,
1555
+ sar_acceleration: float,
1556
+ sar_maximum: float,
1557
+ adx_timeperiod: int,
1558
+ bb_timeperiod: int,
1559
+ bb_dev: float,
1560
+ scalp: bool,
1561
+ use_last_closed_candle: bool,
1562
+ use_sar_stop_on_shortest_interval: bool,
1563
+ snapshot: AutoScanLoopSnapshot,
1564
+ use_websocket_candles: bool = False,
1565
+ ) -> AutoScanCandidate:
1566
+ """Scan one market using shared loop snapshots to avoid redundant REST calls."""
1567
+ existing_pos = snapshot.positions_by_coin.get(scan_coin.upper())
1568
+ if existing_pos is not None:
1569
+ return AutoScanCandidate(coin=scan_coin, decision=None, existing_position=existing_pos)
1570
+
1571
+ decision = await evaluate_auto_trade_decision(
1572
+ info=info,
1573
+ coin=scan_coin,
1574
+ intervals=intervals,
1575
+ periods=periods,
1576
+ min_agreement=min_agreement,
1577
+ adx_threshold=adx_threshold,
1578
+ take_profit_pct_override=take_profit_pct,
1579
+ stop_loss_pct_override=stop_loss_pct,
1580
+ min_take_profit_pct=min_take_profit_pct,
1581
+ max_take_profit_pct=max_take_profit_pct,
1582
+ macd_fast=macd_fast,
1583
+ macd_slow=macd_slow,
1584
+ macd_signal_period=macd_signal_period,
1585
+ sar_acceleration=sar_acceleration,
1586
+ sar_maximum=sar_maximum,
1587
+ adx_timeperiod=adx_timeperiod,
1588
+ bb_timeperiod=bb_timeperiod,
1589
+ bb_dev=bb_dev,
1590
+ use_last_closed_candle=use_last_closed_candle,
1591
+ use_sar_stop_on_shortest_interval=use_sar_stop_on_shortest_interval,
1592
+ use_websocket_candles=use_websocket_candles,
1593
+ current_px=snapshot.mids.get(scan_coin.upper()),
1594
+ )
1595
+ print_auto_decision(decision, scan_coin)
1596
+
1597
+ if decision.direction is None or decision.take_profit_pct is None:
1598
+ return AutoScanCandidate(coin=scan_coin, decision=decision, existing_position=None)
1599
+
1600
+ interval_to_closes = {interval_snapshot.interval: list(interval_snapshot.closes) for interval_snapshot in decision.snapshots}
1601
+ bb_allowed, bb_reason, _bb_states = confirm_signal_with_bollinger(
1602
+ side=decision.direction,
1603
+ interval_to_closes=interval_to_closes,
1604
+ active_intervals=intervals,
1605
+ scalp=scalp,
1606
+ period=bb_timeperiod,
1607
+ stddev_multiplier=bb_dev,
1608
+ )
1609
+ print(f"[BB] {scan_coin} {bb_reason}")
1610
+ if not bb_allowed:
1611
+ print(f"[AUTO] {scan_coin} Bollinger confirmation rejected; skipping entry this loop.")
1612
+ return AutoScanCandidate(
1613
+ coin=scan_coin,
1614
+ decision=decision,
1615
+ existing_position=None,
1616
+ rejection_reason="bollinger_confirmation_rejected",
1617
+ )
1618
+
1619
+ return AutoScanCandidate(coin=scan_coin, decision=decision, existing_position=None)
1620
+
1621
+
1622
+ async def run_auto_trader(
1623
+ coin: Optional[str],
1624
+ size: Optional[float],
1625
+ size_pct: Optional[Any],
1626
+ top_markets: int,
1627
+ intervals_value: str,
1628
+ periods: int,
1629
+ scan_interval: float,
1630
+ max_concurrent_scans: int,
1631
+ min_agreement: int,
1632
+ adx_threshold: float,
1633
+ take_profit_pct: Optional[float],
1634
+ stop_loss_pct: Optional[float],
1635
+ min_take_profit_pct: float,
1636
+ max_take_profit_pct: float,
1637
+ take_profit_levels: int,
1638
+ use_trailing_tp: bool,
1639
+ trailing_tp_trigger_level: int,
1640
+ trailing_tp_profit_pct: float,
1641
+ entry_retries: int,
1642
+ entry_repost_interval: float,
1643
+ poll_interval: float,
1644
+ tp_reversal_pct: Optional[float],
1645
+ entry_tif: str,
1646
+ tp_tif: str,
1647
+ market_fallback: bool,
1648
+ market_slippage: float,
1649
+ cancel_existing_tpsl: bool,
1650
+ tp_reversal_limit_exit: bool,
1651
+ tp_reversal_stop_buffer_pct: Optional[float],
1652
+ macd_fast: int,
1653
+ macd_slow: int,
1654
+ macd_signal_period: int,
1655
+ sar_acceleration: float,
1656
+ sar_maximum: float,
1657
+ adx_timeperiod: int,
1658
+ bb_timeperiod: int,
1659
+ bb_dev: float,
1660
+ scalp: bool,
1661
+ use_last_closed_candle: bool,
1662
+ use_sar_stop_on_shortest_interval: bool,
1663
+ dry_run: bool,
1664
+ max_trades: int,
1665
+ cooldown_after_trade: float,
1666
+ loop_after_trade: bool,
1667
+ max_coin_trades_per_session: int,
1668
+ coin_session_cooldown_seconds: float,
1669
+ coin_session_profit_target: float,
1670
+ coin_session_min_profit_to_lock: float,
1671
+ coin_session_giveback_pct: float,
1672
+ cooldown_after_loss_following_wins: int,
1673
+ session_profit_target: float,
1674
+ session_max_loss: float,
1675
+ session_giveback_pct: float,
1676
+ use_testnet: bool,
1677
+ use_websocket: bool = True,
1678
+ use_websocket_candles: bool = False,
1679
+ hide_orders: bool = False,
1680
+ risk_session_log: str = "",
1681
+ account_address: Optional[str] = None,
1682
+ info: Optional[Info] = None,
1683
+ exchange: Optional[Exchange] = None,
1684
+ ) -> None:
1685
+ """Automatically scan TA-Lib signals, enter positions, and hand off to bracket management."""
1686
+
1687
+ require_talib_available()
1688
+ coin = coin.upper() if coin is not None else None
1689
+ intervals = parse_interval_list(intervals_value)
1690
+ if (size is None) == (size_pct is None):
1691
+ raise RuntimeError("Specify exactly one of --size or --size-pct.")
1692
+ if size is not None and size <= 0.0:
1693
+ raise RuntimeError("--size must be > 0.")
1694
+ size_pct_fraction: Optional[float] = None
1695
+ if size_pct is not None:
1696
+ size_pct_fraction = parse_fractional_pct(size_pct, field_name="--size-pct")
1697
+ if top_markets <= 0:
1698
+ raise RuntimeError("--top-markets must be > 0.")
1699
+ if periods <= 0:
1700
+ raise RuntimeError("--auto-periods must be > 0.")
1701
+ if scan_interval <= 0.0:
1702
+ raise RuntimeError("--scan-interval must be > 0.")
1703
+ if max_concurrent_scans <= 0:
1704
+ raise RuntimeError("--max-concurrent-scans must be > 0.")
1705
+ if min_agreement < 0:
1706
+ raise RuntimeError("--min-agreement must be >= 0. Use 0 to require all configured intervals.")
1707
+ if adx_threshold < 0.0:
1708
+ raise RuntimeError("--adx-threshold must be >= 0.")
1709
+ if take_profit_pct is not None and not (0.0 < take_profit_pct < 1.0):
1710
+ raise RuntimeError("--take-profit-pct must be between 0 and 1.")
1711
+ if stop_loss_pct is not None and not (0.0 < stop_loss_pct < 1.0):
1712
+ raise RuntimeError("--stop-loss-pct must be between 0 and 1.")
1713
+ if not (0.0 < min_take_profit_pct < 1.0):
1714
+ raise RuntimeError("--min-take-profit-pct must be between 0 and 1.")
1715
+ if not (0.0 < max_take_profit_pct < 1.0):
1716
+ raise RuntimeError("--max-take-profit-pct must be between 0 and 1.")
1717
+ if min_take_profit_pct > max_take_profit_pct:
1718
+ raise RuntimeError("--min-take-profit-pct cannot be greater than --max-take-profit-pct.")
1719
+ if take_profit_levels <= 0:
1720
+ raise RuntimeError("--take-profit-levels must be > 0.")
1721
+ if trailing_tp_trigger_level <= 0:
1722
+ raise RuntimeError("--trailing-tp-trigger-level must be > 0.")
1723
+ if trailing_tp_trigger_level > take_profit_levels:
1724
+ raise RuntimeError("--trailing-tp-trigger-level cannot exceed --take-profit-levels.")
1725
+ if not (0.0 < trailing_tp_profit_pct < 1.0):
1726
+ raise RuntimeError("--trailing-tp-profit-pct must be between 0 and 1.")
1727
+ if entry_retries < 0:
1728
+ raise RuntimeError("--entry-retries must be >= 0.")
1729
+ if entry_repost_interval <= 0.0:
1730
+ raise RuntimeError("--entry-repost-interval must be > 0.")
1731
+ if poll_interval <= 0.0:
1732
+ raise RuntimeError("--poll-interval must be > 0.")
1733
+ if max_trades < 0:
1734
+ raise RuntimeError("--max-trades must be >= 0.")
1735
+ if cooldown_after_trade < 0.0:
1736
+ raise RuntimeError("--cooldown-after-trade must be >= 0.")
1737
+ if max_coin_trades_per_session < 0:
1738
+ raise RuntimeError("--max-coin-trades-per-session must be >= 0.")
1739
+ if coin_session_cooldown_seconds < 0.0:
1740
+ raise RuntimeError("--coin-session-cooldown-seconds must be >= 0.")
1741
+ if coin_session_profit_target < 0.0:
1742
+ raise RuntimeError("--coin-session-profit-target must be >= 0.")
1743
+ if coin_session_min_profit_to_lock < 0.0:
1744
+ raise RuntimeError("--coin-session-min-profit-to-lock must be >= 0.")
1745
+ if not (0.0 <= coin_session_giveback_pct < 1.0):
1746
+ raise RuntimeError("--coin-session-giveback-pct must be >= 0 and < 1.")
1747
+ if cooldown_after_loss_following_wins < 0:
1748
+ raise RuntimeError("--cooldown-after-loss-following-wins must be >= 0.")
1749
+ if session_profit_target < 0.0:
1750
+ raise RuntimeError("--session-profit-target must be >= 0.")
1751
+ if session_max_loss < 0.0:
1752
+ raise RuntimeError("--session-max-loss must be >= 0.")
1753
+ if not (0.0 <= session_giveback_pct < 1.0):
1754
+ raise RuntimeError("--session-giveback-pct must be >= 0 and < 1.")
1755
+ if use_websocket_candles and not use_websocket:
1756
+ raise RuntimeError("--ws-candles requires websocket market data. Remove --no-websocket to enable it.")
1757
+
1758
+ owns_clients = account_address is None and info is None and exchange is None
1759
+ if not owns_clients and (account_address is None or info is None or exchange is None):
1760
+ raise RuntimeError("Pass account_address, info, and exchange together when reusing initialized clients.")
1761
+ completed_trades = 0
1762
+ auto_trades_logger = get_auto_trades_logger()
1763
+
1764
+ try:
1765
+ if owns_clients:
1766
+ account_address, info, exchange = await init_clients(use_testnet, use_websocket=use_websocket)
1767
+ metrics_start_time_ms = int(time.time() * 1000)
1768
+ risk_session = AutoRiskSessionState(started_ms=metrics_start_time_ms)
1769
+ coin_sessions: Dict[str, CoinTradeSessionState] = {}
1770
+ print("============================================================")
1771
+ print(" Hyperliquid Async Auto Trader")
1772
+ print("============================================================")
1773
+ print(f"Account: {account_address}")
1774
+ print(f"Network: {'TESTNET' if use_testnet else 'MAINNET'}")
1775
+ print(f"Websocket: {'ENABLED' if use_websocket else 'DISABLED'}")
1776
+ print(f"WS candles: {'ENABLED' if use_websocket_candles else 'DISABLED'}")
1777
+ print(f"Hide orders: {hide_orders}")
1778
+ print(f"Coin scope: {coin if coin is not None else f'TOP {top_markets} PERPS BY VOLUME'}")
1779
+ print(f"Size mode: {'fixed contracts' if size is not None else 'available collateral pct'}")
1780
+ if size is not None:
1781
+ print(f"Size: {size:.8f}")
1782
+ else:
1783
+ print(f"Size pct: {size_pct_fraction:.4f}")
1784
+ print(f"Intervals: {', '.join(intervals)}")
1785
+ print(f"Bollinger confirm: {'SCALP' if scalp else 'NON-SCALP'}")
1786
+ print(f"Required agreement: {'ALL' if min_agreement == 0 else min_agreement}")
1787
+ print(f"ADX threshold: {adx_threshold:.2f}")
1788
+ print(f"Bollinger: timeperiod={bb_timeperiod}, dev={bb_dev}")
1789
+ print(f"SAR stop mode: {use_sar_stop_on_shortest_interval}")
1790
+ print(f"Trailing TP: {use_trailing_tp}")
1791
+ if use_trailing_tp:
1792
+ print(f"Trailing TP level: {trailing_tp_trigger_level}")
1793
+ print(f"Trailing TP pct: {trailing_tp_profit_pct * 100:.4f}% of favorable unrealized profit")
1794
+ print(f"Scan interval: {scan_interval:.2f}s")
1795
+ print(f"Scan concurrency: {max_concurrent_scans}")
1796
+ print(f"Dry run: {dry_run}")
1797
+ print(f"Max trades: {'unlimited' if max_trades == 0 else max_trades}")
1798
+ print(f"Loop after trade: {loop_after_trade}")
1799
+ print("Coin risk controls:")
1800
+ print(f" max_coin_trades_per_session={max_coin_trades_per_session}")
1801
+ print(f" coin_session_cooldown_seconds={coin_session_cooldown_seconds}")
1802
+ print(f" coin_session_profit_target={coin_session_profit_target}")
1803
+ print(f" coin_session_min_profit_to_lock={coin_session_min_profit_to_lock}")
1804
+ print(f" coin_session_giveback_pct={coin_session_giveback_pct}")
1805
+ print(f" cooldown_after_loss_following_wins={cooldown_after_loss_following_wins}")
1806
+ print("Session risk controls:")
1807
+ print(f" session_profit_target={session_profit_target}")
1808
+ print(f" session_max_loss={session_max_loss}")
1809
+ print(f" session_giveback_pct={session_giveback_pct}")
1810
+ print("============================================================")
1811
+
1812
+ while True:
1813
+ if 0 < max_trades <= completed_trades:
1814
+ print(f"[AUTO] Max trades reached ({completed_trades}); exiting auto mode.")
1815
+ return
1816
+
1817
+ if coin is not None:
1818
+ scan_coins = [coin]
1819
+ else:
1820
+ ranked_markets = await get_top_perp_markets_by_volume(info, top_markets)
1821
+ scan_coins = [market_coin for market_coin, _ in ranked_markets]
1822
+ market_labels = ", ".join(f"{market_coin}({volume:.0f})" for market_coin, volume in ranked_markets)
1823
+ print(f"[AUTO] Top volume scan set: {market_labels}")
1824
+ if not scan_coins:
1825
+ print("[AUTO-WARN] No perp markets with usable volume data were returned.")
1826
+ await asyncio.sleep(scan_interval)
1827
+ continue
1828
+
1829
+ eligible_scan_coins: List[str] = []
1830
+ current_time_ms = now_ms()
1831
+ for scan_coin in scan_coins:
1832
+ coin_state = get_or_create_coin_session(coin_sessions, scan_coin)
1833
+ reset_coin_session_after_cooldown_if_needed(
1834
+ coin_state,
1835
+ current_time_ms,
1836
+ risk_session_log=risk_session_log,
1837
+ session_pnl=risk_session.realized_pnl,
1838
+ )
1839
+ is_blocked, block_reason = coin_is_blocked_by_risk(coin_state, current_time_ms)
1840
+ if is_blocked:
1841
+ print(f"[AUTO-RISK] Skipping {scan_coin}: {block_reason}")
1842
+ _append_risk_event_log(
1843
+ risk_session_log,
1844
+ {
1845
+ "ts_ms": current_time_ms,
1846
+ "event": "coin_skipped",
1847
+ "coin": coin_state.coin,
1848
+ "reason": coin_state.cooldown_reason,
1849
+ "blocked_detail": block_reason,
1850
+ "coin_pnl": coin_state.realized_pnl,
1851
+ "session_pnl": risk_session.realized_pnl,
1852
+ },
1853
+ )
1854
+ continue
1855
+ eligible_scan_coins.append(scan_coin)
1856
+
1857
+ if not eligible_scan_coins:
1858
+ print("[AUTO-RISK] All scan candidates are blocked by coin session controls.")
1859
+ await asyncio.sleep(scan_interval)
1860
+ continue
1861
+
1862
+ shared_snapshot = await build_auto_scan_loop_snapshot(
1863
+ info=info,
1864
+ account_address=account_address,
1865
+ metrics_start_time_ms=metrics_start_time_ms,
1866
+ )
1867
+ selected_coin: Optional[str] = None
1868
+ selected_decision: Optional[AutoTradeDecision] = None
1869
+ selected_size: Optional[float] = None
1870
+
1871
+ scan_concurrency = min(max_concurrent_scans, max(1, len(eligible_scan_coins)))
1872
+ scan_semaphore = asyncio.Semaphore(scan_concurrency)
1873
+
1874
+ async def _scan_with_limit(scan_coin: str) -> AutoScanCandidate:
1875
+ async with scan_semaphore:
1876
+ return await scan_auto_trade_candidate(
1877
+ info=info,
1878
+ scan_coin=scan_coin,
1879
+ intervals=intervals,
1880
+ periods=periods,
1881
+ min_agreement=min_agreement,
1882
+ adx_threshold=adx_threshold,
1883
+ take_profit_pct=take_profit_pct,
1884
+ stop_loss_pct=stop_loss_pct,
1885
+ min_take_profit_pct=min_take_profit_pct,
1886
+ max_take_profit_pct=max_take_profit_pct,
1887
+ macd_fast=macd_fast,
1888
+ macd_slow=macd_slow,
1889
+ macd_signal_period=macd_signal_period,
1890
+ sar_acceleration=sar_acceleration,
1891
+ sar_maximum=sar_maximum,
1892
+ adx_timeperiod=adx_timeperiod,
1893
+ bb_timeperiod=bb_timeperiod,
1894
+ bb_dev=bb_dev,
1895
+ scalp=scalp,
1896
+ use_last_closed_candle=use_last_closed_candle,
1897
+ use_sar_stop_on_shortest_interval=use_sar_stop_on_shortest_interval,
1898
+ snapshot=shared_snapshot,
1899
+ use_websocket_candles=use_websocket_candles,
1900
+ )
1901
+
1902
+ scan_tasks = [asyncio.create_task(_scan_with_limit(scan_coin)) for scan_coin in eligible_scan_coins]
1903
+ try:
1904
+ for completed_task in asyncio.as_completed(scan_tasks):
1905
+ try:
1906
+ candidate = await completed_task
1907
+ except asyncio.CancelledError:
1908
+ raise
1909
+ except Exception as exc:
1910
+ cp.warning(f"[AUTO-WARN] Market scan task failed: {exc}")
1911
+ continue
1912
+
1913
+ if candidate.existing_position is not None:
1914
+ mid = float(shared_snapshot.mids.get(candidate.coin.upper(), 0.0))
1915
+ upnl = compute_position_unrealized_pnl(candidate.existing_position, mid) if mid > 0.0 else None
1916
+ upnl_str = f"{upnl:.8f}" if upnl is not None else "N/A"
1917
+ print(
1918
+ f"[AUTO] Existing {candidate.coin} position detected; skipping new auto trade on that market. "
1919
+ f"uPnL={upnl_str} {format_auto_scan_metrics(shared_snapshot)}"
1920
+ )
1921
+ continue
1922
+
1923
+ if candidate.decision is None or candidate.decision.direction is None or candidate.decision.take_profit_pct is None:
1924
+ continue
1925
+
1926
+ try:
1927
+ resolved_size, size_reason = await resolve_auto_trade_size(
1928
+ info=info,
1929
+ account_address=account_address,
1930
+ coin=candidate.coin,
1931
+ side=candidate.decision.direction,
1932
+ size=size,
1933
+ size_pct=size_pct_fraction,
1934
+ )
1935
+ except Exception as exc:
1936
+ print(f"[AUTO-WARN] {candidate.coin} size resolution failed; skipping trade candidate: {exc}")
1937
+ continue
1938
+ print(f"[AUTO] {candidate.coin} size resolved to {resolved_size:.8f} ({size_reason})")
1939
+
1940
+ selected_coin = candidate.coin
1941
+ selected_decision = candidate.decision
1942
+ selected_size = resolved_size
1943
+ break
1944
+ except Exception as exc:
1945
+ logger.error(exc)
1946
+
1947
+ for task in scan_tasks:
1948
+ if not task.done():
1949
+ task.cancel()
1950
+ await asyncio.gather(*scan_tasks, return_exceptions=True)
1951
+
1952
+ if selected_coin is None or selected_decision is None or selected_size is None:
1953
+ print('=' * 40 + f'Sleeping {scan_interval} seconds .. ' + '=' * 40)
1954
+ await asyncio.sleep(scan_interval)
1955
+ continue
1956
+
1957
+ if dry_run:
1958
+ print(f"[AUTO] Dry run enabled; {selected_coin} signal will not be traded.")
1959
+ await asyncio.sleep(scan_interval)
1960
+ continue
1961
+
1962
+ direction = selected_decision.direction
1963
+ auto_tp_pct = selected_decision.take_profit_pct
1964
+ auto_sl_pct = stop_loss_pct if stop_loss_pct is not None else selected_decision.stop_loss_pct
1965
+ auto_sl_trigger_px = None
1966
+ if use_sar_stop_on_shortest_interval:
1967
+ auto_sl_trigger_px = selected_decision.stop_loss_trigger_px
1968
+ shortest_snapshot = get_shortest_interval_snapshot(selected_decision.snapshots)
1969
+ sl_display = f"{auto_sl_pct * 100:.4f}%" if auto_sl_pct is not None else "N/A"
1970
+ print(
1971
+ f"[AUTO] Trading {direction.upper()} {selected_coin}: size={selected_size:.8f}, "
1972
+ f"tp_pct={auto_tp_pct * 100:.4f}%, sl_pct={sl_display}, "
1973
+ f"sl_trigger={f'{auto_sl_trigger_px:.8f}' if auto_sl_trigger_px is not None else 'N/A'}"
1974
+ )
1975
+
1976
+ trade_cycle_started_ms = now_ms()
1977
+ await run_bracket_entry(
1978
+ coin=selected_coin,
1979
+ direction=direction,
1980
+ size=selected_size,
1981
+ take_profit_pct=auto_tp_pct,
1982
+ stop_loss_pct=auto_sl_pct,
1983
+ stop_loss_trigger_px=auto_sl_trigger_px,
1984
+ take_profit_levels=take_profit_levels,
1985
+ use_trailing_tp=use_trailing_tp,
1986
+ trailing_tp_trigger_level=trailing_tp_trigger_level,
1987
+ trailing_tp_profit_pct=trailing_tp_profit_pct,
1988
+ entry_retries=entry_retries,
1989
+ entry_repost_interval=entry_repost_interval,
1990
+ poll_interval=poll_interval,
1991
+ tp_reversal_pct=tp_reversal_pct,
1992
+ entry_tif=entry_tif,
1993
+ tp_tif=tp_tif,
1994
+ market_fallback=market_fallback,
1995
+ market_slippage=market_slippage,
1996
+ cancel_existing_tpsl=cancel_existing_tpsl,
1997
+ tp_reversal_limit_exit=tp_reversal_limit_exit,
1998
+ tp_reversal_stop_buffer_pct=tp_reversal_stop_buffer_pct,
1999
+ use_testnet=use_testnet,
2000
+ use_websocket=use_websocket,
2001
+ hide_orders=hide_orders,
2002
+ auto_sar_stop_interval=(
2003
+ shortest_snapshot.interval
2004
+ if use_sar_stop_on_shortest_interval and shortest_snapshot is not None
2005
+ else None
2006
+ ),
2007
+ auto_sar_stop_periods=periods if use_sar_stop_on_shortest_interval else None,
2008
+ auto_sar_acceleration=sar_acceleration if use_sar_stop_on_shortest_interval else None,
2009
+ auto_sar_maximum=sar_maximum if use_sar_stop_on_shortest_interval else None,
2010
+ auto_use_last_closed_candle=use_last_closed_candle,
2011
+ auto_use_websocket_candles=use_websocket_candles,
2012
+ account_address=account_address,
2013
+ info=info,
2014
+ exchange=exchange,
2015
+ )
2016
+ trade_cycle_closed_ms = now_ms()
2017
+ completed_trades += 1
2018
+ auto_trades_logger.info(
2019
+ "[AUTO-TRADE-COMPLETE] coin=%s direction=%s size=%.8f tp_pct=%.8f sl_pct=%s completed_trades=%d",
2020
+ selected_coin,
2021
+ direction,
2022
+ selected_size,
2023
+ auto_tp_pct,
2024
+ f"{auto_sl_pct:.8f}" if auto_sl_pct is not None else "N/A",
2025
+ completed_trades,
2026
+ )
2027
+
2028
+ if info is None or exchange is None:
2029
+ account_address, info, exchange = await init_clients(use_testnet, use_websocket=use_websocket)
2030
+
2031
+ cycle_pnl, cycle_fills, pnl_reason = await fetch_trade_cycle_net_pnl(
2032
+ info=info,
2033
+ account_address=account_address,
2034
+ coin=selected_coin,
2035
+ start_time_ms=trade_cycle_started_ms,
2036
+ end_time_ms=trade_cycle_closed_ms,
2037
+ )
2038
+ if pnl_reason:
2039
+ print(
2040
+ f"[AUTO-WARN] {selected_coin} post-trade PnL unavailable; "
2041
+ f"risk session not updated for this cycle: {pnl_reason}"
2042
+ )
2043
+ _append_risk_event_log(
2044
+ risk_session_log,
2045
+ {
2046
+ "ts_ms": trade_cycle_closed_ms,
2047
+ "event": "cycle_pnl_unavailable",
2048
+ "coin": selected_coin,
2049
+ "reason": pnl_reason,
2050
+ "session_pnl": risk_session.realized_pnl,
2051
+ },
2052
+ )
2053
+ else:
2054
+ coin_state = get_or_create_coin_session(coin_sessions, selected_coin)
2055
+ coin_reason, session_reason = update_risk_after_trade_cycle(
2056
+ coin_state=coin_state,
2057
+ session_state=risk_session,
2058
+ cycle_pnl=cycle_pnl,
2059
+ cycle_started_ms=trade_cycle_started_ms,
2060
+ cycle_closed_ms=trade_cycle_closed_ms,
2061
+ max_coin_trades_per_session=max_coin_trades_per_session,
2062
+ coin_session_cooldown_seconds=coin_session_cooldown_seconds,
2063
+ coin_session_profit_target=coin_session_profit_target,
2064
+ coin_session_min_profit_to_lock=coin_session_min_profit_to_lock,
2065
+ coin_session_giveback_pct=coin_session_giveback_pct,
2066
+ cooldown_after_loss_following_wins=cooldown_after_loss_following_wins,
2067
+ session_profit_target=session_profit_target,
2068
+ session_max_loss=session_max_loss,
2069
+ session_giveback_pct=session_giveback_pct,
2070
+ )
2071
+ print(
2072
+ f"[AUTO-RISK] {selected_coin} cycle_pnl={cycle_pnl:.6f} "
2073
+ f"coin_pnl={coin_state.realized_pnl:.6f} coin_peak={coin_state.peak_pnl:.6f} "
2074
+ f"coin_cycles={coin_state.completed_cycles} session_pnl={risk_session.realized_pnl:.6f} "
2075
+ f"session_peak={risk_session.peak_pnl:.6f}"
2076
+ )
2077
+ _append_risk_event_log(
2078
+ risk_session_log,
2079
+ {
2080
+ "ts_ms": trade_cycle_closed_ms,
2081
+ "event": "cycle_complete",
2082
+ "coin": selected_coin,
2083
+ "cycle_pnl": cycle_pnl,
2084
+ "coin_pnl": coin_state.realized_pnl,
2085
+ "coin_peak_pnl": coin_state.peak_pnl,
2086
+ "coin_cycles": coin_state.completed_cycles,
2087
+ "session_pnl": risk_session.realized_pnl,
2088
+ "session_peak_pnl": risk_session.peak_pnl,
2089
+ "fills_count": len(cycle_fills),
2090
+ },
2091
+ )
2092
+ if coin_reason is not None:
2093
+ print(
2094
+ f"[AUTO-RISK] {selected_coin} entering cooldown for "
2095
+ f"{coin_session_cooldown_seconds:.1f}s reason=\"{coin_reason}\""
2096
+ )
2097
+ _append_risk_event_log(
2098
+ risk_session_log,
2099
+ {
2100
+ "ts_ms": trade_cycle_closed_ms,
2101
+ "event": "coin_cooldown",
2102
+ "coin": selected_coin,
2103
+ "reason": coin_reason,
2104
+ "coin_pnl": coin_state.realized_pnl,
2105
+ "session_pnl": risk_session.realized_pnl,
2106
+ },
2107
+ )
2108
+ if session_reason is not None:
2109
+ print(f"[AUTO-RISK] Session stop triggered: {session_reason}")
2110
+ _append_risk_event_log(
2111
+ risk_session_log,
2112
+ {
2113
+ "ts_ms": trade_cycle_closed_ms,
2114
+ "event": "session_stop",
2115
+ "coin": selected_coin,
2116
+ "reason": session_reason,
2117
+ "coin_pnl": coin_state.realized_pnl,
2118
+ "session_pnl": risk_session.realized_pnl,
2119
+ },
2120
+ )
2121
+ if risk_session.stopped:
2122
+ return
2123
+
2124
+ if 0 < max_trades <= completed_trades:
2125
+ print(f"[AUTO] Completed {completed_trades} trade(s); exiting auto mode.")
2126
+ return
2127
+
2128
+ if not loop_after_trade:
2129
+ print(f"[AUTO] Completed {completed_trades} trade(s); exiting because auto looping is disabled.")
2130
+ return
2131
+
2132
+ if cooldown_after_trade > 0.0:
2133
+ print(f"[AUTO] Cooling down for {cooldown_after_trade:.2f}s before resuming scans.")
2134
+ await asyncio.sleep(cooldown_after_trade)
2135
+
2136
+ metrics_start_time_ms = int(time.time() * 1000)
2137
+ except KeyboardInterrupt:
2138
+ print("\n[!] Caught Ctrl+C, stopping auto trader.")
2139
+ finally:
2140
+ if owns_clients:
2141
+ await close_clients(info, exchange)