hypertrader 0.1.9__tar.gz → 0.2.0__tar.gz
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.9 → hypertrader-0.2.0}/PKG-INFO +2 -1
- {hypertrader-0.1.9 → hypertrader-0.2.0}/README.md +1 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/hypertrader.egg-info/PKG-INFO +2 -1
- {hypertrader-0.1.9 → hypertrader-0.2.0}/hypertrader.egg-info/SOURCES.txt +3 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/hypertrader.py +18 -23
- {hypertrader-0.1.9 → hypertrader-0.2.0}/modes/auto_trader.py +12 -6
- {hypertrader-0.1.9 → hypertrader-0.2.0}/setup.py +1 -1
- hypertrader-0.2.0/tests/test_api_retry.py +127 -0
- hypertrader-0.2.0/tests/test_cli_shutdown.py +86 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/utils/helpers.py +18 -6
- hypertrader-0.2.0/utils/retry.py +41 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/hypertrader.egg-info/dependency_links.txt +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/hypertrader.egg-info/entry_points.txt +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/hypertrader.egg-info/requires.txt +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/hypertrader.egg-info/top_level.txt +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/modes/market_maker.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/modes/position_management.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/modes/position_watcher.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/modes/trailing_stop.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/setup.cfg +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/strategies/__init__.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/strategies/base.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/strategies/default.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/strategies/fib_reversal.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/strategies/orderflow_metrics.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/strategies/orderflow_models.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/strategies/orderflow_pullback.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/strategies/registry.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/strategies/reversal.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/tests/test_async_hyperliquid.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/tests/test_auto_dry_run.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/tests/test_auto_strategy_registry.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/tests/test_auto_trader_logging.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/tests/test_fib_reversal_strategy.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/tests/test_market_ids.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/tests/test_orderflow_metrics.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/tests/test_orderflow_pullback_lifecycle.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/tests/test_reversal_strategy.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/tests/test_simulated_logging.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/tests/test_trailing_stop.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/utils/constants.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/utils/simulated_logging.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/utils/style.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/utils/timer.py +0 -0
- {hypertrader-0.1.9 → hypertrader-0.2.0}/utils/worker.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hypertrader
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Async Hyperliquid trading helper built on the async SDK.
|
|
5
5
|
Author: darkerego
|
|
6
6
|
License: MIT
|
|
@@ -69,6 +69,7 @@ The canonical bot file in this repo is [`hypertrader.py`](/media/anon/developmen
|
|
|
69
69
|
Install from pip: https://pypi.org/project/hypertrader
|
|
70
70
|
|
|
71
71
|
<pre>
|
|
72
|
+
pip install --upgrade pip setuptools wheel
|
|
72
73
|
pip install hypertrader[auto] --upgrade
|
|
73
74
|
</pre>
|
|
74
75
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: hypertrader
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Async Hyperliquid trading helper built on the async SDK.
|
|
5
5
|
Author: darkerego
|
|
6
6
|
License: MIT
|
|
@@ -69,6 +69,7 @@ The canonical bot file in this repo is [`hypertrader.py`](/media/anon/developmen
|
|
|
69
69
|
Install from pip: https://pypi.org/project/hypertrader
|
|
70
70
|
|
|
71
71
|
<pre>
|
|
72
|
+
pip install --upgrade pip setuptools wheel
|
|
72
73
|
pip install hypertrader[auto] --upgrade
|
|
73
74
|
</pre>
|
|
74
75
|
|
|
@@ -21,10 +21,12 @@ strategies/orderflow_models.py
|
|
|
21
21
|
strategies/orderflow_pullback.py
|
|
22
22
|
strategies/registry.py
|
|
23
23
|
strategies/reversal.py
|
|
24
|
+
tests/test_api_retry.py
|
|
24
25
|
tests/test_async_hyperliquid.py
|
|
25
26
|
tests/test_auto_dry_run.py
|
|
26
27
|
tests/test_auto_strategy_registry.py
|
|
27
28
|
tests/test_auto_trader_logging.py
|
|
29
|
+
tests/test_cli_shutdown.py
|
|
28
30
|
tests/test_fib_reversal_strategy.py
|
|
29
31
|
tests/test_market_ids.py
|
|
30
32
|
tests/test_orderflow_metrics.py
|
|
@@ -34,6 +36,7 @@ tests/test_simulated_logging.py
|
|
|
34
36
|
tests/test_trailing_stop.py
|
|
35
37
|
utils/constants.py
|
|
36
38
|
utils/helpers.py
|
|
39
|
+
utils/retry.py
|
|
37
40
|
utils/simulated_logging.py
|
|
38
41
|
utils/style.py
|
|
39
42
|
utils/timer.py
|
|
@@ -1033,45 +1033,40 @@ async def async_main(argv: Optional[List[str]] = None) -> None:
|
|
|
1033
1033
|
|
|
1034
1034
|
def main(argv: Optional[List[str]] = None) -> None:
|
|
1035
1035
|
from utils.simulated_logging import LoggedConsole
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
asyncio.
|
|
1039
|
-
install_asyncio_exception_logging(_loop)
|
|
1036
|
+
|
|
1037
|
+
async def start() -> None:
|
|
1038
|
+
install_asyncio_exception_logging(asyncio.get_running_loop())
|
|
1040
1039
|
print(f"[LOG] Writing runtime logs to {log_path}")
|
|
1041
1040
|
print(f"[START] argv={argv if argv is not None else sys.argv[1:]}")
|
|
1042
|
-
|
|
1041
|
+
await async_main(argv)
|
|
1043
1042
|
|
|
1044
1043
|
args = argv if argv is not None else sys.argv[1:]
|
|
1045
1044
|
dry_run = "--dry-run" in args
|
|
1046
1045
|
log_path = configure_runtime_logging(SIMULATED_LOG_FILE if dry_run else None)
|
|
1047
1046
|
original_stdout, original_stderr = sys.stdout, sys.stderr
|
|
1048
|
-
restart = False
|
|
1049
1047
|
if dry_run:
|
|
1050
1048
|
sys.stdout = LoggedConsole(original_stdout)
|
|
1051
1049
|
# sys.stderr = LoggedConsole(original_stderr)
|
|
1052
1050
|
try:
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1051
|
+
# Give both the initial run and the single retry a complete lifecycle:
|
|
1052
|
+
# cancellation, awaited cleanup, then event-loop shutdown.
|
|
1053
|
+
for attempt in range(2):
|
|
1054
|
+
try:
|
|
1055
|
+
asyncio.run(start())
|
|
1056
|
+
break
|
|
1057
|
+
except KeyboardInterrupt:
|
|
1058
|
+
print("\n[!] Interrupted.")
|
|
1059
|
+
break
|
|
1060
|
+
except Exception as err:
|
|
1061
|
+
logging.getLogger("hypertrader").exception("[FATAL] Unhandled main() exception: %s", err)
|
|
1062
|
+
if attempt == 1:
|
|
1063
|
+
raise
|
|
1064
|
+
print('[!] Restarting ... ')
|
|
1061
1065
|
finally:
|
|
1062
1066
|
if dry_run:
|
|
1063
1067
|
sys.stdout.flush()
|
|
1064
1068
|
sys.stderr.flush()
|
|
1065
1069
|
sys.stdout, sys.stderr = original_stdout, original_stderr
|
|
1066
|
-
try:
|
|
1067
|
-
loop = asyncio.get_event_loop()
|
|
1068
|
-
except RuntimeError:
|
|
1069
|
-
loop = None
|
|
1070
|
-
if loop is not None and not loop.is_closed():
|
|
1071
|
-
loop.close()
|
|
1072
|
-
if restart:
|
|
1073
|
-
print('[!] Restarting ... ')
|
|
1074
|
-
start()
|
|
1075
1070
|
|
|
1076
1071
|
|
|
1077
1072
|
if __name__ == "__main__":
|
|
@@ -18,6 +18,7 @@ from strategies.base import AutoStrategy, StrategyContext, StrategySignal
|
|
|
18
18
|
from strategies.registry import create_strategy
|
|
19
19
|
from strategies.reversal import normalize_signal_prices
|
|
20
20
|
from utils.constants import AUTO_TRADES_LOG_FILE, SIMULATED_LOG_FILE, INTERVAL_TO_MS, PRICE_EPS, cp
|
|
21
|
+
from utils.retry import retry_api_read
|
|
21
22
|
from utils.helpers import parse_interval_list, init_clients, _try_float, parse_fractional_pct, \
|
|
22
23
|
extract_account_balance_from_user_state, round_size_for_hyperliquid, get_user_state_with_retry, get_all_mids, \
|
|
23
24
|
fetch_recent_candles, compute_position_unrealized_pnl, close_clients, compute_default_stop_loss_pct, \
|
|
@@ -105,7 +106,9 @@ async def get_top_perp_markets_by_volume(info: Info, limit: int) -> List[Tuple[s
|
|
|
105
106
|
if limit <= 0:
|
|
106
107
|
raise RuntimeError("--top-markets must be > 0.")
|
|
107
108
|
|
|
108
|
-
meta_and_ctxs = await
|
|
109
|
+
meta_and_ctxs = await retry_api_read(
|
|
110
|
+
info.meta_and_asset_ctxs, context="auto market ranking"
|
|
111
|
+
)
|
|
109
112
|
if not isinstance(meta_and_ctxs, (list, tuple)) or len(meta_and_ctxs) < 2:
|
|
110
113
|
raise RuntimeError(f"Unexpected metaAndAssetCtxs response shape: {type(meta_and_ctxs).__name__}")
|
|
111
114
|
|
|
@@ -2883,11 +2886,14 @@ async def run_auto_trader(
|
|
|
2883
2886
|
startup_backfill_state.begin_iteration()
|
|
2884
2887
|
|
|
2885
2888
|
try:
|
|
2886
|
-
shared_snapshot = await
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2889
|
+
shared_snapshot = await retry_api_read(
|
|
2890
|
+
lambda: build_auto_scan_loop_snapshot(
|
|
2891
|
+
info=info,
|
|
2892
|
+
account_address=account_address,
|
|
2893
|
+
metrics_start_time_ms=metrics_start_time_ms,
|
|
2894
|
+
dry_run=dry_run,
|
|
2895
|
+
),
|
|
2896
|
+
context="auto shared snapshot",
|
|
2891
2897
|
)
|
|
2892
2898
|
except Exception as exc:
|
|
2893
2899
|
if is_rate_limit_error(exc):
|
|
@@ -9,7 +9,7 @@ README = ROOT / "README.md"
|
|
|
9
9
|
|
|
10
10
|
setup(
|
|
11
11
|
name="hypertrader",
|
|
12
|
-
version="0.
|
|
12
|
+
version="0.2.0",
|
|
13
13
|
description="Async Hyperliquid trading helper built on the async SDK.",
|
|
14
14
|
long_description=README.read_text(encoding="utf-8"),
|
|
15
15
|
long_description_content_type="text/markdown",
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import unittest
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
from unittest.mock import AsyncMock, Mock, call, patch
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
from hyperliquid.utils.error import ClientError, ServerError
|
|
8
|
+
|
|
9
|
+
from modes.auto_trader import get_top_perp_markets_by_volume
|
|
10
|
+
from utils import helpers, retry
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ApiRetryTests(unittest.IsolatedAsyncioTestCase):
|
|
14
|
+
async def test_repeated_gateway_errors_back_off_until_recovery(self):
|
|
15
|
+
read = AsyncMock(side_effect=[ServerError(502, "Bad Gateway")] * 7 + ["ok"])
|
|
16
|
+
with patch.object(retry.asyncio, "sleep", new_callable=AsyncMock) as sleep:
|
|
17
|
+
result = await retry.retry_api_read(read, context="test read")
|
|
18
|
+
self.assertEqual(result, "ok")
|
|
19
|
+
self.assertEqual(read.await_count, 8)
|
|
20
|
+
self.assertEqual(sleep.await_args_list, [call(n) for n in (5, 10, 20, 40, 60, 60, 60)])
|
|
21
|
+
|
|
22
|
+
async def test_backoff_resets_for_next_operation(self):
|
|
23
|
+
read = AsyncMock(side_effect=[ServerError(503, "unavailable"), 1] * 2)
|
|
24
|
+
with patch.object(retry.asyncio, "sleep", new_callable=AsyncMock) as sleep:
|
|
25
|
+
await retry.retry_api_read(read, context="first")
|
|
26
|
+
await retry.retry_api_read(read, context="second")
|
|
27
|
+
self.assertEqual(sleep.await_args_list, [call(5), call(5)])
|
|
28
|
+
|
|
29
|
+
async def test_non_transient_errors_propagate_without_retry(self):
|
|
30
|
+
for error in (ClientError(400, None, "bad request", None), ValueError("bad data")):
|
|
31
|
+
with self.subTest(error=error):
|
|
32
|
+
read = AsyncMock(side_effect=error)
|
|
33
|
+
with patch.object(retry.asyncio, "sleep", new_callable=AsyncMock) as sleep:
|
|
34
|
+
with self.assertRaises(type(error)):
|
|
35
|
+
await retry.retry_api_read(read, context="test read")
|
|
36
|
+
read.assert_awaited_once()
|
|
37
|
+
sleep.assert_not_awaited()
|
|
38
|
+
|
|
39
|
+
async def test_rate_limit_connection_and_timeout_errors_retry(self):
|
|
40
|
+
for error in (
|
|
41
|
+
ClientError(429, None, "rate limited", None),
|
|
42
|
+
aiohttp.ClientConnectionError("disconnected"),
|
|
43
|
+
asyncio.TimeoutError(),
|
|
44
|
+
):
|
|
45
|
+
with self.subTest(error=error):
|
|
46
|
+
read = AsyncMock(side_effect=[error, "ok"])
|
|
47
|
+
with patch.object(retry.asyncio, "sleep", new_callable=AsyncMock) as sleep:
|
|
48
|
+
self.assertEqual(await retry.retry_api_read(read, context="read"), "ok")
|
|
49
|
+
sleep.assert_awaited_once_with(5)
|
|
50
|
+
|
|
51
|
+
async def test_cancellation_during_request_or_backoff_propagates(self):
|
|
52
|
+
read = AsyncMock(side_effect=asyncio.CancelledError())
|
|
53
|
+
with self.assertRaises(asyncio.CancelledError):
|
|
54
|
+
await retry.retry_api_read(read, context="cancelled read")
|
|
55
|
+
read.assert_awaited_once()
|
|
56
|
+
|
|
57
|
+
read = AsyncMock(side_effect=ServerError(502, "Bad Gateway"))
|
|
58
|
+
with patch.object(retry.asyncio, "sleep", new_callable=AsyncMock,
|
|
59
|
+
side_effect=asyncio.CancelledError()):
|
|
60
|
+
with self.assertRaises(asyncio.CancelledError):
|
|
61
|
+
await retry.retry_api_read(read, context="cancelled backoff")
|
|
62
|
+
read.assert_awaited_once()
|
|
63
|
+
|
|
64
|
+
async def test_market_ranking_recovers_from_logged_502(self):
|
|
65
|
+
info = SimpleNamespace(meta_and_asset_ctxs=AsyncMock(side_effect=[
|
|
66
|
+
ServerError(502, "<html>502 Bad Gateway</html>"),
|
|
67
|
+
[{"universe": [{"name": "BTC"}, {"name": "ETH"}]},
|
|
68
|
+
[{"dayNtlVlm": "100"}, {"dayNtlVlm": "200"}]],
|
|
69
|
+
]))
|
|
70
|
+
with patch.object(retry.asyncio, "sleep", new_callable=AsyncMock) as sleep:
|
|
71
|
+
result = await get_top_perp_markets_by_volume(info, 1)
|
|
72
|
+
self.assertEqual(result, [("ETH", 200.0)])
|
|
73
|
+
sleep.assert_awaited_once_with(5)
|
|
74
|
+
self.assertEqual(info.meta_and_asset_ctxs.await_count, 2)
|
|
75
|
+
|
|
76
|
+
async def test_startup_closes_failed_clients_before_backoff_then_recovers(self):
|
|
77
|
+
for stage in ("info", "exchange", "cache"):
|
|
78
|
+
with self.subTest(stage=stage):
|
|
79
|
+
infos = [SimpleNamespace(initialize=AsyncMock(), aclose=AsyncMock()) for _ in range(2)]
|
|
80
|
+
exchanges = [SimpleNamespace(initialize=AsyncMock(), aclose=AsyncMock()) for _ in range(2)]
|
|
81
|
+
caches = [SimpleNamespace(start=AsyncMock(), stop=AsyncMock()) for _ in range(2)]
|
|
82
|
+
failure = ServerError(502, "Bad Gateway")
|
|
83
|
+
if stage == "info":
|
|
84
|
+
infos[0].initialize.side_effect = failure
|
|
85
|
+
elif stage == "exchange":
|
|
86
|
+
exchanges[0].initialize.side_effect = failure
|
|
87
|
+
else:
|
|
88
|
+
caches[0].start.side_effect = failure
|
|
89
|
+
|
|
90
|
+
async def check_cleanup(delay):
|
|
91
|
+
self.assertEqual(delay, 5)
|
|
92
|
+
infos[0].aclose.assert_awaited_once()
|
|
93
|
+
if stage != "info":
|
|
94
|
+
exchanges[0].aclose.assert_awaited_once()
|
|
95
|
+
if stage == "cache":
|
|
96
|
+
caches[0].stop.assert_awaited_once()
|
|
97
|
+
|
|
98
|
+
with (
|
|
99
|
+
patch.object(helpers, "load_credentials", return_value=("secret", "0xabc")),
|
|
100
|
+
patch.object(helpers.eth_account.Account, "from_key", return_value=object()),
|
|
101
|
+
patch.object(helpers, "Info", side_effect=infos),
|
|
102
|
+
patch.object(helpers, "Exchange", side_effect=exchanges),
|
|
103
|
+
patch.object(helpers, "HyperliquidWebsocketCache", side_effect=caches),
|
|
104
|
+
patch.object(helpers, "get_ws_cache", side_effect=lambda info: getattr(info, "ws_cache", None)),
|
|
105
|
+
patch.object(retry.asyncio, "sleep", new_callable=AsyncMock, side_effect=check_cleanup),
|
|
106
|
+
):
|
|
107
|
+
address, info, exchange = await helpers.init_clients(False, use_websocket=False)
|
|
108
|
+
self.assertEqual(address, "0xabc")
|
|
109
|
+
self.assertIs(info, infos[1])
|
|
110
|
+
self.assertIs(exchange, exchanges[0 if stage == "info" else 1])
|
|
111
|
+
info.aclose.assert_not_awaited()
|
|
112
|
+
exchange.aclose.assert_not_awaited()
|
|
113
|
+
|
|
114
|
+
async def test_startup_cancellation_closes_partial_client(self):
|
|
115
|
+
info = SimpleNamespace(initialize=AsyncMock(side_effect=asyncio.CancelledError()), aclose=AsyncMock())
|
|
116
|
+
with (
|
|
117
|
+
patch.object(helpers, "load_credentials", return_value=("secret", "0xabc")),
|
|
118
|
+
patch.object(helpers.eth_account.Account, "from_key", return_value=object()),
|
|
119
|
+
patch.object(helpers, "Info", return_value=info),
|
|
120
|
+
patch.object(helpers, "Exchange", new_callable=Mock) as exchange,
|
|
121
|
+
patch.object(retry.asyncio, "sleep", new_callable=AsyncMock) as sleep,
|
|
122
|
+
):
|
|
123
|
+
with self.assertRaises(asyncio.CancelledError):
|
|
124
|
+
await helpers.init_clients(False)
|
|
125
|
+
info.aclose.assert_awaited_once()
|
|
126
|
+
exchange.assert_not_called()
|
|
127
|
+
sleep.assert_not_awaited()
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
import textwrap
|
|
4
|
+
import unittest
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
# Run SIGINT in a child process so it cannot interrupt the test runner.
|
|
8
|
+
SHUTDOWN_SCRIPT = textwrap.dedent("""\
|
|
9
|
+
import asyncio
|
|
10
|
+
import gc
|
|
11
|
+
import signal
|
|
12
|
+
import sys
|
|
13
|
+
from unittest.mock import patch
|
|
14
|
+
|
|
15
|
+
import aiohttp
|
|
16
|
+
import hypertrader
|
|
17
|
+
|
|
18
|
+
backend, scenario = sys.argv[1:]
|
|
19
|
+
if backend == "uvloop":
|
|
20
|
+
import uvloop
|
|
21
|
+
uvloop.install()
|
|
22
|
+
|
|
23
|
+
loops = []
|
|
24
|
+
sessions = []
|
|
25
|
+
tasks = []
|
|
26
|
+
events = []
|
|
27
|
+
errors = []
|
|
28
|
+
|
|
29
|
+
async def background(attempt):
|
|
30
|
+
try:
|
|
31
|
+
await asyncio.Event().wait()
|
|
32
|
+
finally:
|
|
33
|
+
await asyncio.sleep(0)
|
|
34
|
+
events.append((attempt, "background closed"))
|
|
35
|
+
|
|
36
|
+
async def application(argv):
|
|
37
|
+
attempt = len(loops)
|
|
38
|
+
loop = asyncio.get_running_loop()
|
|
39
|
+
loops.append(loop)
|
|
40
|
+
loop.set_exception_handler(lambda loop, context: errors.append(context))
|
|
41
|
+
session = aiohttp.ClientSession()
|
|
42
|
+
sessions.append(session)
|
|
43
|
+
tasks.append(asyncio.create_task(background(attempt)))
|
|
44
|
+
try:
|
|
45
|
+
await asyncio.sleep(0)
|
|
46
|
+
if scenario.startswith("restart") and attempt == 0:
|
|
47
|
+
raise RuntimeError("trigger restart")
|
|
48
|
+
if scenario.endswith("interrupt"):
|
|
49
|
+
loop.call_soon(signal.raise_signal, signal.SIGINT)
|
|
50
|
+
await asyncio.Event().wait()
|
|
51
|
+
finally:
|
|
52
|
+
await asyncio.sleep(0)
|
|
53
|
+
await session.close()
|
|
54
|
+
events.append((attempt, "session closed"))
|
|
55
|
+
|
|
56
|
+
with patch.object(hypertrader, "async_main", application), patch.object(
|
|
57
|
+
hypertrader, "configure_runtime_logging", return_value="unused.log"
|
|
58
|
+
):
|
|
59
|
+
hypertrader.main([])
|
|
60
|
+
|
|
61
|
+
gc.collect()
|
|
62
|
+
assert len(loops) == (2 if scenario.startswith("restart") else 1), events
|
|
63
|
+
assert all(loop.is_closed() for loop in loops), "event loop left open"
|
|
64
|
+
assert all(session.closed for session in sessions), "HTTP session left open"
|
|
65
|
+
assert all(task.done() for task in tasks), "background task left pending"
|
|
66
|
+
for attempt in range(len(loops)):
|
|
67
|
+
assert (attempt, "session closed") in events, events
|
|
68
|
+
assert (attempt, "background closed") in events, events
|
|
69
|
+
assert not errors, errors
|
|
70
|
+
print("shutdown verified")
|
|
71
|
+
""")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class CliShutdownTests(unittest.TestCase):
|
|
75
|
+
def test_shutdown_and_restart_release_async_resources(self):
|
|
76
|
+
for backend in ("asyncio", "uvloop"):
|
|
77
|
+
for scenario in ("normal", "interrupt", "restart", "restart_interrupt"):
|
|
78
|
+
with self.subTest(backend=backend, scenario=scenario):
|
|
79
|
+
result = subprocess.run(
|
|
80
|
+
[sys.executable, "-c", SHUTDOWN_SCRIPT, backend, scenario],
|
|
81
|
+
capture_output=True, text=True, timeout=20,
|
|
82
|
+
)
|
|
83
|
+
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
|
84
|
+
self.assertIn("shutdown verified", result.stdout)
|
|
85
|
+
self.assertNotIn("Unclosed", result.stderr)
|
|
86
|
+
self.assertNotIn("Task was destroyed", result.stderr)
|
|
@@ -15,6 +15,7 @@ from hyperliquid.utils import constants
|
|
|
15
15
|
|
|
16
16
|
from utils.constants import INTERVAL_TO_MS, WATCH_RETRY_SLEEP_SECONDS
|
|
17
17
|
from utils.worker import AsyncTaskQueue
|
|
18
|
+
from utils.retry import retry_api_read
|
|
18
19
|
|
|
19
20
|
decimal.getcontext().prec = 4
|
|
20
21
|
|
|
@@ -698,13 +699,24 @@ async def init_clients(use_testnet: bool, use_websocket: bool = True) -> Tuple[s
|
|
|
698
699
|
api_url = constants.TESTNET_API_URL if use_testnet else constants.MAINNET_API_URL
|
|
699
700
|
account: LocalAccount = eth_account.Account.from_key(secret_key)
|
|
700
701
|
|
|
701
|
-
|
|
702
|
-
|
|
702
|
+
async def initialize() -> Tuple[str, Info, Exchange]:
|
|
703
|
+
# Keep references before initialization so failed SDK requests cannot
|
|
704
|
+
# strand HTTP sessions or websockets inside the SDK's create() methods.
|
|
705
|
+
info = Info(api_url, skip_ws=(not use_websocket))
|
|
706
|
+
exchange = None
|
|
707
|
+
try:
|
|
708
|
+
await info.initialize()
|
|
709
|
+
exchange = Exchange(account, api_url, account_address=account_address)
|
|
710
|
+
await exchange.initialize()
|
|
711
|
+
ws_cache = HyperliquidWebsocketCache(info, account_address, enabled=use_websocket)
|
|
712
|
+
setattr(info, "ws_cache", ws_cache)
|
|
713
|
+
await ws_cache.start()
|
|
714
|
+
return account_address, info, exchange
|
|
715
|
+
except BaseException:
|
|
716
|
+
await close_clients(info, exchange)
|
|
717
|
+
raise
|
|
703
718
|
|
|
704
|
-
|
|
705
|
-
setattr(info, "ws_cache", ws_cache)
|
|
706
|
-
await ws_cache.start()
|
|
707
|
-
return account_address, info, exchange
|
|
719
|
+
return await retry_api_read(initialize, context="Hyperliquid client initialization")
|
|
708
720
|
|
|
709
721
|
|
|
710
722
|
async def close_clients(info: Optional[Info], exchange: Optional[Exchange]) -> None:
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Backoff for read-only requests and client initialization."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Awaitable, Callable, TypeVar
|
|
6
|
+
|
|
7
|
+
import aiohttp
|
|
8
|
+
from hyperliquid.utils.error import ClientError, ServerError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
T = TypeVar("T")
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def is_transient_api_error(exc: Exception) -> bool:
|
|
16
|
+
if isinstance(exc, (ClientError, ServerError)):
|
|
17
|
+
return exc.status_code == 429 or 500 <= exc.status_code < 600
|
|
18
|
+
if isinstance(exc, aiohttp.ClientResponseError):
|
|
19
|
+
return exc.status == 429 or 500 <= exc.status < 600
|
|
20
|
+
return isinstance(exc, (aiohttp.ClientConnectionError, asyncio.TimeoutError))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def retry_api_read(operation: Callable[[], Awaitable[T]], *, context: str) -> T:
|
|
24
|
+
"""Retry transient failures until recovery or cancellation; never use for orders."""
|
|
25
|
+
delay = 5.0
|
|
26
|
+
while True:
|
|
27
|
+
try:
|
|
28
|
+
return await operation()
|
|
29
|
+
except Exception as exc:
|
|
30
|
+
if not is_transient_api_error(exc):
|
|
31
|
+
raise
|
|
32
|
+
status = getattr(exc, "status_code", getattr(exc, "status", None))
|
|
33
|
+
message = (
|
|
34
|
+
f"[API-RETRY] {context}: {type(exc).__name__}"
|
|
35
|
+
f"{f' HTTP {status}' if status is not None else ''}. "
|
|
36
|
+
f"Sleeping {delay:.1f}s before retry."
|
|
37
|
+
)
|
|
38
|
+
print(message)
|
|
39
|
+
logger.warning(message)
|
|
40
|
+
await asyncio.sleep(delay)
|
|
41
|
+
delay = min(delay * 2, 60.0)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|