tribulnation-hyperliquid 0.1.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.
Files changed (34) hide show
  1. tribulnation_hyperliquid-0.1.0/PKG-INFO +20 -0
  2. tribulnation_hyperliquid-0.1.0/README.md +9 -0
  3. tribulnation_hyperliquid-0.1.0/pyproject.toml +19 -0
  4. tribulnation_hyperliquid-0.1.0/setup.cfg +4 -0
  5. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/__init__.py +2 -0
  6. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/__init__.pyi +8 -0
  7. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/core/__init__.py +12 -0
  8. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/core/constants.py +41 -0
  9. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/core/exc.py +48 -0
  10. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/core/settings.py +8 -0
  11. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/__init__.py +6 -0
  12. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/impl/__init__.py +14 -0
  13. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/impl/depth.py +44 -0
  14. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/impl/funding.py +50 -0
  15. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/impl/index.py +19 -0
  16. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/impl/mixin.py +363 -0
  17. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/impl/orders.py +125 -0
  18. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/impl/perps_position.py +17 -0
  19. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/impl/perps_rules.py +46 -0
  20. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/impl/spot_position.py +17 -0
  21. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/impl/spot_rules.py +48 -0
  22. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/impl/trades.py +68 -0
  23. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/perps_exchange.py +29 -0
  24. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/perps_market.py +113 -0
  25. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/spot_exchange.py +49 -0
  26. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/spot_market.py +92 -0
  27. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/venue.py +38 -0
  28. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/report/__init__.py +1 -0
  29. tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/report/snapshots.py +77 -0
  30. tribulnation_hyperliquid-0.1.0/src/tribulnation_hyperliquid.egg-info/PKG-INFO +20 -0
  31. tribulnation_hyperliquid-0.1.0/src/tribulnation_hyperliquid.egg-info/SOURCES.txt +32 -0
  32. tribulnation_hyperliquid-0.1.0/src/tribulnation_hyperliquid.egg-info/dependency_links.txt +1 -0
  33. tribulnation_hyperliquid-0.1.0/src/tribulnation_hyperliquid.egg-info/requires.txt +2 -0
  34. tribulnation_hyperliquid-0.1.0/src/tribulnation_hyperliquid.egg-info/top_level.txt +1 -0
