hyperquant 0.26__py3-none-any.whl → 0.31__py3-none-any.whl
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.
- hyperquant/__init__.py +3 -1
- hyperquant/broker/auth.py +51 -0
- hyperquant/broker/hyperliquid.py +67 -35
- hyperquant/broker/models/hyperliquid.py +284 -0
- hyperquant/broker/models/ourbit.py +502 -0
- hyperquant/broker/ourbit.py +247 -0
- hyperquant/broker/ws.py +12 -0
- {hyperquant-0.26.dist-info → hyperquant-0.31.dist-info}/METADATA +1 -1
- {hyperquant-0.26.dist-info → hyperquant-0.31.dist-info}/RECORD +10 -5
- {hyperquant-0.26.dist-info → hyperquant-0.31.dist-info}/WHEEL +0 -0
hyperquant/__init__.py
CHANGED
@@ -0,0 +1,51 @@
|
|
1
|
+
import time
|
2
|
+
import hashlib
|
3
|
+
from typing import Any
|
4
|
+
from multidict import CIMultiDict
|
5
|
+
from yarl import URL
|
6
|
+
import pybotters
|
7
|
+
import json as pyjson
|
8
|
+
from urllib.parse import urlencode
|
9
|
+
|
10
|
+
|
11
|
+
def md5_hex(s: str) -> str:
|
12
|
+
return hashlib.md5(s.encode("utf-8")).hexdigest()
|
13
|
+
|
14
|
+
|
15
|
+
# 🔑 Ourbit 的鉴权函数
|
16
|
+
class Auth:
|
17
|
+
@staticmethod
|
18
|
+
def ourbit(args: tuple[str, URL], kwargs: dict[str, Any]) -> tuple[str, URL]:
|
19
|
+
method: str = args[0]
|
20
|
+
url: URL = args[1]
|
21
|
+
data = kwargs.get("data") or {}
|
22
|
+
headers: CIMultiDict = kwargs["headers"]
|
23
|
+
|
24
|
+
# 从 session 里取 token
|
25
|
+
session = kwargs["session"]
|
26
|
+
token = session.__dict__["_apis"][pybotters.auth.Hosts.items[url.host].name][0]
|
27
|
+
|
28
|
+
# 时间戳 & body
|
29
|
+
now_ms = int(time.time() * 1000)
|
30
|
+
raw_body_for_sign = data if isinstance(data, str) else pyjson.dumps(data, separators=(",", ":"), ensure_ascii=False)
|
31
|
+
|
32
|
+
# 签名
|
33
|
+
mid_hash = md5_hex(f"{token}{now_ms}")[7:]
|
34
|
+
final_hash = md5_hex(f"{now_ms}{raw_body_for_sign}{mid_hash}")
|
35
|
+
|
36
|
+
# 设置 headers
|
37
|
+
headers.update({
|
38
|
+
"Authorization": token,
|
39
|
+
"Language": "Chinese",
|
40
|
+
"language": "Chinese",
|
41
|
+
"Content-Type": "application/json",
|
42
|
+
"x-ourbit-sign": final_hash,
|
43
|
+
"x-ourbit-nonce": str(now_ms),
|
44
|
+
})
|
45
|
+
|
46
|
+
# 更新 kwargs.body,保证发出去的与签名一致
|
47
|
+
kwargs.update({"data": raw_body_for_sign})
|
48
|
+
|
49
|
+
return args
|
50
|
+
|
51
|
+
pybotters.auth.Hosts.items['futures.ourbit.com'] = pybotters.auth.Item("ourbit", Auth.ourbit)
|
hyperquant/broker/hyperliquid.py
CHANGED
@@ -26,10 +26,34 @@ from typing import Any, Dict, Optional
|
|
26
26
|
import pybotters
|
27
27
|
from yarl import URL
|
28
28
|
|
29
|
-
from .
|
30
|
-
from .lib.hyper_types import AccountBalance
|
29
|
+
from .models.hyperliquid import MyHyperStore
|
31
30
|
|
32
|
-
|
31
|
+
def to_cloid(s: str) -> str:
|
32
|
+
"""
|
33
|
+
可逆地将字符串转为 Hyperliquid cloid 格式(要求字符串最多16字节,超出报错)。
|
34
|
+
:param s: 原始字符串
|
35
|
+
:return: 形如0x...的cloid字符串
|
36
|
+
"""
|
37
|
+
b = s.encode('utf-8')
|
38
|
+
if len(b) > 16:
|
39
|
+
raise ValueError("String too long for reversible cloid (max 16 bytes)")
|
40
|
+
# 补齐到16字节
|
41
|
+
b = b.ljust(16, b'\0')
|
42
|
+
return "0x" + b.hex()
|
43
|
+
|
44
|
+
def cloid_to_str(cloid: str) -> str:
|
45
|
+
"""
|
46
|
+
从cloid还原回原始字符串
|
47
|
+
:param cloid: 形如0x...的cloid字符串
|
48
|
+
:return: 原始字符串
|
49
|
+
"""
|
50
|
+
try:
|
51
|
+
if not (cloid.startswith("0x") and len(cloid) == 34):
|
52
|
+
raise ValueError("Invalid cloid format for reversal")
|
53
|
+
b = bytes.fromhex(cloid[2:])
|
54
|
+
return b.rstrip(b'\0').decode('utf-8')
|
55
|
+
except Exception as e:
|
56
|
+
return ''
|
33
57
|
|
34
58
|
|
35
59
|
__all__ = [
|
@@ -56,6 +80,7 @@ class AssetMeta:
|
|
56
80
|
asset_id: int
|
57
81
|
name: str
|
58
82
|
sz_decimals: int
|
83
|
+
tick_size: float = None
|
59
84
|
|
60
85
|
@dataclass(frozen=True, slots=True)
|
61
86
|
class SpotAssetMeta:
|
@@ -80,31 +105,27 @@ class OrderData():
|
|
80
105
|
|
81
106
|
_DECIMAL_CTX_5 = decimal.Context(prec=5)
|
82
107
|
|
108
|
+
def normalize_number(n):
|
109
|
+
# 能去掉小数点后多余的零
|
110
|
+
return format(decimal.Decimal(str(n)).normalize(), "f")
|
83
111
|
|
84
|
-
def
|
85
|
-
"""Normalize a number.
|
86
|
-
|
87
|
-
e.g. "3300.0" -> "3300"
|
88
|
-
|
89
|
-
Hyperliquid API expects normalized numbers. Otherwise, `L1 error` will occur.
|
112
|
+
def _fmt_price(price: float, sz_decimals: int, *, max_decimals: int = 6) -> float:
|
90
113
|
"""
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
"""Format *price* according to Hyperliquid rules.
|
95
|
-
|
96
|
-
* For `price >= 1` keep five significant digits.
|
97
|
-
* For small prices keep *max_decimals - sz_decimals* after the dot.
|
114
|
+
格式化价格:
|
115
|
+
- 大于100000直接取整数
|
116
|
+
- 其它情况保留5个有效数字,然后再按max_decimals-sz_decimals截断小数位
|
98
117
|
"""
|
99
|
-
if price
|
100
|
-
return
|
118
|
+
if price > 100_000:
|
119
|
+
return str(round(price))
|
120
|
+
# 先保留5个有效数字,再截断小数位
|
121
|
+
price_5sf = float(f"{price:.5g}")
|
122
|
+
return normalize_number(round(price_5sf, max_decimals - sz_decimals))
|
101
123
|
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
107
|
-
return _normalize_number(format(decimal.Decimal(size).quantize(decimal.Decimal(f"1e-{sz_decimals}")), "f"))
|
124
|
+
def _fmt_size(sz: float, sz_decimals: int) -> float:
|
125
|
+
"""
|
126
|
+
格式化数量:直接按 sz_decimals 小数位截断
|
127
|
+
"""
|
128
|
+
return normalize_number(round(sz, sz_decimals))
|
108
129
|
|
109
130
|
|
110
131
|
# ╭─────────────────────────────────────────────────────────────────────────╮
|
@@ -144,6 +165,7 @@ class HyperliquidTrader:
|
|
144
165
|
|
145
166
|
self._ws_app: Optional[pybotters.ws.WebSocketConnection] = None
|
146
167
|
self.store: MyHyperStore = MyHyperStore()
|
168
|
+
|
147
169
|
|
148
170
|
|
149
171
|
# ──────────────────────────────────────────────────────────────────────
|
@@ -165,6 +187,7 @@ class HyperliquidTrader:
|
|
165
187
|
if self._user:
|
166
188
|
await self.store.initialize(
|
167
189
|
("orders", self._client.post(_INFO, data={"type": "openOrders", "user": self._user})),
|
190
|
+
("positions", self._client.post(_INFO, data={"type": "clearinghouseState", "user": self._user})),
|
168
191
|
)
|
169
192
|
self._client.ws_connect(
|
170
193
|
self._wss_url,
|
@@ -198,6 +221,12 @@ class HyperliquidTrader:
|
|
198
221
|
async def __aexit__(self, exc_type, exc, tb): # noqa: D401
|
199
222
|
if not self._external_client and self._client is not None:
|
200
223
|
await self._client.__aexit__(exc_type, exc, tb)
|
224
|
+
|
225
|
+
async def sync_orders(self):
|
226
|
+
await self.store.orders._clear()
|
227
|
+
await self.store.initialize(
|
228
|
+
("orders", self._client.post(_INFO, data={"type": "openOrders", "user": self._user})),
|
229
|
+
)
|
201
230
|
|
202
231
|
# ──────────────────────────────────────────────────────────────────────
|
203
232
|
# Internal – metadata & formatting helpers
|
@@ -209,7 +238,7 @@ class HyperliquidTrader:
|
|
209
238
|
raise RuntimeError(f"Failed to fetch meta: {resp.error}")
|
210
239
|
|
211
240
|
self._assets = {
|
212
|
-
d["name"]: AssetMeta(asset_id=i, name=d["name"], sz_decimals=d["szDecimals"])
|
241
|
+
d["name"]: AssetMeta(asset_id=i, name=d["name"], sz_decimals=d["szDecimals"], tick_size=10**(d["szDecimals"] - 6))
|
213
242
|
for i, d in enumerate(resp.data["universe"])
|
214
243
|
}
|
215
244
|
|
@@ -313,7 +342,7 @@ class HyperliquidTrader:
|
|
313
342
|
# ──────────────────────────────────────────────────────────────────────
|
314
343
|
# Public API – account
|
315
344
|
# ──────────────────────────────────────────────────────────────────────
|
316
|
-
async def balances(self, user: Optional[str] = None)
|
345
|
+
async def balances(self, user: Optional[str] = None): # todo support spot
|
317
346
|
try:
|
318
347
|
user = user or self._user
|
319
348
|
if user is None:
|
@@ -437,6 +466,8 @@ class HyperliquidTrader:
|
|
437
466
|
price: Optional[float] = None, # required for limit orders
|
438
467
|
use_ws: bool = False,
|
439
468
|
is_spot: bool = False,
|
469
|
+
reduce_only: bool = False, # whether to place a reduce‑only order
|
470
|
+
cloid: Optional[str] = None,
|
440
471
|
) -> OrderData:
|
441
472
|
"""`place_order` 下订单
|
442
473
|
|
@@ -462,6 +493,7 @@ class HyperliquidTrader:
|
|
462
493
|
tif = "Ioc"
|
463
494
|
|
464
495
|
size_str = _fmt_size(size, meta.sz_decimals)
|
496
|
+
# print(f'下单 @ size_str: {size_str}, price_str: {price_str}, asset: {asset}, is_spot: {is_spot}')
|
465
497
|
order_payload = {
|
466
498
|
"action": {
|
467
499
|
"type": "order",
|
@@ -471,26 +503,26 @@ class HyperliquidTrader:
|
|
471
503
|
"b": is_buy,
|
472
504
|
"p": price_str,
|
473
505
|
"s": size_str,
|
474
|
-
"r":
|
506
|
+
"r": reduce_only,
|
475
507
|
"t": {"limit": {"tif": tif}},
|
476
508
|
}
|
477
509
|
],
|
478
510
|
"grouping": "na",
|
479
511
|
}
|
480
512
|
}
|
481
|
-
|
482
|
-
|
483
|
-
|
484
|
-
|
485
|
-
|
486
|
-
order_payload["action"]["orders"][0]["c"] = cloid
|
513
|
+
if cloid is not None:
|
514
|
+
if not cloid.startswith("0x"):
|
515
|
+
cloid = to_cloid(cloid)
|
516
|
+
|
517
|
+
order_payload["action"]["orders"][0]["c"] = cloid
|
487
518
|
|
488
519
|
# print(f"Placing order: {order_payload}")
|
489
|
-
print(order_payload)
|
520
|
+
# print(order_payload)
|
490
521
|
|
491
522
|
if not use_ws:
|
492
523
|
ret = await self._post(_EXCHANGE, order_payload)
|
493
|
-
|
524
|
+
print(ret)
|
525
|
+
if 'error' in str(ret) or 'err' in str(ret):
|
494
526
|
raise RuntimeError(f"Failed to place order: {ret}")
|
495
527
|
elif 'filled' in str(ret):
|
496
528
|
return OrderData(
|
@@ -0,0 +1,284 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import time
|
4
|
+
from aiohttp import ClientWebSocketResponse
|
5
|
+
import aiohttp
|
6
|
+
|
7
|
+
def callback(msg, ws: ClientWebSocketResponse = None):
|
8
|
+
print("Received message:", msg)
|
9
|
+
|
10
|
+
|
11
|
+
import asyncio
|
12
|
+
import pybotters
|
13
|
+
from pybotters.store import DataStore, DataStoreCollection
|
14
|
+
from pybotters.models.hyperliquid import HyperliquidDataStore
|
15
|
+
from typing import TYPE_CHECKING, Awaitable
|
16
|
+
|
17
|
+
if TYPE_CHECKING:
|
18
|
+
from pybotters.typedefs import Item
|
19
|
+
from pybotters.ws import ClientWebSocketResponse
|
20
|
+
|
21
|
+
|
22
|
+
|
23
|
+
# {'channel': 'orderUpdates', 'data': [{'order': {'coin': 'HYPE', 'side': 'A', 'limitPx': '22.887', 'sz': '1.12', 'oid': 29641480516, 'timestamp': 1746766108031, 'origSz': '1.12', 'reduceOnly': True}, 'status': 'rejected', 'statusTimestamp': 1746766108031}]}
|
24
|
+
class OrderStore(DataStore):
|
25
|
+
_KEYS = ["oid"]
|
26
|
+
|
27
|
+
def _onmessage(self, msg: Item) -> None:
|
28
|
+
|
29
|
+
for rec in msg:
|
30
|
+
order = rec["order"]
|
31
|
+
item = {
|
32
|
+
**order,
|
33
|
+
"status": rec.get("status"),
|
34
|
+
"px": None,
|
35
|
+
'fee': None,
|
36
|
+
"statusTimestamp": rec.get("statusTimestamp"),
|
37
|
+
}
|
38
|
+
|
39
|
+
if item["status"] == "open":
|
40
|
+
self._update([item])
|
41
|
+
else:
|
42
|
+
self._delete([item])
|
43
|
+
|
44
|
+
class FillStore(DataStore):
|
45
|
+
_KEYS = ["oid"]
|
46
|
+
|
47
|
+
def _onmessage(self, msg: Item) -> None:
|
48
|
+
for fill in msg:
|
49
|
+
self._update([fill])
|
50
|
+
|
51
|
+
|
52
|
+
|
53
|
+
class Account(DataStore):
|
54
|
+
_KEYS = ["marginCoin", "value"]
|
55
|
+
|
56
|
+
def _onmessage(self, data: list[Item]) -> None:
|
57
|
+
self._update(
|
58
|
+
[
|
59
|
+
{
|
60
|
+
"marginCoin": 'USDC',
|
61
|
+
'value': float(item['accountValue']),
|
62
|
+
'frozen': float(item['totalMarginUsed']),
|
63
|
+
'available': float(item['accountValue']) - float(item['totalMarginUsed']),
|
64
|
+
}
|
65
|
+
for item in data
|
66
|
+
]
|
67
|
+
)
|
68
|
+
|
69
|
+
class SpotAccount(DataStore):
|
70
|
+
|
71
|
+
_KEYS = ["coin"]
|
72
|
+
|
73
|
+
def _onmessage(self, data: list[Item]) -> None:
|
74
|
+
self._update(
|
75
|
+
[
|
76
|
+
{
|
77
|
+
"coin": item['coin'],
|
78
|
+
"total": float(item['total']),
|
79
|
+
"frozen": float(item['hold']),
|
80
|
+
"available": float(item['total']) - float(item['hold']),
|
81
|
+
"entryNtl": float(item['entryNtl']),
|
82
|
+
}
|
83
|
+
for item in data
|
84
|
+
]
|
85
|
+
)
|
86
|
+
|
87
|
+
class PositionStore(DataStore):
|
88
|
+
_KEYS = ["coin"]
|
89
|
+
|
90
|
+
def _onmessage(self, data: list[Item]) -> None:
|
91
|
+
|
92
|
+
self._clear()
|
93
|
+
self._update([
|
94
|
+
|
95
|
+
{
|
96
|
+
"coin": item['position']['coin'],
|
97
|
+
"sz": float(item['position']['szi']),
|
98
|
+
"px": float(item['position']['entryPx']),
|
99
|
+
'unpnl': float(item['position']['unrealizedPnl']),
|
100
|
+
'rt': float(item['position']['returnOnEquity']),
|
101
|
+
'lv': int(item['position']['leverage']['value']),
|
102
|
+
'update_time': int(time.time() * 1000)
|
103
|
+
}
|
104
|
+
for item in data
|
105
|
+
])
|
106
|
+
|
107
|
+
|
108
|
+
|
109
|
+
class MyHyperStore(HyperliquidDataStore):
|
110
|
+
ORDER_TYPE = 'orderUpdates'
|
111
|
+
WEBDATA2_TYPE = 'webData2'
|
112
|
+
ORDER_FILL_TYPE = 'userFills'
|
113
|
+
|
114
|
+
def _init(self) -> None:
|
115
|
+
self._create("orders", datastore_class=OrderStore)
|
116
|
+
self._create("account", datastore_class=Account)
|
117
|
+
self._create("positions", datastore_class=PositionStore)
|
118
|
+
self._create("spot_account", datastore_class=SpotAccount)
|
119
|
+
self._create("fills", datastore_class=FillStore)
|
120
|
+
super()._init()
|
121
|
+
|
122
|
+
def _onmessage(self, msg: Item, ws: ClientWebSocketResponse | None = None) -> None:
|
123
|
+
|
124
|
+
if msg.get("channel") == self.ORDER_TYPE:
|
125
|
+
self.orders._onmessage(msg.get('data', []))
|
126
|
+
elif msg.get("channel") == self.WEBDATA2_TYPE:
|
127
|
+
# print(msg.get('data', {}).get('clearinghouseState', {}))
|
128
|
+
act_data = msg.get('data', {}).get('clearinghouseState', {}).get('crossMarginSummary', [])
|
129
|
+
if act_data:
|
130
|
+
self.account._onmessage([act_data])
|
131
|
+
|
132
|
+
pos_data = msg.get('data', {}).get('clearinghouseState', {}).get('assetPositions', [])
|
133
|
+
self.positions._onmessage(pos_data)
|
134
|
+
|
135
|
+
spot_act_data = msg.get('data', {}).get('spotState', {}).get('balances', [])
|
136
|
+
self.spot_account._onmessage(spot_act_data)
|
137
|
+
|
138
|
+
elif msg.get("channel") == self.ORDER_FILL_TYPE:
|
139
|
+
fills = msg.get('data', {}).get('fills', [])
|
140
|
+
is_snap = msg.get('data', {}).get('isSnapshot', False)
|
141
|
+
if not is_snap:
|
142
|
+
self.fills._onmessage(fills)
|
143
|
+
|
144
|
+
super()._onmessage(msg, ws)
|
145
|
+
|
146
|
+
|
147
|
+
async def initialize(self, *aws: tuple[str, Awaitable[aiohttp.ClientResponse]]) -> None:
|
148
|
+
for a in aws:
|
149
|
+
method, f = a
|
150
|
+
resp = await f
|
151
|
+
data = await resp.json()
|
152
|
+
|
153
|
+
if method == "orders":
|
154
|
+
self.orders._onmessage(
|
155
|
+
[
|
156
|
+
{
|
157
|
+
'order': o,
|
158
|
+
'status': "open",
|
159
|
+
'statusTimestamp': int(time.time() * 1000)
|
160
|
+
} for o in data
|
161
|
+
]
|
162
|
+
)
|
163
|
+
elif method == "positions":
|
164
|
+
# 处理持仓数据初始化
|
165
|
+
pos_data = data.get('assetPositions', [])
|
166
|
+
if pos_data:
|
167
|
+
self.positions._onmessage(pos_data)
|
168
|
+
|
169
|
+
# 处理账户数据初始化
|
170
|
+
# act_data = data.get('crossMarginSummary', {})
|
171
|
+
# if act_data:
|
172
|
+
# self.account._onmessage([act_data])
|
173
|
+
pass
|
174
|
+
|
175
|
+
@property
|
176
|
+
def orders(self) -> OrderStore:
|
177
|
+
"""``orders`` data stream.
|
178
|
+
|
179
|
+
https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket/subscriptions
|
180
|
+
|
181
|
+
Data structure:
|
182
|
+
|
183
|
+
.. code:: python
|
184
|
+
[
|
185
|
+
{
|
186
|
+
"coin": "HYPE",
|
187
|
+
"side": "A",
|
188
|
+
"limitPx": "22.887",
|
189
|
+
"sz": "1.12",
|
190
|
+
"oid": 29641480516,
|
191
|
+
"timestamp": 1746766108031,
|
192
|
+
"origSz": "1.12",
|
193
|
+
"reduceOnly": True
|
194
|
+
"status": "open",
|
195
|
+
"statusTimestamp": 1746766108031
|
196
|
+
}...
|
197
|
+
]
|
198
|
+
"""
|
199
|
+
return self._get("orders", OrderStore)
|
200
|
+
@property
|
201
|
+
def account(self) -> Account:
|
202
|
+
"""``account`` data stream.
|
203
|
+
https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket/subscriptions
|
204
|
+
Data structure:
|
205
|
+
.. code:: python
|
206
|
+
[
|
207
|
+
{
|
208
|
+
"marginCoin": 'USDC',
|
209
|
+
'value': float(item['accountValue']),
|
210
|
+
'frozen': float(item['totalMarginUsed']),
|
211
|
+
'available': float(item['accountValue']) - float(item['totalMarginUsed']),
|
212
|
+
}...
|
213
|
+
]
|
214
|
+
"""
|
215
|
+
return self._get("account", Account)
|
216
|
+
|
217
|
+
@property
|
218
|
+
def positions(self) -> PositionStore:
|
219
|
+
"""
|
220
|
+
获取当前的持仓信息。
|
221
|
+
Data structure:
|
222
|
+
.. code:: python
|
223
|
+
[
|
224
|
+
{
|
225
|
+
"coin": item['position']['coin'],
|
226
|
+
"sz": float(item['position']['szi']),
|
227
|
+
"px": float(item['position']['entryPx']),
|
228
|
+
'unpnl': float(item['position']['unrealizedPnl']),
|
229
|
+
'rt': float(item['position']['returnOnEquity']),
|
230
|
+
'lv': int(item['position']['leverage']['value']),
|
231
|
+
'update_time': int(time.time() * 1000)
|
232
|
+
}...
|
233
|
+
]
|
234
|
+
"""
|
235
|
+
|
236
|
+
return self._get("positions", PositionStore)
|
237
|
+
|
238
|
+
@property
|
239
|
+
def spot_account(self) -> SpotAccount:
|
240
|
+
"""``spot_account`` data stream.
|
241
|
+
https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket/subscriptions
|
242
|
+
Data structure:
|
243
|
+
.. code:: python
|
244
|
+
[
|
245
|
+
{
|
246
|
+
"coin": 'FEUSD',
|
247
|
+
"sz": "21.0",
|
248
|
+
"px": "0.9719",
|
249
|
+
"unpnl": "0.0",
|
250
|
+
"rt": "0.0",
|
251
|
+
"lv": 1,
|
252
|
+
}...
|
253
|
+
]
|
254
|
+
"""
|
255
|
+
return self._get("spot_account", SpotAccount)
|
256
|
+
|
257
|
+
@property
|
258
|
+
def fills(self) -> FillStore:
|
259
|
+
"""``fills`` data stream.
|
260
|
+
https://hyperliquid.gitbook.io/hyperliquid-docs/for-developers/api/websocket/subscriptions
|
261
|
+
Data structure:
|
262
|
+
.. code:: python
|
263
|
+
[
|
264
|
+
{
|
265
|
+
"coin": 'FEUSD',
|
266
|
+
"px": "0.9719",
|
267
|
+
"sz": "21.0",
|
268
|
+
"side": 'buy',
|
269
|
+
"time": 1679999999999,
|
270
|
+
"startPosition": '0.0',
|
271
|
+
"dir": 'buy',
|
272
|
+
"closedPnl": '0.0',
|
273
|
+
"hash": '0x123456789abcdef',
|
274
|
+
"oid": 123456789,
|
275
|
+
"crossed": True,
|
276
|
+
"fee": '-0.0001',
|
277
|
+
"tid": 987654321,
|
278
|
+
"liquidation": None,
|
279
|
+
"feeToken": 'USDC',
|
280
|
+
}...
|
281
|
+
]
|
282
|
+
"""
|
283
|
+
return self._get("fills", FillStore)
|
284
|
+
|
@@ -0,0 +1,502 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
import asyncio
|
4
|
+
import logging
|
5
|
+
from typing import TYPE_CHECKING, Any, Awaitable
|
6
|
+
|
7
|
+
import aiohttp
|
8
|
+
from pybotters.store import DataStore, DataStoreCollection
|
9
|
+
|
10
|
+
if TYPE_CHECKING:
|
11
|
+
from pybotters.typedefs import Item
|
12
|
+
from pybotters.ws import ClientWebSocketResponse
|
13
|
+
|
14
|
+
|
15
|
+
logger = logging.getLogger(__name__)
|
16
|
+
|
17
|
+
|
18
|
+
class Book(DataStore):
|
19
|
+
"""深度数据存储类,用于处理订单簿深度信息
|
20
|
+
|
21
|
+
Channel: push.depth.step
|
22
|
+
|
23
|
+
用于存储和管理订单簿深度数据,包含买卖盘的价格和数量信息
|
24
|
+
Keys: ["symbol", "side", "px"]
|
25
|
+
- symbol: 交易对符号
|
26
|
+
- side: 买卖方向 (A: ask卖出, B: bid买入)
|
27
|
+
- px: 价格
|
28
|
+
|
29
|
+
|
30
|
+
"""
|
31
|
+
|
32
|
+
_KEYS = ["symbol", "side", "px"]
|
33
|
+
|
34
|
+
def _init(self) -> None:
|
35
|
+
# super().__init__()
|
36
|
+
self._time: int | None = None
|
37
|
+
|
38
|
+
def _on_message(self, msg: dict[str, Any]) -> None:
|
39
|
+
|
40
|
+
symbol = msg.get("symbol")
|
41
|
+
data = msg.get("data", {})
|
42
|
+
asks = data.get("asks", [])
|
43
|
+
bids = data.get("bids", [])
|
44
|
+
timestamp = data.get("ct") # 使用服务器时间
|
45
|
+
|
46
|
+
data_to_insert: list[Item] = []
|
47
|
+
|
48
|
+
# 先删除旧的订单簿数据
|
49
|
+
self._find_and_delete({"symbol": symbol})
|
50
|
+
|
51
|
+
# 处理买卖盘数据
|
52
|
+
for side_id, levels in (("B", bids), ("A", asks)):
|
53
|
+
for level in levels:
|
54
|
+
# level格式: [price, size, count]
|
55
|
+
if len(level) >= 3:
|
56
|
+
price, size, count = level[0:3]
|
57
|
+
data_to_insert.append(
|
58
|
+
{
|
59
|
+
"symbol": symbol,
|
60
|
+
"side": side_id,
|
61
|
+
"px": str(price),
|
62
|
+
"sz": str(size),
|
63
|
+
"count": count,
|
64
|
+
}
|
65
|
+
)
|
66
|
+
|
67
|
+
# 插入新的订单簿数据
|
68
|
+
self._insert(data_to_insert)
|
69
|
+
self._time = timestamp
|
70
|
+
|
71
|
+
@property
|
72
|
+
def time(self) -> int | None:
|
73
|
+
"""返回最后更新时间"""
|
74
|
+
return self._time
|
75
|
+
|
76
|
+
@property
|
77
|
+
def sorted(self) -> dict[str, list[Item]]:
|
78
|
+
"""获取排序后的订单簿数据
|
79
|
+
|
80
|
+
Returns:
|
81
|
+
返回按价格排序的买卖盘数据,卖盘升序,买盘降序
|
82
|
+
|
83
|
+
.. code-block:: python
|
84
|
+
|
85
|
+
{
|
86
|
+
"asks": [
|
87
|
+
{"symbol": "BTC_USDT", "side": "A", "px": "110152.5", "sz": "53539", "count": 1},
|
88
|
+
{"symbol": "BTC_USDT", "side": "A", "px": "110152.6", "sz": "95513", "count": 2}
|
89
|
+
],
|
90
|
+
"bids": [
|
91
|
+
{"symbol": "BTC_USDT", "side": "B", "px": "110152.4", "sz": "76311", "count": 1},
|
92
|
+
{"symbol": "BTC_USDT", "side": "B", "px": "110152.3", "sz": "104688", "count": 2}
|
93
|
+
]
|
94
|
+
}
|
95
|
+
"""
|
96
|
+
return self._sorted(
|
97
|
+
item_key="side",
|
98
|
+
item_asc_key="A", # asks 升序
|
99
|
+
item_desc_key="B", # bids 降序
|
100
|
+
sort_key="px",
|
101
|
+
)
|
102
|
+
|
103
|
+
|
104
|
+
class Ticker(DataStore):
|
105
|
+
_KEYS = ["symbol"]
|
106
|
+
|
107
|
+
def _on_message(self, data: dict[str, Any]):
|
108
|
+
self._onresponse(data)
|
109
|
+
|
110
|
+
def _onresponse(self, data: dict[str, Any]):
|
111
|
+
tickers = data.get("data", [])
|
112
|
+
if tickers:
|
113
|
+
data_to_insert: list[Item] = []
|
114
|
+
for ticker in tickers:
|
115
|
+
ticker: dict[str, Any] = ticker
|
116
|
+
for ticker in tickers:
|
117
|
+
data_to_insert.append(
|
118
|
+
{
|
119
|
+
"amount24": ticker.get("amount24"),
|
120
|
+
"fair_price": ticker.get("fairPrice"),
|
121
|
+
"high24_price": ticker.get("high24Price"),
|
122
|
+
"index_price": ticker.get("indexPrice"),
|
123
|
+
"last_price": ticker.get("lastPrice"),
|
124
|
+
"lower24_price": ticker.get("lower24Price"),
|
125
|
+
"max_bid_price": ticker.get("maxBidPrice"),
|
126
|
+
"min_ask_price": ticker.get("minAskPrice"),
|
127
|
+
"rise_fall_rate": ticker.get("riseFallRate"),
|
128
|
+
"symbol": ticker.get("symbol"),
|
129
|
+
"timestamp": ticker.get("timestamp"),
|
130
|
+
"volume24": ticker.get("volume24"),
|
131
|
+
}
|
132
|
+
)
|
133
|
+
# self._clear()
|
134
|
+
self._insert(data_to_insert)
|
135
|
+
|
136
|
+
|
137
|
+
class Orders(DataStore):
|
138
|
+
_KEYS = ["order_id"]
|
139
|
+
|
140
|
+
# {'success': True, 'code': 0, 'data': [{'orderId': '219108574599630976', 'symbol': 'SOL_USDT', 'positionId': 0, 'price': 190, 'priceStr': '190', 'vol': 1, 'leverage': 20, 'side': 1, 'category': 1, 'orderType': 1, 'dealAvgPrice': 0, 'dealAvgPriceStr': '0', 'dealVol': 0, 'orderMargin': 0.09652, 'takerFee': 0, 'makerFee': 0, 'profit': 0, 'feeCurrency': 'USDT', 'openType': 1, 'state': 2, 'externalOid': '_m_2228b23a75204e1982b301e44d439cbb', 'errorCode': 0, 'usedMargin': 0, 'createTime': 1756277955008, 'updateTime': 1756277955037, 'positionMode': 1, 'version': 1, 'showCancelReason': 0, 'showProfitRateShare': 0, 'voucher': False}]}
|
141
|
+
def _onresponse(self, data: dict[str, Any]):
|
142
|
+
orders = data.get("data", [])
|
143
|
+
if orders:
|
144
|
+
data_to_insert: list[Item] = []
|
145
|
+
for order in orders:
|
146
|
+
order: dict[str, Any] = order
|
147
|
+
|
148
|
+
data_to_insert.append(
|
149
|
+
{
|
150
|
+
"order_id": order.get("orderId"),
|
151
|
+
"symbol": order.get("symbol"),
|
152
|
+
"px": order.get("priceStr"),
|
153
|
+
"vol": order.get("vol"),
|
154
|
+
"lev": order.get("leverage"),
|
155
|
+
"side": "buy" if order.get("side") == 1 else "sell",
|
156
|
+
"deal_vol": order.get("dealVol"),
|
157
|
+
"deal_avg_px": order.get("dealAvgPriceStr"),
|
158
|
+
"create_ts": order.get("createTime"),
|
159
|
+
"update_ts": order.get("updateTime"),
|
160
|
+
}
|
161
|
+
)
|
162
|
+
|
163
|
+
self._clear()
|
164
|
+
self._update(data_to_insert)
|
165
|
+
|
166
|
+
|
167
|
+
class Detail(DataStore):
|
168
|
+
_KEYS = ["symbol"]
|
169
|
+
|
170
|
+
def _on_message(self, data: dict[str, Any]):
|
171
|
+
self._onresponse(data)
|
172
|
+
|
173
|
+
def _onresponse(self, data: dict[str, Any]):
|
174
|
+
details: dict = data.get("data", {})
|
175
|
+
data_to_insert: list[Item] = []
|
176
|
+
if details:
|
177
|
+
for detail in details:
|
178
|
+
data_to_insert.append(
|
179
|
+
{
|
180
|
+
"symbol": detail.get("symbol"),
|
181
|
+
"ft": detail.get("ft"),
|
182
|
+
"max_lev": detail.get("maxL"),
|
183
|
+
"tick_size": detail.get("pu"),
|
184
|
+
"vol_unit": detail.get("vu"),
|
185
|
+
"io": detail.get("io"),
|
186
|
+
"contract_sz": detail.get("cs"),
|
187
|
+
"minv": detail.get("minV"),
|
188
|
+
"maxv": detail.get("maxV")
|
189
|
+
}
|
190
|
+
)
|
191
|
+
self._update(data_to_insert)
|
192
|
+
|
193
|
+
class Position(DataStore):
|
194
|
+
_KEYS = ["position_id"]
|
195
|
+
# {"success":true,"code":0,"data":[{"positionId":5355366,"symbol":"SOL_USDT","positionType":1,"openType":1,"state":1,"holdVol":1,"frozenVol":0,"closeVol":0,"holdAvgPrice":203.44,"holdAvgPriceFullyScale":"203.44","openAvgPrice":203.44,"openAvgPriceFullyScale":"203.44","closeAvgPrice":0,"liquidatePrice":194.07,"oim":0.10253376,"im":0.10253376,"holdFee":0,"realised":-0.0008,"leverage":20,"marginRatio":0.0998,"createTime":1756275984696,"updateTime":1756275984696,"autoAddIm":false,"version":1,"profitRatio":0,"newOpenAvgPrice":203.44,"newCloseAvgPrice":0,"closeProfitLoss":0,"fee":0.00081376}]}
|
196
|
+
def _onresponse(self, data: dict[str, Any]):
|
197
|
+
positions = data.get("data", [])
|
198
|
+
if positions:
|
199
|
+
data_to_insert: list[Item] = []
|
200
|
+
for position in positions:
|
201
|
+
position: dict[str, Any] = position
|
202
|
+
|
203
|
+
data_to_insert.append(
|
204
|
+
{
|
205
|
+
"position_id": position.get("positionId"),
|
206
|
+
"symbol": position.get("symbol"),
|
207
|
+
"side": "short" if position.get("positionType") == 2 else "long",
|
208
|
+
"open_type": position.get("openType"),
|
209
|
+
"state": position.get("state"),
|
210
|
+
"hold_vol": position.get("holdVol"),
|
211
|
+
"frozen_vol": position.get("frozenVol"),
|
212
|
+
"close_vol": position.get("closeVol"),
|
213
|
+
"hold_avg_price": position.get("holdAvgPriceFullyScale"),
|
214
|
+
"open_avg_price": position.get("openAvgPriceFullyScale"),
|
215
|
+
"close_avg_price": str(position.get("closeAvgPrice")),
|
216
|
+
"liquidate_price": str(position.get("liquidatePrice")),
|
217
|
+
"oim": position.get("oim"),
|
218
|
+
"im": position.get("im"),
|
219
|
+
"hold_fee": position.get("holdFee"),
|
220
|
+
"realised": position.get("realised"),
|
221
|
+
"leverage": position.get("leverage"),
|
222
|
+
"margin_ratio": position.get("marginRatio"),
|
223
|
+
"create_ts": position.get("createTime"),
|
224
|
+
"update_ts": position.get("updateTime"),
|
225
|
+
}
|
226
|
+
)
|
227
|
+
|
228
|
+
self._clear()
|
229
|
+
self._insert(data_to_insert)
|
230
|
+
|
231
|
+
class Balance(DataStore):
|
232
|
+
_KEYS = ["currency"]
|
233
|
+
|
234
|
+
def _onresponse(self, data: dict[str, Any]):
|
235
|
+
balances = data.get("data", [])
|
236
|
+
if balances:
|
237
|
+
data_to_insert: list[Item] = []
|
238
|
+
for balance in balances:
|
239
|
+
balance: dict[str, Any] = balance
|
240
|
+
data_to_insert.append({
|
241
|
+
"currency": balance.get("currency"),
|
242
|
+
"position_margin": balance.get("positionMargin"),
|
243
|
+
"available_balance": balance.get("availableBalance"),
|
244
|
+
"cash_balance": balance.get("cashBalance"),
|
245
|
+
"frozen_balance": balance.get("frozenBalance"),
|
246
|
+
"equity": balance.get("equity"),
|
247
|
+
"unrealized": balance.get("unrealized"),
|
248
|
+
"bonus": balance.get("bonus"),
|
249
|
+
"last_bonus": balance.get("lastBonus"),
|
250
|
+
"wallet_balance": balance.get("walletBalance"),
|
251
|
+
"voucher": balance.get("voucher"),
|
252
|
+
"voucher_using": balance.get("voucherUsing"),
|
253
|
+
})
|
254
|
+
self._clear()
|
255
|
+
self._insert(data_to_insert)
|
256
|
+
|
257
|
+
class OurbitSwapDataStore(DataStoreCollection):
|
258
|
+
"""
|
259
|
+
Ourbit DataStoreCollection
|
260
|
+
|
261
|
+
REST API:
|
262
|
+
- 地址: https://futures.ourbit.com
|
263
|
+
- 合约详情
|
264
|
+
GET /api/v1/contract/detailV2?client=web
|
265
|
+
- ticker
|
266
|
+
GET /api/v1/contract/ticker
|
267
|
+
- open_orders
|
268
|
+
GET /api/v1/private/order/list/open_orders?page_size=200
|
269
|
+
- open_positions
|
270
|
+
GET /api/v1/private/position/open_positions
|
271
|
+
|
272
|
+
WebSocket API:
|
273
|
+
- 地址: wss://futures.ourbit.com/edge or /ws
|
274
|
+
- 支持频道:
|
275
|
+
* 深度数据(Book): push.depth.step
|
276
|
+
* 行情数据(Ticker): push.tickers
|
277
|
+
|
278
|
+
示例订阅 JSON:
|
279
|
+
|
280
|
+
.. code:: json
|
281
|
+
|
282
|
+
{
|
283
|
+
"method": "sub.depth.step",
|
284
|
+
"param": {
|
285
|
+
"symbol": "BTC_USDT",
|
286
|
+
"step": "0.1"
|
287
|
+
}
|
288
|
+
}
|
289
|
+
|
290
|
+
.. code:: json
|
291
|
+
|
292
|
+
{
|
293
|
+
"method": "sub.tickers",
|
294
|
+
"param": {
|
295
|
+
"timezone": "UTC+8"
|
296
|
+
}
|
297
|
+
}
|
298
|
+
|
299
|
+
TODO:
|
300
|
+
- 添加 trades、ticker、candle 等其他数据流
|
301
|
+
"""
|
302
|
+
|
303
|
+
def _init(self) -> None:
|
304
|
+
self._create("book", datastore_class=Book)
|
305
|
+
self._create("detail", datastore_class=Detail)
|
306
|
+
self._create("ticker", datastore_class=Ticker)
|
307
|
+
self._create("orders", datastore_class=Orders)
|
308
|
+
self._create("position", datastore_class=Position)
|
309
|
+
self._create("balance", datastore_class=Balance)
|
310
|
+
# TODO: 添加其他数据流,如 trades, ticker, candle 等
|
311
|
+
|
312
|
+
def onmessage(self, msg: Item, ws: ClientWebSocketResponse | None = None) -> None:
|
313
|
+
channel = msg.get("channel")
|
314
|
+
|
315
|
+
if channel == "push.depth.step":
|
316
|
+
self.book._on_message(msg)
|
317
|
+
if channel == "push.tickers":
|
318
|
+
self.ticker._on_message(msg)
|
319
|
+
else:
|
320
|
+
logger.debug(f"未知的channel: {channel}")
|
321
|
+
|
322
|
+
async def initialize(self, *aws: Awaitable[aiohttp.ClientResponse]) -> None:
|
323
|
+
"""Initialize DataStore from HTTP response data."""
|
324
|
+
for f in asyncio.as_completed(aws):
|
325
|
+
res = await f
|
326
|
+
data = await res.json()
|
327
|
+
if res.url.path == "/api/v1/contract/detailV2":
|
328
|
+
self.detail._onresponse(data)
|
329
|
+
if res.url.path == "/api/v1/contract/ticker":
|
330
|
+
self.ticker._onresponse(data)
|
331
|
+
if res.url.path == "/api/v1/private/order/list/open_orders":
|
332
|
+
self.orders._onresponse(data)
|
333
|
+
if res.url.path == "/api/v1/private/position/open_positions":
|
334
|
+
self.position._onresponse(data)
|
335
|
+
if res.url.path == "/api/v1/private/account/assets":
|
336
|
+
self.balance._onresponse(data)
|
337
|
+
|
338
|
+
@property
|
339
|
+
def detail(self) -> Detail:
|
340
|
+
"""合约详情
|
341
|
+
Data structure:
|
342
|
+
.. code:: python
|
343
|
+
[
|
344
|
+
{
|
345
|
+
"symbol": "BTC_USDT", # 交易对
|
346
|
+
"ft": 100, # 合约面值
|
347
|
+
"max_lev": 100, # 最大杠杆
|
348
|
+
"tick_size": 0.1, # 最小变动价位
|
349
|
+
"vol_unit": 1, # 合约单位
|
350
|
+
"io": ["binance", "mexc"], # 交易所列表
|
351
|
+
"contract_sz": 1,
|
352
|
+
"minv": 1,
|
353
|
+
"maxv": 10000
|
354
|
+
|
355
|
+
}
|
356
|
+
]
|
357
|
+
"""
|
358
|
+
return self._get("detail", Detail)
|
359
|
+
|
360
|
+
@property
|
361
|
+
def book(self) -> Book:
|
362
|
+
"""订单簿深度数据流
|
363
|
+
|
364
|
+
Data type: Mutable
|
365
|
+
|
366
|
+
Keys: ("symbol", "side", "px")
|
367
|
+
|
368
|
+
Data structure:
|
369
|
+
|
370
|
+
.. code:: python
|
371
|
+
|
372
|
+
[
|
373
|
+
{
|
374
|
+
"symbol": "BTC_USDT", # 交易对
|
375
|
+
"side": "A", # 卖出方向
|
376
|
+
"px": "110152.5", # 价格
|
377
|
+
"sz": "53539", # 数量
|
378
|
+
"count": 1 # 订单数量
|
379
|
+
},
|
380
|
+
{
|
381
|
+
"symbol": "BTC_USDT", # 交易对
|
382
|
+
"side": "B", # 买入方向
|
383
|
+
"px": "110152.4", # 价格
|
384
|
+
"sz": "76311", # 数量
|
385
|
+
"count": 1 # 订单数量
|
386
|
+
}
|
387
|
+
]
|
388
|
+
"""
|
389
|
+
return self._get("book", Book)
|
390
|
+
|
391
|
+
@property
|
392
|
+
def ticker(self) -> Ticker:
|
393
|
+
"""市场行情数据流
|
394
|
+
|
395
|
+
Data type: Mutable
|
396
|
+
|
397
|
+
Keys: ("symbol",)
|
398
|
+
|
399
|
+
Data structure:
|
400
|
+
|
401
|
+
.. code:: python
|
402
|
+
|
403
|
+
[
|
404
|
+
{
|
405
|
+
"symbol": "BTC_USDT", # 交易对
|
406
|
+
"last_price": "110152.5", # 最新价格
|
407
|
+
"index_price": "110000.0", # 指数价格
|
408
|
+
"fair_price": "110100.0", # 公允价格
|
409
|
+
"high24_price": "115000.0", # 24小时最高价
|
410
|
+
"lower24_price": "105000.0", # 24小时最低价
|
411
|
+
"volume24": "1500", # 24小时交易量
|
412
|
+
"amount24": "165000000", # 24小时交易额
|
413
|
+
"rise_fall_rate": "0.05", # 涨跌幅
|
414
|
+
"max_bid_price": "110150.0", # 买一价
|
415
|
+
"min_ask_price": "110155.0", # 卖一价
|
416
|
+
"timestamp": 1625247600000 # 时间戳
|
417
|
+
}
|
418
|
+
]
|
419
|
+
"""
|
420
|
+
return self._get("ticker", Ticker)
|
421
|
+
|
422
|
+
@property
|
423
|
+
def orders(self) -> Orders:
|
424
|
+
"""
|
425
|
+
订单数据
|
426
|
+
Data structure:
|
427
|
+
|
428
|
+
.. code:: json
|
429
|
+
|
430
|
+
[
|
431
|
+
{
|
432
|
+
"id": "123456",
|
433
|
+
"symbol": "BTC_USDT",
|
434
|
+
"side": "buy",
|
435
|
+
"price": "110152.5",
|
436
|
+
"size": "0.1",
|
437
|
+
"status": "open",
|
438
|
+
"create_ts": 1625247600000,
|
439
|
+
"update_ts": 1625247600000
|
440
|
+
}
|
441
|
+
]
|
442
|
+
"""
|
443
|
+
return self._get("orders", Orders)
|
444
|
+
|
445
|
+
@property
|
446
|
+
def position(self) -> Position:
|
447
|
+
"""
|
448
|
+
持仓数据
|
449
|
+
|
450
|
+
Data structure:
|
451
|
+
.. code:: python
|
452
|
+
[
|
453
|
+
{
|
454
|
+
"position_id": "123456",
|
455
|
+
"symbol": "BTC_USDT",
|
456
|
+
"side": "long",
|
457
|
+
"open_type": "limit",
|
458
|
+
"state": "open",
|
459
|
+
"hold_vol": "0.1",
|
460
|
+
"frozen_vol": "0.0",
|
461
|
+
"close_vol": "0.0",
|
462
|
+
"hold_avg_price": "110152.5",
|
463
|
+
"open_avg_price": "110152.5",
|
464
|
+
"close_avg_price": "0.0",
|
465
|
+
"liquidate_price": "100000.0",
|
466
|
+
"oim": "0.0",
|
467
|
+
"im": "0.0",
|
468
|
+
"hold_fee": "0.0",
|
469
|
+
"realised": "0.0",
|
470
|
+
"leverage": "10",
|
471
|
+
"margin_ratio": "0.1",
|
472
|
+
"create_ts": 1625247600000,
|
473
|
+
"update_ts": 1625247600000
|
474
|
+
}
|
475
|
+
]
|
476
|
+
"""
|
477
|
+
return self._get("position", Position)
|
478
|
+
|
479
|
+
@property
|
480
|
+
def balance(self) -> Balance:
|
481
|
+
"""账户余额数据
|
482
|
+
|
483
|
+
Data structure:
|
484
|
+
.. code:: python
|
485
|
+
[
|
486
|
+
{
|
487
|
+
"currency": "USDT", # 币种
|
488
|
+
"position_margin": 0.3052, # 持仓保证金
|
489
|
+
"available_balance": 19.7284, # 可用余额
|
490
|
+
"cash_balance": 19.7284, # 现金余额
|
491
|
+
"frozen_balance": 0, # 冻结余额
|
492
|
+
"equity": 19.9442, # 权益
|
493
|
+
"unrealized": -0.0895, # 未实现盈亏
|
494
|
+
"bonus": 0, # 奖励
|
495
|
+
"last_bonus": 0, # 最后奖励
|
496
|
+
"wallet_balance": 20.0337, # 钱包余额
|
497
|
+
"voucher": 0, # 代金券
|
498
|
+
"voucher_using": 0 # 使用中的代金券
|
499
|
+
}
|
500
|
+
]
|
501
|
+
"""
|
502
|
+
return self._get("balance", Balance)
|
@@ -0,0 +1,247 @@
|
|
1
|
+
from typing import Literal, Optional
|
2
|
+
import pybotters
|
3
|
+
from .models.ourbit import OurbitSwapDataStore
|
4
|
+
|
5
|
+
|
6
|
+
class OurbitSwap:
|
7
|
+
|
8
|
+
def __init__(self, client: pybotters.Client):
|
9
|
+
"""
|
10
|
+
✅ 完成:
|
11
|
+
下单, 撤单, 查询资金, 查询持有订单, 查询历史订单
|
12
|
+
|
13
|
+
"""
|
14
|
+
self.client = client
|
15
|
+
self.store = OurbitSwapDataStore()
|
16
|
+
self.api_url = "https://futures.ourbit.com"
|
17
|
+
self.ws_url = "wss://futures.ourbit.com/edge"
|
18
|
+
|
19
|
+
async def __aenter__(self) -> "OurbitSwap":
|
20
|
+
client = self.client
|
21
|
+
await self.store.initialize(
|
22
|
+
client.get(f"{self.api_url}/api/v1/contract/detailV2?client=web")
|
23
|
+
)
|
24
|
+
return self
|
25
|
+
|
26
|
+
async def update(
|
27
|
+
self, update_type: Literal["position", "orders", "balance", "ticker", "all"] = "all"
|
28
|
+
):
|
29
|
+
"""由于交易所很多不支持ws推送,这里使用Rest"""
|
30
|
+
all_urls = [
|
31
|
+
f"{self.api_url}/api/v1/private/position/open_positions",
|
32
|
+
f"{self.api_url}/api/v1/private/order/list/open_orders?page_size=200",
|
33
|
+
f"{self.api_url}/api/v1/private/account/assets",
|
34
|
+
f"{self.api_url}/api/v1/contract/ticker",
|
35
|
+
]
|
36
|
+
|
37
|
+
url_map = {
|
38
|
+
"position": [all_urls[0]],
|
39
|
+
"orders": [all_urls[1]],
|
40
|
+
"balance": [all_urls[2]],
|
41
|
+
"ticker": [all_urls[3]],
|
42
|
+
"all": all_urls,
|
43
|
+
}
|
44
|
+
|
45
|
+
try:
|
46
|
+
urls = url_map[update_type]
|
47
|
+
except KeyError:
|
48
|
+
raise ValueError(f"update_type err: {update_type}")
|
49
|
+
|
50
|
+
# 直接传协程进去,initialize 会自己 await
|
51
|
+
await self.store.initialize(*(self.client.get(url) for url in urls))
|
52
|
+
|
53
|
+
async def sub_tickers(self):
|
54
|
+
self.client.ws_connect(
|
55
|
+
self.ws_url,
|
56
|
+
send_json={
|
57
|
+
"method": "sub.tickers",
|
58
|
+
"param": {
|
59
|
+
"timezone": "UTC+8"
|
60
|
+
}
|
61
|
+
},
|
62
|
+
hdlr_json=self.store.onmessage
|
63
|
+
)
|
64
|
+
|
65
|
+
async def sub_order_book(self, symbols: str | list[str]):
|
66
|
+
if isinstance(symbols, str):
|
67
|
+
symbols = [symbols]
|
68
|
+
|
69
|
+
send_jsons = []
|
70
|
+
# send_json={"method":"sub.depth.step","param":{"symbol":"BTC_USDT","step":"0.1"}},
|
71
|
+
|
72
|
+
for symbol in symbols:
|
73
|
+
step = self.store.detail.find({"symbol": symbol})[0].get("tick_size")
|
74
|
+
|
75
|
+
send_jsons.append({
|
76
|
+
"method": "sub.depth.step",
|
77
|
+
"param": {
|
78
|
+
"symbol": symbol,
|
79
|
+
"step": str(step)
|
80
|
+
}
|
81
|
+
})
|
82
|
+
|
83
|
+
await self.client.ws_connect(
|
84
|
+
self.ws_url,
|
85
|
+
send_json=send_jsons,
|
86
|
+
hdlr_json=self.store.onmessage
|
87
|
+
)
|
88
|
+
|
89
|
+
def ret_content(self, res: pybotters.FetchResult):
|
90
|
+
match res.data:
|
91
|
+
case {"success": True}:
|
92
|
+
return res.data["data"]
|
93
|
+
case _:
|
94
|
+
raise Exception(f"Failed api {res.response.url}: {res.data}")
|
95
|
+
|
96
|
+
|
97
|
+
async def place_order(
|
98
|
+
self,
|
99
|
+
symbol: str,
|
100
|
+
side: Literal["buy", "sell", "close_buy", "close_sell"],
|
101
|
+
size: float = None,
|
102
|
+
price: float = None,
|
103
|
+
order_type: Literal["market", "limit_GTC", "limit_IOC"] = "market",
|
104
|
+
usdt_amount: Optional[float] = None,
|
105
|
+
leverage: Optional[int] = 20,
|
106
|
+
position_id: Optional[int] = None,
|
107
|
+
):
|
108
|
+
"""
|
109
|
+
size为合约张数
|
110
|
+
|
111
|
+
.. code ::
|
112
|
+
{
|
113
|
+
"orderId": "219602019841167810",
|
114
|
+
"ts": 1756395601543
|
115
|
+
}
|
116
|
+
|
117
|
+
"""
|
118
|
+
if (size is None) == (usdt_amount is None):
|
119
|
+
raise ValueError("params err")
|
120
|
+
|
121
|
+
max_lev = self.store.detail.find({"symbol": symbol})[0].get("max_lev")
|
122
|
+
|
123
|
+
if usdt_amount is not None:
|
124
|
+
cs = self.store.detail.find({"symbol": symbol})[0].get("contract_sz")
|
125
|
+
size = max(int(usdt_amount / cs / price), 1)
|
126
|
+
|
127
|
+
|
128
|
+
leverage = max(max_lev, leverage)
|
129
|
+
|
130
|
+
data = {
|
131
|
+
"symbol": symbol,
|
132
|
+
"side": 1 if side == "buy" else 3,
|
133
|
+
"openType": 1,
|
134
|
+
"type": "5",
|
135
|
+
"vol": size,
|
136
|
+
"leverage": leverage,
|
137
|
+
"marketCeiling": False,
|
138
|
+
"priceProtect": "0",
|
139
|
+
}
|
140
|
+
|
141
|
+
if order_type == "limit_IOC":
|
142
|
+
data["type"] = 3
|
143
|
+
data["price"] = str(price)
|
144
|
+
if order_type == "limit_GTC":
|
145
|
+
data["type"] = "1"
|
146
|
+
data["price"] = str(price)
|
147
|
+
|
148
|
+
if "close" in side:
|
149
|
+
if side == 'close_buy':
|
150
|
+
data["side"] = 4
|
151
|
+
elif side == 'close_sell':
|
152
|
+
data["side"] = 2
|
153
|
+
data["type"] = 5
|
154
|
+
if position_id is None:
|
155
|
+
raise ValueError("position_id is required for closing position")
|
156
|
+
data["positionId"] = position_id
|
157
|
+
import time
|
158
|
+
print(time.time(), '下单')
|
159
|
+
res = await self.client.fetch(
|
160
|
+
"POST", f"{self.api_url}/api/v1/private/order/create", data=data
|
161
|
+
)
|
162
|
+
return self.ret_content(res)
|
163
|
+
|
164
|
+
async def cancel_orders(self, order_ids: list[str]):
|
165
|
+
res = await self.client.fetch(
|
166
|
+
"POST",
|
167
|
+
f"{self.api_url}/api/v1/private/order/cancel",
|
168
|
+
data=order_ids,
|
169
|
+
)
|
170
|
+
return self.ret_content(res)
|
171
|
+
|
172
|
+
async def query_orders(
|
173
|
+
self,
|
174
|
+
symbol: str,
|
175
|
+
states: list[Literal["filled", "canceled"]], # filled:已成交, canceled:已撤销
|
176
|
+
start_time: Optional[int] = None,
|
177
|
+
end_time: Optional[int] = None,
|
178
|
+
page_size: int = 200,
|
179
|
+
page_num: int = 1,
|
180
|
+
):
|
181
|
+
"""查询历史订单
|
182
|
+
|
183
|
+
Args:
|
184
|
+
symbol: 交易对
|
185
|
+
states: 订单状态列表 ["filled":已成交, "canceled":已撤销]
|
186
|
+
start_time: 开始时间戳(毫秒), 可选
|
187
|
+
end_time: 结束时间戳(毫秒), 可选
|
188
|
+
page_size: 每页数量, 默认200
|
189
|
+
page_num: 页码, 默认1
|
190
|
+
"""
|
191
|
+
state_map = {"filled": 3, "canceled": 4}
|
192
|
+
|
193
|
+
params = {
|
194
|
+
"symbol": symbol,
|
195
|
+
"states": ",".join(str(state_map[state]) for state in states),
|
196
|
+
"page_size": page_size,
|
197
|
+
"page_num": page_num,
|
198
|
+
"category": 1,
|
199
|
+
}
|
200
|
+
|
201
|
+
if start_time:
|
202
|
+
params["start_time"] = start_time
|
203
|
+
if end_time:
|
204
|
+
params["end_time"] = end_time
|
205
|
+
|
206
|
+
res = await self.client.fetch(
|
207
|
+
"GET",
|
208
|
+
f"{self.api_url}/api/v1/private/order/list/history_orders",
|
209
|
+
params=params,
|
210
|
+
)
|
211
|
+
|
212
|
+
return self.ret_content(res)
|
213
|
+
|
214
|
+
async def query_order(self, order_id: str):
|
215
|
+
"""查询单个订单的详细信息
|
216
|
+
|
217
|
+
Args:
|
218
|
+
order_id: 订单ID
|
219
|
+
|
220
|
+
Returns:
|
221
|
+
..code:python
|
222
|
+
|
223
|
+
订单详情数据,例如:
|
224
|
+
[
|
225
|
+
{
|
226
|
+
"id": "38600506", # 成交ID
|
227
|
+
"symbol": "SOL_USDT", # 交易对
|
228
|
+
"side": 4, # 方向(1:买入, 3:卖出, 4:平仓)
|
229
|
+
"vol": 1, # 成交数量
|
230
|
+
"price": 204.11, # 成交价格
|
231
|
+
"fee": 0.00081644, # 手续费
|
232
|
+
"feeCurrency": "USDT", # 手续费币种
|
233
|
+
"profit": -0.0034, # 盈亏
|
234
|
+
"category": 1, # 品类
|
235
|
+
"orderId": "219079365441409152", # 订单ID
|
236
|
+
"timestamp": 1756270991000, # 时间戳
|
237
|
+
"positionMode": 1, # 持仓模式
|
238
|
+
"voucher": false, # 是否使用代金券
|
239
|
+
"taker": true # 是否是taker
|
240
|
+
}
|
241
|
+
]
|
242
|
+
"""
|
243
|
+
res = await self.client.fetch(
|
244
|
+
"GET",
|
245
|
+
f"{self.api_url}/api/v1/private/order/deal_details/{order_id}",
|
246
|
+
)
|
247
|
+
return self.ret_content(res)
|
hyperquant/broker/ws.py
ADDED
@@ -0,0 +1,12 @@
|
|
1
|
+
import asyncio
|
2
|
+
import pybotters
|
3
|
+
|
4
|
+
|
5
|
+
class Heartbeat:
|
6
|
+
@staticmethod
|
7
|
+
async def ourbit(ws: pybotters.ws.ClientWebSocketResponse):
|
8
|
+
while not ws.closed:
|
9
|
+
await ws.send_str('{"method":"ping"}')
|
10
|
+
await asyncio.sleep(10.0)
|
11
|
+
|
12
|
+
pybotters.ws.HeartbeatHosts.items['futures.ourbit.com'] = Heartbeat.ourbit
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: hyperquant
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.31
|
4
4
|
Summary: A minimal yet hyper-efficient backtesting framework for quantitative trading
|
5
5
|
Project-URL: Homepage, https://github.com/yourusername/hyperquant
|
6
6
|
Project-URL: Issues, https://github.com/yourusername/hyperquant/issues
|
@@ -1,16 +1,21 @@
|
|
1
|
-
hyperquant/__init__.py,sha256=
|
1
|
+
hyperquant/__init__.py,sha256=UpjiX4LS5jmrBc2kE8RiLR02eCfD8JDQrR1q8zkLNcQ,161
|
2
2
|
hyperquant/core.py,sha256=7XrpuHvccWl9lNyVihqaptupqUMsG3xYmQr8eEDrwS4,20610
|
3
3
|
hyperquant/db.py,sha256=i2TjkCbmH4Uxo7UTDvOYBfy973gLcGexdzuT_YcSeIE,6678
|
4
4
|
hyperquant/draw.py,sha256=up_lQ3pHeVLoNOyh9vPjgNwjD0M-6_IetSGviQUgjhY,54624
|
5
5
|
hyperquant/logkit.py,sha256=WALpXpIA3Ywr5DxKKK3k5EKubZ2h-ISGfc5dUReQUBQ,7795
|
6
6
|
hyperquant/notikit.py,sha256=x5yAZ_tAvLQRXcRbcg-VabCaN45LUhvlTZnUqkIqfAA,3596
|
7
|
-
hyperquant/broker/
|
7
|
+
hyperquant/broker/auth.py,sha256=hrdVuBTZwD3xSy5GI5JTFJhKiHhvIjZHA_7G5IUFznQ,1580
|
8
|
+
hyperquant/broker/hyperliquid.py,sha256=7MxbI9OyIBcImDelPJu-8Nd53WXjxPB5TwE6gsjHbto,23252
|
9
|
+
hyperquant/broker/ourbit.py,sha256=VE1eLvQ1hakOxWbXHmW8KXwYrP_Apsv0_FOPtmU3MKs,8033
|
10
|
+
hyperquant/broker/ws.py,sha256=98Djt5n5sHUJKVbQ8Ql1t-G-Wiwu__4MYcUr5P6SDL0,326
|
8
11
|
hyperquant/broker/lib/hpstore.py,sha256=LnLK2zmnwVvhEbLzYI-jz_SfYpO1Dv2u2cJaRAb84D8,8296
|
9
12
|
hyperquant/broker/lib/hyper_types.py,sha256=HqjjzjUekldjEeVn6hxiWA8nevAViC2xHADOzDz9qyw,991
|
13
|
+
hyperquant/broker/models/hyperliquid.py,sha256=c4r5739ibZfnk69RxPjQl902AVuUOwT8RNvKsMtwXBY,9459
|
14
|
+
hyperquant/broker/models/ourbit.py,sha256=zvjtx6fmOuOPAJ8jxD2RK9_mao2XuE2EXkxR74nmXKM,18525
|
10
15
|
hyperquant/datavison/_util.py,sha256=92qk4vO856RqycO0YqEIHJlEg-W9XKapDVqAMxe6rbw,533
|
11
16
|
hyperquant/datavison/binance.py,sha256=3yNKTqvt_vUQcxzeX4ocMsI5k6Q6gLZrvgXxAEad6Kc,5001
|
12
17
|
hyperquant/datavison/coinglass.py,sha256=PEjdjISP9QUKD_xzXNzhJ9WFDTlkBrRQlVL-5pxD5mo,10482
|
13
18
|
hyperquant/datavison/okx.py,sha256=yg8WrdQ7wgWHNAInIgsWPM47N3Wkfr253169IPAycAY,6898
|
14
|
-
hyperquant-0.
|
15
|
-
hyperquant-0.
|
16
|
-
hyperquant-0.
|
19
|
+
hyperquant-0.31.dist-info/METADATA,sha256=ZfMLDTVVmQdtj-qCg1kwN1fl1TJfRpzHki-6AoUnhEI,4317
|
20
|
+
hyperquant-0.31.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
21
|
+
hyperquant-0.31.dist-info/RECORD,,
|
File without changes
|