async-hyperliquid 1.1.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.1.0 → async_hyperliquid-1.2.1}/CHANGELOG.md +27 -0
  2. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/PKG-INFO +2 -2
  3. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/README.md +1 -1
  4. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/benchmarks/README.md +24 -0
  5. async_hyperliquid-1.2.1/benchmarks/fast_cancel.py +178 -0
  6. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/coin-name-mapping.md +7 -0
  7. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/howto/markets.rst +2 -1
  8. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/howto/orders.rst +5 -0
  9. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/coin-name-mapping.po +13 -0
  10. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/howto/markets.po +4 -2
  11. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/howto/orders.po +12 -0
  12. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/pyproject.toml +1 -1
  13. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/_internal/encoding.py +7 -3
  14. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/_internal/metadata.py +9 -1
  15. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/client.py +32 -8
  16. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/exchange.py +11 -1
  17. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/info.py +29 -7
  18. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/types/exchange.py +2 -0
  19. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/LICENSE +0 -0
  20. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/benchmarks/__init__.py +0 -0
  21. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/benchmarks/hotpath.py +0 -0
  22. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/benchmarks/live/__init__.py +0 -0
  23. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/benchmarks/live/models.py +0 -0
  24. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/benchmarks/live/pacing.py +0 -0
  25. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/benchmarks/live/preflight.py +0 -0
  26. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/benchmarks/live/providers.py +0 -0
  27. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/benchmarks/live/reporting.py +0 -0
  28. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/benchmarks/live/results.py +0 -0
  29. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/benchmarks/live/runner.py +0 -0
  30. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/benchmarks/live/workload.py +0 -0
  31. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/benchmarks/live_exchange.py +0 -0
  32. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/benchmarks/results/20260805T192621Z/cancel-id-latency.png +0 -0
  33. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/benchmarks/results/20260805T192621Z/cancel-id-latency.svg +0 -0
  34. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/benchmarks/results/20260805T192621Z/report.json +0 -0
  35. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/benchmarks/results/20260805T192621Z/samples.csv +0 -0
  36. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/benchmarks/signing.py +0 -0
  37. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/_static/language-switcher.css +0 -0
  38. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/_templates/sidebar/language-switcher.html +0 -0
  39. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/conf.py +0 -0
  40. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/howto/index.rst +0 -0
  41. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/howto/info-queries.rst +0 -0
  42. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/howto/lifecycle-reconciliation.rst +0 -0
  43. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/howto/routing.rst +0 -0
  44. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/howto/transfers-administration.rst +0 -0
  45. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/index.rst +0 -0
  46. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/introduction/index.rst +0 -0
  47. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/introduction/installation.rst +0 -0
  48. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/introduction/quickstart.rst +0 -0
  49. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/howto/index.po +0 -0
  50. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/howto/info-queries.po +0 -0
  51. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/howto/lifecycle-reconciliation.po +0 -0
  52. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/howto/routing.po +0 -0
  53. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/howto/transfers-administration.po +0 -0
  54. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/index.po +0 -0
  55. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/introduction/index.po +0 -0
  56. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/introduction/installation.po +0 -0
  57. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/introduction/quickstart.po +0 -0
  58. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/migration-0.5-to-1.0.po +0 -0
  59. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/project/about.po +0 -0
  60. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/project/benchmarks.po +0 -0
  61. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/project/index.po +0 -0
  62. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/project/license.po +0 -0
  63. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/reference/async-hyperliquid.po +0 -0
  64. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/reference/errors.po +0 -0
  65. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/reference/exchange-client.po +0 -0
  66. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/reference/index.po +0 -0
  67. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/reference/info-client.po +0 -0
  68. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/reference/response-types.po +0 -0
  69. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/locale/zh_CN/LC_MESSAGES/reference/types.po +0 -0
  70. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/migration-0.5-to-1.0.md +0 -0
  71. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/project/about.rst +0 -0
  72. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/project/benchmarks.md +0 -0
  73. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/project/index.rst +0 -0
  74. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/project/license.rst +0 -0
  75. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/reference/async-hyperliquid.rst +0 -0
  76. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/reference/errors.rst +0 -0
  77. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/reference/exchange-client.rst +0 -0
  78. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/reference/index.rst +0 -0
  79. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/reference/info-client.rst +0 -0
  80. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/reference/response-types.rst +0 -0
  81. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/docs/reference/types.rst +0 -0
  82. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/__init__.py +0 -0
  83. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/_internal/__init__.py +0 -0
  84. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/_internal/exchange.py +0 -0
  85. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/_internal/http.py +0 -0
  86. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/_internal/info.py +0 -0
  87. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/_internal/signing.py +0 -0
  88. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/constants.py +0 -0
  89. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/errors.py +0 -0
  90. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/py.typed +0 -0
  91. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/types/__init__.py +0 -0
  92. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/types/common.py +0 -0
  93. {async_hyperliquid-1.1.0 → async_hyperliquid-1.2.1}/src/async_hyperliquid/types/info.py +0 -0
