hyperquant 0.59__py3-none-any.whl → 0.62__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.
@@ -0,0 +1,183 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from typing import Any, Iterable, Literal
5
+
6
+ import pybotters
7
+
8
+ from .models.edgex import EdgexDataStore
9
+
10
+
11
+ class Edgex:
12
+ """
13
+ Edgex 公共 API (HTTP/WS) 封装。
14
+
15
+ 说明
16
+ - 当前仅包含公共行情数据(不包含私有接口)。
17
+ - 订单簿频道命名规则:``depth.{contractId}.{level}``。
18
+ 成功订阅后,服务器会先推送一次完整快照(depthType=SNAPSHOT),之后持续推送增量(depthType=CHANGED)。
19
+ 解析后的结果存入 ``EdgexDataStore.book``。
20
+
21
+ 参数
22
+ - client: ``pybotters.Client`` 实例
23
+ - api_url: REST 基地址;默认使用 Edgex 官方 testnet 站点
24
+ - ws_url: WebSocket 基地址;如不提供,则默认使用官方文档地址。
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ client: pybotters.Client,
30
+ *,
31
+ api_url: str | None = None,
32
+ ) -> None:
33
+ self.client = client
34
+ self.store = EdgexDataStore()
35
+ # 公共端点可能因环境/地区不同而变化,允许外部覆盖。
36
+ self.api_url = api_url or "https://pro.edgex.exchange"
37
+ self.ws_url = "wss://quote.edgex.exchange"
38
+
39
+ async def __aenter__(self) -> "Edgex":
40
+ # 初始化基础合约元数据,便于后续使用 tickSize 等字段。
41
+ await self.update_detail()
42
+ return self
43
+
44
+ async def __aexit__(
45
+ self,
46
+ exc_type: type[BaseException] | None,
47
+ exc: BaseException | None,
48
+ tb: BaseException | None,
49
+ ) -> None:
50
+ # Edgex 当前没有需要关闭的资源;保持接口与 Ourbit 等类一致。
51
+ return None
52
+
53
+ async def update_detail(self) -> dict[str, Any]:
54
+ """Fetch and cache contract metadata via the public REST endpoint."""
55
+
56
+ url = self._resolve_api_path("/api/v1/public/meta/getMetaData")
57
+ res = await self.client.get(url)
58
+ res.raise_for_status()
59
+ data = await res.json()
60
+
61
+ if data.get("code") != "SUCCESS": # pragma: no cover - defensive guard
62
+ raise RuntimeError(f"Failed to fetch Edgex metadata: {data}")
63
+
64
+ self.store._apply_metadata(data)
65
+ return data
66
+
67
+ def _resolve_api_path(self, path: str) -> str:
68
+ base = (self.api_url or "").rstrip("/")
69
+ return f"{base}{path}"
70
+
71
+ async def sub_orderbook(
72
+ self,
73
+ contract_ids: str | Iterable[str] | None = None,
74
+ *,
75
+ symbols: str | Iterable[str] | None = None,
76
+ level: int = 15,
77
+ ws_url: str | None = None,
78
+ ) -> None:
79
+ """订阅指定合约 ID 或交易对名的订单簿(遵循 Edgex 协议)。
80
+
81
+ 规范
82
+ - 默认 WS 端点:wss://quote.edgex.exchange(可通过参数/实例覆盖)
83
+ - 每个频道的订阅报文:
84
+ {"type": "subscribe", "channel": "depth.{contractId}.{level}"}
85
+ - 服务端在订阅成功后,会先推送一次快照,再持续推送增量。
86
+ """
87
+
88
+ ids: list[str] = []
89
+ if contract_ids is not None:
90
+ if isinstance(contract_ids, str):
91
+ ids.extend([contract_ids])
92
+ else:
93
+ ids.extend(contract_ids)
94
+
95
+ if symbols is not None:
96
+ if isinstance(symbols, str):
97
+ lookup_symbols = [symbols]
98
+ else:
99
+ lookup_symbols = list(symbols)
100
+
101
+ for symbol in lookup_symbols:
102
+ matches = self.store.detail.find({"contractName": symbol})
103
+ if not matches:
104
+ raise ValueError(f"Unknown Edgex symbol: {symbol}")
105
+ ids.append(str(matches[0]["contractId"]))
106
+
107
+ if not ids:
108
+ raise ValueError("contract_ids or symbols must be provided")
109
+
110
+ channels = [f"depth.{cid}.{level}" for cid in ids]
111
+
112
+ # 优先使用参数 ws_url,其次使用实例的 ws_url,最后使用默认地址。
113
+ url = f"{self.ws_url}/api/v1/public/ws?timestamp=" + str(int(time.time() * 1000))
114
+
115
+ # 根据文档:每个频道一条订阅指令,允许一次发送多个订阅对象。
116
+ payload = [{"type": "subscribe", "channel": ch} for ch in channels]
117
+
118
+ wsapp = self.client.ws_connect(url, send_json=payload, hdlr_json=self.store.onmessage)
119
+ # 等待 WS 完成握手再返回,确保订阅报文成功发送。
120
+ await wsapp._event.wait()
121
+
122
+ async def sub_ticker(
123
+ self,
124
+ contract_ids: str | Iterable[str] | None = None,
125
+ *,
126
+ symbols: str | Iterable[str] | None = None,
127
+ all_contracts: bool = False,
128
+ periodic: bool = False,
129
+ ws_url: str | None = None,
130
+ ) -> None:
131
+ """订阅 24 小时行情推送。
132
+
133
+ 参数
134
+ - contract_ids / symbols: 指定单个或多个合约;二者至少提供一个。
135
+ - all_contracts: 订阅 ``ticker.all``(或 ``ticker.all.1s``)。
136
+ - periodic: 与 ``all_contracts`` 配合,true 则订阅 ``ticker.all.1s``。
137
+ """
138
+
139
+ channels: list[str] = []
140
+
141
+ if all_contracts:
142
+ channel = "ticker.all.1s" if periodic else "ticker.all"
143
+ channels.append(channel)
144
+ else:
145
+ ids: list[str] = []
146
+ if contract_ids is not None:
147
+ if isinstance(contract_ids, str):
148
+ ids.append(contract_ids)
149
+ else:
150
+ ids.extend(contract_ids)
151
+
152
+ if symbols is not None:
153
+ if isinstance(symbols, str):
154
+ lookup_symbols = [symbols]
155
+ else:
156
+ lookup_symbols = list(symbols)
157
+
158
+ for symbol in lookup_symbols:
159
+ matches = self.store.detail.find({"contractName": symbol})
160
+ if not matches:
161
+ raise ValueError(f"Unknown Edgex symbol: {symbol}")
162
+ ids.append(str(matches[0]["contractId"]))
163
+
164
+ if not ids:
165
+ raise ValueError("Provide contract_ids/symbols or set all_contracts=True")
166
+
167
+ channels.extend(f"ticker.{cid}" for cid in ids)
168
+
169
+ url = ws_url or f"{self.ws_url}/api/v1/public/ws?timestamp=" + str(int(time.time() * 1000))
170
+ payload = [{"type": "subscribe", "channel": ch} for ch in channels]
171
+
172
+ wsapp = self.client.ws_connect(url, send_json=payload, hdlr_json=self.store.onmessage)
173
+ await wsapp._event.wait()
174
+
175
+
176
+ async def __aexit__(
177
+ self,
178
+ exc_type: type[BaseException] | None,
179
+ exc: BaseException | None,
180
+ tb: BaseException | None,
181
+ ) -> None:
182
+ # Edgex 当前没有需要关闭的资源;保持接口与 Ourbit 等类一致。
183
+ return None
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import itertools
5
+ import json
6
+ import logging
7
+ import time
8
+ import zlib
9
+ from typing import Iterable, Literal
10
+
11
+ import pybotters
12
+
13
+ from .models.lbank import LbankDataStore
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ # https://ccapi.rerrkvifj.com 似乎是spot的api
18
+ # https://uuapi.rerrkvifj.com 似乎是合约的api
19
+
20
+
21
+ class Lbank:
22
+ """LBank public market-data client (REST + WS)."""
23
+
24
+ def __init__(
25
+ self,
26
+ client: pybotters.Client,
27
+ *,
28
+ front_api: str | None = None,
29
+ rest_api: str | None = None,
30
+ ws_url: str | None = None,
31
+ ) -> None:
32
+ self.client = client
33
+ self.store = LbankDataStore()
34
+ self.front_api = front_api or "https://uuapi.rerrkvifj.com"
35
+ self.rest_api = rest_api or "https://api.lbkex.com"
36
+ self.ws_url = ws_url or "wss://uuws.rerrkvifj.com/ws/v3"
37
+ self._req_id = itertools.count(int(time.time() * 1000))
38
+ self._ws_app = None
39
+
40
+ async def __aenter__(self) -> "Lbank":
41
+ await self.update("detail")
42
+ return self
43
+
44
+ async def __aexit__(self, exc_type, exc, tb) -> None:
45
+ pass
46
+
47
+ async def update(self, update_type: Literal["detail", "ticker", "all"]) -> list[dict]:
48
+ all_urls = [f"{self.front_api}/cfd/agg/v1/instrument"]
49
+ url_map = {"detail": [all_urls[0]], "all": all_urls}
50
+
51
+
52
+ try:
53
+ urls = url_map[update_type]
54
+ except KeyError:
55
+ raise ValueError(f"update_type err: {update_type}")
56
+
57
+ # await self.store.initialize(*(self.client.get(url) for url in urls))
58
+ if update_type == "detail" or update_type == "all":
59
+ await self.store.initialize(
60
+ self.client.post(
61
+ all_urls[0],
62
+ json={"ProductGroup": "SwapU"},
63
+ headers={"source": "4", "versionflage": "true"},
64
+ )
65
+ )
66
+
67
+
68
+ async def sub_orderbook(self, symbols: list[str], limit: int | None = None) -> None:
69
+ """订阅指定交易对的订单簿(遵循 LBank 协议)。
70
+ """
71
+
72
+ send_jsons = []
73
+ y = 3000000001
74
+ if limit:
75
+ self.store.book.limit = limit
76
+
77
+ for symbol in symbols:
78
+
79
+ info = self.store.detail.get({"symbol": symbol})
80
+ if not info:
81
+ raise ValueError(f"Unknown LBank symbol: {symbol}")
82
+
83
+ tick_size = info['tick_size']
84
+ sub_i = symbol + "_" + str(tick_size) + "_25"
85
+ send_jsons.append(
86
+ {
87
+ "x": 3,
88
+ "y": str(y),
89
+ "a": {"i": sub_i},
90
+ "z": 1,
91
+ }
92
+ )
93
+
94
+ self.store.register_book_channel(str(y), symbol)
95
+ y += 1
96
+
97
+ wsapp = self.client.ws_connect(
98
+ self.ws_url,
99
+ send_json=send_jsons,
100
+ hdlr_bytes=self.store.onmessage,
101
+ )
102
+
103
+ await wsapp._event.wait()
@@ -0,0 +1,518 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import Any, Awaitable, TYPE_CHECKING
5
+
6
+ from aiohttp import ClientResponse
7
+ from pybotters.store import DataStore, DataStoreCollection
8
+
9
+ if TYPE_CHECKING:
10
+ from pybotters.typedefs import Item
11
+ from pybotters.ws import ClientWebSocketResponse
12
+
13
+
14
+ class Book(DataStore):
15
+ """Order book data store for the Edgex websocket feed."""
16
+
17
+ _KEYS = ["c", "S", "p"]
18
+
19
+ def _init(self) -> None:
20
+ self._version: int | str | None = None
21
+ self.limit: int | None = None
22
+
23
+ def _on_message(self, msg: dict[str, Any]) -> None:
24
+ content = msg.get("content") or {}
25
+ entries = content.get("data") or []
26
+ data_type = (content.get("dataType") or "").lower()
27
+
28
+ for entry in entries:
29
+ contract_id = entry.get("contractId")
30
+ if contract_id is None:
31
+ continue
32
+
33
+ contract_name = entry.get("contractName")
34
+ end_version = entry.get("endVersion")
35
+ depth_type = (entry.get("depthType") or "").lower()
36
+
37
+ is_snapshot = data_type == "snapshot" or depth_type == "snapshot"
38
+
39
+ if is_snapshot:
40
+ self._handle_snapshot(
41
+ contract_id,
42
+ contract_name,
43
+ entry,
44
+ )
45
+ else:
46
+ self._handle_delta(
47
+ contract_id,
48
+ contract_name,
49
+ entry,
50
+ )
51
+
52
+ if end_version is not None:
53
+ self._version = self._normalize_version(end_version)
54
+
55
+ def _handle_snapshot(
56
+ self,
57
+ contract_id: str,
58
+ contract_name: str | None,
59
+ entry: dict[str, Any],
60
+ ) -> None:
61
+ asks = entry.get("asks") or []
62
+ bids = entry.get("bids") or []
63
+
64
+ self._find_and_delete({"c": contract_id})
65
+
66
+ payload: list[dict[str, Any]] = []
67
+ payload.extend(
68
+ self._build_items(
69
+ contract_id,
70
+ contract_name,
71
+ "a",
72
+ asks,
73
+ )
74
+ )
75
+ payload.extend(
76
+ self._build_items(
77
+ contract_id,
78
+ contract_name,
79
+ "b",
80
+ bids,
81
+ )
82
+ )
83
+
84
+ if payload:
85
+ self._insert(payload)
86
+ self._trim(contract_id, contract_name)
87
+
88
+ def _handle_delta(
89
+ self,
90
+ contract_id: str,
91
+ contract_name: str | None,
92
+ entry: dict[str, Any],
93
+ ) -> None:
94
+ updates: list[dict[str, Any]] = []
95
+ deletes: list[dict[str, Any]] = []
96
+
97
+ asks = entry.get("asks") or []
98
+ bids = entry.get("bids") or []
99
+
100
+ for side, levels in (("a", asks), ("b", bids)):
101
+ for row in levels:
102
+ price, size = self._extract_price_size(row)
103
+ criteria = {"c": contract_id, "S": side, "p": price}
104
+
105
+ if not size or float(size) == 0.0:
106
+ deletes.append(criteria)
107
+ continue
108
+
109
+ updates.append(
110
+ {
111
+ "c": contract_id,
112
+ "S": side,
113
+ "p": price,
114
+ "q": size,
115
+ "s": self._symbol(contract_id, contract_name),
116
+ }
117
+ )
118
+
119
+ if deletes:
120
+ self._delete(deletes)
121
+ if updates:
122
+ self._update(updates)
123
+ self._trim(contract_id, contract_name)
124
+
125
+
126
+ def _build_items(
127
+ self,
128
+ contract_id: str,
129
+ contract_name: str | None,
130
+ side: str,
131
+ rows: list[dict[str, Any]],
132
+ ) -> list[dict[str, Any]]:
133
+ items: list[dict[str, Any]] = []
134
+ for row in rows:
135
+ price, size = self._extract_price_size(row)
136
+ if not size or float(size) == 0.0:
137
+ continue
138
+ items.append(
139
+ {
140
+ "c": contract_id,
141
+ "S": side,
142
+ "p": price,
143
+ "q": size,
144
+ "s": self._symbol(contract_id, contract_name),
145
+ }
146
+ )
147
+ return items
148
+
149
+ @staticmethod
150
+ def _normalize_version(value: Any) -> int | str:
151
+ if value is None:
152
+ return value
153
+ try:
154
+ return int(value)
155
+ except (TypeError, ValueError):
156
+ return str(value)
157
+
158
+ @staticmethod
159
+ def _to_str(value: Any) -> str | None:
160
+ if value is None:
161
+ return None
162
+ return str(value)
163
+
164
+ @staticmethod
165
+ def _extract_price_size(row: dict[str, Any]) -> tuple[str, str]:
166
+ return str(row["price"]), str(row["size"])
167
+
168
+ def _trim(self, contract_id: str, contract_name: str | None) -> None:
169
+ if self.limit is None:
170
+ return
171
+
172
+ query: dict[str, Any]
173
+ symbol = self._symbol(contract_id, contract_name)
174
+ if symbol:
175
+ query = {"s": symbol}
176
+ else:
177
+ query = {"c": contract_id}
178
+
179
+ sort_data = self.sorted(query, self.limit)
180
+ asks = sort_data.get("a", [])
181
+ bids = sort_data.get("b", [])
182
+
183
+ self._find_and_delete(query)
184
+
185
+ trimmed = asks + bids
186
+ if trimmed:
187
+ self._insert(trimmed)
188
+
189
+ @staticmethod
190
+ def _symbol(contract_id: str, contract_name: str | None) -> str:
191
+ if contract_name:
192
+ return str(contract_name)
193
+ return str(contract_id)
194
+
195
+ @property
196
+ def version(self) -> int | str | None:
197
+ """返回当前缓存的订单簿版本号。"""
198
+ return self._version
199
+
200
+ def sorted(
201
+ self,
202
+ query: dict[str, Any] | None = None,
203
+ limit: int | None = None,
204
+ ) -> dict[str, list[dict[str, Any]]]:
205
+ """按买卖方向与价格排序后的订单簿视图。"""
206
+ return self._sorted(
207
+ item_key="S",
208
+ item_asc_key="a",
209
+ item_desc_key="b",
210
+ sort_key="p",
211
+ query=query,
212
+ limit=limit,
213
+ )
214
+
215
+
216
+ class Ticker(DataStore):
217
+ """24 小时行情推送数据。"""
218
+
219
+ _KEYS = ["c"]
220
+
221
+ def _on_message(self, msg: dict[str, Any]) -> None:
222
+ content = msg.get("content") or {}
223
+ entries = content.get("data") or []
224
+ data_type = (content.get("dataType") or "").lower()
225
+
226
+ for entry in entries:
227
+ item = self._format(entry)
228
+ if item is None:
229
+ continue
230
+
231
+ criteria = {"c": item["c"]}
232
+ if data_type == "snapshot":
233
+ self._find_and_delete(criteria)
234
+ self._insert([item])
235
+ else:
236
+ self._update([item])
237
+
238
+ def _format(self, entry: dict[str, Any]) -> dict[str, Any] | None:
239
+ contract_id = entry.get("contractId")
240
+ if contract_id is None:
241
+ return None
242
+
243
+ item: dict[str, Any] = {"c": str(contract_id)}
244
+
245
+ name = entry.get("contractName")
246
+ if name is not None:
247
+ item["s"] = str(name)
248
+
249
+ fields = [
250
+ "priceChange",
251
+ "priceChangePercent",
252
+ "trades",
253
+ "size",
254
+ "value",
255
+ "high",
256
+ "low",
257
+ "open",
258
+ "close",
259
+ "highTime",
260
+ "lowTime",
261
+ "startTime",
262
+ "endTime",
263
+ "lastPrice",
264
+ "indexPrice",
265
+ "oraclePrice",
266
+ "openInterest",
267
+ "fundingRate",
268
+ "fundingTime",
269
+ "nextFundingTime",
270
+ "bestAskPrice",
271
+ "bestBidPrice",
272
+ ]
273
+
274
+ for key in fields:
275
+ value = entry.get(key)
276
+ if value is not None:
277
+ item[key] = str(value)
278
+
279
+ return item
280
+
281
+
282
+ class CoinMeta(DataStore):
283
+ """Coin metadata (precision, StarkEx info, etc.)."""
284
+
285
+ _KEYS = ["coinId"]
286
+
287
+ def _onresponse(self, data: dict[str, Any]) -> None:
288
+ coins = (data.get("data") or {}).get("coinList") or []
289
+ items: list[dict[str, Any]] = []
290
+
291
+ for coin in coins:
292
+ coin_id = coin.get("coinId")
293
+ if coin_id is None:
294
+ continue
295
+ items.append(
296
+ {
297
+ "coinId": str(coin_id),
298
+ "coinName": coin.get("coinName"),
299
+ "stepSize": coin.get("stepSize"),
300
+ "showStepSize": coin.get("showStepSize"),
301
+ "starkExAssetId": coin.get("starkExAssetId"),
302
+ }
303
+ )
304
+
305
+ self._clear()
306
+ if items:
307
+ self._insert(items)
308
+
309
+
310
+ class ContractMeta(DataStore):
311
+ """Per-contract trading parameters from the metadata endpoint."""
312
+
313
+ _KEYS = ["contractId"]
314
+
315
+ _FIELDS = (
316
+ "contractName",
317
+ "baseCoinId",
318
+ "quoteCoinId",
319
+ "tickSize",
320
+ "stepSize",
321
+ "minOrderSize",
322
+ "maxOrderSize",
323
+ "defaultTakerFeeRate",
324
+ "defaultMakerFeeRate",
325
+ "enableTrade",
326
+ "fundingInterestRate",
327
+ "fundingImpactMarginNotional",
328
+ "fundingRateIntervalMin",
329
+ "starkExSyntheticAssetId",
330
+ "starkExResolution",
331
+ )
332
+
333
+ def _onresponse(self, data: dict[str, Any]) -> None:
334
+ contracts = (data.get("data") or {}).get("contractList") or []
335
+ items: list[dict[str, Any]] = []
336
+
337
+ for contract in contracts:
338
+ contract_id = contract.get("contractId")
339
+ if contract_id is None:
340
+ continue
341
+
342
+ payload = {"contractId": str(contract_id)}
343
+ for key in self._FIELDS:
344
+ payload[key] = contract.get(key)
345
+ payload["riskTierList"] = self._simplify_risk_tiers(contract.get("riskTierList"))
346
+
347
+ items.append(payload)
348
+
349
+ self._clear()
350
+ if items:
351
+ self._insert(items)
352
+
353
+ @staticmethod
354
+ def _simplify_risk_tiers(risk_tiers: Any) -> list[dict[str, Any]]:
355
+ items: list[dict[str, Any]] = []
356
+ for tier in risk_tiers or []:
357
+ items.append(
358
+ {
359
+ "tier": tier.get("tier"),
360
+ "positionValueUpperBound": tier.get("positionValueUpperBound"),
361
+ "maxLeverage": tier.get("maxLeverage"),
362
+ "maintenanceMarginRate": tier.get("maintenanceMarginRate"),
363
+ "starkExRisk": tier.get("starkExRisk"),
364
+ "starkExUpperBound": tier.get("starkExUpperBound"),
365
+ }
366
+ )
367
+ return items
368
+
369
+ class EdgexDataStore(DataStoreCollection):
370
+ """Edgex DataStore collection exposing the order book feed."""
371
+
372
+ def _init(self) -> None:
373
+ self._create("book", datastore_class=Book)
374
+ self._create("ticker", datastore_class=Ticker)
375
+ self._create("meta_coin", datastore_class=CoinMeta)
376
+ self._create("detail", datastore_class=ContractMeta)
377
+
378
+ @property
379
+ def book(self) -> Book:
380
+ """
381
+ 获取 Edgex 合约订单簿数据流。
382
+
383
+ .. code:: json
384
+
385
+ [
386
+ {
387
+ "c": "10000001", # 合约 ID
388
+ "s": "BTCUSD",
389
+ "S": "a", # 方向 a=卖 b=买
390
+ "p": "117388.2", # 价格
391
+ "q": "12.230", # 数量
392
+ }
393
+ ]
394
+ """
395
+ return self._get("book")
396
+
397
+
398
+
399
+ @property
400
+ def coins(self) -> CoinMeta:
401
+ """
402
+ 获取币种精度及 StarkEx 资产信息列表。
403
+
404
+ .. code:: json
405
+
406
+ [
407
+ {
408
+ "coinId": "1000",
409
+ "coinName": "USDT",
410
+ "stepSize": "0.000001",
411
+ "showStepSize": "0.0001",
412
+ "starkExAssetId": "0x33bda5c9..."
413
+ }
414
+ ]
415
+ """
416
+ return self._get("meta_coin")
417
+
418
+ @property
419
+ def detail(self) -> ContractMeta:
420
+ """
421
+ 获取合约级别的交易参数。
422
+
423
+ .. code:: json
424
+
425
+ [
426
+ {
427
+ "contractId": "10000001",
428
+ "contractName": "BTCUSDT",
429
+ "baseCoinId": "1001",
430
+ "quoteCoinId": "1000",
431
+ "tickSize": "0.1",
432
+ "stepSize": "0.001",
433
+ "minOrderSize": "0.001",
434
+ "maxOrderSize": "50.000",
435
+ "defaultMakerFeeRate": "0.0002",
436
+ "defaultTakerFeeRate": "0.00055",
437
+ "enableTrade": true,
438
+ "fundingInterestRate": "0.0003",
439
+ "fundingImpactMarginNotional": "10",
440
+ "fundingRateIntervalMin": "240",
441
+ "starkExSyntheticAssetId": "0x42544332...",
442
+ "starkExResolution": "0x2540be400",
443
+ "riskTierList": [
444
+ {
445
+ "tier": 1,
446
+ "positionValueUpperBound": "50000",
447
+ "maxLeverage": "100",
448
+ "maintenanceMarginRate": "0.005",
449
+ "starkExRisk": "21474837",
450
+ "starkExUpperBound": "214748364800000000000"
451
+ }
452
+ ]
453
+ }
454
+ ]
455
+ """
456
+ return self._get("detail")
457
+
458
+ @property
459
+ def ticker(self) -> Ticker:
460
+ """
461
+ 获取 24 小时行情推送。
462
+
463
+ .. code:: json
464
+
465
+ [
466
+ {
467
+ "c": "10000001", # 合约 ID
468
+ "s": "BTCUSD", # 合约名称
469
+ "lastPrice": "117400", # 最新价
470
+ "priceChange": "200", # 涨跌额
471
+ "priceChangePercent": "0.0172", # 涨跌幅
472
+ "size": "1250", # 24h 成交量
473
+ "value": "147000000", # 24h 成交额
474
+ "high": "118000", # 24h 最高价
475
+ "low": "116500", # 低价
476
+ "open": "116800", # 开盘价
477
+ "close": "117400", # 收盘价
478
+ "indexPrice": "117350", # 指数价
479
+ "oraclePrice": "117360.12", # 预言机价
480
+ "openInterest": "50000", # 持仓量
481
+ "fundingRate": "0.000234", # 当前资金费率
482
+ "fundingTime": "1758240000000", # 上一次结算时间
483
+ "nextFundingTime": "1758254400000", # 下一次结算时间
484
+ "bestAskPrice": "117410", # 卖一价
485
+ "bestBidPrice": "117400" # 买一价
486
+ }
487
+ ]
488
+ """
489
+ return self._get("ticker")
490
+
491
+ async def initialize(self, *aws: Awaitable["ClientResponse"]) -> None:
492
+ """Populate metadata stores from awaited HTTP responses."""
493
+
494
+ for fut in asyncio.as_completed(aws):
495
+ res = await fut
496
+ data = await res.json()
497
+ if res.url.path == "/api/v1/public/meta/getMetaData":
498
+ self._apply_metadata(data)
499
+
500
+ def onmessage(self, msg: Item, ws: ClientWebSocketResponse | None = None) -> None:
501
+ channel = (msg.get("channel") or "").lower()
502
+ msg_type = (msg.get("type") or "").lower()
503
+
504
+ if msg_type == "ping" and ws is not None:
505
+ payload = {"type": "pong", "time": msg.get("time")}
506
+ asyncio.create_task(ws.send_json(payload))
507
+ return
508
+
509
+ if "depth" in channel and msg_type in {"quote-event", "payload"}:
510
+ self.book._on_message(msg)
511
+
512
+ if channel.startswith("ticker") and msg_type in {"payload", "quote-event"}:
513
+ self.ticker._on_message(msg)
514
+
515
+
516
+ def _apply_metadata(self, data: dict[str, Any]) -> None:
517
+ self.coins._onresponse(data)
518
+ self.detail._onresponse(data)
@@ -0,0 +1,222 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ from typing import Any, Awaitable, TYPE_CHECKING
7
+
8
+ from aiohttp import ClientResponse
9
+ from pybotters.store import DataStore, DataStoreCollection
10
+
11
+ if TYPE_CHECKING:
12
+ from pybotters.typedefs import Item
13
+ from pybotters.ws import ClientWebSocketResponse
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _accuracy_to_step(accuracy: int | str | None) -> str:
19
+ try:
20
+ n = int(accuracy) if accuracy is not None else 0
21
+ except (TypeError, ValueError): # pragma: no cover - defensive guard
22
+ n = 0
23
+ if n <= 0:
24
+ return "1"
25
+ return "0." + "0" * (n - 1) + "1"
26
+
27
+
28
+ class Book(DataStore):
29
+ """LBank order book store parsed from the depth channel."""
30
+
31
+ _KEYS = ["id", "S", "p"]
32
+
33
+ def _init(self) -> None:
34
+ self.limit: int | None = None
35
+ self.symbol_map: dict[str, str] = {}
36
+
37
+
38
+ def _format_levels(
39
+ self,
40
+ levels: list[list[Any]],
41
+ channel_id: str | None,
42
+ side: str,
43
+ symbol: str | None,
44
+ ) -> list[dict[str, Any]]:
45
+ formatted: list[dict[str, Any]] = []
46
+ for level in levels:
47
+ if len(level) < 2:
48
+ continue
49
+ try:
50
+ price = float(level[0])
51
+ size = float(level[1])
52
+ except (TypeError, ValueError): # pragma: no cover - defensive guard
53
+ continue
54
+ formatted.append(
55
+ {
56
+ "id": channel_id,
57
+ "S": side,
58
+ "p": price,
59
+ "q": size,
60
+ "s": symbol,
61
+ }
62
+ )
63
+ if self.limit is not None:
64
+ formatted = formatted[: self.limit]
65
+ return formatted
66
+
67
+ def _on_message(self, msg: Any) -> None:
68
+
69
+ data = json.loads(msg)
70
+
71
+ if not data:
72
+ return
73
+
74
+ channel_id = None
75
+ if data.get("y") is not None:
76
+ channel_id = str(data["y"])
77
+
78
+ symbol = None
79
+ if channel_id:
80
+ symbol = self.symbol_map.get(channel_id)
81
+ if symbol is None and data.get("i"):
82
+ symbol = self.symbol_map.get(str(data["i"]))
83
+
84
+ bids = self._format_levels(data.get("b", []), channel_id, "b", symbol)
85
+ asks = self._format_levels(data.get("s", []), channel_id, "a", symbol)
86
+
87
+ if not (bids or asks):
88
+ return
89
+
90
+ if channel_id is not None:
91
+ self._find_and_delete({"id": channel_id})
92
+ self._insert(bids + asks)
93
+
94
+
95
+ class Detail(DataStore):
96
+ """Futures instrument metadata store obtained from the futures instrument endpoint."""
97
+
98
+ _KEYS = ["symbol"]
99
+
100
+ def _transform(self, entry: dict[str, Any]) -> dict[str, Any] | None:
101
+ try:
102
+ instrument:dict = entry["instrument"]
103
+ fee:dict = entry["fee"]
104
+ market_data:dict = entry["marketData"]
105
+ except (KeyError, TypeError):
106
+ return None
107
+ return {
108
+ "symbol": instrument.get("instrumentID"),
109
+ "instrument_name": instrument.get("instrumentName"),
110
+ "base_currency": instrument.get("baseCurrency"),
111
+ "price_currency": instrument.get("priceCurrency"),
112
+ "min_order_volume": instrument.get("minOrderVolume"),
113
+ "max_order_volume": instrument.get("maxOrderVolume"),
114
+ "tick_size": instrument.get("priceTick"),
115
+ "step_size": instrument.get("volumeTick"),
116
+ "maker_fee": fee.get("makerOpenFeeRate"),
117
+ "taker_fee": fee.get("takerOpenFeeRate"),
118
+ "last_price": market_data.get("lastPrice"),
119
+ "amount24": market_data.get("turnover24"),
120
+ }
121
+
122
+ def _onresponse(self, data: list[dict[str, Any]] | dict[str, Any] | None) -> None:
123
+ if not data:
124
+ self._clear()
125
+ return
126
+ entries = data
127
+ if isinstance(data, dict): # pragma: no cover - defensive guard
128
+ entries = data.get("data") or []
129
+ items: list[dict[str, Any]] = []
130
+ for entry in entries or []:
131
+ transformed = self._transform(entry)
132
+ if transformed:
133
+ items.append(transformed)
134
+ if not items:
135
+ self._clear()
136
+ return
137
+ self._clear()
138
+ self._insert(items)
139
+
140
+
141
+ class LbankDataStore(DataStoreCollection):
142
+ """Aggregates book/detail stores for the LBank public feed."""
143
+
144
+ def _init(self) -> None:
145
+ self._create("book", datastore_class=Book)
146
+ self._create("detail", datastore_class=Detail)
147
+ self._channel_to_symbol: dict[str, str] = {}
148
+
149
+ @property
150
+ def book(self) -> Book:
151
+ """
152
+ 订单簿(Order Book)数据流,按订阅ID(channel_id)索引。
153
+
154
+ 此属性表示通过深度频道(depth channel)接收到的订单簿快照和增量更新,数据结构示例如下:
155
+
156
+ Data structure:
157
+ [
158
+ {
159
+ "id": <channel_id>,
160
+ "S": "b" 或 "a", # "b" 表示买单,"a" 表示卖单
161
+ "p": <价格>,
162
+ "q": <数量>,
163
+ "s": <标准化交易对符号>
164
+ },
165
+ ...
166
+ ]
167
+
168
+ 通过本属性可以获取当前 LBank 订单簿的最新状态,便于后续行情分析和撮合逻辑处理。
169
+ """
170
+ return self._get("book")
171
+
172
+ @property
173
+ def detail(self) -> Detail:
174
+ """
175
+
176
+ _KEYS = ["symbol"]
177
+
178
+ 期货合约详情元数据流。
179
+
180
+ 此属性表示通过期货合约接口获取的合约详情,包括合约ID、合约名称、基础币种、计价币种、最小/最大下单量、价格跳动、交易量跳动、maker/taker手续费率、最新价和24小时成交额等信息。
181
+
182
+ Data structure:
183
+ [
184
+ {
185
+ "symbol": "BTCUSDT", # 合约ID
186
+ "instrument_name": "BTCUSDT", # 合约名称
187
+ "base_currency": "BTC", # 基础币种
188
+ "price_currency": "USDT", # 计价币种
189
+ "min_order_volume": "0.0001", # 最小下单量
190
+ "max_order_volume": "600.0", # 最大下单量
191
+ "tick_size": "0.1", # 最小价格变动单位
192
+ "step_size": "0.0001", # 最小数量变动单位
193
+ "maker_fee": "0.0002", # Maker 手续费率
194
+ "taker_fee": "0.0006", # Taker 手续费率
195
+ "last_price": "117025.5", # 最新价
196
+ "amount24": "807363493.97579747" # 24小时成交额
197
+ },
198
+ ...
199
+ ]
200
+
201
+ 通过本属性可以获取所有支持的期货合约元数据,便于下单参数校验和行情展示。
202
+ """
203
+ return self._get("detail")
204
+
205
+
206
+ def register_book_channel(self, channel_id: str, symbol: str, *, raw_symbol: str | None = None) -> None:
207
+ if channel_id is not None:
208
+ self.book.symbol_map[str(channel_id)] = symbol
209
+ if raw_symbol:
210
+ self.book.symbol_map[str(raw_symbol)] = symbol
211
+
212
+
213
+ async def initialize(self, *aws: Awaitable[ClientResponse]) -> None:
214
+ for fut in asyncio.as_completed(aws):
215
+ res = await fut
216
+ data = await res.json()
217
+ if res.url.path == "/cfd/agg/v1/instrument":
218
+ self.detail._onresponse(data)
219
+
220
+
221
+ def onmessage(self, msg: Item, ws: ClientWebSocketResponse | None = None) -> None:
222
+ self.book._on_message(msg)
@@ -80,7 +80,6 @@ class Book(DataStore):
80
80
  """返回最后更新时间"""
81
81
  return self._time
82
82
 
83
- @property
84
83
  def sorted(
85
84
  self, query: Item | None = None, limit: int | None = None
86
85
  ) -> dict[str, list[Item]]:
hyperquant/broker/ws.py CHANGED
@@ -1,4 +1,6 @@
1
1
  import asyncio
2
+ import time
3
+
2
4
  import pybotters
3
5
  from pybotters.ws import ClientWebSocketResponse, logger
4
6
  from pybotters.auth import Hosts
@@ -17,8 +19,16 @@ class Heartbeat:
17
19
  await ws.send_str('{"method":"ping"}')
18
20
  await asyncio.sleep(10.0)
19
21
 
22
+ @staticmethod
23
+ async def edgex(ws: pybotters.ws.ClientWebSocketResponse):
24
+ while not ws.closed:
25
+ now = str(int(time.time() * 1000))
26
+ await ws.send_json({"type": "ping", "time": now})
27
+ await asyncio.sleep(20.0)
28
+
20
29
  pybotters.ws.HeartbeatHosts.items['futures.ourbit.com'] = Heartbeat.ourbit
21
30
  pybotters.ws.HeartbeatHosts.items['www.ourbit.com'] = Heartbeat.ourbit_spot
31
+ pybotters.ws.HeartbeatHosts.items['quote.edgex.exchange'] = Heartbeat.edgex
22
32
 
23
33
  class WssAuth:
24
34
  @staticmethod
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hyperquant
3
- Version: 0.59
3
+ Version: 0.62
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
@@ -5,17 +5,21 @@ hyperquant/draw.py,sha256=up_lQ3pHeVLoNOyh9vPjgNwjD0M-6_IetSGviQUgjhY,54624
5
5
  hyperquant/logkit.py,sha256=nUo7nx5eONvK39GOhWwS41zNRL756P2J7-5xGzwXnTY,8462
6
6
  hyperquant/notikit.py,sha256=x5yAZ_tAvLQRXcRbcg-VabCaN45LUhvlTZnUqkIqfAA,3596
7
7
  hyperquant/broker/auth.py,sha256=oA9Yw1I59-u0Tnoj2e4wUup5q8V5T2qpga5RKbiAiZI,2614
8
+ hyperquant/broker/edgex.py,sha256=qQtc8jZqB5ZODoGGVcG_aIVUlrJX_pRF9EyO927LiVM,6646
8
9
  hyperquant/broker/hyperliquid.py,sha256=7MxbI9OyIBcImDelPJu-8Nd53WXjxPB5TwE6gsjHbto,23252
10
+ hyperquant/broker/lbank.py,sha256=e-DnZg-rBwpTV7FWmPpG89Y7kk7NZOzilvEn7V3LEI0,2978
9
11
  hyperquant/broker/ourbit.py,sha256=NUcDSIttf-HGWzoW1uBTrGLPHlkuemMjYCm91MigTno,18228
10
- hyperquant/broker/ws.py,sha256=umRzxwCaZaRIgIq4YY-AuA0wCXFT0uOBmQbIXFY8CK0,1555
12
+ hyperquant/broker/ws.py,sha256=Ce5-g2BWhS8ZmVoLjtSj3Eb1TNIN4pgEIyJ0ODjiwUo,1902
11
13
  hyperquant/broker/lib/hpstore.py,sha256=LnLK2zmnwVvhEbLzYI-jz_SfYpO1Dv2u2cJaRAb84D8,8296
12
14
  hyperquant/broker/lib/hyper_types.py,sha256=HqjjzjUekldjEeVn6hxiWA8nevAViC2xHADOzDz9qyw,991
15
+ hyperquant/broker/models/edgex.py,sha256=texNBwz_9r9CTl7dRNjvSm_toxV_og0TWnap3dqUk2s,15795
13
16
  hyperquant/broker/models/hyperliquid.py,sha256=c4r5739ibZfnk69RxPjQl902AVuUOwT8RNvKsMtwXBY,9459
14
- hyperquant/broker/models/ourbit.py,sha256=6j6wc2QQsn25hpRk_yVGJm-v_YyiHh-Yq6VNWt39mkY,41979
17
+ hyperquant/broker/models/lbank.py,sha256=_P0oRPID2Yd_cpElHa3QIT471YlfIzexXMrzm1WVnHI,7652
18
+ hyperquant/broker/models/ourbit.py,sha256=xMcbuCEXd3XOpPBq0RYF2zpTFNnxPtuNJZCexMZVZ1k,41965
15
19
  hyperquant/datavison/_util.py,sha256=92qk4vO856RqycO0YqEIHJlEg-W9XKapDVqAMxe6rbw,533
16
20
  hyperquant/datavison/binance.py,sha256=3yNKTqvt_vUQcxzeX4ocMsI5k6Q6gLZrvgXxAEad6Kc,5001
17
21
  hyperquant/datavison/coinglass.py,sha256=PEjdjISP9QUKD_xzXNzhJ9WFDTlkBrRQlVL-5pxD5mo,10482
18
22
  hyperquant/datavison/okx.py,sha256=yg8WrdQ7wgWHNAInIgsWPM47N3Wkfr253169IPAycAY,6898
19
- hyperquant-0.59.dist-info/METADATA,sha256=2AsJ7yBJN3HSrPXOAOmTOJNWAW2-7K0G-Xj5-26Hci0,4317
20
- hyperquant-0.59.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
21
- hyperquant-0.59.dist-info/RECORD,,
23
+ hyperquant-0.62.dist-info/METADATA,sha256=cjL0r-jaGJWLIgCAXp-6RzRUdac8ec_bjEKJ2WtSCoo,4317
24
+ hyperquant-0.62.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
25
+ hyperquant-0.62.dist-info/RECORD,,