@@ -0,0 +1,20 @@
1
+ Metadata-Version: 2.4
2
+ Name: tribulnation-hyperliquid
3
+ Version: 0.1.0
4
+ Summary: Tribulnation SDK implementation for Hyperliquid.
5
+ Author-email: Marcel Claramunt <marcel@tribulnation.com>
6
+ Project-URL: Repository, https://github.com/tribulnation/sdk.git
7
+ Requires-Python: >=3.10
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: tribulnation-sdk
10
+ Requires-Dist: typed-hyperliquid
11
+
12
+ # Hyperliquid SDK
13
+
14
+ > Tribulnation SDK implementation for Hyperliquid.
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ pip install tribulnation-hyperliquid
20
+ ```
@@ -0,0 +1,9 @@
1
+ # Hyperliquid SDK
2
+
3
+ > Tribulnation SDK implementation for Hyperliquid.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install tribulnation-hyperliquid
9
+ ```
@@ -0,0 +1,19 @@
1
+ [build-system]
2
+ requires = ["setuptools", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "tribulnation-hyperliquid"
7
+ version = "0.1.0"
8
+ authors = [
9
+ {name="Marcel Claramunt", email="marcel@tribulnation.com"}
10
+ ]
11
+ description = "Tribulnation SDK implementation for Hyperliquid."
12
+ dependencies = [
13
+ "tribulnation-sdk", "typed-hyperliquid"
14
+ ]
15
+ requires-python = ">=3.10"
16
+ readme = {file="README.md", content-type="text/markdown"}
17
+
18
+ [project.urls]
19
+ Repository = "https://github.com/tribulnation/sdk.git"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ import lazy_loader as lazy
2
+ __getattr__, __dir__, __all__ = lazy.attach_stub(__name__, __file__)
@@ -0,0 +1,8 @@
1
+ from .market import HyperliquidMarket, Settings
2
+ from .report import Snapshots
3
+
4
+ __all__ = [
5
+ 'HyperliquidMarket',
6
+ 'Settings',
7
+ 'Snapshots',
8
+ ]
@@ -0,0 +1,12 @@
1
+ from .exc import wrap_exceptions
2
+ from .constants import (
3
+ PRICE_MAX_DECIMALS,
4
+ SPOT_PRICE_MAX_DECIMALS,
5
+ FUTURES_PRICE_MAX_DECIMALS,
6
+ MAX_SIGNIFICANT_FIGURES,
7
+ MIN_ORDER_VALUE,
8
+ MIN_RELATIVE_PRICE,
9
+ MAX_RELATIVE_PRICE,
10
+ round_price,
11
+ )
12
+ from .settings import Settings
@@ -0,0 +1,41 @@
1
+ from decimal import Decimal, ROUND_HALF_UP
2
+
3
+ PRICE_MAX_DECIMALS = 5
4
+ """See https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/tick-and-lot-size"""
5
+ SPOT_PRICE_MAX_DECIMALS = 8
6
+ """See https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/tick-and-lot-size"""
7
+ FUTURES_PRICE_MAX_DECIMALS = 6
8
+ """See https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/tick-and-lot-size"""
9
+ MAX_SIGNIFICANT_FIGURES = 5
10
+ """See https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/tick-and-lot-size"""
11
+ MIN_ORDER_VALUE = Decimal(10) # MIN ORDER VALUE IN USD
12
+ """Not specified in the docs, but the API returns errors for orders with less than $10."""
13
+ MIN_RELATIVE_PRICE = Decimal('0.2')
14
+ """Not specified in the docs, but the API returns errors for orders >80% away from the current price."""
15
+ MAX_RELATIVE_PRICE = Decimal('1.8')
16
+ """Not specified in the docs, but the API returns errors for orders >80% away from the current price."""
17
+
18
+
19
+ def round_price(price: Decimal, max_sig_figs: int = MAX_SIGNIFICANT_FIGURES) -> Decimal:
20
+ """
21
+ Round `price` to at most `max_sig_figs` significant figures.
22
+ - If `price` is zero or integral, return it unchanged.
23
+
24
+ See https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/tick-and-lot-size for details
25
+ """
26
+ if price.is_zero():
27
+ return price
28
+
29
+ if price >= 10000:
30
+ return price.to_integral_value()
31
+
32
+ x = price.normalize()
33
+ k = x.adjusted()
34
+
35
+ # Number of decimal places needed to keep `max_sig_figs` significant digits
36
+ decimal_places = max_sig_figs - 1 - k
37
+
38
+ # quant = 10^(-decimal_places); works for positive or negative decimal_places
39
+ quant = Decimal(1).scaleb(-decimal_places)
40
+
41
+ return x.quantize(quant, rounding=ROUND_HALF_UP)
@@ -0,0 +1,48 @@
1
+ from functools import wraps
2
+ import inspect
3
+
4
+ from tribulnation.sdk.core import NetworkError, ValidationError, ApiError, Error
5
+ from hyperliquid import core
6
+
7
+ def wrap_exceptions(fn):
8
+ if inspect.iscoroutinefunction(fn):
9
+ @wraps(fn)
10
+ async def wrapper(*args, **kwargs): # type: ignore
11
+ try:
12
+ return await fn(*args, **kwargs)
13
+ except core.NetworkError as e:
14
+ raise NetworkError(*e.args) from e
15
+ except core.ValidationError as e:
16
+ raise ValidationError(*e.args) from e
17
+ except core.ApiError as e:
18
+ raise ApiError(*e.args) from e
19
+ except core.Error as e:
20
+ raise Error(*e.args) from e
21
+
22
+ elif inspect.isgeneratorfunction(fn):
23
+ @wraps(fn)
24
+ async def wrapper(*args, **kwargs): # type: ignore
25
+ try:
26
+ return await fn(*args, **kwargs)
27
+ except core.NetworkError as e:
28
+ raise NetworkError(*e.args) from e
29
+ except core.ValidationError as e:
30
+ raise ValidationError(*e.args) from e
31
+ except core.ApiError as e:
32
+ raise ApiError(*e.args) from e
33
+ except core.Error as e:
34
+ raise Error(*e.args) from e
35
+ else:
36
+ @wraps(fn)
37
+ def wrapper(*args, **kwargs):
38
+ try:
39
+ return fn(*args, **kwargs)
40
+ except core.NetworkError as e:
41
+ raise NetworkError(*e.args) from e
42
+ except core.ValidationError as e:
43
+ raise ValidationError(*e.args) from e
44
+ except core.ApiError as e:
45
+ raise ApiError(*e.args) from e
46
+ except core.Error as e:
47
+ raise Error(*e.args) from e
48
+ return wrapper
@@ -0,0 +1,8 @@
1
+ from typing_extensions import TypedDict, Literal
2
+ from hyperliquid.exchange.order import TimeInForce
3
+
4
+ class Settings(TypedDict, total=False):
5
+ validate: bool
6
+ reduce_only: bool
7
+ limit_tif: TimeInForce
8
+ index_price: Literal['oracle', 'mark']
@@ -0,0 +1,6 @@
1
+ from .impl import Settings
2
+ from .spot_exchange import SpotExchange
3
+ from .spot_market import SpotMarket
4
+ from .perps_exchange import PerpExchange
5
+ from .perps_market import PerpMarket
6
+ from .venue import HyperliquidMarket
@@ -0,0 +1,14 @@
1
+ from .mixin import Shared, SharedMixin, SpotMixin, PerpMixin, SpotMarketMixin, PerpMarketMixin, SpotMeta, PerpMeta, Settings
2
+
3
+ from .depth import depth, depth_stream
4
+ from .orders import open_orders, place_order, cancel_order, query_order
5
+ from .trades import trades_history, trades_stream
6
+
7
+ from .spot_rules import rules as spot_rules
8
+ from .spot_position import position as spot_position
9
+
10
+ from .perps_rules import rules as perps_rules
11
+ from .perps_position import position as perps_position
12
+
13
+ from .index import index
14
+ from .funding import next_funding, funding_history, funding_payments
@@ -0,0 +1,44 @@
1
+ from decimal import Decimal
2
+
3
+ from tribulnation.sdk.core import Stream
4
+ from tribulnation.sdk.market import Book
5
+
6
+ from tribulnation.hyperliquid.core import wrap_exceptions
7
+ from .mixin import SpotMarketMixin, PerpMarketMixin
8
+
9
+ Mixin = SpotMarketMixin | PerpMarketMixin
10
+
11
+ @wrap_exceptions
12
+ async def depth(self: Mixin) -> Book:
13
+ book = await self.client.info.l2_book(self.asset_name)
14
+ raw_bids, raw_asks = book["levels"]
15
+ bids = [Book.Entry(price=Decimal(b["px"]), qty=Decimal(b["sz"])) for b in raw_bids]
16
+ asks = [Book.Entry(price=Decimal(a["px"]), qty=Decimal(a["sz"])) for a in raw_asks]
17
+ return Book(
18
+ bids=sorted(bids, key=lambda e: e.price, reverse=True),
19
+ asks=sorted(asks, key=lambda e: e.price),
20
+ )
21
+
22
+
23
+ async def depth_stream(self: Mixin) -> Stream[Book]:
24
+ l2 = await self.subscribe_l2_book(self.asset_name)
25
+
26
+ async def stream():
27
+ async for update in l2:
28
+ raw_bids, raw_asks = update["levels"]
29
+ # Hyperliquid `l2Book` is a snapshot-per-message feed.
30
+ book = Book(
31
+ bids=sorted(
32
+ [Book.Entry(price=Decimal(b["px"]), qty=Decimal(b["sz"])) for b in raw_bids],
33
+ key=lambda e: e.price,
34
+ reverse=True,
35
+ ),
36
+ asks=sorted(
37
+ [Book.Entry(price=Decimal(a["px"]), qty=Decimal(a["sz"])) for a in raw_asks],
38
+ key=lambda e: e.price,
39
+ ),
40
+ )
41
+ yield book
42
+
43
+ return Stream(stream(), l2.unsubscribe)
44
+
@@ -0,0 +1,50 @@
1
+ from typing_extensions import AsyncIterable, Sequence
2
+ from datetime import datetime, timedelta
3
+ from decimal import Decimal
4
+
5
+ from tribulnation.sdk.market import FundingRate, FundingPayment
6
+
7
+ from hyperliquid.core import timestamp as ts
8
+ from tribulnation.hyperliquid.core import wrap_exceptions
9
+ from .mixin import PerpMarketMixin
10
+
11
+
12
+ @wrap_exceptions
13
+ async def next_funding(self: PerpMarketMixin) -> FundingRate:
14
+ _, perp_meta, asset_ctxs = await self.shared.load_perp_meta_for_dex(self.dex_name, refetch=True)
15
+ if perp_meta["universe"][self.asset_idx]["name"] != self.asset_name:
16
+ raise ValueError(
17
+ f"Expected asset {self.asset_name} at index {self.asset_idx}, got {perp_meta['universe'][self.asset_idx]['name']}"
18
+ )
19
+
20
+ funding = Decimal(asset_ctxs[self.asset_idx]["funding"])
21
+ now = datetime.now().astimezone()
22
+ next_time = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
23
+ return FundingRate(rate=funding, time=next_time)
24
+
25
+
26
+ @wrap_exceptions
27
+ async def funding_history(self: PerpMarketMixin, start: datetime, end: datetime) -> AsyncIterable[Sequence[FundingRate]]:
28
+ start_ts, end_ts = ts.dump(start), ts.dump(end)
29
+ async for chunk in self.client.info.funding_history_paged(self.asset_name, start_ts, end_time=end_ts):
30
+ yield [
31
+ FundingRate(rate=Decimal(entry["fundingRate"]), time=ts.parse(entry["time"]).astimezone())
32
+ for entry in chunk
33
+ ]
34
+
35
+
36
+ @wrap_exceptions
37
+ async def funding_payments(self: PerpMarketMixin, start: datetime, end: datetime) -> AsyncIterable[Sequence[FundingPayment]]:
38
+ start_ts, end_ts = ts.dump(start), ts.dump(end)
39
+ async for chunk in self.client.info.user_funding_paged(self.address, start_ts, end_time=end_ts):
40
+ payments: list[FundingPayment] = []
41
+ for p in chunk:
42
+ if p["delta"]["coin"] != self.asset_name:
43
+ continue
44
+ t = ts.parse(p["time"]).astimezone()
45
+ if t < start or t > end:
46
+ continue
47
+ payments.append(FundingPayment(amount=Decimal(p["delta"]["usdc"]), time=t))
48
+ if payments:
49
+ yield payments
50
+
@@ -0,0 +1,19 @@
1
+ from decimal import Decimal
2
+
3
+ from tribulnation.hyperliquid.core import wrap_exceptions
4
+ from .mixin import PerpMarketMixin
5
+
6
+
7
+ @wrap_exceptions
8
+ async def index(self: PerpMarketMixin) -> Decimal:
9
+ _, perp_meta, asset_ctxs = await self.shared.load_perp_meta_for_dex(self.dex_name, refetch=True)
10
+ if perp_meta["universe"][self.asset_idx]["name"] != self.asset_name:
11
+ raise ValueError(
12
+ f"Expected asset {self.asset_name} at index {self.asset_idx}, got {perp_meta['universe'][self.asset_idx]['name']}"
13
+ )
14
+
15
+ ctx = asset_ctxs[self.asset_idx]
16
+ if self.index_price == "oracle" or (mark := ctx.get("markPx")) is None:
17
+ return Decimal(ctx["oraclePx"])
18
+ return Decimal(mark)
19
+
@@ -0,0 +1,363 @@
1
+ from typing_extensions import TypedDict, Literal, NotRequired
2
+ from dataclasses import dataclass, field
3
+ import asyncio
4
+ import os
5
+
6
+ from tribulnation.sdk.core import SDK, Stream, Subscription
7
+
8
+ from hyperliquid import Hyperliquid, Wallet
9
+ from hyperliquid.info.spot.spot_meta import SpotMetaResponse, SpotAssetInfo, SpotTokenInfo
10
+ from hyperliquid.info.methods.user_fees import UserFeesResponse
11
+ from hyperliquid.info.perps.perp_meta_and_asset_ctxs import (
12
+ PerpMeta as PerpMetaResponse,
13
+ PerpAssetCtx,
14
+ PerpAssetInfo,
15
+ )
16
+ from hyperliquid.info.perps.perp_dexs import PerpDex
17
+ from hyperliquid.streams.user_fills import WsUserFills
18
+ from hyperliquid.streams.l2_book import L2BookData
19
+
20
+ from tribulnation.hyperliquid.core import Settings, wrap_exceptions
21
+
22
+ class DEX(TypedDict):
23
+ name: str
24
+ idx: int
25
+
26
+ class SpotMeta(TypedDict):
27
+ asset_meta: SpotAssetInfo
28
+ base_meta: SpotTokenInfo
29
+ quote_meta: SpotTokenInfo
30
+
31
+ class PerpMeta(TypedDict):
32
+ asset_idx: int
33
+ asset_meta: PerpAssetInfo
34
+ collateral_meta: SpotTokenInfo
35
+
36
+ def find_asset_idx(name: str, perp_meta: PerpMetaResponse) -> int:
37
+ for idx, asset in enumerate(perp_meta['universe']):
38
+ if asset['name'] == name:
39
+ return idx
40
+ raise ValueError(f'Perp {name} not found')
41
+
42
+ def find_dex_idx(name: str, dexs: list[PerpDex | None]) -> int:
43
+ for idx, obj in enumerate(dexs):
44
+ if obj and obj['name'] == name:
45
+ return idx
46
+ raise ValueError(f'DEX {name} not found')
47
+
48
+
49
+ def spot_meta_of(spot_index: int, /, *, spot_meta: SpotMetaResponse) -> SpotMeta:
50
+ # Canonical spot id uses `spotMeta.universe[].index`.
51
+ pos = next((i for i, a in enumerate(spot_meta['universe']) if a['index'] == spot_index), None)
52
+ if pos is None:
53
+ raise ValueError(f'Spot index {spot_index} not found in spot_meta universe')
54
+ asset_meta = spot_meta['universe'][pos]
55
+ base_idx, quote_idx = asset_meta['tokens']
56
+ tokens_by_index = {t['index']: t for t in spot_meta['tokens']}
57
+ return {
58
+ 'asset_meta': asset_meta,
59
+ 'base_meta': tokens_by_index[base_idx],
60
+ 'quote_meta': tokens_by_index[quote_idx],
61
+ }
62
+
63
+
64
+ @dataclass(kw_only=True)
65
+ class Shared:
66
+ client: Hyperliquid
67
+ address: str
68
+ settings: Settings = field(default_factory=Settings)
69
+
70
+ # Cached meta (venue-wide).
71
+ spot_meta: SpotMetaResponse | None = None
72
+ # Lightweight DEX directory: idx -> PerpDex | None (wire type allows None entries).
73
+ perp_dexs: dict[int, PerpDex | None] | None = None
74
+ # Perp meta keyed by dex index; None key is used for the 'no dex' case.
75
+ perp_metas: dict[int | None, PerpMetaResponse] = field(default_factory=dict)
76
+ perp_asset_ctxs: dict[int | None, list[PerpAssetCtx]] = field(default_factory=dict)
77
+ user_fees: UserFeesResponse | None = None
78
+
79
+ # Stream subscriptions.
80
+ user_fills_subscription: Subscription[WsUserFills] | None = None
81
+ l2_book_subscriptions: dict[str, Subscription[L2BookData]] = field(default_factory=dict)
82
+
83
+ # Locks for concurrent lazy loads.
84
+ _spot_meta_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False)
85
+ _perp_meta_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False)
86
+ _fees_lock: asyncio.Lock = field(default_factory=asyncio.Lock, init=False, repr=False)
87
+
88
+ @wrap_exceptions
89
+ async def __aenter__(self):
90
+ await self.client.__aenter__()
91
+ return self
92
+
93
+ @wrap_exceptions
94
+ async def __aexit__(self, exc_type, exc_value, traceback):
95
+ await self.client.__aexit__(exc_type, exc_value, traceback)
96
+
97
+ @wrap_exceptions
98
+ async def load_spot_meta(self, *, refetch: bool = False) -> SpotMetaResponse:
99
+ if not refetch and self.spot_meta is not None:
100
+ return self.spot_meta
101
+ async with self._spot_meta_lock:
102
+ if not refetch and self.spot_meta is not None:
103
+ return self.spot_meta
104
+ self.spot_meta = await self.client.info.spot_meta()
105
+ return self.spot_meta
106
+
107
+ @wrap_exceptions
108
+ async def load_perp_dexs(self, *, refetch: bool = False) -> dict[int, PerpDex | None]:
109
+ """
110
+ Load and cache the list of DEXs (lightweight). Keyed by dex index.
111
+ """
112
+ if not refetch and self.perp_dexs is not None:
113
+ return self.perp_dexs
114
+ # No separate lock; cost is small and we typically call this rarely.
115
+ dexs = await self.client.info.perp_dexs()
116
+ self.perp_dexs = {idx: dex for idx, dex in enumerate(dexs)}
117
+ return self.perp_dexs
118
+
119
+ @wrap_exceptions
120
+ async def load_perp_meta_for_dex(
121
+ self,
122
+ dex_name: str | None,
123
+ *,
124
+ refetch: bool = False,
125
+ ) -> tuple[int, PerpMetaResponse, list[PerpAssetCtx]]:
126
+ """
127
+ Load perp meta + asset ctxs for a given dex name, caching per dex index.
128
+ `dex_name = None` uses the default/no-dex universe.
129
+ """
130
+ if dex_name is None:
131
+ dex_idx = 0
132
+ else:
133
+ dexs = await self.load_perp_dexs()
134
+ dex_idx = find_dex_idx(dex_name, list(dexs.values()))
135
+
136
+ key = dex_idx
137
+ if not refetch and key in self.perp_metas and key in self.perp_asset_ctxs:
138
+ return key, self.perp_metas[key], self.perp_asset_ctxs[key]
139
+ async with self._perp_meta_lock:
140
+ if not refetch and key in self.perp_metas and key in self.perp_asset_ctxs:
141
+ return key, self.perp_metas[key], self.perp_asset_ctxs[key]
142
+ perp_meta, asset_ctxs = await self.client.info.perp_meta_and_asset_ctxs(dex_name)
143
+ self.perp_metas[key] = perp_meta
144
+ self.perp_asset_ctxs[key] = asset_ctxs
145
+ return key, perp_meta, asset_ctxs
146
+
147
+ @wrap_exceptions
148
+ async def load_user_fees(self, *, refetch: bool = False) -> UserFeesResponse:
149
+ if not refetch and self.user_fees is not None:
150
+ return self.user_fees
151
+ async with self._fees_lock:
152
+ if not refetch and self.user_fees is not None:
153
+ return self.user_fees
154
+ self.user_fees = await self.client.info.user_fees(self.address)
155
+ return self.user_fees
156
+
157
+ async def resolve_dex_idx(self, dex_name: str | None, *, refetch: bool = False) -> int:
158
+ """
159
+ Convenience wrapper that just returns the dex index for a given dex name.
160
+ Uses the same caching as `load_perp_meta_for_dex`.
161
+ """
162
+ idx, _, _ = await self.load_perp_meta_for_dex(dex_name, refetch=refetch)
163
+ return idx
164
+
165
+ def user_fills_sub(self) -> Subscription[WsUserFills]:
166
+ if self.user_fills_subscription is None:
167
+ async def subscribe_user_fills() -> Stream[WsUserFills]:
168
+ stream = await self.client.streams.user_fills(self.address, aggregate_by_time=True)
169
+ return Stream(stream, stream.unsubscribe)
170
+ self.user_fills_subscription = Subscription.of(subscribe_user_fills)
171
+ return self.user_fills_subscription
172
+
173
+ def l2_book_subscription(self, coin: str, /) -> Subscription[L2BookData]:
174
+ if coin not in self.l2_book_subscriptions:
175
+ async def subscribe() -> Stream[L2BookData]:
176
+ stream = await self.client.streams.l2_book(coin)
177
+ return Stream(stream, stream.unsubscribe)
178
+ self.l2_book_subscriptions[coin] = Subscription.of(subscribe)
179
+ return self.l2_book_subscriptions[coin]
180
+
181
+ @wrap_exceptions
182
+ async def spot_meta_of(
183
+ self,
184
+ spot_index: int,
185
+ /,
186
+ *,
187
+ refetch: bool = False,
188
+ ) -> SpotMeta:
189
+ spot_meta = await self.load_spot_meta(refetch=refetch)
190
+ return spot_meta_of(spot_index, spot_meta=spot_meta)
191
+
192
+ @wrap_exceptions
193
+ async def perp_meta_of(
194
+ self,
195
+ market: str,
196
+ /,
197
+ *,
198
+ dex_name: str | None,
199
+ refetch: bool = False,
200
+ ) -> PerpMeta:
201
+ spot_meta = await self.load_spot_meta(refetch=refetch)
202
+ dex_idx, perp_meta, _ = await self.load_perp_meta_for_dex(dex_name, refetch=refetch)
203
+
204
+ asset_idx = find_asset_idx(market, perp_meta)
205
+ collateral_idx = perp_meta['collateralToken']
206
+ tokens_by_index = {t['index']: t for t in spot_meta['tokens']}
207
+
208
+ return PerpMeta(
209
+ asset_idx=asset_idx,
210
+ asset_meta=perp_meta['universe'][asset_idx],
211
+ collateral_meta=tokens_by_index[collateral_idx],
212
+ )
213
+
214
+ @dataclass(frozen=True)
215
+ class SharedMixin:
216
+ shared: Shared
217
+
218
+ @classmethod
219
+ def http(
220
+ cls, address: str | None = None, *, wallet: Wallet | None = None,
221
+ mainnet: bool = True, settings: Settings = {}
222
+ ):
223
+ if address is None:
224
+ address = os.environ['HYPERLIQUID_ADDRESS'] if mainnet else os.environ['HYPERLIQUID_TESTNET_ADDRESS']
225
+ client = Hyperliquid.http(wallet, mainnet=mainnet, validate=settings.get('validate', True))
226
+ return cls(shared=Shared(client=client, address=address, settings=settings))
227
+
228
+ @classmethod
229
+ def ws(
230
+ cls, address: str | None = None, *, wallet: Wallet | None = None,
231
+ mainnet: bool = True, settings: Settings = {}
232
+ ):
233
+ if address is None:
234
+ address = os.environ['HYPERLIQUID_ADDRESS']
235
+ client = Hyperliquid.ws(wallet, mainnet=mainnet, validate=settings.get('validate', True))
236
+ return cls(shared=Shared(client=client, address=address, settings=settings))
237
+
238
+ @property
239
+ def client(self) -> Hyperliquid:
240
+ return self.shared.client
241
+
242
+ @property
243
+ def address(self) -> str:
244
+ return self.shared.address
245
+
246
+ @property
247
+ def settings(self) -> Settings:
248
+ return self.shared.settings
249
+
250
+ @property
251
+ def index_price(self) -> Literal['oracle', 'mark']:
252
+ return self.settings.get('index_price', 'oracle')
253
+
254
+ async def __aenter__(self):
255
+ await self.shared.__aenter__()
256
+ return self
257
+
258
+ async def __aexit__(self, exc_type, exc_value, traceback):
259
+ await self.shared.__aexit__(exc_type, exc_value, traceback)
260
+
261
+ async def subscribe_user_fills(self) -> Stream[WsUserFills]:
262
+ return await self.shared.user_fills_sub().subscribe()
263
+
264
+ async def subscribe_l2_book(self, coin: str, /) -> Stream[L2BookData]:
265
+ return await self.shared.l2_book_subscription(coin).subscribe()
266
+
267
+
268
+ @dataclass(kw_only=True, frozen=True)
269
+ class SpotMixin(SharedMixin):
270
+ ...
271
+
272
+
273
+ @dataclass(kw_only=True, frozen=True)
274
+ class SpotMarketMixin(SDK, SpotMixin):
275
+ meta: SpotMeta
276
+
277
+ @property
278
+ def asset_idx(self) -> int:
279
+ return self.meta['asset_meta']['index']
280
+
281
+ @property
282
+ def asset_meta(self) -> SpotAssetInfo:
283
+ return self.meta['asset_meta']
284
+
285
+ @property
286
+ def asset_name(self) -> str:
287
+ return self.asset_meta['name']
288
+
289
+ @property
290
+ def base_meta(self):
291
+ return self.meta['base_meta']
292
+
293
+ @property
294
+ def quote_meta(self):
295
+ return self.meta['quote_meta']
296
+
297
+ @property
298
+ def base_name(self) -> str:
299
+ return self.meta['base_meta']['name']
300
+
301
+ @property
302
+ def quote_name(self) -> str:
303
+ return self.meta['quote_meta']['name']
304
+
305
+ @property
306
+ def asset_id(self) -> int:
307
+ return 10000 + self.asset_idx
308
+
309
+
310
+ @dataclass(kw_only=True, frozen=True)
311
+ class PerpMixin(SharedMixin):
312
+ dex: DEX | None
313
+
314
+ @classmethod
315
+ async def fetch(cls, shared: Shared, *, dex: str | None = None):
316
+ if dex: # we treat '' and None equivalently
317
+ dex_idx = await shared.resolve_dex_idx(dex)
318
+ dex_obj: DEX | None = {'name': dex, 'idx': dex_idx}
319
+ else:
320
+ dex_obj = None
321
+ return cls(shared, dex=dex_obj)
322
+
323
+ @property
324
+ def dex_idx(self) -> int | None:
325
+ if self.dex is not None:
326
+ return self.dex['idx']
327
+
328
+ @property
329
+ def dex_name(self) -> str | None:
330
+ if self.dex is not None:
331
+ return self.dex['name']
332
+
333
+ @dataclass(kw_only=True, frozen=True)
334
+ class PerpMarketMixin(SDK, PerpMixin):
335
+ meta: PerpMeta
336
+
337
+ @property
338
+ def asset_idx(self) -> int:
339
+ return self.meta['asset_idx']
340
+
341
+ @property
342
+ def asset_meta(self) -> PerpAssetInfo:
343
+ return self.meta['asset_meta']
344
+
345
+ @property
346
+ def asset_name(self) -> str:
347
+ return self.asset_meta['name']
348
+
349
+ @property
350
+ def collateral_meta(self) -> SpotTokenInfo:
351
+ return self.meta['collateral_meta']
352
+
353
+ @property
354
+ def collateral_name(self) -> str:
355
+ return self.collateral_meta['name']
356
+
357
+ @property
358
+ def asset_id(self) -> int:
359
+ # Per docs: base perps use asset index; builder perps use dex-scoped formula.
360
+ if (dex := self.dex_idx) is None:
361
+ return self.asset_idx
362
+ return 100000 + dex * 10000 + self.asset_idx
363
+