@@ -5,6 +5,33 @@ 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
+
24
+ ## [1.2.0] - 2026-11-03
25
+
26
+ ### Added
27
+
28
+ - Add `fast=True` to `cancel_order()`, `cancel_orders()`, `cancel_by_cloid()`, and
29
+ `cancel_orders_by_cloid()` to send Hyperliquid's fast-cancel `f` flag; the flag
30
+ is omitted when `fast=False`. The exchange rejects fast cancels of trigger
31
+ orders per status, so `fast` stays opt-in.
32
+ - Add testnet integration coverage for fast cancels and a diagnostic
33
+ `benchmarks/fast_cancel.py` comparing fast and non-fast cancel latency.
34
+
8
35
  ## [1.1.0] - 2026-09-14
9
36
 
10
37
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: async-hyperliquid
3
- Version: 1.1.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.1.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.1.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
@@ -207,6 +207,30 @@ Artifacts: [sanitized JSON](results/20260805T192621Z/report.json), [sample CSV](
207
207
  These measurements describe one testnet run, not exchange capacity. Shared-IP traffic, geography, testnet load, dependency versions, and network conditions can change results.
208
208
  <!-- live-exchange-benchmark:detail:end -->
209
209
 
210
+ ## Fast-cancel benchmark
211
+
212
+ `benchmarks/fast_cancel.py` compares single-order `cancel_order()` latency with
213
+ `fast=False` and `fast=True` on testnet. It uses the same `.env.local`
214
+ credentials and safety checks as the live Exchange benchmark (testnet only,
215
+ `HL_SK` must derive `HL_AK`, roles validated before any order).
216
+
217
+ Each round places `--orders` resting BTC ALO buys at `mid * 0.9` (about 12 USDC
218
+ each) in one batch, then cancels them sequentially, alternating the two methods;
219
+ the method that goes first swaps every round. Only the cancel request is timed,
220
+ pacing sleeps excluded. Any non-success cancel aborts the run, and remaining
221
+ orders are cancelled on exit.
222
+
223
+ ```bash
224
+ uv run --frozen --group benchmark python benchmarks/fast_cancel.py \
225
+ --rounds 30 --warmups 3 --orders 10
226
+ ```
227
+
228
+ The output is a plain-text table of n, mean, p50, p90, p99, min and max in ms.
229
+ Hyperliquid currently documents no effect for `fast` other than rejecting
230
+ trigger orders, so the two methods are expected to match today; the benchmark
231
+ is a baseline for the future mempool-priority upgrade. It is diagnostic only
232
+ and does not produce publishable artifacts.
233
+
210
234
  ## Signing benchmark
211
235
 
212
236
  This benchmark compares Hyperliquid signing and order-payload construction in
@@ -0,0 +1,178 @@
1
+ """Compare single-order cancel latency with fast=False and fast=True on testnet.
2
+
3
+ Each round places ``--orders`` resting BTC ALO buys far below mid in one batch,
4
+ then cancels them one at a time, alternating fast=False / fast=True. The method
5
+ that goes first swaps every round. Only the cancel request itself is timed;
6
+ pacing sleeps are excluded. Any non-success cancel invalidates the run.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import asyncio
13
+ import math
14
+ import os
15
+ import statistics
16
+ import sys
17
+ from collections.abc import Sequence
18
+ from pathlib import Path
19
+ from time import perf_counter_ns
20
+
21
+ if __package__ in (None, ""):
22
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
23
+
24
+ from async_hyperliquid import AsyncHyperliquid
25
+ from async_hyperliquid.types import (
26
+ CancelOrder,
27
+ CancelOrderResponse,
28
+ Network,
29
+ PlaceOrderRequest,
30
+ TimeInForce,
31
+ limit_order_type,
32
+ )
33
+ from benchmarks.live.models import MIN_INTERVAL_NS, BenchmarkFailure
34
+ from benchmarks.live.pacing import WeightedPacer
35
+ from benchmarks.live.preflight import Credentials, validate_roles
36
+
37
+ COIN = "BTC"
38
+ NOTIONAL_USDC = 12
39
+ PRICE_RATIO = 0.9
40
+
41
+
42
+ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
43
+ parser = argparse.ArgumentParser(description=__doc__)
44
+ parser.add_argument("--rounds", type=int, default=30)
45
+ parser.add_argument("--warmups", type=int, default=3)
46
+ parser.add_argument("--orders", type=int, default=10, help="even, per round")
47
+ parser.add_argument("--interval-ms", type=int, default=250)
48
+ args = parser.parse_args(argv)
49
+ if args.rounds < 1 or args.warmups < 0:
50
+ parser.error("--rounds must be positive and --warmups non-negative")
51
+ if args.orders < 2 or args.orders % 2:
52
+ parser.error("--orders must be an even number >= 2")
53
+ if args.interval_ms * 1_000_000 < MIN_INTERVAL_NS:
54
+ parser.error(f"--interval-ms must be at least {MIN_INTERVAL_NS // 1_000_000}")
55
+ return args
56
+
57
+
58
+ def fast_plan(round_index: int, count: int) -> tuple[bool, ...]:
59
+ """Alternate fast flags; the first method swaps every round for fairness."""
60
+ first = round_index % 2 == 1
61
+ return tuple(first if i % 2 == 0 else not first for i in range(count))
62
+
63
+
64
+ def summarize(samples_ns: Sequence[int]) -> dict[str, float]:
65
+ ms = sorted(sample / 1_000_000 for sample in samples_ns)
66
+ cuts = statistics.quantiles(ms, n=100, method="inclusive")
67
+ return {
68
+ "n": len(ms),
69
+ "mean": statistics.fmean(ms),
70
+ "p50": statistics.median(ms),
71
+ "p90": cuts[89],
72
+ "p99": cuts[98],
73
+ "min": ms[0],
74
+ "max": ms[-1],
75
+ }
76
+
77
+
78
+ def format_table(samples: dict[bool, list[int]]) -> str:
79
+ columns = ("n", "mean", "p50", "p90", "p99", "min", "max")
80
+ lines = [f"{'method':<12}" + "".join(f"{name:>10}" for name in columns)]
81
+ for fast in (False, True):
82
+ stats = summarize(samples[fast])
83
+ cells = "".join(
84
+ f"{stats[name]:>10d}" if name == "n" else f"{stats[name]:>10.2f}"
85
+ for name in columns
86
+ )
87
+ lines.append(f"{f'fast={fast}':<12}" + cells)
88
+ return "\n".join(lines) + "\n(latency in ms)"
89
+
90
+
91
+ def _cancelled(response: CancelOrderResponse) -> bool:
92
+ return response["status"] == "ok" and response["response"]["data"]["statuses"] == [
93
+ "success"
94
+ ]
95
+
96
+
97
+ async def _place_round(
98
+ client: AsyncHyperliquid, count: int, size_decimals: int
99
+ ) -> list[int]:
100
+ px = await client.info.mid_price(COIN) * PRICE_RATIO
101
+ step = 10**size_decimals
102
+ sz = math.ceil(NOTIONAL_USDC / px * step) / step
103
+ order: PlaceOrderRequest = {
104
+ "coin": COIN,
105
+ "is_buy": True,
106
+ "sz": sz,
107
+ "px": px,
108
+ "is_market": False,
109
+ "order_type": limit_order_type(TimeInForce.ALO),
110
+ }
111
+ response = await client.place_orders([order.copy() for _ in range(count)])
112
+ if response["status"] != "ok":
113
+ raise BenchmarkFailure(f"placement failed: {response['response']}")
114
+ oids: list[int] = []
115
+ for status in response["response"]["data"]["statuses"]:
116
+ resting = status.get("resting") if isinstance(status, dict) else None
117
+ if not isinstance(resting, dict):
118
+ raise BenchmarkFailure(f"order did not rest: {status!r}")
119
+ oids.append(resting["oid"])
120
+ return oids
121
+
122
+
123
+ async def _run(args: argparse.Namespace, credentials: Credentials) -> str:
124
+ pacer = WeightedPacer(interval_ns=args.interval_ms * 1_000_000)
125
+ samples: dict[bool, list[int]] = {False: [], True: []}
126
+ async with AsyncHyperliquid(
127
+ credentials.master_address,
128
+ credentials.signing_key,
129
+ vault_address=credentials.subaccount_address,
130
+ network=Network.TESTNET,
131
+ ) as client:
132
+ await client.info.refresh_metadata()
133
+ validate_roles(
134
+ credentials.master_address,
135
+ await client.info.user_role(credentials.api_wallet_address),
136
+ await client.info.user_role(credentials.subaccount_address),
137
+ )
138
+ size_decimals = await client.info.size_decimals(COIN)
139
+ pending: list[int] = []
140
+ try:
141
+ for round_index in range(args.warmups + args.rounds):
142
+ await pacer.wait(weight=1)
143
+ pending = await _place_round(client, args.orders, size_decimals)
144
+ plan = fast_plan(round_index, len(pending))
145
+ for oid, fast in zip(tuple(pending), plan, strict=True):
146
+ await pacer.wait(weight=1)
147
+ start = perf_counter_ns()
148
+ response = await client.cancel_order(
149
+ CancelOrder(COIN, oid), fast=fast
150
+ )
151
+ elapsed = perf_counter_ns() - start
152
+ if not _cancelled(response):
153
+ raise BenchmarkFailure(f"cancel failed: {response!r}")
154
+ pending.remove(oid)
155
+ if round_index >= args.warmups:
156
+ samples[fast].append(elapsed)
157
+ finally:
158
+ if pending:
159
+ await client.cancel_orders(tuple(CancelOrder(COIN, o) for o in pending))
160
+ return format_table(samples)
161
+
162
+
163
+ def main(argv: Sequence[str] | None = None) -> int:
164
+ args = parse_args(argv)
165
+ from dotenv import load_dotenv
166
+
167
+ load_dotenv(Path(".env.local"), override=False)
168
+ try:
169
+ credentials = Credentials.from_environ(os.environ)
170
+ print(asyncio.run(_run(args, credentials)))
171
+ except BenchmarkFailure as error:
172
+ print(f"fast-cancel benchmark failed: {error}", file=sys.stderr)
173
+ return 1
174
+ return 0
175
+
176
+
177
+ if __name__ == "__main__":
178
+ raise SystemExit(main())
@@ -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
  ----------
@@ -406,6 +406,11 @@ Use ``CancelOrder(coin, oid)`` with ``cancel_order()`` or ``cancel_orders()``.
406
406
  Use ``CancelByCloid(coin, cloid)`` with ``cancel_by_cloid()`` or
407
407
  ``cancel_orders_by_cloid()``. Empty batches are rejected locally.
408
408
 
409
+ All four cancel methods accept ``fast=True``, which sends Hyperliquid's ``f``
410
+ flag. The exchange rejects fast cancels that target trigger orders, and a future
411
+ network upgrade will prioritize fast cancels in the mempool. With the default
412
+ ``fast=False`` the flag is omitted from the signed action.
413
+
409
414
  ``ModifyOrderRequest`` identifies the order by integer order ID or ``Cloid`` and
410
415
  contains the complete replacement order. ``modify_order()`` returns a default
411
416
  acknowledgement; ``modify_orders()`` returns per-order statuses.
@@ -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"
@@ -288,6 +288,18 @@ msgstr ""
288
288
  "搭配使用。将 ``CancelByCloid(coin, cloid)`` 与 ``cancel_by_cloid()`` 或 "
289
289
  "``cancel_orders_by_cloid()`` 搭配使用。空批次会在本地被拒绝。"
290
290
 
291
+ #: ../../howto/orders.rst:409
292
+ msgid ""
293
+ "All four cancel methods accept ``fast=True``, which sends Hyperliquid's "
294
+ "``f`` flag. The exchange rejects fast cancels that target trigger orders,"
295
+ " and a future network upgrade will prioritize fast cancels in the "
296
+ "mempool. With the default ``fast=False`` the flag is omitted from the "
297
+ "signed action."
298
+ msgstr ""
299
+ "四个撤单方法都接受 ``fast=True``,用于发送 Hyperliquid 的 ``f`` 标志。"
300
+ "交易所会拒绝针对触发单(trigger order)的快速撤单;未来的网络升级会在 mempool "
301
+ "中优先处理快速撤单。默认 ``fast=False`` 时,签名的 action 中不包含该标志。"
302
+
291
303
  #: ../../howto/orders.rst:409 2815403bb6ba4974a7f7a2680ee41f54
292
304
  msgid ""
293
305
  "``ModifyOrderRequest`` identifies the order by integer order ID or "
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "async-hyperliquid"
3
- version = "1.1.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
@@ -332,12 +334,22 @@ class AsyncHyperliquid:
332
334
  )
333
335
 
334
336
  async def cancel_order(
335
- self, order: CancelOrder, *, expires_after: int | None = None
337
+ self,
338
+ order: CancelOrder,
339
+ *,
340
+ fast: bool = False,
341
+ expires_after: int | None = None,
336
342
  ) -> CancelOrderResponse:
337
- return await self.cancel_orders((order,), expires_after=expires_after)
343
+ return await self.cancel_orders(
344
+ (order,), fast=fast, expires_after=expires_after
345
+ )
338
346
 
339
347
  async def cancel_orders(
340
- self, orders: Sequence[CancelOrder], *, expires_after: int | None = None
348
+ self,
349
+ orders: Sequence[CancelOrder],
350
+ *,
351
+ fast: bool = False,
352
+ expires_after: int | None = None,
341
353
  ) -> CancelOrderResponse:
342
354
  commands = tuple(orders)
343
355
  if not commands:
@@ -350,16 +362,26 @@ class AsyncHyperliquid:
350
362
  for order, market in zip(commands, markets, strict=True)
351
363
  )
352
364
  return await self._exchange._submit_cancels(
353
- cancels, expires_after=expires_after
365
+ cancels, fast=fast, expires_after=expires_after
354
366
  )
355
367
 
356
368
  async def cancel_by_cloid(
357
- self, order: CancelByCloid, *, expires_after: int | None = None
369
+ self,
370
+ order: CancelByCloid,
371
+ *,
372
+ fast: bool = False,
373
+ expires_after: int | None = None,
358
374
  ) -> CancelOrderResponse:
359
- return await self.cancel_orders_by_cloid((order,), expires_after=expires_after)
375
+ return await self.cancel_orders_by_cloid(
376
+ (order,), fast=fast, expires_after=expires_after
377
+ )
360
378
 
361
379
  async def cancel_orders_by_cloid(
362
- self, orders: Sequence[CancelByCloid], *, expires_after: int | None = None
380
+ self,
381
+ orders: Sequence[CancelByCloid],
382
+ *,
383
+ fast: bool = False,
384
+ expires_after: int | None = None,
363
385
  ) -> CancelOrderResponse:
364
386
  commands = tuple(orders)
365
387
  if not commands:
@@ -372,7 +394,7 @@ class AsyncHyperliquid:
372
394
  for order, market in zip(commands, markets, strict=True)
373
395
  )
374
396
  return await self._exchange._submit_cloid_cancels(
375
- cancels, expires_after=expires_after
397
+ cancels, fast=fast, expires_after=expires_after
376
398
  )
377
399
 
378
400
  @staticmethod
@@ -464,6 +486,8 @@ class AsyncHyperliquid:
464
486
  if market.is_spot and reduce_only:
465
487
  raise ValueError("spot orders cannot be reduce-only")
466
488
  rounded_size = _normalize_size(size, market.size_decimals)
489
+ if rounded_size == 0:
490
+ raise ValueError("TWAP size must be greater than zero")
467
491
  details: EncodedTwapDetails | None = None
468
492
  if trigger_px is not None or stop_px is not None:
469
493
  encoded_trigger_px = (
@@ -195,24 +195,34 @@ class ExchangeClient:
195
195
  return await self._submit_action(action, "order", expires_after=expires_after)
196
196
 
197
197
  async def _submit_cancels(
198
- self, cancels: Sequence[EncodedCancel], *, expires_after: int | None = None
198
+ self,
199
+ cancels: Sequence[EncodedCancel],
200
+ *,
201
+ fast: bool = False,
202
+ expires_after: int | None = None,
199
203
  ) -> CancelOrderResponse:
200
204
  encoded = list(cancels)
201
205
  if not encoded:
202
206
  raise ValueError("orders must not be empty")
203
207
  action = CancelAction(type="cancel", cancels=encoded)
208
+ if fast:
209
+ # Hyperliquid rejects actions hashed with f=false, so only send f=true.
210
+ action["f"] = True
204
211
  return await self._submit_action(action, "cancel", expires_after=expires_after)
205
212
 
206
213
  async def _submit_cloid_cancels(
207
214
  self,
208
215
  cancels: Sequence[EncodedCancelByCloid],
209
216
  *,
217
+ fast: bool = False,
210
218
  expires_after: int | None = None,
211
219
  ) -> CancelOrderResponse:
212
220
  encoded = list(cancels)
213
221
  if not encoded:
214
222
  raise ValueError("orders must not be empty")
215
223
  action = CancelByCloidAction(type="cancelByCloid", cancels=encoded)
224
+ if fast:
225
+ action["f"] = True
216
226
  return await self._submit_action(action, "cancel", expires_after=expires_after)
217
227
 
218
228
  async def _post_envelope(
@@ -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}")
@@ -144,6 +144,7 @@ class EncodedCancel(TypedDict):
144
144
  class CancelAction(TypedDict):
145
145
  type: Literal["cancel"]
146
146
  cancels: list[EncodedCancel]
147
+ f: NotRequired[Literal[True]]
147
148
 
148
149
 
149
150
  class EncodedCancelByCloid(TypedDict):
@@ -154,6 +155,7 @@ class EncodedCancelByCloid(TypedDict):
154
155
  class CancelByCloidAction(TypedDict):
155
156
  type: Literal["cancelByCloid"]
156
157
  cancels: list[EncodedCancelByCloid]
158
+ f: NotRequired[Literal[True]]
157
159
 
158
160
 
159
161
  class EncodedModify(TypedDict):