async-hyperliquid 1.2.0__tar.gz → 1.2.1__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.
Files changed (93) hide show
  1. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/CHANGELOG.md +16 -0
  2. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/PKG-INFO +2 -2
  3. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/README.md +1 -1
  4. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/coin-name-mapping.md +7 -0
  5. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/howto/markets.rst +2 -1
  6. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/coin-name-mapping.po +13 -0
  7. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/howto/markets.po +4 -2
  8. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/pyproject.toml +1 -1
  9. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/_internal/encoding.py +7 -3
  10. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/_internal/metadata.py +9 -1
  11. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/client.py +4 -0
  12. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/info.py +29 -7
  13. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/LICENSE +0 -0
  14. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/benchmarks/README.md +0 -0
  15. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/benchmarks/__init__.py +0 -0
  16. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/benchmarks/fast_cancel.py +0 -0
  17. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/benchmarks/hotpath.py +0 -0
  18. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/benchmarks/live/__init__.py +0 -0
  19. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/benchmarks/live/models.py +0 -0
  20. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/benchmarks/live/pacing.py +0 -0
  21. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/benchmarks/live/preflight.py +0 -0
  22. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/benchmarks/live/providers.py +0 -0
  23. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/benchmarks/live/reporting.py +0 -0
  24. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/benchmarks/live/results.py +0 -0
  25. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/benchmarks/live/runner.py +0 -0
  26. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/benchmarks/live/workload.py +0 -0
  27. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/benchmarks/live_exchange.py +0 -0
  28. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/benchmarks/results/20260805T192621Z/cancel-id-latency.png +0 -0
  29. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/benchmarks/results/20260805T192621Z/cancel-id-latency.svg +0 -0
  30. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/benchmarks/results/20260805T192621Z/report.json +0 -0
  31. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/benchmarks/results/20260805T192621Z/samples.csv +0 -0
  32. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/benchmarks/signing.py +0 -0
  33. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/_static/language-switcher.css +0 -0
  34. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/_templates/sidebar/language-switcher.html +0 -0
  35. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/conf.py +0 -0
  36. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/howto/index.rst +0 -0
  37. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/howto/info-queries.rst +0 -0
  38. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/howto/lifecycle-reconciliation.rst +0 -0
  39. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/howto/orders.rst +0 -0
  40. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/howto/routing.rst +0 -0
  41. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/howto/transfers-administration.rst +0 -0
  42. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/index.rst +0 -0
  43. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/introduction/index.rst +0 -0
  44. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/introduction/installation.rst +0 -0
  45. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/introduction/quickstart.rst +0 -0
  46. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/howto/index.po +0 -0
  47. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/howto/info-queries.po +0 -0
  48. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/howto/lifecycle-reconciliation.po +0 -0
  49. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/howto/orders.po +0 -0
  50. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/howto/routing.po +0 -0
  51. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/howto/transfers-administration.po +0 -0
  52. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/index.po +0 -0
  53. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/introduction/index.po +0 -0
  54. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/introduction/installation.po +0 -0
  55. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/introduction/quickstart.po +0 -0
  56. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/migration-0.5-to-1.0.po +0 -0
  57. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/project/about.po +0 -0
  58. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/project/benchmarks.po +0 -0
  59. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/project/index.po +0 -0
  60. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/project/license.po +0 -0
  61. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/reference/async-hyperliquid.po +0 -0
  62. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/reference/errors.po +0 -0
  63. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/reference/exchange-client.po +0 -0
  64. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/reference/index.po +0 -0
  65. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/reference/info-client.po +0 -0
  66. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/reference/response-types.po +0 -0
  67. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/reference/types.po +0 -0
  68. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/migration-0.5-to-1.0.md +0 -0
  69. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/project/about.rst +0 -0
  70. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/project/benchmarks.md +0 -0
  71. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/project/index.rst +0 -0
  72. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/project/license.rst +0 -0
  73. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/reference/async-hyperliquid.rst +0 -0
  74. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/reference/errors.rst +0 -0
  75. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/reference/exchange-client.rst +0 -0
  76. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/reference/index.rst +0 -0
  77. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/reference/info-client.rst +0 -0
  78. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/reference/response-types.rst +0 -0
  79. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/docs/reference/types.rst +0 -0
  80. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/__init__.py +0 -0
  81. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/_internal/__init__.py +0 -0
  82. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/_internal/exchange.py +0 -0
  83. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/_internal/http.py +0 -0
  84. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/_internal/info.py +0 -0
  85. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/_internal/signing.py +0 -0
  86. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/constants.py +0 -0
  87. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/errors.py +0 -0
  88. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/exchange.py +0 -0
  89. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/py.typed +0 -0
  90. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/types/__init__.py +0 -0
  91. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/types/common.py +0 -0
  92. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/types/exchange.py +0 -0
  93. {async_hyperliquid-1.2.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/types/info.py +0 -0
@@ -5,6 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.1] - 2026-11-03
9
+
10
+ ### Changed
11
+
12
+ - Accept `sz=0` in order and modify requests; it is encoded as `"0"` and left to
13
+ the exchange to validate. Negative sizes are still rejected, and `place_twap()`
14
+ still requires a positive size.
15
+ - Reload metadata once when a lookup misses the cached snapshot, so markets
16
+ listed after the first load resolve without `refresh_metadata()`. A failed
17
+ reload keeps the last snapshot and the lookup still raises `ValueError`.
18
+ - Reject non-finite or non-positive order prices with
19
+ `price must be finite and greater than zero`; a positive price that rounds to
20
+ zero now reports `order price is below market precision`.
21
+ - Market orders raise `ProtocolError` when the mid price is not finite and
22
+ positive, instead of reporting an invalid order price.
23
+
8
24
  ## [1.2.0] - 2026-11-03
