hyperquant 0.65__py3-none-any.whl → 0.67__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.
@@ -4,6 +4,7 @@ import asyncio
4
4
  from typing import Any, Awaitable, TYPE_CHECKING
5
5
 
6
6
  from aiohttp import ClientResponse
7
+ import aiohttp
7
8
  from pybotters.store import DataStore, DataStoreCollection
8
9
 
9
10
  if TYPE_CHECKING:
@@ -121,7 +122,6 @@ class Book(DataStore):
121
122
  if updates:
122
123
  self._update(updates)
123
124
  self._trim(contract_id, contract_name)
124
-
125
125
 
126
126
  def _build_items(
127
127
  self,
@@ -235,6 +235,22 @@ class Ticker(DataStore):
235
235
  else:
236
236
  self._update([item])
237
237
 
238
+ def _onresponse(self, data: dict[str, Any]) -> None:
239
+ entries = data.get("data") or []
240
+
241
+ if not isinstance(entries, list):
242
+ entries = [entries]
243
+
244
+ items = []
245
+ for entry in entries:
246
+ item = self._format(entry)
247
+ if item:
248
+ items.append(item)
249
+
250
+ self._clear()
251
+ if items:
252
+ self._insert(items)
253
+
238
254
  def _format(self, entry: dict[str, Any]) -> dict[str, Any] | None:
239
255
  contract_id = entry.get("contractId")
240
256
  if contract_id is None:
@@ -279,6 +295,311 @@ class Ticker(DataStore):
279
295
  return item
280
296
 
281
297
 
298
+ class Order(DataStore):
299
+ """Order data store combining REST results with trade-event deltas.
300
+
301
+ We only keep fields that are practical for trading book-keeping: identifiers,
302
+ basic order parameters, cumulative fills, high-level status and timestamps.
303
+ Network payloads carry hundreds of fields (``l2`` signatures, TPSL templates,
304
+ liquidation metadata, etc.), but the extra data adds noise and bloats memory
305
+ consumption. This store narrows every entry to a compact schema while still
306
+ supporting diff events from the private websocket feed.
307
+ """
308
+
309
+ _KEYS = ["orderId"]
310
+
311
+ _TERMINAL_STATUSES = {
312
+ "FILLED",
313
+ "CANCELED",
314
+ "CANCELLED",
315
+ "REJECTED",
316
+ "EXPIRED",
317
+ }
318
+
319
+ _ACTIVE_STATUSES = {
320
+ "OPEN",
321
+ "PARTIALLY_FILLED",
322
+ "PENDING",
323
+ "CREATED",
324
+ "ACKNOWLEDGED",
325
+ }
326
+
327
+ _KEEP_FIELDS = (
328
+ "userId",
329
+ "accountId",
330
+ "coinId",
331
+ "contractId",
332
+ "clientOrderId",
333
+ "type",
334
+ "timeInForce",
335
+ "reduceOnly",
336
+ "price",
337
+ "size",
338
+ "cumFillSize",
339
+ "cumFillValue",
340
+ "cumMatchSize",
341
+ "cumMatchValue",
342
+ "cumMatchFee",
343
+ "triggerPrice",
344
+ "triggerPriceType",
345
+ "cancelReason",
346
+ "createdTime",
347
+ "updatedTime",
348
+ "matchSequenceId",
349
+ )
350
+
351
+ _BOOL_FIELDS = {"reduceOnly"}
352
+
353
+ def _on_message(self, msg: dict[str, Any]) -> None:
354
+ content = msg.get("content") or {}
355
+ data = content.get("data") or {}
356
+ orders = data.get("order") or []
357
+
358
+ if not isinstance(orders, list):
359
+ orders = [orders]
360
+
361
+ items = [self._format(order) for order in orders]
362
+ items = [item for item in items if item]
363
+ if not items:
364
+ return
365
+
366
+ event = (content.get("event") or "").lower()
367
+ if event == "snapshot":
368
+ self._clear()
369
+ self._insert(items)
370
+ return
371
+
372
+ for item in items:
373
+ status = str(item.get("status") or "").upper()
374
+ criteria = {"orderId": item["orderId"]}
375
+ existing = self.find(criteria)
376
+
377
+ if status in self._TERMINAL_STATUSES:
378
+ if existing:
379
+ self._update([item])
380
+ else:
381
+ self._insert([item])
382
+ self._find_and_delete(criteria)
383
+ continue
384
+
385
+ if status and status not in self._ACTIVE_STATUSES:
386
+ if existing:
387
+ self._update([item])
388
+ else:
389
+ self._insert([item])
390
+ self._find_and_delete(criteria)
391
+ continue
392
+
393
+ if existing:
394
+ self._update([item])
395
+ else:
396
+ self._insert([item])
397
+
398
+ def _onresponse(self, data: dict[str, Any]) -> None:
399
+ payload = data.get("data")
400
+
401
+ if isinstance(payload, dict):
402
+ orders = payload.get("dataList") or payload.get("orderList") or []
403
+ else:
404
+ orders = payload or []
405
+
406
+ if not isinstance(orders, list):
407
+ orders = [orders]
408
+
409
+ items = [self._format(order) for order in orders]
410
+ items = [item for item in items if item]
411
+
412
+ self._clear()
413
+ if items:
414
+ self._insert(items)
415
+
416
+ @staticmethod
417
+ def _normalize_order_id(value: Any) -> str | None:
418
+ if value is None:
419
+ return None
420
+ return str(value)
421
+
422
+ @staticmethod
423
+ def _normalize_side(value: Any) -> str | None:
424
+ if value is None:
425
+ return None
426
+ if isinstance(value, str):
427
+ return value.lower()
428
+ return str(value)
429
+
430
+ @staticmethod
431
+ def _normalize_status(value: Any) -> str | None:
432
+ if value is None:
433
+ return None
434
+ return str(value).upper()
435
+
436
+ @staticmethod
437
+ def _stringify(value: Any) -> Any:
438
+ if value is None:
439
+ return None
440
+ if isinstance(value, (bool, dict, list)):
441
+ return value
442
+ return str(value)
443
+
444
+ def _format(self, order: dict[str, Any] | None) -> dict[str, Any] | None:
445
+ if not order:
446
+ return None
447
+
448
+ order_id = (
449
+ order.get("orderId")
450
+ or order.get("id")
451
+ or order.get("order_id")
452
+ or order.get("orderID")
453
+ )
454
+
455
+ normalized_id = self._normalize_order_id(order_id)
456
+ if normalized_id is None:
457
+ return None
458
+
459
+ item: dict[str, Any] = {"orderId": normalized_id, "id": normalized_id}
460
+
461
+ side = self._normalize_side(order.get("side"))
462
+ if side is not None:
463
+ item["side"] = side
464
+
465
+ status = self._normalize_status(order.get("status"))
466
+ if status is not None:
467
+ item["status"] = status
468
+
469
+ contract_name = order.get("contractName")
470
+ if contract_name:
471
+ symbol = self._stringify(contract_name)
472
+ item["contractName"] = symbol
473
+ item.setdefault("symbol", symbol)
474
+
475
+ for field in self._KEEP_FIELDS:
476
+ if field in ("side", "status"):
477
+ continue
478
+ value = order.get(field)
479
+ if value is None:
480
+ continue
481
+ if field in self._BOOL_FIELDS:
482
+ item[field] = bool(value)
483
+ else:
484
+ item[field] = self._stringify(value)
485
+
486
+ return item
487
+
488
+
489
+ class Balance(DataStore):
490
+ """Account balance snapshot retaining only the trading-critical fields."""
491
+
492
+ _KEYS = ["accountId", "coinId"]
493
+
494
+
495
+ def _onresponse(self, data: dict[str, Any]) -> None:
496
+ data = data.get('data', {})
497
+ collateral_assets = data.get('collateralAssetModelList') or []
498
+ if collateral_assets:
499
+ self._update(collateral_assets)
500
+
501
+ def _on_message(self, msg: dict[str, Any]) -> None:
502
+ pass
503
+
504
+
505
+ class Position(DataStore):
506
+ """
507
+ Stores per-account open positions in a simplified camelCase schema.
508
+ Only the current open position fields are retained: positionId, contractId, accountId,
509
+ userId, coinId, side, size, value, fee, fundingFee.
510
+ """
511
+
512
+ _KEYS = ["positionId"]
513
+
514
+ @staticmethod
515
+ def _stringify(value: Any) -> Any:
516
+ if value is None:
517
+ return None
518
+ if isinstance(value, (bool, dict, list)):
519
+ return value
520
+ return str(value)
521
+
522
+ def _onresponse(self, data: dict[str, Any]) -> None:
523
+ """
524
+ Handle REST response for getAccountAsset (open positions snapshot).
525
+ Expects data from getAccountAsset (REST), which returns a snapshot of **current open positions**,
526
+ as a list in data["positionList"].
527
+ Each entry is normalized to camelCase schema, only including essential fields for the current open position.
528
+ """
529
+ data = data.get("data", {}) or {}
530
+ positions = data.get("positionList") or []
531
+ if not isinstance(positions, list):
532
+ positions = [positions]
533
+ items = [self._normalize_position(pos) for pos in positions]
534
+ self._clear()
535
+ if items:
536
+ self._update(items)
537
+
538
+ def _on_message(self, msg: dict[str, Any]) -> None:
539
+ data = msg.get("content", {}).get("data", {})
540
+ if not data:
541
+ return
542
+ positions = data.get("position")
543
+ if not positions:
544
+ return
545
+ items = [self._normalize_position(pos) for pos in positions]
546
+ self._clear()
547
+ if items:
548
+ self._update(items)
549
+
550
+ def _normalize_position(self, pos: dict[str, Any]) -> dict[str, Any]:
551
+ # Only keep essential fields for the current open position
552
+ def get(key, *alts):
553
+ for k in (key,) + alts:
554
+ if k in pos and pos[k] is not None:
555
+ return pos[k]
556
+ return None
557
+
558
+ open_size = get("openSize")
559
+ open_value = get("openValue")
560
+ open_fee = get("openFee")
561
+ funding_fee = get("fundingFee")
562
+
563
+ # side: "long" if openSize > 0, "short" if openSize < 0, None if 0
564
+ side = None
565
+ try:
566
+ if open_size is not None:
567
+ fsize = float(open_size)
568
+ if fsize > 0:
569
+ side = "long"
570
+ elif fsize < 0:
571
+ side = "short"
572
+ except Exception:
573
+ side = None
574
+
575
+ size = None
576
+ if open_size is not None:
577
+ try:
578
+ size = str(abs(float(open_size)))
579
+ except Exception:
580
+ size = str(open_size)
581
+ value = None
582
+ if open_value is not None:
583
+ try:
584
+ value = str(abs(float(open_value)))
585
+ except Exception:
586
+ value = str(open_value)
587
+
588
+ item = {
589
+ "positionId": self._stringify(get("positionId", "position_id")),
590
+ "contractId": self._stringify(get("contractId")),
591
+ "accountId": self._stringify(get("accountId")),
592
+ "userId": self._stringify(get("userId")),
593
+ "coinId": self._stringify(get("coinId")),
594
+ "side": side,
595
+ "size": size,
596
+ "value": value,
597
+ "fee": self._stringify(open_fee),
598
+ "fundingFee": self._stringify(funding_fee),
599
+ }
600
+ return item
601
+
602
+
282
603
  class CoinMeta(DataStore):
283
604
  """Coin metadata (precision, StarkEx info, etc.)."""
284
605
 
@@ -310,7 +631,7 @@ class CoinMeta(DataStore):
310
631
  class ContractMeta(DataStore):
311
632
  """Per-contract trading parameters from the metadata endpoint."""
312
633
 
313
- _KEYS = ["contractId"]
634
+ _KEYS = ["contractName"]
314
635
 
315
636
  _FIELDS = (
316
637
  "contractName",
@@ -342,7 +663,9 @@ class ContractMeta(DataStore):
342
663
  payload = {"contractId": str(contract_id)}
343
664
  for key in self._FIELDS:
344
665
  payload[key] = contract.get(key)
345
- payload["riskTierList"] = self._simplify_risk_tiers(contract.get("riskTierList"))
666
+ payload["riskTierList"] = self._simplify_risk_tiers(
667
+ contract.get("riskTierList")
668
+ )
346
669
 
347
670
  items.append(payload)
348
671
 
@@ -366,14 +689,57 @@ class ContractMeta(DataStore):
366
689
  )
367
690
  return items
368
691
 
692
+
693
+ class AppMeta(DataStore):
694
+ """Global metadata (appName, env, fee account, etc.)."""
695
+
696
+ _KEYS = ["appName"]
697
+
698
+ def _onresponse(self, data: dict[str, Any]) -> None:
699
+ appdata = (data.get("data") or {}).get("global") or {}
700
+ if not appdata:
701
+ self._clear()
702
+ return
703
+ # Convert all values to str where appropriate, but preserve fields as-is (for bool/int etc).
704
+ item = {}
705
+ for k, v in appdata.items():
706
+ if k == "starkExCollateralCoin" and isinstance(v, dict):
707
+ # Flatten the dict into top-level fields with prefix
708
+ for subk, subv in v.items():
709
+ # Compose the flattened key
710
+ prefix = "starkExCollateral"
711
+ # Capitalize first letter of subkey
712
+ if subk and subk[0].islower():
713
+ flatkey = prefix + subk[0].upper() + subk[1:]
714
+ else:
715
+ flatkey = prefix + subk
716
+ item[flatkey] = subv if subv is None or isinstance(subv, (bool, int, float)) else str(subv)
717
+ continue
718
+ # Convert to str except for None; preserve bool/int/float as-is
719
+ if v is None:
720
+ item[k] = v
721
+ elif isinstance(v, (bool, int, float)):
722
+ item[k] = v
723
+ else:
724
+ item[k] = str(v)
725
+ self._clear()
726
+ if item:
727
+ self._insert([item])
728
+
729
+
369
730
  class EdgexDataStore(DataStoreCollection):
370
731
  """Edgex DataStore collection exposing the order book feed."""
371
732
 
372
733
  def _init(self) -> None:
373
734
  self._create("book", datastore_class=Book)
374
735
  self._create("ticker", datastore_class=Ticker)
736
+ self._create("orders", datastore_class=Order)
737
+ self._create("balance", datastore_class=Balance)
738
+ # Position store holds per-account open positions in simplified camelCase form
739
+ self._create("position", datastore_class=Position)
375
740
  self._create("meta_coin", datastore_class=CoinMeta)
376
741
  self._create("detail", datastore_class=ContractMeta)
742
+ self._create("app", datastore_class=AppMeta)
377
743
 
378
744
  @property
379
745
  def book(self) -> Book:
@@ -394,8 +760,121 @@ class EdgexDataStore(DataStoreCollection):
394
760
  """
395
761
  return self._get("book")
396
762
 
763
+ @property
764
+ def orders(self) -> Order:
765
+ """
766
+ 账户订单数据流(REST 快照 + 私有 WS 增量)。
767
+
768
+ 存储为**精简 schema**,仅保留实操必需字段。终态订单(FILLED / CANCELED / CANCELLED / REJECTED / EXPIRED)
769
+ 会在写入一次后从本地缓存删除,只保留进行中的订单(OPEN / PARTIALLY_FILLED / PENDING / CREATED / ACKNOWLEDGED)。
397
770
 
398
771
 
772
+ 存储示例(本地条目)
773
+ -------------------
774
+ REST 快照:
775
+
776
+ .. code:: json
777
+
778
+ [
779
+ {
780
+ "orderId": "564815695875932430",
781
+ "id": "564815695875932430",
782
+ "contractId": "10000001",
783
+ "contractName": "BTCUSD",
784
+ "symbol": "BTCUSD",
785
+ "side": "buy",
786
+ "status": "OPEN",
787
+ "type": "LIMIT",
788
+ "timeInForce": "GOOD_TIL_CANCEL",
789
+ "reduceOnly": false,
790
+ "price": "97444.5",
791
+ "size": "0.010",
792
+ "cumFillSize": "0.000",
793
+ "cumFillValue": "0",
794
+ "clientOrderId": "553364074986685",
795
+ "createdTime": "1734662555665",
796
+ "updatedTime": "1734662555665"
797
+ }
798
+ ]
799
+
800
+
801
+ """
802
+ return self._get("orders")
803
+
804
+ @property
805
+ def balance(self) -> Balance:
806
+ """
807
+ 获取账户资产余额(REST 快照 + 私有 WS 增量)。
808
+
809
+ .. code:: json
810
+
811
+ [
812
+ {
813
+ userId: "663528067892773124",
814
+ accountId: "663528067938910372",
815
+ coinId: "1000",
816
+ totalEquity: "22.721859",
817
+ totalPositionValueAbs: "0",
818
+ initialMarginRequirement: "0",
819
+ starkExRiskValue: "0",
820
+ pendingWithdrawAmount: "0",
821
+ pendingTransferOutAmount: "0",
822
+ orderFrozenAmount: "3.001126965030794963240623474121093750",
823
+ availableAmount: "19.720732",
824
+ },
825
+ ]
826
+
827
+ """
828
+ return self._get("balance")
829
+
830
+ @property
831
+ def position(self) -> "Position":
832
+ """
833
+ 获取账户当前未平仓持仓(open positions,来自 getAccountAsset)。
834
+
835
+ 本属性提供**当前未平仓持仓**的快照(由 REST ``getAccountAsset`` 提供),每条数据为当前账户的一个持仓(多/空/逐仓/全仓等)。
836
+ 字段为 snake_case,包含持仓数量、均价、强平价、杠杆、保证金率等信息,适合用于持仓管理与风险监控。
837
+
838
+ 数据示例:
839
+
840
+ .. code:: python
841
+
842
+ [
843
+ {
844
+ orderId: "665307878751470244",
845
+ id: "665307878751470244",
846
+ side: "buy",
847
+ status: "OPEN",
848
+ userId: "663528067892773124",
849
+ accountId: "663528067938910372",
850
+ coinId: "1000",
851
+ contractId: "10000003",
852
+ clientOrderId: "32570392453812747",
853
+ type: "LIMIT",
854
+ timeInForce: "GOOD_TIL_CANCEL",
855
+ reduceOnly: False,
856
+ price: "210.00",
857
+ size: "0.3",
858
+ cumFillSize: "0",
859
+ cumFillValue: "0",
860
+ cumMatchSize: "0",
861
+ cumMatchValue: "0",
862
+ cumMatchFee: "0",
863
+ triggerPrice: "0",
864
+ triggerPriceType: "UNKNOWN_PRICE_TYPE",
865
+ cancelReason: "UNKNOWN_ORDER_CANCEL_REASON",
866
+ createdTime: "1758621759117",
867
+ updatedTime: "1758621759122",
868
+ matchSequenceId: "784278904",
869
+ },
870
+ ];
871
+
872
+
873
+ 本属性仅包含**当前持有的未平仓持仓**(由 REST ``getAccountAsset`` 提供)。
874
+ 若需获取**历史已平仓持仓周期**,请调用 ``getPositionTermPage``。
875
+ """
876
+ return self._get("position")
877
+
399
878
  @property
400
879
  def coins(self) -> CoinMeta:
401
880
  """
@@ -425,7 +904,7 @@ class EdgexDataStore(DataStoreCollection):
425
904
  [
426
905
  {
427
906
  "contractId": "10000001",
428
- "contractName": "BTCUSDT",
907
+ "contractName": "BTCUSD",
429
908
  "baseCoinId": "1001",
430
909
  "quoteCoinId": "1000",
431
910
  "tickSize": "0.1",
@@ -494,10 +973,20 @@ class EdgexDataStore(DataStoreCollection):
494
973
  for fut in asyncio.as_completed(aws):
495
974
  res = await fut
496
975
  data = await res.json()
976
+ if data['code'] != 'SUCCESS':
977
+ raise ValueError(f"Unexpected response code: {data}")
497
978
  if res.url.path == "/api/v1/public/meta/getMetaData":
498
979
  self._apply_metadata(data)
980
+ elif res.url.path == "/api/v1/private/account/getAccountAsset":
981
+ self.balance._onresponse(data)
982
+ self.position._onresponse(data)
983
+ elif res.url.path == "/api/v1/private/order/getActiveOrderPage":
984
+ self.orders._onresponse(data)
985
+ elif res.url.path == "/api/v1/public/quote/getTicker":
986
+ self.ticker._onresponse(data)
499
987
 
500
988
  def onmessage(self, msg: Item, ws: ClientWebSocketResponse | None = None) -> None:
989
+ # print(msg)
501
990
  channel = (msg.get("channel") or "").lower()
502
991
  msg_type = (msg.get("type") or "").lower()
503
992
 
@@ -506,13 +995,59 @@ class EdgexDataStore(DataStoreCollection):
506
995
  asyncio.create_task(ws.send_json(payload))
507
996
  return
508
997
 
998
+ if msg_type in {"trade-event", "trade_event", "order-event", "order_event"}:
999
+ self.orders._on_message(msg)
1000
+ self.position._on_message(msg)
1001
+
509
1002
  if "depth" in channel and msg_type in {"quote-event", "payload"}:
510
1003
  self.book._on_message(msg)
511
1004
 
512
1005
  if channel.startswith("ticker") and msg_type in {"payload", "quote-event"}:
513
1006
  self.ticker._on_message(msg)
514
1007
 
515
-
516
1008
  def _apply_metadata(self, data: dict[str, Any]) -> None:
1009
+ self.app._onresponse(data)
517
1010
  self.coins._onresponse(data)
518
1011
  self.detail._onresponse(data)
1012
+
1013
+
1014
+ @property
1015
+ def app(self) -> AppMeta:
1016
+ """
1017
+ 获取全局元数据,如 appName、环境、fee 账户等。
1018
+
1019
+ .. code:: python
1020
+
1021
+
1022
+ [
1023
+ {
1024
+ "appName": "edgeX",
1025
+ "appEnv": "mainnet",
1026
+ "appOnlySignOn": "https://pro.edgex.exchange",
1027
+ "feeAccountId": "256105",
1028
+ "feeAccountL2Key": "0x70092acf49d535fbb64d99883abda95dcf9a4fc60f494437a3d76f27db0a0f5",
1029
+ "poolAccountId": "508126509156794507",
1030
+ "poolAccountL2Key": "0x7f2e1e8a572c847086ee93c9b5bbce8b96320aaa69147df1cfca91d5e90bc60",
1031
+ "fastWithdrawAccountId": "508126509156794507",
1032
+ "fastWithdrawAccountL2Key": "0x7f2e1e8a572c847086ee93c9b5bbce8b96320aaa69147df1cfca91d5e90bc60",
1033
+ "fastWithdrawMaxAmount": "100000",
1034
+ "fastWithdrawRegistryAddress": "0xBE9a129909EbCb954bC065536D2bfAfBd170d27A",
1035
+ "starkExChainId": "0x1",
1036
+ "starkExContractAddress": "0xfAaE2946e846133af314d1Df13684c89fA7d83DD",
1037
+ "starkExCollateralCoinId": "1000",
1038
+ "starkExCollateralCoinName": "USD",
1039
+ "starkExCollateralStepSize": "0.000001",
1040
+ "starkExCollateralShowStepSize": "0.0001",
1041
+ "starkExCollateralIconUrl": "https://static.edgex.exchange/icons/coin/USDT.svg",
1042
+ "starkExCollateralStarkExAssetId": "0x2ce625e94458d39dd0bf3b45a843544dd4a14b8169045a3a3d15aa564b936c5",
1043
+ "starkExCollateralStarkExResolution": "0xf4240",
1044
+ "starkExMaxFundingRate": 12000,
1045
+ "starkExOrdersTreeHeight": 64,
1046
+ "starkExPositionsTreeHeight": 64,
1047
+ "starkExFundingValidityPeriod": 86400,
1048
+ "starkExPriceValidityPeriod": 86400,
1049
+ "maintenanceReason": "",
1050
+ }
1051
+ ]
1052
+ """
1053
+ return self._get("app")