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.
- tribulnation_hyperliquid-0.1.0/PKG-INFO +20 -0
- tribulnation_hyperliquid-0.1.0/README.md +9 -0
- tribulnation_hyperliquid-0.1.0/pyproject.toml +19 -0
- tribulnation_hyperliquid-0.1.0/setup.cfg +4 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/__init__.py +2 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/__init__.pyi +8 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/core/__init__.py +12 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/core/constants.py +41 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/core/exc.py +48 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/core/settings.py +8 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/__init__.py +6 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/impl/__init__.py +14 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/impl/depth.py +44 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/impl/funding.py +50 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/impl/index.py +19 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/impl/mixin.py +363 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/impl/orders.py +125 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/impl/perps_position.py +17 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/impl/perps_rules.py +46 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/impl/spot_position.py +17 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/impl/spot_rules.py +48 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/impl/trades.py +68 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/perps_exchange.py +29 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/perps_market.py +113 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/spot_exchange.py +49 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/spot_market.py +92 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/market/venue.py +38 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/report/__init__.py +1 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation/hyperliquid/report/snapshots.py +77 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation_hyperliquid.egg-info/PKG-INFO +20 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation_hyperliquid.egg-info/SOURCES.txt +32 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation_hyperliquid.egg-info/dependency_links.txt +1 -0
- tribulnation_hyperliquid-0.1.0/src/tribulnation_hyperliquid.egg-info/requires.txt +2 -0
- 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,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,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,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
|
+
|