9
25
 
10
26
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: async-hyperliquid
3
- Version: 1.2.0
3
+ Version: 1.2.1
4
4
  Summary: Async Hyperliquid client using aiohttp
5
5
  Keywords: dex,hyperliquid,async,aiohttp,trading,cryptocurrency,defi
6
6
  Author: Yuki
@@ -36,7 +36,7 @@ Description-Content-Type: text/markdown
36
36
 
37
37
  [![CI](https://github.com/owlyfi/async-hyperliquid/actions/workflows/ci.yml/badge.svg)](https://github.com/owlyfi/async-hyperliquid/actions/workflows/ci.yml)
38
38
  [![Documentation Status](https://readthedocs.org/projects/async-hyperliquid/badge/?version=latest)](https://async-hyperliquid.readthedocs.io/en/latest/)
39
- [![PyPI](https://img.shields.io/pypi/v/async-hyperliquid.svg?v=1.2.0)](https://pypi.org/project/async-hyperliquid/)
39
+ [![PyPI](https://img.shields.io/pypi/v/async-hyperliquid.svg?v=1.2.1)](https://pypi.org/project/async-hyperliquid/)
40
40
 
41
41
  `async-hyperliquid` is a typed, asynchronous Python client for the Hyperliquid
42
42
  REST API. It provides credential-free market and account queries through
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/owlyfi/async-hyperliquid/actions/workflows/ci.yml/badge.svg)](https://github.com/owlyfi/async-hyperliquid/actions/workflows/ci.yml)
4
4
  [![Documentation Status](https://readthedocs.org/projects/async-hyperliquid/badge/?version=latest)](https://async-hyperliquid.readthedocs.io/en/latest/)
5
- [![PyPI](https://img.shields.io/pypi/v/async-hyperliquid.svg?v=1.2.0)](https://pypi.org/project/async-hyperliquid/)
5
+ [![PyPI](https://img.shields.io/pypi/v/async-hyperliquid.svg?v=1.2.1)](https://pypi.org/project/async-hyperliquid/)
6
6
 
7
7
  `async-hyperliquid` is a typed, asynchronous Python client for the Hyperliquid
8
8
  REST API. It provides credential-free market and account queries through
@@ -13,6 +13,13 @@ The first lookup loads metadata. Concurrent cold lookups share one load;
13
13
  `refresh_metadata()` explicitly replaces the last complete snapshot. A partial
14
14
  or inconsistent response is rejected without publishing partial state.
15
15
 
16
+ A lookup for a coin missing from the cached snapshot reloads metadata once
17
+ before raising, so markets listed after the first load resolve without an
18
+ explicit refresh. If that reload fails, the last snapshot stays in place and the
19
+ lookup still raises `ValueError`. Misses are not cached: every lookup of an
20
+ unknown coin repeats the reload, so validate user input before it reaches a hot
21
+ path.
22
+
16
23
  ## Asset spaces
17
24
 
18
25
  | Market | Asset ID |
@@ -17,7 +17,8 @@ helpers instead of deriving protocol offsets in application code.
17
17
  The first metadata lookup loads one immutable snapshot. Concurrent cold
18
18
  lookups share that load. ``refresh_metadata()`` atomically replaces the last
19
19
  complete snapshot; malformed or inconsistent metadata is rejected before it is
20
- published.
20
+ published. A lookup for a coin missing from the cached snapshot reloads
21
+ metadata once before raising ``ValueError``.
21
22
 
22
23
  Coin forms
23
24
  ----------
@@ -63,6 +63,19 @@ msgstr ""
63
63
  "首次查询会加载元数据。并发冷查询共享同一次加载;`refresh_metadata()` 会显式替换最近一次完整快照。"
64
64
  "不完整或不一致的响应会被拒绝,且不会发布部分状态。"
65
65
 
66
+ #: ../../coin-name-mapping.md:16
67
+ msgid ""
68
+ "A lookup for a coin missing from the cached snapshot reloads metadata "
69
+ "once before raising, so markets listed after the first load resolve "
70
+ "without an explicit refresh. If that reload fails, the last snapshot stays "
71
+ "in place and the lookup still raises `ValueError`. Misses are not cached: "
72
+ "every lookup of an unknown coin repeats the reload, so validate user input "
73
+ "before it reaches a hot path."
74
+ msgstr ""
75
+ "若查询的 coin 不在缓存快照中,会先重新加载一次元数据再抛出错误,因此首次加载之后新上线的市场无需显式刷新即可解析。"
76
+ "如果这次重新加载失败,会保留上一次的快照,查询仍然抛出 `ValueError`。未命中的结果不会被缓存:"
77
+ "每次查询未知 coin 都会重新加载一次,因此请在用户输入进入热路径之前先做校验。"
78
+
66
79
  #: ../../coin-name-mapping.md:16 667c70e0123949059329339edf3f8ca6
67
80
  msgid "Asset spaces"
68
81
  msgstr "资产编号空间"
@@ -34,10 +34,12 @@ msgid ""
34
34
  "The first metadata lookup loads one immutable snapshot. Concurrent cold "
35
35
  "lookups share that load. ``refresh_metadata()`` atomically replaces the "
36
36
  "last complete snapshot; malformed or inconsistent metadata is rejected "
37
- "before it is published."
37
+ "before it is published. A lookup for a coin missing from the cached "
38
+ "snapshot reloads metadata once before raising ``ValueError``."
38
39
  msgstr ""
39
40
  "首次元数据查询会加载一个不可变快照。并发的冷查询会共享该加载。``refresh_metadata()`` 会原子地替换最后一个完整快照;"
40
- "格式错误或不一致的元数据会在发布前被拒绝。"
41
+ "格式错误或不一致的元数据会在发布前被拒绝。若查询的 coin 不在缓存快照中,会先重新加载一次元数据,"
42
+ "仍未命中才抛出 ``ValueError``。"
41
43
 
42
44
  #: ../../howto/markets.rst:23 a74ae47aabbf4ba5817d21a500d064ae
43
45
  msgid "Coin forms"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "async-hyperliquid"
3
- version = "1.2.0"
3
+ version = "1.2.1"
4
4
  requires-python = ">=3.12"
5
5
  description = "Async Hyperliquid client using aiohttp"
6
6
  authors = [{ name = "Yuki", email = "yuqi.lyle@gmail.com" }]
@@ -19,8 +19,10 @@ def _round_size(value: float, size_decimals: int) -> float | int:
19
19
 
20
20
  def _normalize_size(value: float, size_decimals: int) -> float | int:
21
21
  size = float(value)
22
- if not math.isfinite(size) or size <= 0:
23
- raise ValueError("size must be finite and greater than zero")
22
+ if not math.isfinite(size) or size < 0:
23
+ raise ValueError("size must be finite and non-negative")
24
+ if size == 0:
25
+ return 0
24
26
  rounded = _round_size(size, size_decimals)
25
27
  if rounded <= 0:
26
28
  raise ValueError("order size is below market precision")
@@ -49,13 +51,15 @@ def _normalize_price(
49
51
  value: float | str, *, max_decimals: int, is_outcome: bool
50
52
  ) -> float | int:
51
53
  price = float(value)
54
+ if not math.isfinite(price) or price <= 0:
55
+ raise ValueError("price must be finite and greater than zero")
52
56
  if is_outcome and not OUTCOME_MIN_PRICE <= price <= OUTCOME_MAX_PRICE:
53
57
  raise ValueError("outcome price must be between 0.00001 and 0.99999 USDC")
54
58
  rounded = _round_price(
55
59
  price, OUTCOME_PRICE_DECIMALS if is_outcome else max_decimals
56
60
  )
57
61
  if rounded <= 0:
58
- raise ValueError("order value is below market precision")
62
+ raise ValueError("order price is below market precision")
59
63
  return rounded
60
64
 
61
65
 
@@ -251,8 +251,16 @@ def _build_metadata(
251
251
  )
252
252
 
253
253
 
254
+ def _is_outcome_coin(coin: str) -> bool:
255
+ return coin.startswith(("#", "+"))
256
+
257
+
258
+ def _is_known_coin(snapshot: _MetadataSnapshot, coin: str) -> bool:
259
+ return _is_outcome_coin(coin) or coin in snapshot.coin_by_alias
260
+
261
+
254
262
  def _outcome_market_info(coin: str) -> _MarketInfo | None:
255
- if not coin.startswith(("#", "+")):
263
+ if not _is_outcome_coin(coin):
256
264
  return None
257
265
  raw_encoding = coin[1:]
258
266
  if not raw_encoding.isascii() or not raw_encoding.isdecimal():
@@ -57,6 +57,8 @@ def _coin_dex(coin: str) -> str:
57
57
  def _market_price(
58
58
  mid: float, *, is_buy: bool, slippage: float, is_outcome: bool
59
59
  ) -> float:
60
+ if not math.isfinite(mid) or mid <= 0:
61
+ raise ProtocolError("mid price must be finite and greater than zero")
60
62
  price = mid * (1 + slippage if is_buy else 1 - slippage)
61
63
  if not is_outcome:
62
64
  return price
@@ -484,6 +486,8 @@ class AsyncHyperliquid:
484
486
  if market.is_spot and reduce_only:
485
487
  raise ValueError("spot orders cannot be reduce-only")
486
488
  rounded_size = _normalize_size(size, market.size_decimals)
489
+ if rounded_size == 0:
490
+ raise ValueError("TWAP size must be greater than zero")
487
491
  details: EncodedTwapDetails | None = None
488
492
  if trigger_px is not None or stop_px is not None:
489
493
  encoded_trigger_px = (
@@ -1,4 +1,5 @@
1
1
  import asyncio
2
+ import logging
2
3
  from collections.abc import Sequence
3
4
  from types import TracebackType
4
5
  from typing import Literal, Self, cast, overload
@@ -23,9 +24,10 @@ from ._internal.metadata import (
23
24
  _MarketInfo,
24
25
  _MetadataSnapshot,
25
26
  _build_metadata,
27
+ _is_known_coin,
26
28
  _market_info,
27
29
  )
28
- from .errors import ProtocolError
30
+ from .errors import HyperliquidError, ProtocolError
29
31
  from .types import CandleInterval, JsonObject, JsonValue, Network
30
32
  from .types.info import (
31
33
  AccountState,
@@ -73,6 +75,9 @@ from .types.info import (
73
75
  )
74
76
 
75
77
 
78
+ logger = logging.getLogger(__name__)
79
+
80
+
76
81
  class InfoClient:
77
82
  """Asynchronous, credential-free client for Hyperliquid Info endpoints."""
78
83
 
@@ -506,15 +511,32 @@ class InfoClient:
506
511
  snapshot = await self._load_metadata()
507
512
  self._metadata = snapshot
508
513
 
509
- async def coin_name(self, coin: str) -> str:
514
+ async def _metadata_for(self, *coins: str) -> _MetadataSnapshot:
515
+ cold = self._metadata is None
510
516
  snapshot = await self._ensure_metadata()
517
+ if cold or all(_is_known_coin(snapshot, coin) for coin in coins):
518
+ return snapshot
519
+ async with self._get_metadata_lock():
520
+ # Concurrent misses on the same stale snapshot share one reload.
521
+ if self._metadata is snapshot:
522
+ try:
523
+ self._metadata = await self._load_metadata()
524
+ except (HyperliquidError, TimeoutError):
525
+ # Keep the miss a ValueError raised by the caller's lookup.
526
+ logger.debug("metadata refresh after a miss failed", exc_info=True)
527
+ return snapshot
528
+ # Refreshes only replace a published snapshot, never clear it.
529
+ return cast(_MetadataSnapshot, self._metadata)
530
+
531
+ async def coin_name(self, coin: str) -> str:
532
+ snapshot = await self._metadata_for(coin)
511
533
  name = snapshot.coin_by_alias.get(coin)
512
534
  if name is None:
513
535
  raise ValueError(f"unknown coin: {coin}")
514
536
  return name
515
537
 
516
538
  async def coin_symbol(self, coin: str) -> str:
517
- snapshot = await self._ensure_metadata()
539
+ snapshot = await self._metadata_for(coin)
518
540
  name = snapshot.coin_by_alias.get(coin)
519
541
  if name is None:
520
542
  raise ValueError(f"unknown coin: {coin}")
@@ -530,14 +552,14 @@ class InfoClient:
530
552
  return (await self._market_info(coin)).size_decimals
531
553
 
532
554
  async def _market_info(self, coin: str) -> _MarketInfo:
533
- return _market_info(await self._ensure_metadata(), coin)
555
+ return _market_info(await self._metadata_for(coin), coin)
534
556
 
535
557
  async def _market_infos(self, coins: Sequence[str]) -> tuple[_MarketInfo, ...]:
536
- snapshot = await self._ensure_metadata()
558
+ snapshot = await self._metadata_for(*coins)
537
559
  return tuple(_market_info(snapshot, coin) for coin in coins)
538
560
 
539
561
  async def spot_token_metadata(self, coin: str) -> SpotToken:
540
- snapshot = await self._ensure_metadata()
562
+ snapshot = await self._metadata_for(coin)
541
563
  name = snapshot.coin_by_alias.get(coin)
542
564
  token = None if name is None else snapshot.spot_token_by_coin.get(name)
543
565
  if token is None:
@@ -548,7 +570,7 @@ class InfoClient:
548
570
  return (await self.spot_token_metadata(coin))["tokenId"]
549
571
 
550
572
  async def mark_price(self, coin: str) -> float:
551
- snapshot = await self._ensure_metadata()
573
+ snapshot = await self._metadata_for(coin)
552
574
  name = snapshot.coin_by_alias.get(coin)
553
575
  if name is None:
554
576
  raise ValueError(f"unknown coin: {coin}")